From 5d2a6b96a31fceb9b562f17ba4b66184720b9996 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 22 Mar 2021 17:24:51 -0700 Subject: [PATCH 1/3] Use WebAssembly.instantiateStreaming when available --- lib/browser.ts | 43 ++++++++++++++++++++++++++------ lib/common.ts | 2 ++ lib/types.ts | 6 +++++ lib/worker.ts | 10 ++++++-- scripts/browser/browser-tests.js | 4 +++ 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/browser.ts b/lib/browser.ts index 4b62c16194c..5d25dbccc42 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -42,10 +42,11 @@ export const initialize: typeof types.initialize = options => { options = common.validateInitializeOptions(options || {}); let wasmURL = options.wasmURL; let useWorker = options.worker !== false; + let verifyWasmURL = options.verifyWasmURL !== false; if (!wasmURL) throw new Error('Must provide the "wasmURL" option'); wasmURL += ''; if (initializePromise) throw new Error('Cannot call "initialize" more than once'); - initializePromise = startRunningService(wasmURL, useWorker); + initializePromise = startRunningService(wasmURL, useWorker, verifyWasmURL); initializePromise.catch(() => { // Let the caller try again if this fails initializePromise = void 0; @@ -53,12 +54,35 @@ export const initialize: typeof types.initialize = options => { return initializePromise; } -const startRunningService = async (wasmURL: string, useWorker: boolean): Promise => { - let res = await fetch(wasmURL); - if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); - let wasm = await res.arrayBuffer(); +const startRunningService = async (wasmURL: string, useWorker: boolean, verifyWasmURL: boolean ): Promise => { + let wasm: ArrayBuffer | void; + if ('instantiateStreaming' in WebAssembly) { + if (verifyWasmURL) { + + const resp = await fetch(wasmURL, { + method: "HEAD", + // micro-optimization: try to keep the connection open for longer to reduce the added latency for fetching the WASM + keepalive: true + }); + + if (!resp.ok) { + throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); + } else if (!resp.headers.get("Content-Type")?.includes("application/wasm")) { + let res = await fetch(wasmURL); + if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); + // Log this after so they don't see two logs. + console.info(`Make esbuild-wasm load faster by setting the \"Content-Type\" header to \"application/wasm\" in \"${JSON.stringify(wasmURL)}\". Learn more at https://v8.dev/blog/wasm-code-caching#stream.`) + wasm = await res.arrayBuffer(); + } + } + } else { + let res = await fetch(wasmURL); + if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); + wasm = await res.arrayBuffer(); + } + let code = `{` + - `let global={};` + + `let global={ESBUILD_WASM_URL: ${wasm ? '""' : JSON.stringify(wasmURL)}};` + `for(let o=self;o;o=Object.getPrototypeOf(o))` + `for(let k of Object.getOwnPropertyNames(o))` + `if(!(k in global))` + @@ -87,7 +111,12 @@ const startRunningService = async (wasmURL: string, useWorker: boolean): Promise } } - worker.postMessage(wasm) + if (typeof wasm === 'undefined') { + worker.postMessage(new ArrayBuffer(0)) + } else { + worker.postMessage(wasm) + } + worker.onmessage = ({ data }) => readFromStdout(data) let { readFromStdout, service } = common.createChannel({ diff --git a/lib/common.ts b/lib/common.ts index 3b305c48ce2..3c8d81457fe 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -73,10 +73,12 @@ export function validateInitializeOptions(options: types.InitializeOptions): typ let keys: OptionKeys = Object.create(null); let wasmURL = getFlag(options, keys, 'wasmURL', mustBeString); let worker = getFlag(options, keys, 'worker', mustBeBoolean); + let verifyWasmURL = getFlag(options, keys, 'verifyWasmURL', mustBeBoolean); checkForInvalidFlags(options, keys, 'in startService() call'); return { wasmURL, worker, + verifyWasmURL, }; } diff --git a/lib/types.ts b/lib/types.ts index 3601996a68f..b33eee4e39c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -347,6 +347,12 @@ export interface InitializeOptions { // to avoid blocking the UI thread. This can be disabled by setting "worker" // to false. worker?: boolean + + // For supported browsers, esbuild uses WebAssembly.instantiateStreaming + // to speed up WASM load time. Setting this option to false skips the extra + // HEAD request that validates the "Content-Type" header. This option has no + // effect on Node or browers without WebAssembly.instantiateStreaming. + verifyWasmURL?: boolean } export let version: string; diff --git a/lib/worker.ts b/lib/worker.ts index 3772bc49a7b..7f96ad0a6fe 100644 --- a/lib/worker.ts +++ b/lib/worker.ts @@ -60,6 +60,12 @@ onmessage = ({ data: wasm }) => { let go = new (global as any).Go() go.argv = ['', `--service=${ESBUILD_VERSION}`] - WebAssembly.instantiate(wasm, go.importObject) - .then(({ instance }) => go.run(instance)) + if ('instantiateStreaming' in WebAssembly && (global as any).ESBUILD_WASM_URL) { + WebAssembly.instantiateStreaming(fetch((global as any).ESBUILD_WASM_URL), go.importObject) + .then(({ instance }) => go.run(instance)) + } else { + WebAssembly.instantiate(wasm, go.importObject) + .then(({ instance }) => go.run(instance)) + } + } diff --git a/scripts/browser/browser-tests.js b/scripts/browser/browser-tests.js index 115cc598057..13cf5e7bbf0 100644 --- a/scripts/browser/browser-tests.js +++ b/scripts/browser/browser-tests.js @@ -264,6 +264,10 @@ const server = http.createServer((req, res) => { return } } + } else if (req.method === "HEAD" && req.url === "/esbuild.wasm") { + res.writeHead(200, { 'Content-Type': 'application/wasm' }) + res.end() + return } console.log(`[http] ${req.method} ${req.url}`) From 3bed16aa480c1b7e287cc3c0087552e3ad135240 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 22 Mar 2021 18:03:17 -0700 Subject: [PATCH 2/3] Check for same-origin --- lib/browser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/browser.ts b/lib/browser.ts index 5d25dbccc42..de962a8090a 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -56,7 +56,12 @@ export const initialize: typeof types.initialize = options => { const startRunningService = async (wasmURL: string, useWorker: boolean, verifyWasmURL: boolean ): Promise => { let wasm: ArrayBuffer | void; - if ('instantiateStreaming' in WebAssembly) { + // Per https://webassembly.org/docs/web/#webassemblyinstantiatestreaming, + // Automatically use 'instantiateStreaming' when available and: + // - The Response is CORS-same-origin + // - The Response represents an ok status + // - The Response Matches the `application/wasm` MIME type + if ('instantiateStreaming' in WebAssembly && new URL(wasmURL, location.href).origin === location.origin) { if (verifyWasmURL) { const resp = await fetch(wasmURL, { From 6ffbce60e3241bef15d9b7af6b66561307d6a282 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 22 Mar 2021 19:57:09 -0700 Subject: [PATCH 3/3] Fix & add tests for each case (except different origin?) --- lib/browser.ts | 9 ++- scripts/browser/browser-tests.js | 95 ++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/lib/browser.ts b/lib/browser.ts index de962a8090a..7909a187523 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -56,14 +56,17 @@ export const initialize: typeof types.initialize = options => { const startRunningService = async (wasmURL: string, useWorker: boolean, verifyWasmURL: boolean ): Promise => { let wasm: ArrayBuffer | void; + let url: URL; // Per https://webassembly.org/docs/web/#webassemblyinstantiatestreaming, // Automatically use 'instantiateStreaming' when available and: // - The Response is CORS-same-origin // - The Response represents an ok status // - The Response Matches the `application/wasm` MIME type - if ('instantiateStreaming' in WebAssembly && new URL(wasmURL, location.href).origin === location.origin) { - if (verifyWasmURL) { + if ('instantiateStreaming' in WebAssembly && (url = new URL(wasmURL, location.href), url.origin === location.origin)) { + // If its a relative URL, it must be made absolute since the href of the worker might be a blob + wasmURL = url.toString(); + if (verifyWasmURL) { const resp = await fetch(wasmURL, { method: "HEAD", // micro-optimization: try to keep the connection open for longer to reduce the added latency for fetching the WASM @@ -76,7 +79,7 @@ const startRunningService = async (wasmURL: string, useWorker: boolean, verifyWa let res = await fetch(wasmURL); if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`); // Log this after so they don't see two logs. - console.info(`Make esbuild-wasm load faster by setting the \"Content-Type\" header to \"application/wasm\" in \"${JSON.stringify(wasmURL)}\". Learn more at https://v8.dev/blog/wasm-code-caching#stream.`) + console.info(`Make esbuild-wasm load faster by setting the "Content-Type" header to "application/wasm" in ${JSON.stringify(wasmURL)}. Learn more at https://v8.dev/blog/wasm-code-caching#stream.`) wasm = await res.arrayBuffer(); } } diff --git a/scripts/browser/browser-tests.js b/scripts/browser/browser-tests.js index 13cf5e7bbf0..1c553607c1a 100644 --- a/scripts/browser/browser-tests.js +++ b/scripts/browser/browser-tests.js @@ -180,36 +180,65 @@ let pages = {}; for (let format of ['iife', 'esm']) { for (let min of [false, true]) { for (let async of [false, true]) { - let code = ` - window.testStart = function() { - esbuild.initialize({ - wasmURL: '/esbuild.wasm', - worker: ${async}, - }).then(() => { - return (${runAllTests})({ esbuild }) - }).then(() => { - testDone() - }).catch(e => { - testFail('' + (e && e.stack || e)) - testDone() - }) - } - `; - let page; - if (format === 'esm') { - page = ` - - `; - } else { - page = ` - - + // -1: No instantiate streaming + // 0: Instantiate streaming, verifyWasmURL false + // 1: Instantiate streaming, verifyWasmURL true + // 2: Instantiate streaming, verifyWasmURL true, return wrong content type. It should fallback to WebAssembly.instantiate + // The missing test cases are: + // - Different orign + for (let instantiateStreamingCase of [-1, 0,1,2]) { + const instantiateStreamingFallback = instantiateStreamingCase === 2; + const verifyWasmURL = instantiateStreamingCase > 0; + let code = ` + ${instantiateStreamingCase === -1 ? "delete WebAssembly.instantiateStreaming;" : ""} + let didCallConsoleInfo = false; + let expectConsoleInfo = ${instantiateStreamingFallback}; + let originalConsoleInfo = console.info; + console.info = (...args) => { + // Testing that it specifically logs the correct message + if (args.join("").includes("https://v8.dev/blog/wasm-code-caching#stream")) { + didCallConsoleInfo = true; + // prevent noise in test logs + return; + } + originalConsoleInfo(...args) + } + window.testStart = function() { + esbuild.initialize({ + wasmURL: "${instantiateStreamingFallback ? "/esbuild-wrong-content-type.wasm" : "/esbuild.wasm"}", + worker: ${async}, + verifyWasmURL: ${verifyWasmURL}, + }).then(() => { + return (${runAllTests})({ esbuild }) + }).then(() => { + if (expectConsoleInfo !== didCallConsoleInfo && didCallConsoleInfo) { + testFail("Didn't expect to use WebAssembly.instantiateStreaming") + } else if (expectConsoleInfo !== didCallConsoleInfo && !didCallConsoleInfo) { + testFail("Expected to fallback to WebAssembly.instantiate") + } + testDone() + }).catch(e => { + testFail('' + (e && e.stack || e)) + testDone() + }) + } `; + let page; + if (format === 'esm') { + page = ` + + `; + } else { + page = ` + + + `; + } + pages[format + (min ? 'Min' : '') + (async ? 'Async' : '') + (instantiateStreamingCase > -1 ? 'Streaming' : '') + (verifyWasmURL ? 'VerifyWasmURL' : '') + (instantiateStreamingFallback ? 'Fallback' : '')] = page; } - pages[format + (min ? 'Min' : '') + (async ? 'Async' : '')] = page; } } } @@ -246,6 +275,12 @@ const server = http.createServer((req, res) => { return } + if (req.url === '/esbuild-wrong-content-type.wasm') { + res.writeHead(200, { 'Content-Type': 'application/bagel' }) + res.end(wasm) + return + } + if (req.url.startsWith('/page/')) { let key = req.url.slice('/page/'.length) if (Object.prototype.hasOwnProperty.call(pages, key)) { @@ -268,6 +303,10 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/wasm' }) res.end() return + } else if (req.method === "HEAD" && req.url === "/esbuild-wrong-content-type.wasm") { + res.writeHead(200, { 'Content-Type': 'application/bagel' }) + res.end() + return } console.log(`[http] ${req.method} ${req.url}`)