Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
2 changes: 1 addition & 1 deletion packages/vite/src/node/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1404,7 +1404,7 @@ export function toOutputFilePathInJS(
return result
}
}
if (relative && !ssr) {
Copy link
Author

@upsuper upsuper Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to admit I have no idea why this condition was written this way before. I can see it was initially added in #8762 but couldn't figure out why it was there. Changing this also doesn't seem to fail any test, so I suppose it isn't very critical.

I think it makes more sense for SSR to always use relative path for building. While client code can use either absolute path or relative path, server code almost definitely need relative path, since files have to be accessed through filesystem rather than network request, and the current directory the process starts from is often not reliable for resolving paths.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is to generate the same path in both client build and SSR build. That is the expected behavior by meta-frameworks. The failure in the downstream project looks like this: https://github.com/vitejs/vite-ecosystem-ci/actions/runs/19456693621/job/55671884733#step:7:508

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. This is quite challenging. There are three potential solutions I have in mind:

  • Only use relative if ssrEmitAssets is set.
  • Only use relative if the file is a .wasm file.
  • A combination of both (use relative if both emitting is enabled and the file is a .wasm file).

The main conflict here is probably that WebAssembly files, while can be considered as assets given they are binary files, also involve in computation, unlike most other assets where the code really just need a reference to pass to the browser.

That being said, there could still be cases where ssr code wants to read from assets, for example, a page that renders background color based on an image asset. Those are probably more edge cases, though.

I'm leaning towards the first option so that we don't treat wasm too specially, and if one decides that ssr should emit assets, it could be a good signal that the code may want to read from it rather than just passing a reference to browser.

WDYT? Do you have any other options in mind?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, some meta-frameworks uses ssrEmitAssets: false to avoid processing the assets twice while expecting the URL to reference the asset's URL (which I think needs to be fixed in the future though).

I think we need to treat this PR's case specially for now. We need to keep the normal wasm asset URLs as-is for compatibility.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By “if ssrEmitAssets is set”, I mean ssrEmitAssets === true, since otherwise the wasm file wouldn't even present.

I can look into treating it specially and see how it goes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By “if ssrEmitAssets is set”, I mean ssrEmitAssets === true, since otherwise the wasm file wouldn't even present.

Some meta-frameworks uses ssrEmitAssets: true, so that doesn't work as well.

if (relative || ssr) {
return toRelative(filename, hostId)
}
return joinUrlSegments(decodedBase, filename)
Expand Down
78 changes: 59 additions & 19 deletions packages/vite/src/node/plugins/wasm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { fileURLToPath, pathToFileURL } from 'node:url'
import { readFile } from 'node:fs/promises'
import { exactRegex } from '@rolldown/pluginutils'
import type { Plugin } from '../plugin'
import { fsPathFromId } from '../utils'
import { FS_PREFIX } from '../constants'
import { fileToUrl } from './asset'

const wasmHelperId = '\0vite/wasm-helper.js'
Expand All @@ -26,28 +30,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 = (): Plugin => {
return {
name: 'vite:wasm-helper',
Expand All @@ -62,12 +85,29 @@ export const wasmHelperPlugin = (): 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 && url.startsWith(FS_PREFIX)) {
url = pathToFileURL(fsPathFromId(id)).href
}
return `
import initWasm from "${wasmHelperId}"
export default opts => initWasm(opts, ${JSON.stringify(url)})
Expand Down
42 changes: 42 additions & 0 deletions playground/ssr-wasm/__tests__/serve.ts
Original file line number Diff line number Diff line change
@@ -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<void> }> {
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)
}
})
}
15 changes: 15 additions & 0 deletions playground/ssr-wasm/__tests__/ssr-wasm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from 'vitest'
import { port } from './serve'
import { 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')
})
15 changes: 15 additions & 0 deletions playground/ssr-wasm/package.json
Original file line number Diff line number Diff line change
@@ -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.1.0"
}
}
60 changes: 60 additions & 0 deletions playground/ssr-wasm/server.js
Original file line number Diff line number Diff line change
@@ -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')
}),
)
}
8 changes: 8 additions & 0 deletions playground/ssr-wasm/src/app.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
1 change: 1 addition & 0 deletions playground/ssr-wasm/src/heavy.wasm
1 change: 1 addition & 0 deletions playground/ssr-wasm/src/light.wasm
12 changes: 12 additions & 0 deletions playground/ssr-wasm/src/static-heavy.js
Original file line number Diff line number Diff line change
@@ -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 `<div class="static-heavy">${result}</div>`
}
12 changes: 12 additions & 0 deletions playground/ssr-wasm/src/static-light.js
Original file line number Diff line number Diff line change
@@ -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 `<div class="static-light">${result}</div>`
}
9 changes: 9 additions & 0 deletions playground/ssr-wasm/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
2 changes: 2 additions & 0 deletions playground/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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,
Expand Down
Loading