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