From ed5e2c3ea499710e2575e5675f75b94d195545cb Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sat, 3 Dec 2022 19:00:44 -0500 Subject: [PATCH] fix #1900: use `WebAssembly.instantiateStreaming` This also closes #1036. --- CHANGELOG.md | 6 ++++ lib/deno/wasm.ts | 30 +++++++++++------- lib/npm/browser.ts | 31 +++++++++++-------- lib/shared/common.ts | 5 ++- lib/shared/types.ts | 2 +- lib/shared/worker.ts | 35 +++++++++++++++++---- scripts/browser/browser-tests.js | 10 ++++-- scripts/browser/esbuild.wasm.bagel | 1 + scripts/browser/index.html | 49 +++++++++++++++++------------- 9 files changed, 115 insertions(+), 54 deletions(-) create mode 120000 scripts/browser/esbuild.wasm.bagel diff --git a/CHANGELOG.md b/CHANGELOG.md index e480bfb8def..a76465c89ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ TypeScript code that does this should now be parsed correctly. +* Use `WebAssembly.instantiateStreaming` if available ([#1036](https://github.com/evanw/esbuild/pull/1036), [#1900](https://github.com/evanw/esbuild/pull/1900)) + + Currently the WebAssembly version of esbuild uses `fetch` to download `esbuild.wasm` and then `WebAssembly.instantiate` to compile it. There is a newer API called `WebAssembly.instantiateStreaming` that both downloads and compiles at the same time, which can be a performance improvement if both downloading and compiling are slow. With this release, esbuild now attempts to use `WebAssembly.instantiateStreaming` and falls back to the original approach if that fails. + + The implementation for this builds on a PR by [@lbwa](https://github.com/lbwa). + * Preserve Webpack comments inside constructor calls ([#2439](https://github.com/evanw/esbuild/issues/2439)) This improves the use of esbuild as a faster TypeScript-to-JavaScript frontend for Webpack, which has special [magic comments](https://webpack.js.org/api/module-methods/#magic-comments) inside `new Worker()` expressions that affect Webpack's behavior. diff --git a/lib/deno/wasm.ts b/lib/deno/wasm.ts index c162b452ccc..87d795fde1a 100644 --- a/lib/deno/wasm.ts +++ b/lib/deno/wasm.ts @@ -76,15 +76,7 @@ export const initialize: typeof types.initialize = async (options) => { await initializePromise; } -const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Module | undefined, useWorker: boolean): Promise => { - let wasm: WebAssembly.Module; - if (wasmModule) { - wasm = wasmModule; - } else { - if (!wasmURL) wasmURL = new URL('esbuild.wasm', import.meta.url).href - wasm = await WebAssembly.compileStreaming(fetch(wasmURL)) - } - +const startRunningService = async (wasmURL: string | URL, wasmModule: WebAssembly.Module | undefined, useWorker: boolean): Promise => { let worker: { onmessage: ((event: any) => void) | null postMessage: (data: Uint8Array | ArrayBuffer | WebAssembly.Module) => void @@ -106,8 +98,21 @@ const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Modu } } - worker.postMessage(wasm) - worker.onmessage = ({ data }) => readFromStdout(data) + let firstMessageResolve: (value: void) => void + let firstMessageReject: (error: any) => void + + const firstMessagePromise = new Promise((resolve, reject) => { + firstMessageResolve = resolve + firstMessageReject = reject + }) + + worker.onmessage = ({ data: error }) => { + worker.onmessage = ({ data }) => readFromStdout(data) + if (error) firstMessageReject(error) + else firstMessageResolve() + } + + worker.postMessage(wasmModule || new URL(wasmURL, import.meta.url).toString()) let { readFromStdout, service } = common.createChannel({ writeToStdin(bytes) { @@ -118,6 +123,9 @@ const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Modu esbuild: ourselves, }) + // This will throw if WebAssembly module instantiation fails + await firstMessagePromise + stopService = () => { worker.terminate() initializePromise = undefined diff --git a/lib/npm/browser.ts b/lib/npm/browser.ts index 292d8f17e9c..e4fa20ebc84 100644 --- a/lib/npm/browser.ts +++ b/lib/npm/browser.ts @@ -71,16 +71,7 @@ export const initialize: typeof types.initialize = options => { return initializePromise; } -const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Module | undefined, useWorker: boolean): Promise => { - let wasm: ArrayBuffer | WebAssembly.Module; - if (wasmModule) { - wasm = wasmModule; - } else { - let res = await fetch(wasmURL); - if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); - wasm = await res.arrayBuffer(); - } - +const startRunningService = async (wasmURL: string | URL, wasmModule: WebAssembly.Module | undefined, useWorker: boolean): Promise => { let worker: { onmessage: ((event: any) => void) | null postMessage: (data: Uint8Array | ArrayBuffer | WebAssembly.Module) => void @@ -102,8 +93,21 @@ const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Modu } } - worker.postMessage(wasm) - worker.onmessage = ({ data }) => readFromStdout(data) + let firstMessageResolve: (value: void) => void + let firstMessageReject: (error: any) => void + + const firstMessagePromise = new Promise((resolve, reject) => { + firstMessageResolve = resolve + firstMessageReject = reject + }) + + worker.onmessage = ({ data: error }) => { + worker.onmessage = ({ data }) => readFromStdout(data) + if (error) firstMessageReject(error) + else firstMessageResolve() + } + + worker.postMessage(wasmModule || new URL(wasmURL, location.href).toString()) let { readFromStdout, service } = common.createChannel({ writeToStdin(bytes) { @@ -114,6 +118,9 @@ const startRunningService = async (wasmURL: string, wasmModule: WebAssembly.Modu esbuild: ourselves, }) + // This will throw if WebAssembly module instantiation fails + await firstMessagePromise + longLivedService = { build: (options: types.BuildOptions): Promise => new Promise((resolve, reject) => diff --git a/lib/shared/common.ts b/lib/shared/common.ts index c0d1d4efb57..49bd406ef32 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -59,6 +59,9 @@ let mustBeStringOrArray = (value: string | string[] | undefined): string | null let mustBeStringOrUint8Array = (value: string | Uint8Array | undefined): string | null => typeof value === 'string' || value instanceof Uint8Array ? null : 'a string or a Uint8Array'; +let mustBeStringOrURL = (value: string | URL | undefined): string | null => + typeof value === 'string' || value instanceof URL ? null : 'a string or a URL'; + type OptionKeys = { [key: string]: boolean }; function getFlag(object: T, keys: OptionKeys, key: K, mustBeFn: (value: T[K]) => string | null): T[K] | undefined { @@ -80,7 +83,7 @@ function checkForInvalidFlags(object: Object, keys: OptionKeys, where: string): export function validateInitializeOptions(options: types.InitializeOptions): types.InitializeOptions { let keys: OptionKeys = Object.create(null); - let wasmURL = getFlag(options, keys, 'wasmURL', mustBeString); + let wasmURL = getFlag(options, keys, 'wasmURL', mustBeStringOrURL); let wasmModule = getFlag(options, keys, 'wasmModule', mustBeWebAssemblyModule); let worker = getFlag(options, keys, 'worker', mustBeBoolean); checkForInvalidFlags(options, keys, 'in initialize() call'); diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 0304463b9be..56426a7274a 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -588,7 +588,7 @@ export interface InitializeOptions { * The URL of the "esbuild.wasm" file. This must be provided when running * esbuild in the browser. */ - wasmURL?: string + wasmURL?: string | URL /** * The result of calling "new WebAssembly.Module(buffer)" where "buffer" diff --git a/lib/shared/worker.ts b/lib/shared/worker.ts index 4005613816c..710e90a13d6 100644 --- a/lib/shared/worker.ts +++ b/lib/shared/worker.ts @@ -9,7 +9,7 @@ interface Go { declare const ESBUILD_VERSION: string; declare function postMessage(message: any): void; -onmessage = ({ data: wasm }: { data: ArrayBuffer | WebAssembly.Module }) => { +onmessage = ({ data: wasm }: { data: WebAssembly.Module | string }) => { let decoder = new TextDecoder() let fs = (globalThis as any).fs @@ -66,11 +66,34 @@ onmessage = ({ data: wasm }: { data: ArrayBuffer | WebAssembly.Module }) => { let go: Go = new (globalThis as any).Go() go.argv = ['', `--service=${ESBUILD_VERSION}`] + // Try to instantiate the module in the worker, then report back to the main thread + tryToInstantiateModule(wasm, go).then( + instance => { + postMessage(null) + go.run(instance) + }, + error => { + postMessage(error) + }, + ) +} + +async function tryToInstantiateModule(wasm: WebAssembly.Module | string, go: Go): Promise { if (wasm instanceof WebAssembly.Module) { - WebAssembly.instantiate(wasm, go.importObject) - .then(instance => go.run(instance)) - } else { - WebAssembly.instantiate(wasm, go.importObject) - .then(({ instance }) => go.run(instance)) + return WebAssembly.instantiate(wasm, go.importObject) + } + + const res = await fetch(wasm) + if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasm)}`); + + // Attempt to use the superior "instantiateStreaming" API first + if ('instantiateStreaming' in WebAssembly && /^application\/wasm($|;)/i.test(res.headers.get('Content-Type') || '')) { + const result = await WebAssembly.instantiateStreaming(res, go.importObject) + return result.instance } + + // Otherwise, fall back to the inferior "instantiate" API + const bytes = await res.arrayBuffer() + const result = await WebAssembly.instantiate(bytes, go.importObject) + return result.instance } diff --git a/scripts/browser/browser-tests.js b/scripts/browser/browser-tests.js index 75e3c6a3434..ad05e84bd06 100644 --- a/scripts/browser/browser-tests.js +++ b/scripts/browser/browser-tests.js @@ -52,6 +52,12 @@ const server = http.createServer((req, res) => { res.end(html) return } + + if (parsed.pathname === '/scripts/browser/esbuild.wasm.bagel') { + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }) + res.end(wasm) + return + } } res.writeHead(404) @@ -81,8 +87,8 @@ async function main() { }) page.exposeFunction('testBegin', args => { - const { esm, min, worker, url } = JSON.parse(args) - console.log(`💬 config: esm=${esm}, min=${min}, worker=${worker}, url=${url}`) + const { esm, min, worker, mime, approach } = JSON.parse(args) + console.log(`💬 config: esm=${esm}, min=${min}, worker=${worker}, mime=${mime}, approach=${approach}`) }) page.exposeFunction('testEnd', args => { diff --git a/scripts/browser/esbuild.wasm.bagel b/scripts/browser/esbuild.wasm.bagel new file mode 120000 index 00000000000..889e97ade83 --- /dev/null +++ b/scripts/browser/esbuild.wasm.bagel @@ -0,0 +1 @@ +../../npm/esbuild-wasm/esbuild.wasm \ No newline at end of file diff --git a/scripts/browser/index.html b/scripts/browser/index.html index 37d63c80eb7..12c9e50b467 100644 --- a/scripts/browser/index.html +++ b/scripts/browser/index.html @@ -187,8 +187,8 @@ async function testStart() { if (!window.testBegin) window.testBegin = args => { - const { esm, min, worker, url } = JSON.parse(args) - console.log(`💬 config: esm=${esm}, min=${min}, worker=${worker}, url=${url}`) + const { esm, min, worker, mime, approach } = JSON.parse(args) + console.log(`💬 config: esm=${esm}, min=${min}, worker=${worker}, mime=${mime}, approach=${approach}`) } if (!window.testEnd) window.testEnd = args => { @@ -206,25 +206,32 @@ for (const esm of [false, true]) { for (const min of [false, true]) { for (const worker of [false, true]) { - for (const url of [false, true]) { - try { - testBegin(JSON.stringify({ esm, min, worker, url })) - const esbuild = esm - ? await import('/npm/esbuild-wasm/esm/browser' + (min ? '.min' : '') + '.js?' + Math.random()) - : await loadScript('/npm/esbuild-wasm/lib/browser' + (min ? '.min' : '') + '.js?' + Math.random()) - const initializePromise = url - ? esbuild.initialize({ wasmURL: '/npm/esbuild-wasm/esbuild.wasm', worker }) - : WebAssembly.compileStreaming(fetch('/npm/esbuild-wasm/esbuild.wasm')).then(module => - esbuild.initialize({ wasmModule: module, worker })) - await initializePromise - await runAllTests({ esbuild }) - testEnd(null) - } catch (e) { - testEnd(JSON.stringify({ - test: e.test || null, - stack: e.stack || null, - error: (e && e.message || e) + '', - })) + for (const mime of ['correct', 'incorrect']) { + for (const approach of ['string', 'url', 'module']) { + try { + testBegin(JSON.stringify({ esm, min, worker, mime, approach })) + const esbuild = esm + ? await import('/npm/esbuild-wasm/esm/browser' + (min ? '.min' : '') + '.js?' + Math.random()) + : await loadScript('/npm/esbuild-wasm/lib/browser' + (min ? '.min' : '') + '.js?' + Math.random()) + const url = mime === 'correct' ? '/npm/esbuild-wasm/esbuild.wasm' : '/scripts/browser/esbuild.wasm.bagel' + const initializePromise = { + string: () => esbuild.initialize({ wasmURL: url, worker }), + url: () => esbuild.initialize({ wasmURL: new URL(url, location.href), worker }), + module: () => fetch(url) + .then(r => r.arrayBuffer()) + .then(bytes => WebAssembly.compile(bytes)) + .then(module => esbuild.initialize({ wasmModule: module, worker })), + }[approach]() + await initializePromise + await runAllTests({ esbuild }) + testEnd(null) + } catch (e) { + testEnd(JSON.stringify({ + test: e.test || null, + stack: e.stack || null, + error: (e && e.message || e) + '', + })) + } } } }