Files
esbuild/lib/npm/node.ts
2021-04-13 02:38:00 -07:00

515 lines
18 KiB
TypeScript

import * as types from "../shared/types";
import * as common from "../shared/common";
import child_process = require('child_process');
import crypto = require('crypto');
import path = require('path');
import fs = require('fs');
import os = require('os');
import tty = require('tty');
declare const ESBUILD_VERSION: string;
// This file is used for both the "esbuild" package and the "esbuild-wasm"
// package. "WASM" will be true for "esbuild-wasm" and false for "esbuild".
declare const WASM: boolean;
let worker_threads: typeof import('worker_threads') | undefined;
// This optimization is opt-in for now because it could break if node has bugs
// with "worker_threads", and node has had such bugs in the past.
//
// TODO: Determine under which conditions this is safe to enable, and then
// replace this check with a check for those conditions.
if (process.env.ESBUILD_WORKER_THREADS) {
// Don't crash if the "worker_threads" library isn't present
try {
worker_threads = require('worker_threads');
} catch {
}
}
let esbuildCommandAndArgs = (): [string, string[]] => {
// This feature was added to give external code a way to modify the binary
// path without modifying the code itself. Do not remove this because
// external code relies on this.
if (process.env.ESBUILD_BINARY_PATH) {
return [path.resolve(process.env.ESBUILD_BINARY_PATH), []];
}
if (WASM) {
return ['node', [path.join(__dirname, '..', 'bin', 'esbuild')]];
}
if (process.platform === 'win32') {
return [path.join(__dirname, '..', 'esbuild.exe'), []];
}
// Yarn 2 is deliberately incompatible with binary modules because the
// developers of Yarn 2 don't think they should be used. See this thread for
// details: https://github.com/yarnpkg/berry/issues/882.
//
// As a compatibility hack we replace the binary with a wrapper script only
// for Yarn 2. The wrapper script is avoided for other platforms because
// running the binary directly without going through node first is faster.
// However, this will make using the JavaScript API with Yarn 2 unnecessarily
// slow because the wrapper means running the binary will now start another
// nested node process just to call "spawnSync" and run the actual binary.
//
// To work around this workaround, we query for the place the binary is moved
// to if the original location is replaced by our Yarn 2 compatibility hack.
// If it exists, we can infer that we are running within Yarn 2 and the
// JavaScript API should invoke the binary here instead to avoid a slowdown.
// Calling the binary directly can be over 6x faster than calling the wrapper
// script instead.
let pathForYarn2 = path.join(__dirname, '..', 'esbuild');
if (fs.existsSync(pathForYarn2)) {
return [pathForYarn2, []];
}
return [path.join(__dirname, '..', 'bin', 'esbuild'), []];
};
// Return true if stderr is a TTY
let isTTY = () => tty.isatty(2);
let fsSync: common.StreamFS = {
readFile(tempFile, callback) {
try {
let contents = fs.readFileSync(tempFile, 'utf8');
try {
fs.unlinkSync(tempFile);
} catch {
}
callback(null, contents);
} catch (err) {
callback(err, null);
}
},
writeFile(contents, callback) {
try {
let tempFile = randomFileName();
fs.writeFileSync(tempFile, contents);
callback(tempFile);
} catch {
callback(null);
}
},
};
let fsAsync: common.StreamFS = {
readFile(tempFile, callback) {
try {
fs.readFile(tempFile, 'utf8', (err, contents) => {
try {
fs.unlink(tempFile, () => callback(err, contents));
} catch {
callback(err, contents);
}
});
} catch (err) {
callback(err, null);
}
},
writeFile(contents, callback) {
try {
let tempFile = randomFileName();
fs.writeFile(tempFile, contents, err =>
err !== null ? callback(null) : callback(tempFile));
} catch {
callback(null);
}
},
};
export let version = ESBUILD_VERSION;
export let build: typeof types.build = (options: types.BuildOptions): Promise<any> =>
ensureServiceIsRunning().build(options);
export let serve: typeof types.serve = (serveOptions, buildOptions) =>
ensureServiceIsRunning().serve(serveOptions, buildOptions);
export let transform: typeof types.transform = (input, options) =>
ensureServiceIsRunning().transform(input, options);
export let formatMessages: typeof types.formatMessages = (messages, options) =>
ensureServiceIsRunning().formatMessages(messages, options);
export let buildSync: typeof types.buildSync = (options: types.BuildOptions): any => {
// Try using a long-lived worker thread to avoid repeated start-up overhead
if (worker_threads) {
if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
return workerThreadService.buildSync(options);
}
let result: types.BuildResult;
runServiceSync(service => service.buildOrServe({
callName: 'buildSync',
refs: null,
serveOptions: null,
options,
isTTY: isTTY(),
defaultWD,
callback: (err, res) => { if (err) throw err; result = res as types.BuildResult },
}));
return result!;
};
export let transformSync: typeof types.transformSync = (input, options) => {
// Try using a long-lived worker thread to avoid repeated start-up overhead
if (worker_threads) {
if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
return workerThreadService.transformSync(input, options);
}
let result: types.TransformResult;
runServiceSync(service => service.transform({
callName: 'transformSync',
refs: null,
input,
options: options || {},
isTTY: isTTY(),
fs: fsSync,
callback: (err, res) => { if (err) throw err; result = res! },
}));
return result!;
};
export let formatMessagesSync: typeof types.formatMessagesSync = (messages, options) => {
// Try using a long-lived worker thread to avoid repeated start-up overhead
if (worker_threads) {
if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
return workerThreadService.formatMessagesSync(messages, options);
}
let result: string[];
runServiceSync(service => service.formatMessages({
callName: 'formatMessagesSync',
refs: null,
messages,
options,
callback: (err, res) => { if (err) throw err; result = res! },
}));
return result!;
};
let initializeWasCalled = false;
export let initialize: typeof types.initialize = options => {
options = common.validateInitializeOptions(options || {});
if (options.wasmURL) throw new Error(`The "wasmURL" option only works in the browser`)
if (options.worker) throw new Error(`The "worker" option only works in the browser`)
if (initializeWasCalled) throw new Error('Cannot call "initialize" more than once')
ensureServiceIsRunning()
initializeWasCalled = true
return Promise.resolve();
}
interface Service {
build: typeof types.build;
serve: typeof types.serve;
transform: typeof types.transform;
formatMessages: typeof types.formatMessages;
}
let defaultWD = process.cwd();
let longLivedService: Service | undefined;
let ensureServiceIsRunning = (): Service => {
if (longLivedService) return longLivedService;
let [command, args] = esbuildCommandAndArgs();
let child = child_process.spawn(command, args.concat(`--service=${ESBUILD_VERSION}`, '--ping'), {
windowsHide: true,
stdio: ['pipe', 'pipe', 'inherit'],
cwd: defaultWD,
});
let { readFromStdout, afterClose, service } = common.createChannel({
writeToStdin(bytes) {
child.stdin.write(bytes);
},
readFileSync: fs.readFileSync,
isSync: false,
isBrowser: false,
});
const stdin: typeof child.stdin & { unref?(): void } = child.stdin;
const stdout: typeof child.stdout & { unref?(): void } = child.stdout;
stdout.on('data', readFromStdout);
stdout.on('end', afterClose);
let refCount = 0;
child.unref();
if (stdin.unref) {
stdin.unref();
}
if (stdout.unref) {
stdout.unref();
}
const refs: common.Refs = {
ref() { if (++refCount === 1) child.ref(); },
unref() { if (--refCount === 0) child.unref(); },
}
longLivedService = {
build: (options: types.BuildOptions): Promise<any> => {
return new Promise<types.BuildResult>((resolve, reject) => {
service.buildOrServe({
callName: 'build',
refs,
serveOptions: null,
options,
isTTY: isTTY(),
defaultWD,
callback: (err, res) => err ? reject(err) : resolve(res as types.BuildResult),
})
})
},
serve: (serveOptions, buildOptions) => {
if (serveOptions === null || typeof serveOptions !== 'object')
throw new Error('The first argument must be an object')
return new Promise((resolve, reject) =>
service.buildOrServe({
callName: 'serve',
refs,
serveOptions,
options: buildOptions,
isTTY: isTTY(),
defaultWD, callback: (err, res) => err ? reject(err) : resolve(res as types.ServeResult),
}))
},
transform: (input, options) => {
return new Promise((resolve, reject) =>
service.transform({
callName: 'transform',
refs,
input,
options: options || {},
isTTY: isTTY(),
fs: fsAsync,
callback: (err, res) => err ? reject(err) : resolve(res!),
}));
},
formatMessages: (messages, options) => {
return new Promise((resolve, reject) =>
service.formatMessages({
callName: 'formatMessages',
refs,
messages,
options,
callback: (err, res) => err ? reject(err) : resolve(res!),
}));
},
};
return longLivedService;
}
let runServiceSync = (callback: (service: common.StreamService) => void): void => {
let [command, args] = esbuildCommandAndArgs();
let stdin = new Uint8Array();
let { readFromStdout, afterClose, service } = common.createChannel({
writeToStdin(bytes) {
if (stdin.length !== 0) throw new Error('Must run at most one command');
stdin = bytes;
},
isSync: true,
isBrowser: false,
});
callback(service);
let stdout = child_process.execFileSync(command, args.concat(`--service=${ESBUILD_VERSION}`), {
cwd: defaultWD,
windowsHide: true,
input: stdin,
// We don't know how large the output could be. If it's too large, the
// command will fail with ENOBUFS. Reserve 16mb for now since that feels
// like it should be enough. Also allow overriding this with an environment
// variable.
maxBuffer: +process.env.ESBUILD_MAX_BUFFER! || 16 * 1024 * 1024,
});
readFromStdout(stdout);
afterClose();
};
let randomFileName = () => {
return path.join(os.tmpdir(), `esbuild-${crypto.randomBytes(32).toString('hex')}`);
};
interface MainToWorkerMessage {
sharedBuffer: SharedArrayBuffer;
id: number;
command: string;
args: any[];
}
interface WorkerThreadService {
buildSync(options: types.BuildOptions): types.BuildResult;
transformSync: typeof types.transformSync;
formatMessagesSync: typeof types.formatMessagesSync;
}
let workerThreadService: WorkerThreadService | null = null;
let startWorkerThreadService = (worker_threads: typeof import('worker_threads')): WorkerThreadService => {
let { port1: mainPort, port2: workerPort } = new worker_threads.MessageChannel();
let worker = new worker_threads.Worker(__filename, {
workerData: { workerPort, defaultWD },
transferList: [workerPort],
// From node's documentation: https://nodejs.org/api/worker_threads.html
//
// Take care when launching worker threads from preload scripts (scripts loaded
// and run using the `-r` command line flag). Unless the `execArgv` option is
// explicitly set, new Worker threads automatically inherit the command line flags
// from the running process and will preload the same preload scripts as the main
// thread. If the preload script unconditionally launches a worker thread, every
// thread spawned will spawn another until the application crashes.
//
execArgv: [],
});
let nextID = 0;
let wasStopped = false;
// This forbids options which would cause structured clone errors
let fakeBuildError = (text: string) => {
let error: any = new Error(`Build failed with 1 error:\nerror: ${text}`);
let errors: types.Message[] = [{ text, location: null, notes: [], detail: void 0 }];
error.errors = errors;
error.warnings = [];
return error;
};
let validateBuildSyncOptions = (options: types.BuildOptions | undefined): void => {
if (!options) return
let plugins = options.plugins
let incremental = options.incremental
if (plugins && plugins.length > 0) throw fakeBuildError(`Cannot use plugins in synchronous API calls`);
if (incremental) throw fakeBuildError(`Cannot use "incremental" with a synchronous build`);
};
// MessagePort doesn't copy the properties of Error objects. We still want
// error objects to have extra properties such as "warnings" so implement the
// property copying manually.
let applyProperties = (object: any, properties: Record<string, any>): void => {
for (let key in properties) {
object[key] = properties[key];
}
};
let runCallSync = (command: string, args: any[]): any => {
if (wasStopped) throw new Error('The service was stopped');
let id = nextID++;
// Make a fresh shared buffer for every request. That way we can't have a
// race where a notification from the previous call overlaps with this call.
let sharedBuffer = new SharedArrayBuffer(8);
let sharedBufferView = new Int32Array(sharedBuffer);
// Send the message to the worker. Note that the worker could potentially
// complete the request before this thread returns from this call.
let msg: MainToWorkerMessage = { sharedBuffer, id, command, args };
worker.postMessage(msg);
// If the value hasn't changed (i.e. the request hasn't been completed,
// wait until the worker thread notifies us that the request is complete).
//
// Otherwise, if the value has changed, the request has already been
// completed. Don't wait in that case because the notification may never
// arrive if it has already been sent.
let status = Atomics.wait(sharedBufferView, 0, 0);
if (status !== 'ok' && status !== 'not-equal') throw new Error('Internal error: Atomics.wait() failed: ' + status);
let { message: { id: id2, resolve, reject, properties } } = worker_threads!.receiveMessageOnPort(mainPort)!;
if (id !== id2) throw new Error(`Internal error: Expected id ${id} but got id ${id2}`);
if (reject) {
applyProperties(reject, properties);
throw reject;
}
return resolve;
};
// Calling unref() on a worker will allow the thread to exit if it's the last
// only active handle in the event system. This means node will still exit
// when there are no more event handlers from the main thread. So there's no
// need to have a "stop()" function.
worker.unref();
return {
buildSync(options) {
validateBuildSyncOptions(options);
return runCallSync('build', [options]);
},
transformSync(input, options) {
return runCallSync('transform', [input, options]);
},
formatMessagesSync(messages, options) {
return runCallSync('formatMessages', [messages, options]);
},
};
};
let startSyncServiceWorker = () => {
let workerPort: import('worker_threads').MessagePort = worker_threads!.workerData.workerPort;
let parentPort = worker_threads!.parentPort!;
let service = ensureServiceIsRunning();
// Take the default working directory from the main thread because we want it
// to be consistent. This will be the working directory that was current at
// the time the "esbuild" package was first imported.
defaultWD = worker_threads!.workerData.defaultWD;
// MessagePort doesn't copy the properties of Error objects. We still want
// error objects to have extra properties such as "warnings" so implement the
// property copying manually.
let extractProperties = (object: any): Record<string, any> => {
let properties: Record<string, any> = {};
if (object && typeof object === 'object') {
for (let key in object) {
properties[key] = object[key];
}
}
return properties;
};
parentPort.on('message', (msg: MainToWorkerMessage) => {
(async () => {
let { sharedBuffer, id, command, args } = msg;
let sharedBufferView = new Int32Array(sharedBuffer);
try {
if (command === 'build') {
workerPort.postMessage({ id, resolve: await service.build(args[0]) });
} else if (command === 'transform') {
workerPort.postMessage({ id, resolve: await service.transform(args[0], args[1]) });
} else if (command === 'formatMessages') {
workerPort.postMessage({ id, resolve: await service.formatMessages(args[0], args[1]) });
} else {
throw new Error(`Invalid command: ${command}`);
}
} catch (reject) {
workerPort.postMessage({ id, reject, properties: extractProperties(reject) });
}
// The message has already been posted by this point, so it should be
// safe to wake the main thread. The main thread should always get the
// message we sent above.
// First, change the shared value. That way if the main thread attempts
// to wait for us after this point, the wait will fail because the shared
// value has changed.
Atomics.add(sharedBufferView, 0, 1);
// Then, wake the main thread. This handles the case where the main
// thread was already waiting for us before the shared value was changed.
Atomics.notify(sharedBufferView, 0, Infinity);
})();
});
};
// If we're in the worker thread, start the worker code
if (worker_threads && !worker_threads.isMainThread) {
startSyncServiceWorker();
}