Skip to content
Open
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
38 changes: 36 additions & 2 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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.

:::

Expand Down
173 changes: 153 additions & 20 deletions packages/vite/src/node/plugins/wasm.ts
Original file line number Diff line number Diff line change
@@ -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 = /(?<![?#].*)\.wasm\?init/
const wasmSourceRE = /(?<![?#].*)\.wasm\?source/
const wasmDirectRE = /(?<![?#].*)\.wasm$/

// Detects the `import source` / `import.source(...)` phase forms cheaply so we
// only run the full lexer pass over modules that actually use them.
const wasmSourcePhaseRE = /\bimport\s+source\b|\bimport\s*\.\s*source\b/

const wasmInitUrlRE: RegExp = /__VITE_WASM_INIT__([\w$]+)__/g

// Enable the JS String Builtins and Imported String Constants proposals on
// compile/instantiate, matching the WebAssembly/ES Module Integration loader.
const wasmCompileOptions = {
builtins: ['js-string'],
importedStringConstants: 'wasm:js/string-constants',
}

// Decodes a base64 wasm data URL to bytes in both browser and SSR runtimes.
const dataUrlToBytes = (url: string) => {
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)
}
Expand All @@ -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
Expand All @@ -53,27 +91,52 @@ 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,
) => {
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 {
Expand All @@ -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<typeof parseImports>[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
Expand All @@ -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, {
Expand Down
18 changes: 18 additions & 0 deletions playground/wasm/__tests__/wasm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
35 changes: 35 additions & 0 deletions playground/wasm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,31 @@ <h3>Direct wasm import with wasm imports, result should be 42</h3>
<span class="result"></span>
</div>

<div class="source-phase-wasm">
<h3>Source phase import, result should be 3</h3>
<span class="result"></span>
</div>

<div class="source-phase-wasm-dynamic">
<h3>Dynamic source phase import, result should be 7</h3>
<span class="result"></span>
</div>

<div class="source-phase-wasm-with-imports">
<h3>Source phase import instantiated with imports, result should be 42</h3>
<span class="result"></span>
</div>

<script type="module">
import light from './light.wasm?init'
import heavy from './heavy.wasm?init'
import myWorker from './worker?worker'
import { add } from './add.wasm'
import { exported_func } from './light-with-imports.wasm'
import { getResult } from './imports.js'
import source addModule from './add.wasm'
import source lightWithImportsModule from './light-with-imports.wasm'
import { imported_func } from './imports.js'

const w = new myWorker()
w.addEventListener('message', (ev) => {
Expand All @@ -56,6 +74,23 @@ <h3>Direct wasm import with wasm imports, result should be 42</h3>
exported_func()
text('.direct-wasm-with-imports .result', getResult())

// Source phase import: `addModule` is the compiled WebAssembly.Module, the
// consumer owns instantiation.
const addInstance = await WebAssembly.instantiate(addModule)
text('.source-phase-wasm .result', addInstance.exports.add(1, 2))

// Dynamic source phase import also yields a WebAssembly.Module.
const dynamicModule = await import.source('./add.wasm')
const dynamicInstance = await WebAssembly.instantiate(dynamicModule)
text('.source-phase-wasm-dynamic .result', dynamicInstance.exports.add(3, 4))

// Source phase import instantiated with the wasm's own imports satisfied.
const lwiInstance = await WebAssembly.instantiate(lightWithImportsModule, {
'./imports.js': { imported_func },
})
lwiInstance.exports.exported_func()
text('.source-phase-wasm-with-imports .result', getResult())

async function testWasm(init, resultElement) {
const { exported_func } = await init({
imports: {
Expand Down
Loading