Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use WebAssembly.instantiateStreaming when available #1036

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions lib/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,55 @@ 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;
});
return initializePromise;
}

const startRunningService = async (wasmURL: string, useWorker: boolean): Promise<void> => {
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<void> => {
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 && (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
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))` +
Expand Down Expand Up @@ -87,7 +119,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({
Expand Down
2 changes: 2 additions & 0 deletions lib/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
6 changes: 6 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 8 additions & 2 deletions lib/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

}
99 changes: 71 additions & 28 deletions scripts/browser/browser-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<script type="module">
import * as esbuild from '/esm/browser${min ? '.min' : ''}.js'
${code}
</script>
`;
} else {
page = `
<script src="/lib/browser${min ? '.min' : ''}.js"></script>
<script>${code}</script>
// -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 = `
<script type="module">
import * as esbuild from '/esm/browser${min ? '.min' : ''}.js'
${code}
</script>
`;
} else {
page = `
<script src="/lib/browser${min ? '.min' : ''}.js"></script>
<script>${code}</script>
`;
}
pages[format + (min ? 'Min' : '') + (async ? 'Async' : '') + (instantiateStreamingCase > -1 ? 'Streaming' : '') + (verifyWasmURL ? 'VerifyWasmURL' : '') + (instantiateStreamingFallback ? 'Fallback' : '')] = page;
}
pages[format + (min ? 'Min' : '') + (async ? 'Async' : '')] = page;
}
}
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -264,6 +299,14 @@ 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
} 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}`)
Expand Down