1import * as types from "../shared/types";
2import * as common from "../shared/common";
3import * as ourselves from "./node"
4import { ESBUILD_BINARY_PATH, generateBinPath } from "./node-platform";
5
6import child_process = require('child_process');
7import crypto = require('crypto');
8import path = require('path');
9import fs = require('fs');
10import os = require('os');
11import tty = require('tty');
12
13declare const ESBUILD_VERSION: string;
14
15// This file is used for both the "esbuild" package and the "esbuild-wasm"
16// package. "WASM" will be true for "esbuild-wasm" and false for "esbuild".
17declare const WASM: boolean;
18
19let worker_threads: typeof import('worker_threads') | undefined;
20
21if (process.env.ESBUILD_WORKER_THREADS !== '0') {
22  // Don't crash if the "worker_threads" library isn't present
23  try {
24    worker_threads = require('worker_threads');
25  } catch {
26  }
27
28  // Creating a worker in certain node versions doesn't work. The specific
29  // error is "TypeError: MessagePort was found in message but not listed
30  // in transferList". See: https://github.com/nodejs/node/issues/32250.
31  // We just pretend worker threads are unavailable in these cases.
32  let [major, minor] = process.versions.node.split('.');
33  if (
34    // <v12.17.0 does not work
35    +major < 12 || (+major === 12 && +minor < 17)
36
37    // >=v13.0.0 && <v13.13.0 also does not work
38    || (+major === 13 && +minor < 13)
39  ) {
40    worker_threads = void 0;
41  }
42}
43
44// This should only be true if this is our internal worker thread. We want this
45// library to be usable from other people's worker threads, so we should not be
46// checking for "isMainThread".
47let isInternalWorkerThread = worker_threads?.workerData?.esbuildVersion === ESBUILD_VERSION;
48
49let esbuildCommandAndArgs = (): [string, string[]] => {
50  // Try to have a nice error message when people accidentally bundle esbuild
51  // without providing an explicit path to the binary, or when using WebAssembly.
52  if ((!ESBUILD_BINARY_PATH || WASM) && (path.basename(__filename) !== 'main.js' || path.basename(__dirname) !== 'lib')) {
53    throw new Error(
54      `The esbuild JavaScript API cannot be bundled. Please mark the "esbuild" ` +
55      `package as external so it's not included in the bundle.\n` +
56      `\n` +
57      `More information: The file containing the code for esbuild's JavaScript ` +
58      `API (${__filename}) does not appear to be inside the esbuild package on ` +
59      `the file system, which usually means that the esbuild package was bundled ` +
60      `into another file. This is problematic because the API needs to run a ` +
61      `binary executable inside the esbuild package which is located using a ` +
62      `relative path from the API code to the executable. If the esbuild package ` +
63      `is bundled, the relative path will be incorrect and the executable won't ` +
64      `be found.`);
65  }
66
67  if (WASM) {
68    return ['node', [path.join(__dirname, '..', 'bin', 'esbuild')]];
69  }
70
71  return [generateBinPath(), []];
72};
73
74// Return true if stderr is a TTY
75let isTTY = () => tty.isatty(2);
76
77let fsSync: common.StreamFS = {
78  readFile(tempFile, callback) {
79    try {
80      let contents = fs.readFileSync(tempFile, 'utf8');
81      try {
82        fs.unlinkSync(tempFile);
83      } catch {
84      }
85      callback(null, contents);
86    } catch (err: any) {
87      callback(err, null);
88    }
89  },
90  writeFile(contents, callback) {
91    try {
92      let tempFile = randomFileName();
93      fs.writeFileSync(tempFile, contents);
94      callback(tempFile);
95    } catch {
96      callback(null);
97    }
98  },
99};
100
101let fsAsync: common.StreamFS = {
102  readFile(tempFile, callback) {
103    try {
104      fs.readFile(tempFile, 'utf8', (err, contents) => {
105        try {
106          fs.unlink(tempFile, () => callback(err, contents));
107        } catch {
108          callback(err, contents);
109        }
110      });
111    } catch (err: any) {
112      callback(err, null);
113    }
114  },
115  writeFile(contents, callback) {
116    try {
117      let tempFile = randomFileName();
118      fs.writeFile(tempFile, contents, err =>
119        err !== null ? callback(null) : callback(tempFile));
120    } catch {
121      callback(null);
122    }
123  },
124};
125
126export let version = ESBUILD_VERSION;
127
128export let build: typeof types.build = (options: types.BuildOptions): Promise<any> =>
129  ensureServiceIsRunning().build(options);
130
131export let serve: typeof types.serve = (serveOptions, buildOptions) =>
132  ensureServiceIsRunning().serve(serveOptions, buildOptions);
133
134export let transform: typeof types.transform = (input, options) =>
135  ensureServiceIsRunning().transform(input, options);
136
137export let formatMessages: typeof types.formatMessages = (messages, options) =>
138  ensureServiceIsRunning().formatMessages(messages, options);
139
140export let analyzeMetafile: typeof types.analyzeMetafile = (messages, options) =>
141  ensureServiceIsRunning().analyzeMetafile(messages, options);
142
143export let buildSync: typeof types.buildSync = (options: types.BuildOptions): any => {
144  // Try using a long-lived worker thread to avoid repeated start-up overhead
145  if (worker_threads && !isInternalWorkerThread) {
146    if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
147    return workerThreadService.buildSync(options);
148  }
149
150  let result: types.BuildResult;
151  runServiceSync(service => service.buildOrServe({
152    callName: 'buildSync',
153    refs: null,
154    serveOptions: null,
155    options,
156    isTTY: isTTY(),
157    defaultWD,
158    callback: (err, res) => { if (err) throw err; result = res as types.BuildResult },
159  }));
160  return result!;
161};
162
163export let transformSync: typeof types.transformSync = (input, options) => {
164  // Try using a long-lived worker thread to avoid repeated start-up overhead
165  if (worker_threads && !isInternalWorkerThread) {
166    if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
167    return workerThreadService.transformSync(input, options);
168  }
169
170  let result: types.TransformResult;
171  runServiceSync(service => service.transform({
172    callName: 'transformSync',
173    refs: null,
174    input,
175    options: options || {},
176    isTTY: isTTY(),
177    fs: fsSync,
178    callback: (err, res) => { if (err) throw err; result = res! },
179  }));
180  return result!;
181};
182
183export let formatMessagesSync: typeof types.formatMessagesSync = (messages, options) => {
184  // Try using a long-lived worker thread to avoid repeated start-up overhead
185  if (worker_threads && !isInternalWorkerThread) {
186    if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
187    return workerThreadService.formatMessagesSync(messages, options);
188  }
189
190  let result: string[];
191  runServiceSync(service => service.formatMessages({
192    callName: 'formatMessagesSync',
193    refs: null,
194    messages,
195    options,
196    callback: (err, res) => { if (err) throw err; result = res! },
197  }));
198  return result!;
199};
200
201export let analyzeMetafileSync: typeof types.analyzeMetafileSync = (metafile, options) => {
202  // Try using a long-lived worker thread to avoid repeated start-up overhead
203  if (worker_threads && !isInternalWorkerThread) {
204    if (!workerThreadService) workerThreadService = startWorkerThreadService(worker_threads);
205    return workerThreadService.analyzeMetafileSync(metafile, options);
206  }
207
208  let result: string;
209  runServiceSync(service => service.analyzeMetafile({
210    callName: 'analyzeMetafileSync',
211    refs: null,
212    metafile: typeof metafile === 'string' ? metafile : JSON.stringify(metafile),
213    options,
214    callback: (err, res) => { if (err) throw err; result = res! },
215  }));
216  return result!;
217};
218
219let initializeWasCalled = false;
220
221export let initialize: typeof types.initialize = options => {
222  options = common.validateInitializeOptions(options || {});
223  if (options.wasmURL) throw new Error(`The "wasmURL" option only works in the browser`)
224  if (options.worker) throw new Error(`The "worker" option only works in the browser`)
225  if (initializeWasCalled) throw new Error('Cannot call "initialize" more than once')
226  ensureServiceIsRunning()
227  initializeWasCalled = true
228  return Promise.resolve();
229}
230
231interface Service {
232  build: typeof types.build;
233  serve: typeof types.serve;
234  transform: typeof types.transform;
235  formatMessages: typeof types.formatMessages;
236  analyzeMetafile: typeof types.analyzeMetafile;
237}
238
239let defaultWD = process.cwd();
240let longLivedService: Service | undefined;
241
242let ensureServiceIsRunning = (): Service => {
243  if (longLivedService) return longLivedService;
244  let [command, args] = esbuildCommandAndArgs();
245  let child = child_process.spawn(command, args.concat(`--service=${ESBUILD_VERSION}`, '--ping'), {
246    windowsHide: true,
247    stdio: ['pipe', 'pipe', 'inherit'],
248    cwd: defaultWD,
249  });
250
251  let { readFromStdout, afterClose, service } = common.createChannel({
252    writeToStdin(bytes) {
253      child.stdin.write(bytes);
254    },
255    readFileSync: fs.readFileSync,
256    isSync: false,
257    isBrowser: false,
258    esbuild: ourselves,
259  });
260
261  const stdin: typeof child.stdin & { unref?(): void } = child.stdin;
262  const stdout: typeof child.stdout & { unref?(): void } = child.stdout;
263
264  stdout.on('data', readFromStdout);
265  stdout.on('end', afterClose);
266
267  let refCount = 0;
268  child.unref();
269  if (stdin.unref) {
270    stdin.unref();
271  }
272  if (stdout.unref) {
273    stdout.unref();
274  }
275
276  const refs: common.Refs = {
277    ref() { if (++refCount === 1) child.ref(); },
278    unref() { if (--refCount === 0) child.unref(); },
279  }
280
281  longLivedService = {
282    build: (options: types.BuildOptions): Promise<any> => {
283      return new Promise<types.BuildResult>((resolve, reject) => {
284        service.buildOrServe({
285          callName: 'build',
286          refs,
287          serveOptions: null,
288          options,
289          isTTY: isTTY(),
290          defaultWD,
291          callback: (err, res) => err ? reject(err) : resolve(res as types.BuildResult),
292        })
293      })
294    },
295    serve: (serveOptions, buildOptions) => {
296      if (serveOptions === null || typeof serveOptions !== 'object')
297        throw new Error('The first argument must be an object')
298      return new Promise((resolve, reject) =>
299        service.buildOrServe({
300          callName: 'serve',
301          refs,
302          serveOptions,
303          options: buildOptions,
304          isTTY: isTTY(),
305          defaultWD, callback: (err, res) => err ? reject(err) : resolve(res as types.ServeResult),
306        }))
307    },
308    transform: (input, options) => {
309      return new Promise((resolve, reject) =>
310        service.transform({
311          callName: 'transform',
312          refs,
313          input,
314          options: options || {},
315          isTTY: isTTY(),
316          fs: fsAsync,
317          callback: (err, res) => err ? reject(err) : resolve(res!),
318        }));
319    },
320    formatMessages: (messages, options) => {
321      return new Promise((resolve, reject) =>
322        service.formatMessages({
323          callName: 'formatMessages',
324          refs,
325          messages,
326          options,
327          callback: (err, res) => err ? reject(err) : resolve(res!),
328        }));
329    },
330    analyzeMetafile: (metafile, options) => {
331      return new Promise((resolve, reject) =>
332        service.analyzeMetafile({
333          callName: 'analyzeMetafile',
334          refs,
335          metafile: typeof metafile === 'string' ? metafile : JSON.stringify(metafile),
336          options,
337          callback: (err, res) => err ? reject(err) : resolve(res!),
338        }));
339    },
340  };
341  return longLivedService;
342}
343
344let runServiceSync = (callback: (service: common.StreamService) => void): void => {
345  let [command, args] = esbuildCommandAndArgs();
346  let stdin = new Uint8Array();
347  let { readFromStdout, afterClose, service } = common.createChannel({
348    writeToStdin(bytes) {
349      if (stdin.length !== 0) throw new Error('Must run at most one command');
350      stdin = bytes;
351    },
352    isSync: true,
353    isBrowser: false,
354    esbuild: ourselves,
355  });
356  callback(service);
357  let stdout = child_process.execFileSync(command, args.concat(`--service=${ESBUILD_VERSION}`), {
358    cwd: defaultWD,
359    windowsHide: true,
360    input: stdin,
361
362    // We don't know how large the output could be. If it's too large, the
363    // command will fail with ENOBUFS. Reserve 16mb for now since that feels
364    // like it should be enough. Also allow overriding this with an environment
365    // variable.
366    maxBuffer: +process.env.ESBUILD_MAX_BUFFER! || 16 * 1024 * 1024,
367  });
368  readFromStdout(stdout);
369  afterClose();
370};
371
372let randomFileName = () => {
373  return path.join(os.tmpdir(), `esbuild-${crypto.randomBytes(32).toString('hex')}`);
374};
375
376interface MainToWorkerMessage {
377  sharedBuffer: SharedArrayBuffer;
378  id: number;
379  command: string;
380  args: any[];
381}
382
383interface WorkerThreadService {
384  buildSync(options: types.BuildOptions): types.BuildResult;
385  transformSync: typeof types.transformSync;
386  formatMessagesSync: typeof types.formatMessagesSync;
387  analyzeMetafileSync: typeof types.analyzeMetafileSync;
388}
389
390let workerThreadService: WorkerThreadService | null = null;
391
392let startWorkerThreadService = (worker_threads: typeof import('worker_threads')): WorkerThreadService => {
393  let { port1: mainPort, port2: workerPort } = new worker_threads.MessageChannel();
394  let worker = new worker_threads.Worker(__filename, {
395    workerData: { workerPort, defaultWD, esbuildVersion: ESBUILD_VERSION },
396    transferList: [workerPort],
397
398    // From node's documentation: https://nodejs.org/api/worker_threads.html
399    //
400    //   Take care when launching worker threads from preload scripts (scripts loaded
401    //   and run using the `-r` command line flag). Unless the `execArgv` option is
402    //   explicitly set, new Worker threads automatically inherit the command line flags
403    //   from the running process and will preload the same preload scripts as the main
404    //   thread. If the preload script unconditionally launches a worker thread, every
405    //   thread spawned will spawn another until the application crashes.
406    //
407    execArgv: [],
408  });
409  let nextID = 0;
410  let wasStopped = false;
411
412  // This forbids options which would cause structured clone errors
413  let fakeBuildError = (text: string) => {
414    let error: any = new Error(`Build failed with 1 error:\nerror: ${text}`);
415    let errors: types.Message[] = [{ pluginName: '', text, location: null, notes: [], detail: void 0 }];
416    error.errors = errors;
417    error.warnings = [];
418    return error;
419  };
420  let validateBuildSyncOptions = (options: types.BuildOptions | undefined): void => {
421    if (!options) return
422    let plugins = options.plugins
423    let incremental = options.incremental
424    let watch = options.watch
425    if (plugins && plugins.length > 0) throw fakeBuildError(`Cannot use plugins in synchronous API calls`);
426    if (incremental) throw fakeBuildError(`Cannot use "incremental" with a synchronous build`);
427    if (watch) throw fakeBuildError(`Cannot use "watch" with a synchronous build`);
428  };
429
430  // MessagePort doesn't copy the properties of Error objects. We still want
431  // error objects to have extra properties such as "warnings" so implement the
432  // property copying manually.
433  let applyProperties = (object: any, properties: Record<string, any>): void => {
434    for (let key in properties) {
435      object[key] = properties[key];
436    }
437  };
438
439  let runCallSync = (command: string, args: any[]): any => {
440    if (wasStopped) throw new Error('The service was stopped');
441    let id = nextID++;
442
443    // Make a fresh shared buffer for every request. That way we can't have a
444    // race where a notification from the previous call overlaps with this call.
445    let sharedBuffer = new SharedArrayBuffer(8);
446    let sharedBufferView = new Int32Array(sharedBuffer);
447
448    // Send the message to the worker. Note that the worker could potentially
449    // complete the request before this thread returns from this call.
450    let msg: MainToWorkerMessage = { sharedBuffer, id, command, args };
451    worker.postMessage(msg);
452
453    // If the value hasn't changed (i.e. the request hasn't been completed,
454    // wait until the worker thread notifies us that the request is complete).
455    //
456    // Otherwise, if the value has changed, the request has already been
457    // completed. Don't wait in that case because the notification may never
458    // arrive if it has already been sent.
459    let status = Atomics.wait(sharedBufferView, 0, 0);
460    if (status !== 'ok' && status !== 'not-equal') throw new Error('Internal error: Atomics.wait() failed: ' + status);
461
462    let { message: { id: id2, resolve, reject, properties } } = worker_threads!.receiveMessageOnPort(mainPort)!;
463    if (id !== id2) throw new Error(`Internal error: Expected id ${id} but got id ${id2}`);
464    if (reject) {
465      applyProperties(reject, properties);
466      throw reject;
467    }
468    return resolve;
469  };
470
471  // Calling unref() on a worker will allow the thread to exit if it's the last
472  // only active handle in the event system. This means node will still exit
473  // when there are no more event handlers from the main thread. So there's no
474  // need to have a "stop()" function.
475  worker.unref();
476
477  return {
478    buildSync(options) {
479      validateBuildSyncOptions(options);
480      return runCallSync('build', [options]);
481    },
482    transformSync(input, options) {
483      return runCallSync('transform', [input, options]);
484    },
485    formatMessagesSync(messages, options) {
486      return runCallSync('formatMessages', [messages, options]);
487    },
488    analyzeMetafileSync(metafile, options) {
489      return runCallSync('analyzeMetafile', [metafile, options]);
490    },
491  };
492};
493
494let startSyncServiceWorker = () => {
495  let workerPort: import('worker_threads').MessagePort = worker_threads!.workerData.workerPort;
496  let parentPort = worker_threads!.parentPort!;
497  let service = ensureServiceIsRunning();
498
499  // Take the default working directory from the main thread because we want it
500  // to be consistent. This will be the working directory that was current at
501  // the time the "esbuild" package was first imported.
502  defaultWD = worker_threads!.workerData.defaultWD;
503
504  // MessagePort doesn't copy the properties of Error objects. We still want
505  // error objects to have extra properties such as "warnings" so implement the
506  // property copying manually.
507  let extractProperties = (object: any): Record<string, any> => {
508    let properties: Record<string, any> = {};
509    if (object && typeof object === 'object') {
510      for (let key in object) {
511        properties[key] = object[key];
512      }
513    }
514    return properties;
515  };
516
517  parentPort.on('message', (msg: MainToWorkerMessage) => {
518    (async () => {
519      let { sharedBuffer, id, command, args } = msg;
520      let sharedBufferView = new Int32Array(sharedBuffer);
521
522      try {
523        switch (command) {
524          case 'build':
525            workerPort.postMessage({ id, resolve: await service.build(args[0]) });
526            break;
527
528          case 'transform':
529            workerPort.postMessage({ id, resolve: await service.transform(args[0], args[1]) });
530            break;
531
532          case 'formatMessages':
533            workerPort.postMessage({ id, resolve: await service.formatMessages(args[0], args[1]) });
534            break;
535
536          case 'analyzeMetafile':
537            workerPort.postMessage({ id, resolve: await service.analyzeMetafile(args[0], args[1]) });
538            break;
539
540          default:
541            throw new Error(`Invalid command: ${command}`);
542        }
543      } catch (reject) {
544        workerPort.postMessage({ id, reject, properties: extractProperties(reject) });
545      }
546
547      // The message has already been posted by this point, so it should be
548      // safe to wake the main thread. The main thread should always get the
549      // message we sent above.
550
551      // First, change the shared value. That way if the main thread attempts
552      // to wait for us after this point, the wait will fail because the shared
553      // value has changed.
554      Atomics.add(sharedBufferView, 0, 1);
555
556      // Then, wake the main thread. This handles the case where the main
557      // thread was already waiting for us before the shared value was changed.
558      Atomics.notify(sharedBufferView, 0, Infinity);
559    })();
560  });
561};
562
563// If we're in the worker thread, start the worker code
564if (isInternalWorkerThread) {
565  startSyncServiceWorker();
566}
567