diff --git a/packages/vite/src/node/plugins/wasm.ts b/packages/vite/src/node/plugins/wasm.ts index 7c0ec3e706fd7b..2a60305a154463 100644 --- a/packages/vite/src/node/plugins/wasm.ts +++ b/packages/vite/src/node/plugins/wasm.ts @@ -1,16 +1,25 @@ +import { fileURLToPath, pathToFileURL } from 'node:url' +import { readFile } from 'node:fs/promises' +import MagicString from 'magic-string' import { exactRegex } from '@rolldown/pluginutils' import { viteWasmFallbackPlugin as nativeWasmFallbackPlugin, viteWasmHelperPlugin as nativeWasmHelperPlugin, } from 'rolldown/experimental' +import { createToImportMetaURLBasedRelativeRuntime } from '../build' import type { Plugin } from '../plugin' +import { fsPathFromId } from '../utils' +import { FS_PREFIX } from '../constants' +import { cleanUrl } from '../../shared/utils' import type { ResolvedConfig } from '..' -import { fileToUrl } from './asset' +import { assetUrlRE, fileToUrl } from './asset' const wasmHelperId = '\0vite/wasm-helper.js' const wasmInitRE = /(? { let result if (url.startsWith('data:')) { @@ -31,28 +40,47 @@ const wasmHelper = async (opts = {}, url: string) => { } result = await WebAssembly.instantiate(bytes, opts) } else { - // https://github.com/mdn/webassembly-examples/issues/5 - // WebAssembly.instantiateStreaming requires the server to provide the - // correct MIME type for .wasm files, which unfortunately doesn't work for - // a lot of static file servers, so we just work around it by getting the - // raw buffer. - const response = await fetch(url) - const contentType = response.headers.get('Content-Type') || '' - if ( - 'instantiateStreaming' in WebAssembly && - contentType.startsWith('application/wasm') - ) { - result = await WebAssembly.instantiateStreaming(response, opts) - } else { - const buffer = await response.arrayBuffer() - result = await WebAssembly.instantiate(buffer, opts) - } + result = await instantiateFromUrl(url, opts) } return result.instance } const wasmHelperCode = wasmHelper.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 + // correct MIME type for .wasm files, which unfortunately doesn't work for + // a lot of static file servers, so we just work around it by getting the + // raw buffer. + const response = await fetch(url) + const contentType = response.headers.get('Content-Type') || '' + if ( + 'instantiateStreaming' in WebAssembly && + contentType.startsWith('application/wasm') + ) { + return WebAssembly.instantiateStreaming(response, opts) + } else { + const buffer = await response.arrayBuffer() + return WebAssembly.instantiate(buffer, opts) + } +} + +const instantiateFromUrlCode = instantiateFromUrl.toString() + +const instantiateFromFile = async (url: string, opts?: WebAssembly.Imports) => { + let fsPath = url + if (url.startsWith('file:')) { + fsPath = fileURLToPath(url) + } else if (url.startsWith('/')) { + fsPath = url.slice(1) + } + const buffer = await readFile(fsPath) + return WebAssembly.instantiate(buffer, opts) +} + +const instantiateFromFileCode = instantiateFromFile.toString() + export const wasmHelperPlugin = (config: ResolvedConfig): Plugin => { if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return nativeWasmHelperPlugin({ @@ -73,18 +101,74 @@ export const wasmHelperPlugin = (config: ResolvedConfig): Plugin => { load: { filter: { id: [exactRegex(wasmHelperId), wasmInitRE] }, async handler(id) { + const isServer = this.environment.config.consumer === 'server' + if (id === wasmHelperId) { - return `export default ${wasmHelperCode}` + if (isServer) { + return ` +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +const instantiateFromUrl = ${instantiateFromFileCode} +export default ${wasmHelperCode} +` + } else { + return ` +const instantiateFromUrl = ${instantiateFromUrlCode} +export default ${wasmHelperCode} +` + } } - const url = await fileToUrl(this, id) - + id = id.split('?')[0] + let url = await fileToUrl(this, id) + if (isServer) { + if (url.startsWith(FS_PREFIX)) { + url = pathToFileURL(fsPathFromId(id)).href + } else if (assetUrlRE.test(url)) { + url = url.replace('__VITE_ASSET__', '__VITE_WASM_INIT__') + } + } return ` import initWasm from "${wasmHelperId}" export default opts => initWasm(opts, ${JSON.stringify(url)}) ` }, }, + + renderChunk(code, chunk, opts) { + if (this.environment.config.consumer !== 'server') { + return null + } + + const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( + opts.format, + this.environment.config.isWorker, + ) + + let match: RegExpExecArray | null + let s: MagicString | undefined + + wasmInitUrlRE.lastIndex = 0 + while ((match = wasmInitUrlRE.exec(code))) { + const [full, referenceId] = match + const file = this.getFileName(referenceId) + chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) + const { runtime } = toRelativeRuntime(file, chunk.fileName) + s ||= new MagicString(code) + s.update(match.index, match.index + full.length, `"+${runtime}+"`) + } + + if (s) { + return { + code: s.toString(), + map: this.environment.config.build.sourcemap + ? s.generateMap({ hires: 'boundary' }) + : null, + } + } else { + return null + } + }, } } diff --git a/playground/ssr-wasm/__tests__/serve.ts b/playground/ssr-wasm/__tests__/serve.ts new file mode 100644 index 00000000000000..95a3e0552cb17a --- /dev/null +++ b/playground/ssr-wasm/__tests__/serve.ts @@ -0,0 +1,42 @@ +// this is automatically detected by playground/vitestSetup.ts and will replace +// the default e2e test serve behavior + +import path from 'node:path' +import kill from 'kill-port' +import { build } from 'vite' +import { hmrPorts, isBuild, ports, rootDir } from '~utils' + +export const port = ports['ssr-wasm'] + +export async function preServe() { + if (isBuild) { + await build({ root: rootDir }) + } +} + +export async function serve(): Promise<{ close(): Promise }> { + await kill(port) + + const { createServer } = await import(path.resolve(rootDir, 'server.js')) + const { app, vite } = await createServer(rootDir, hmrPorts['ssr-wasm']) + + return new Promise((resolve, reject) => { + try { + const server = app.listen(port, () => { + resolve({ + // for test teardown + async close() { + await new Promise((resolve) => { + server.close(resolve) + }) + if (vite) { + await vite.close() + } + }, + }) + }) + } catch (e) { + reject(e) + } + }) +} diff --git a/playground/ssr-wasm/__tests__/ssr-wasm.spec.ts b/playground/ssr-wasm/__tests__/ssr-wasm.spec.ts new file mode 100644 index 00000000000000..5aba47e37dae80 --- /dev/null +++ b/playground/ssr-wasm/__tests__/ssr-wasm.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'vitest' +import { port } from './serve' +import { findAssetFile, isBuild, listAssets, page } from '~utils' + +const url = `http://localhost:${port}` + +test('should work when inlined', async () => { + await page.goto(`${url}/static-light`) + expect(await page.textContent('.static-light')).toMatch('42') +}) + +test('should work when output', async () => { + await page.goto(`${url}/static-heavy`) + expect(await page.textContent('.static-heavy')).toMatch('24') +}) + +test.runIf(isBuild)('should not contain wasm file when inlined', async () => { + const assets = await listAssets() + const lightWasm = assets.find((f) => /light-.+\.wasm$/.test(f)) + expect(lightWasm).toBeUndefined() + + const staticLight = await findAssetFile(/^static-light-.+\.js$/) + expect(staticLight).toContain('data:application/wasm;base64,') +}) + +test.runIf(isBuild)( + 'should contain and reference wasm file when output', + async () => { + const assets = await listAssets() + const heavyWasm = assets.find((f) => /heavy-.+\.wasm$/.test(f)) + expect(heavyWasm).toBeDefined() + + const staticHeavy = await findAssetFile(/^static-heavy-.+\.js$/) + expect(staticHeavy).toContain(heavyWasm) + expect(staticHeavy).not.toContain('data:application/wasm;base64,') + }, +) diff --git a/playground/ssr-wasm/package.json b/playground/ssr-wasm/package.json new file mode 100644 index 00000000000000..5a4e530ea3df6b --- /dev/null +++ b/playground/ssr-wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "@vitejs/test-ssr-wasm", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "node server", + "build": "vite build", + "preview": "NODE_ENV=production node server" + }, + "dependencies": {}, + "devDependencies": { + "express": "^5.2.1" + } +} diff --git a/playground/ssr-wasm/server.js b/playground/ssr-wasm/server.js new file mode 100644 index 00000000000000..14f594694411bc --- /dev/null +++ b/playground/ssr-wasm/server.js @@ -0,0 +1,60 @@ +import express from 'express' + +const isTest = process.env.VITEST +const isProduction = process.env.NODE_ENV === 'production' + +export async function createServer(root = process.cwd(), hmrPort) { + const app = express() + + /** @type {import('vite').ViteDevServer} */ + let vite + if (!isProduction) { + vite = await ( + await import('vite') + ).createServer({ + root, + logLevel: isTest ? 'error' : 'info', + server: { + middlewareMode: true, + watch: { + // During tests we edit the files too fast and sometimes chokidar + // misses change events, so enforce polling for consistency + usePolling: true, + interval: 100, + }, + hmr: { + port: hmrPort, + }, + }, + appType: 'custom', + }) + // use vite's connect instance as middleware + app.use(vite.middlewares) + } + + app.use('*all', async (req, res, next) => { + try { + const url = req.originalUrl + const render = isProduction + ? (await import('./dist/app.js')).render + : (await vite.ssrLoadModule('/src/app.js')).render + const html = await render(url) + res.status(200).set({ 'Content-Type': 'text/html' }).end(html) + } catch (e) { + vite?.ssrFixStacktrace(e) + if (isTest) throw e + console.log(e.stack) + res.status(500).end(e.stack) + } + }) + + return { app, vite } +} + +if (!isTest) { + createServer().then(({ app }) => + app.listen(5173, () => { + console.log('http://localhost:5173') + }), + ) +} diff --git a/playground/ssr-wasm/src/app.js b/playground/ssr-wasm/src/app.js new file mode 100644 index 00000000000000..a1c9b3336bc8cc --- /dev/null +++ b/playground/ssr-wasm/src/app.js @@ -0,0 +1,8 @@ +export async function render(url) { + switch (url) { + case '/static-light': + return (await import('./static-light')).render() + case '/static-heavy': + return (await import('./static-heavy')).render() + } +} diff --git a/playground/ssr-wasm/src/heavy.wasm b/playground/ssr-wasm/src/heavy.wasm new file mode 120000 index 00000000000000..e606d782a550fd --- /dev/null +++ b/playground/ssr-wasm/src/heavy.wasm @@ -0,0 +1 @@ +../../wasm/heavy.wasm \ No newline at end of file diff --git a/playground/ssr-wasm/src/light.wasm b/playground/ssr-wasm/src/light.wasm new file mode 120000 index 00000000000000..24ab4b13e7fd1c --- /dev/null +++ b/playground/ssr-wasm/src/light.wasm @@ -0,0 +1 @@ +../../wasm/light.wasm \ No newline at end of file diff --git a/playground/ssr-wasm/src/static-heavy.js b/playground/ssr-wasm/src/static-heavy.js new file mode 100644 index 00000000000000..49d1772dd30473 --- /dev/null +++ b/playground/ssr-wasm/src/static-heavy.js @@ -0,0 +1,12 @@ +import heavy from './heavy.wasm?init' + +export async function render() { + let result + const { exported_func } = await heavy({ + imports: { + imported_func: (res) => (result = res), + }, + }).then((i) => i.exports) + exported_func() + return `
${result}
` +} diff --git a/playground/ssr-wasm/src/static-light.js b/playground/ssr-wasm/src/static-light.js new file mode 100644 index 00000000000000..18a1ee291b0899 --- /dev/null +++ b/playground/ssr-wasm/src/static-light.js @@ -0,0 +1,12 @@ +import light from './light.wasm?init' + +export async function render() { + let result + const { exported_func } = await light({ + imports: { + imported_func: (res) => (result = res), + }, + }).then((i) => i.exports) + exported_func() + return `
${result}
` +} diff --git a/playground/ssr-wasm/vite.config.ts b/playground/ssr-wasm/vite.config.ts new file mode 100644 index 00000000000000..e02cb3a969afa5 --- /dev/null +++ b/playground/ssr-wasm/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +export default defineConfig({ + build: { + // make cannot emit light.wasm + assetsInlineLimit: 80, + ssr: './src/app.js', + ssrEmitAssets: true, + }, +}) diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 97e4c57455f7b2..5e084e636d61d6 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -34,6 +34,7 @@ export const ports = { 'ssr-html': 9602, 'ssr-noexternal': 9603, 'ssr-pug': 9604, + 'ssr-wasm': 9608, 'ssr-webworker': 9605, 'proxy-bypass': 9606, // not imported but used in `proxy-hmr/vite.config.js` 'proxy-bypass/non-existent-app': 9607, // not imported but used in `proxy-hmr/other-app/vite.config.js` @@ -57,6 +58,7 @@ export const hmrPorts = { 'ssr-html': 24683, 'ssr-noexternal': 24684, 'ssr-pug': 24685, + 'ssr-wasm': 24691, 'css/lightningcss-proxy': 24686, json: 24687, 'ssr-conditions': 24688, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 786c486347cde6..1cd4b8f6af88dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1672,6 +1672,12 @@ importers: playground/ssr-resolve/pkg-module-sync: {} + playground/ssr-wasm: + devDependencies: + express: + specifier: ^5.2.1 + version: 5.2.1(ms@2.1.3) + playground/ssr-webworker: dependencies: '@vitejs/test-browser-exports':