From 7e9b9cd1405f6a13646e3f25f14afd83b10cfdce Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 17 Sep 2026 19:02:57 +0000 Subject: [PATCH 1/2] fix(ssr): encode whitespace in module runner sourceURL --- .../module-runner/sourcemap/interceptor.ts | 7 +++--- .../fixtures/modules/with space/has-error.js | 3 +++ .../node/ssr/__tests__/ssrStacktrace.spec.ts | 16 ++++++++++++++ packages/vite/src/node/ssr/fetchModule.ts | 8 +++---- .../fixtures/with space/has-error-deep.ts | 7 ++++++ .../__tests__/server-source-maps.spec.ts | 15 +++++++++++++ packages/vite/src/node/ssr/ssrStacktrace.ts | 6 +++-- packages/vite/src/shared/utils.ts | 22 +++++++++++++++++++ 8 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 packages/vite/src/node/ssr/__tests__/fixtures/modules/with space/has-error.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/with space/has-error-deep.ts diff --git a/packages/vite/src/module-runner/sourcemap/interceptor.ts b/packages/vite/src/module-runner/sourcemap/interceptor.ts index aab50ce30ea90d..7216b8d12435bf 100644 --- a/packages/vite/src/module-runner/sourcemap/interceptor.ts +++ b/packages/vite/src/module-runner/sourcemap/interceptor.ts @@ -1,5 +1,5 @@ import type { OriginalMapping } from '@jridgewell/trace-mapping' -import { slash } from '../../shared/utils' +import { decodeSourceURL, slash } from '../../shared/utils' import type { EvaluatedModules } from '../evaluatedModules' import type { ModuleRunner } from '../runner' import { decodeBase64, posixDirname, posixResolve } from '../utils' @@ -102,11 +102,12 @@ function supportRelativeURL(file: string, url: string) { } function getRunnerSourceMap(position: OriginalMapping): CachedMapEntry | null { + const id = decodeSourceURL(position.source!) for (const moduleGraph of evaluatedModulesCache) { - const sourceMap = moduleGraph.getModuleSourceMapById(position.source!) + const sourceMap = moduleGraph.getModuleSourceMapById(id) if (sourceMap) { return { - url: position.source, + url: id, map: sourceMap, vite: true, } diff --git a/packages/vite/src/node/ssr/__tests__/fixtures/modules/with space/has-error.js b/packages/vite/src/node/ssr/__tests__/fixtures/modules/with space/has-error.js new file mode 100644 index 00000000000000..9faaeca94bc04d --- /dev/null +++ b/packages/vite/src/node/ssr/__tests__/fixtures/modules/with space/has-error.js @@ -0,0 +1,3 @@ +export function main() { + throw new Error() +} diff --git a/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts index 422a5d077d9315..c005861fd5de25 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts @@ -28,6 +28,22 @@ test('call rewriteStacktrace twice', async () => { } }) +test('rewrites stacktrace when the module path contains a space', async () => { + const server = await createDevServer() + const mod = await server.ssrLoadModule( + '/fixtures/modules/with space/has-error.js', + ) + try { + mod.main() + expect.unreachable() + } catch (e: any) { + server.ssrFixStacktrace(e) + expect(e.stack.split('\n')[1].replace(root, '')).toBe( + ' at Module.main (fixtures/modules/with space/has-error.js:2:9)', + ) + } +}) + test('outputs message when stacktrace appears to be already rewritten', async () => { const server = await createDevServer() try { diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 8efc98f1056d22..0158c009620e3d 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -5,7 +5,7 @@ import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, } from '../../shared/constants' -import { unwrapId } from '../../shared/utils' +import { encodeSourceURL, unwrapId } from '../../shared/utils' import { tryNodeResolve } from '../plugins/resolve' import type { DevEnvironment } from '../server/environment' import { genSourceMapUrl } from '../server/sourcemap' @@ -146,9 +146,9 @@ function inlineSourceMap( mappings: ';'.repeat(startOffset) + map.mappings, } : map - result.code = `${code.trimEnd()}\n//# sourceURL=${ - mod.id - }\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` + result.code = `${code.trimEnd()}\n//# sourceURL=${encodeSourceURL( + mod.id!, + )}\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` return result } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/with space/has-error-deep.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/with space/has-error-deep.ts new file mode 100644 index 00000000000000..8da094a3fa4800 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/with space/has-error-deep.ts @@ -0,0 +1,7 @@ +function crash(message: string) { + throw new Error(message) +} + +export function main(): void { + crash('crash') +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts index 2e43d374c114de..8f087669c453df 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -158,6 +158,21 @@ describe('module runner initialization', async () => { ]) }) + it('maps stack traces when the module path contains a space', async ({ + runner, + server, + }) => { + const methodError = await getError(async () => { + const mod = await runner.import('/fixtures/with space/has-error-deep.ts') + mod.main() + }) + expect(serializeStackDeep(server, methodError).slice(0, 3)).toEqual([ + 'Error: crash', + ' at crash (/fixtures/with space/has-error-deep.ts:2:9)', + ' at Module.main (/fixtures/with space/has-error-deep.ts:6:3)', + ]) + }) + it('should not crash when sourceMappingURL pattern appears in string literals', async ({ runner, server, diff --git a/packages/vite/src/node/ssr/ssrStacktrace.ts b/packages/vite/src/node/ssr/ssrStacktrace.ts index eefd4cee1f5260..42bff89d5011c5 100644 --- a/packages/vite/src/node/ssr/ssrStacktrace.ts +++ b/packages/vite/src/node/ssr/ssrStacktrace.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping' import type { EnvironmentModuleGraph } from '..' +import { decodeSourceURL } from '../../shared/utils' let offset: number @@ -34,9 +35,10 @@ export function ssrRewriteStacktrace( .map((line) => { return line.replace( /^ {4}at (?:(\S.*?)\s\()?(.+?):(\d+)(?::(\d+))?\)?/, - (input, varName, id, originalLine, originalColumn) => { - if (!id) return input + (input, varName, rawId, originalLine, originalColumn) => { + if (!rawId) return input + const id = decodeSourceURL(rawId) const mod = moduleGraph.getModuleById(id) const rawSourceMap = mod?.transformResult?.map diff --git a/packages/vite/src/shared/utils.ts b/packages/vite/src/shared/utils.ts index bfa47cbd2c87a7..9e76794d1e734b 100644 --- a/packages/vite/src/shared/utils.ts +++ b/packages/vite/src/shared/utils.ts @@ -82,3 +82,25 @@ export function promiseWithResolvers(): PromiseWithResolvers { }) return { promise, resolve, reject } } + +const whitespaceRE = /\s/g +const percentEncodedRE = /(?:%[0-9a-f]{2})+/gi +const whitespaceOnlyRE = /^\s+$/ + +/** Percent-encodes whitespace, which terminates a `//# sourceURL=` value in V8. */ +export function encodeSourceURL(id: string): string { + return id.replace(whitespaceRE, encodeURIComponent) +} + +/** Reverses {@link encodeSourceURL}. */ +export function decodeSourceURL(url: string): string { + return url.replace(percentEncodedRE, (match) => { + let decoded: string + try { + decoded = decodeURIComponent(match) + } catch { + return match + } + return whitespaceOnlyRE.test(decoded) ? decoded : match + }) +} From 893a08eedf1cd84f6586df04c2603ac7cf906362 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 17 Sep 2026 19:50:09 +0000 Subject: [PATCH 2/2] test: normalise path separators for windows --- packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts index c005861fd5de25..ea4162f9eb1635 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrStacktrace.spec.ts @@ -38,7 +38,9 @@ test('rewrites stacktrace when the module path contains a space', async () => { expect.unreachable() } catch (e: any) { server.ssrFixStacktrace(e) - expect(e.stack.split('\n')[1].replace(root, '')).toBe( + expect( + e.stack.split('\n')[1].replace(root, '').replaceAll('\\', '/'), + ).toBe( ' at Module.main (fixtures/modules/with space/has-error.js:2:9)', ) }