From 384f34845371566d40bd3b2841ff9da9a58a06a1 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 12 Jun 2026 17:18:09 -0700 Subject: [PATCH] feat(wasm): support source phase imports and enable js-string builtins Add support for the source phase import proposal for `.wasm` files, giving the compiled `WebAssembly.Module` so consumers own instantiation: import source mod from './mod.wasm' const instance = await WebAssembly.instantiate(mod, imports) The dynamic `import.source('./mod.wasm')` form is supported too. This is the standard alternative to `?init` for instantiation control. Source phase imports are polyfilled on top of Vite's existing module pipeline: `import source x from './m.wasm'` is rewritten to a plain import of `./m.wasm?source`, whose module resolves to a compiled `WebAssembly.Module`. The dynamic form additionally unwraps the namespace default to match native semantics. This avoids relying on native `import source` support in browsers or Node (SSR compiles via node:fs), so it works across all supported runtimes. Also enable the JS String Builtins and Imported String Constants proposals on compile/instantiate, matching the WebAssembly/ES Module Integration loader. --- docs/guide/features.md | 38 +++++- packages/vite/src/node/plugins/wasm.ts | 173 ++++++++++++++++++++++--- playground/wasm/__tests__/wasm.spec.ts | 18 +++ playground/wasm/index.html | 35 +++++ 4 files changed, 242 insertions(+), 22 deletions(-) diff --git a/docs/guide/features.md b/docs/guide/features.md index f32d37bd457980..6c6f632ba2babf 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -663,7 +663,7 @@ These rules are enforced to prevent accidentally importing files that are not in ## WebAssembly -Vite supports importing pre-compiled `.wasm` files in two ways: directly as an [ES module](#esm-integration) when you only need the module's exports, or with [`?init`](#manual-initialization) when you need explicit control over instantiation. +Vite supports importing pre-compiled `.wasm` files directly as an [ES module](#esm-integration) when you only need the module's exports. When you need explicit control over instantiation, import the compiled module with a [source phase import](#source-phase-imports), or use [`?init`](#manual-initialization). ### ESM Integration @@ -679,6 +679,40 @@ If the WebAssembly module declares imports of its own, Vite resolves them from J This follows the [WebAssembly/ES Module Integration proposal](https://github.com/WebAssembly/esm-integration). Because a WebAssembly module is instantiated asynchronously, a directly imported `.wasm` file behaves as an async module and requires top-level `await` support. +### Source Phase Imports + +A [source phase import](https://github.com/tc39/proposal-source-phase-imports) gives you the compiled [`WebAssembly.Module`](https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Module) without instantiating it, leaving instantiation entirely to you. This is the standard way to take control over instantiation and is the recommended alternative to [`?init`](#manual-initialization): + +```js +import source mod from './example.wasm' + +// `mod` is a `WebAssembly.Module`; instantiate it whenever (and however) you like +const instance = await WebAssembly.instantiate(mod) +instance.exports.test() +``` + +Because you own the `WebAssembly.Instance`, you can satisfy the module's own imports and instantiate it as many times as needed: + +```js +import source mod from './example.wasm' + +const instance = await WebAssembly.instantiate(mod, { + './imports.js': { + someFunc: () => { + /* ... */ + }, + }, +}) +``` + +The dynamic form resolves to the `WebAssembly.Module` as well: + +```js +const mod = await import.source('./example.wasm') +``` + +Modules are compiled with the [JS String Builtins](https://github.com/WebAssembly/js-string-builtins) and [Imported String Constants](https://github.com/WebAssembly/js-string-builtins) proposals enabled, matching the WebAssembly/ES Module Integration loader. + ### Manual Initialization When you need control over when and how the module is instantiated, import it with `?init`. The default export will be an initialization function that returns a Promise of the [`WebAssembly.Instance`](https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Instance): @@ -714,7 +748,7 @@ In the production build, `.wasm` files smaller than `assetInlineLimit` will be i ::: warning For SSR build, Node.js compatible runtimes are only supported -Due to the lack of a universal way to load a file, the internal implementation for both direct `.wasm` imports and `.wasm?init` relies on the `node:fs` module. This means that these features will only work in Node.js compatible runtimes for SSR builds. +Due to the lack of a universal way to load a file, the internal implementation for direct `.wasm` imports, source phase imports, and `.wasm?init` relies on the `node:fs` module. This means that these features will only work in Node.js compatible runtimes for SSR builds. ::: diff --git a/packages/vite/src/node/plugins/wasm.ts b/packages/vite/src/node/plugins/wasm.ts index 77fe1301f63952..61a93c95f03c08 100644 --- a/packages/vite/src/node/plugins/wasm.ts +++ b/packages/vite/src/node/plugins/wasm.ts @@ -1,38 +1,64 @@ import fsp from 'node:fs/promises' import MagicString from 'magic-string' +import { + init as esModuleLexerInit, + parse as parseImports, +} from 'es-module-lexer' import { exactRegex } from 'rolldown/filter' import type { RolldownMagicString } from 'rolldown' import { createToImportMetaURLBasedRelativeRuntime } from '../build' import { type Plugin, perEnvironmentPlugin } from '../plugin' import { cleanUrl } from '../../shared/utils' +import { injectQuery } from '../utils' import { assetUrlRE, fileToUrl } from './asset' const wasmHelperId = '\0vite/wasm-helper.js' const wasmInitRE = /(? { + const urlContent = url.replace(/^data:.*?base64,/, '') + if (typeof Buffer === 'function' && typeof Buffer.from === 'function') { + return Buffer.from(urlContent, 'base64') + } else if (typeof atob === 'function') { + const binaryString = atob(urlContent) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes + } + throw new Error( + 'Failed to decode base64-encoded data URL, Buffer and atob are not supported', + ) +} + +const dataUrlToBytesCode = dataUrlToBytes.toString() + const wasmHelper = async (opts = {}, url: string) => { let result if (url.startsWith('data:')) { - const urlContent = url.replace(/^data:.*?base64,/, '') - let bytes - if (typeof Buffer === 'function' && typeof Buffer.from === 'function') { - bytes = Buffer.from(urlContent, 'base64') - } else if (typeof atob === 'function') { - const binaryString = atob(urlContent) - bytes = new Uint8Array(binaryString.length) - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i) - } - } else { - throw new Error( - 'Failed to decode base64-encoded data URL, Buffer and atob are not supported', - ) - } - result = await WebAssembly.instantiate(bytes, opts) + result = await WebAssembly.instantiate( + dataUrlToBytes(url), + opts, + wasmCompileOptions, + ) } else { result = await instantiateFromUrl(url, opts) } @@ -41,6 +67,18 @@ const wasmHelper = async (opts = {}, url: string) => { const wasmHelperCode = wasmHelper.toString() +// Compiles a `.wasm` to a `WebAssembly.Module` without instantiating it, backing +// the source phase import (`import source mod from './mod.wasm'`). Callers own +// instantiation, mirroring the native source phase import semantics. +const compileWasm = async (url: string) => { + if (url.startsWith('data:')) { + return WebAssembly.compile(dataUrlToBytes(url), wasmCompileOptions) + } + return compileFromUrl(url) +} + +const compileWasmCode = compileWasm.toString() + const instantiateFromUrl = async (url: string, opts?: WebAssembly.Imports) => { // https://github.com/mdn/webassembly-examples/issues/5 // WebAssembly.instantiateStreaming requires the server to provide the @@ -53,15 +91,31 @@ const instantiateFromUrl = async (url: string, opts?: WebAssembly.Imports) => { 'instantiateStreaming' in WebAssembly && contentType.startsWith('application/wasm') ) { - return WebAssembly.instantiateStreaming(response, opts) + return WebAssembly.instantiateStreaming(response, opts, wasmCompileOptions) } else { const buffer = await response.arrayBuffer() - return WebAssembly.instantiate(buffer, opts) + return WebAssembly.instantiate(buffer, opts, wasmCompileOptions) } } const instantiateFromUrlCode = instantiateFromUrl.toString() +const compileFromUrl = async (url: string) => { + const response = await fetch(url) + const contentType = response.headers.get('Content-Type') || '' + if ( + 'compileStreaming' in WebAssembly && + contentType.startsWith('application/wasm') + ) { + return WebAssembly.compileStreaming(response, wasmCompileOptions) + } else { + const buffer = await response.arrayBuffer() + return WebAssembly.compile(buffer, wasmCompileOptions) + } +} + +const compileFromUrlCode = compileFromUrl.toString() + const instantiateFromFile = async ( fileUrlString: string, opts?: WebAssembly.Imports, @@ -69,11 +123,20 @@ const instantiateFromFile = async ( const { readFile } = await import('node:fs/promises') const fileUrl = new URL(fileUrlString, /** #__KEEP__ */ import.meta.url) const buffer = await readFile(fileUrl) - return WebAssembly.instantiate(buffer, opts) + return WebAssembly.instantiate(buffer, opts, wasmCompileOptions) } const instantiateFromFileCode = instantiateFromFile.toString() +const compileFromFile = async (fileUrlString: string) => { + const { readFile } = await import('node:fs/promises') + const fileUrl = new URL(fileUrlString, /** #__KEEP__ */ import.meta.url) + const buffer = await readFile(fileUrl) + return WebAssembly.compile(buffer, wasmCompileOptions) +} + +const compileFromFileCode = compileFromFile.toString() + export const wasmHelperPlugin = (): Plugin => { return perEnvironmentPlugin('vite:wasm-helper', (env) => { return { @@ -86,19 +149,82 @@ export const wasmHelperPlugin = (): Plugin => { }, }, + // Rewrite source phase imports (`import source mod from './mod.wasm'` and + // the dynamic `import.source('./mod.wasm')`) to a plain import of the + // `?source` module, which resolves to a compiled `WebAssembly.Module`. + // This polyfills the proposal on top of Vite's existing module pipeline. + transform: { + filter: { code: wasmSourcePhaseRE }, + async handler(code) { + await esModuleLexerInit + let imports: ReturnType[0] + try { + ;[imports] = parseImports(code) + } catch { + return + } + + let s: MagicString | undefined + for (const imp of imports) { + // 4 = `import source ...`, 5 = `import.source(...)` + if (imp.t !== 4 && imp.t !== 5) continue + const specifier = imp.n + if (specifier === undefined) continue + // Source phase imports are only meaningful for `.wasm` in Vite. + if (!wasmDirectRE.test(cleanUrl(specifier))) continue + + s ??= new MagicString(code) + const sourced = injectQuery(specifier, 'source') + if (imp.t === 5) { + // `import.source(spec)` resolves to the module source object (the + // `WebAssembly.Module`), whereas `import(spec)` resolves to the + // namespace, so rewrite to `import(spec?source).then(m => m.default)`. + s.remove(imp.ss + 'import'.length, imp.d) + // For dynamic imports the specifier span includes the quotes. + s.update(imp.s, imp.e, JSON.stringify(sourced)) + s.appendLeft(imp.se, '.then((m) => m.default)') + } else { + // Drop the `source` phase keyword, keeping `import x from ...`. + const keyword = imp.ss + 'import'.length + const offset = code.slice(keyword, imp.s).indexOf('source') + s.remove(keyword + offset, keyword + offset + 'source'.length + 1) + s.update(imp.s, imp.e, sourced) + } + } + + if (!s) return + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + }, + }, + load: { - filter: { id: [exactRegex(wasmHelperId), wasmInitRE, wasmDirectRE] }, + filter: { + id: [ + exactRegex(wasmHelperId), + wasmInitRE, + wasmSourceRE, + wasmDirectRE, + ], + }, async handler(id) { const ssr = this.environment.config.consumer === 'server' if (id === wasmHelperId) { return ` +const wasmCompileOptions = ${JSON.stringify(wasmCompileOptions)} +const dataUrlToBytes = ${dataUrlToBytesCode} const instantiateFromUrl = ${ssr ? instantiateFromFileCode : instantiateFromUrlCode} +const compileFromUrl = ${ssr ? compileFromFileCode : compileFromUrlCode} export default ${wasmHelperCode} +export const compileWasm = ${compileWasmCode} ` } const isInit = wasmInitRE.test(id) + const isSource = wasmSourceRE.test(id) const cleanedId = id.split('?')[0] let url = await fileToUrl(this, cleanedId, ssr) assetUrlRE.lastIndex = 0 @@ -113,6 +239,13 @@ export default ${wasmHelperCode} ` } + if (isSource) { + return ` +import { compileWasm } from "${wasmHelperId}" +export default await compileWasm(${JSON.stringify(url)}) +` + } + // Direct .wasm import (WASM ESM Integration) const wasmInfo = await parseWasm(cleanedId) const glueCode = generateGlueCode(wasmInfo, { diff --git a/playground/wasm/__tests__/wasm.spec.ts b/playground/wasm/__tests__/wasm.spec.ts index 86cb015b86a84f..bbb9aac52e3000 100644 --- a/playground/wasm/__tests__/wasm.spec.ts +++ b/playground/wasm/__tests__/wasm.spec.ts @@ -41,3 +41,21 @@ test('direct wasm import with wasm imports', async () => { .poll(() => page.textContent('.direct-wasm-with-imports .result')) .toMatch('42') }) + +test('source phase import yields a WebAssembly.Module', async () => { + await expect + .poll(() => page.textContent('.source-phase-wasm .result')) + .toMatch('3') +}) + +test('dynamic source phase import yields a WebAssembly.Module', async () => { + await expect + .poll(() => page.textContent('.source-phase-wasm-dynamic .result')) + .toMatch('7') +}) + +test('source phase import instantiated with imports', async () => { + await expect + .poll(() => page.textContent('.source-phase-wasm-with-imports .result')) + .toMatch('42') +}) diff --git a/playground/wasm/index.html b/playground/wasm/index.html index f2d3c226cc5022..0ef6dd18dad593 100644 --- a/playground/wasm/index.html +++ b/playground/wasm/index.html @@ -38,6 +38,21 @@

Direct wasm import with wasm imports, result should be 42

+
+

Source phase import, result should be 3

+ +
+ +
+

Dynamic source phase import, result should be 7

+ +
+ +
+

Source phase import instantiated with imports, result should be 42

+ +
+