Skip to content

Commit 5b2624c

Browse files
committed
fix(vite): strip deno:: prefix from server-side stack traces (#3464)
Stack traces from server-side errors in Fresh 2 Vite apps leaked the internal `\0deno::{type}::{specifier}` virtual module IDs used by the deno plugin, displayed as `deno::N::…` in frames like `at async eval (deno::0::https:/jsr.io/@fresh/core/2.1.0/src/render.ts:9:332)`. The plugin's `load` hook returned the loader's code with its inline `//# sourceMappingURL=` data URL untouched. That inline source map embeds `sources` as a *relative* path (e.g. `packages/plugin-vite/demo/routes/tests/throw.tsx`). When V8 cannot apply the source map, it falls back to the module ID — the `\0deno::…` virtual ID — leaking it into stack frames. When V8 *does* apply the relative source path, it can be resolved against the module URL's directory, producing doubled paths like `packages/fresh/src/packages/fresh/src/segments.ts`. This patch: - Rewrites the inline source map in the deno-specifier branch of the `load` hook so `sources` is the absolute specifier with an empty `sourceRoot`, while keeping the inline `//# sourceMappingURL=` comment in place for V8 to pick up natively. - Returns the rewritten map as the explicit `map` field too, so Rollup composes consistent `sources` during production builds. - For the Babel-transformed JSX branch, rewrites both the inline and the explicit map's `sources` to the specifier with `sourceRoot: ""`. - Skips the rewrite for non-JS media (JSON, CSS, …) since appending a `//# sourceMappingURL=` comment would corrupt those payloads. - Adds unit tests for the two new helpers, plus dev-server and prod-build integration tests that fetch `/tests/throw` and assert the response contains neither `deno::N::` nor doubled cwd paths. Closes bartlomieju/orchid-inbox#56
1 parent 39b5f06 commit 5b2624c

4 files changed

Lines changed: 243 additions & 3 deletions

File tree

packages/plugin-vite/src/plugins/deno.ts

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,32 @@ export function deno(): Plugin {
190190
isDev,
191191
});
192192
if (maybeJsx !== null) {
193+
if (maybeJsx.map) {
194+
// Babel reads the loader's inline source map but inherits its
195+
// `sources` (relative path). Rewrite to the absolute specifier
196+
// with an empty `sourceRoot` so stack frames show the real URL
197+
// instead of the `\0deno::…` virtual ID and don't double the cwd.
198+
maybeJsx.map.sources = [specifier];
199+
maybeJsx.map.sourceRoot = "";
200+
}
201+
// Babel emits its own inline `//# sourceMappingURL=` comment via
202+
// `sourceMaps: "both"`. Rewrite that one too so V8 stack traces
203+
// (which read the inline map natively) point at the specifier.
204+
maybeJsx.code = rewriteInlineSourceMapSources(
205+
maybeJsx.code,
206+
specifier,
207+
);
193208
return maybeJsx;
194209
}
195210

196-
return {
197-
code,
198-
};
211+
// For non-JS media (JSON, CSS, …) the loaded code is not JavaScript,
212+
// so appending a `//# sourceMappingURL=` comment would corrupt it.
213+
// Those modules don't show up in JS stack traces, so leave them alone.
214+
if (!isJsMediaType(result.mediaType)) {
215+
return { code };
216+
}
217+
218+
return rewriteLoadedSourceMap(code, specifier);
199219
}
200220

201221
if (id.startsWith("\0")) {
@@ -368,6 +388,94 @@ function getDenoType(id: string, type: string): RequestedModuleType {
368388
}
369389
}
370390

391+
// Builds a 1:1 (line-by-line, column 0) source map so that Vite/V8 can
392+
// rewrite stack frames from `\0deno::{type}::{specifier}` virtual IDs back
393+
// to the original specifier. Uses an absolute URL/path in `sources` combined
394+
// with an empty `sourceRoot` to avoid Vite resolving sources relative to the
395+
// cwd, which would produce doubled paths like
396+
// `packages/fresh/src/packages/fresh/src/segments.ts`.
397+
export function identitySourceMap(source: string, code: string) {
398+
const lineCount = code.split("\n").length;
399+
// VLQ "AAAA" → [genCol=0, srcIdx=0, srcLine=0, srcCol=0]
400+
// ";AACA" → newline, then deltas [0, 0, +1, 0]
401+
let mappings = "AAAA";
402+
for (let i = 1; i < lineCount; i++) {
403+
mappings += ";AACA";
404+
}
405+
return {
406+
version: 3,
407+
sources: [source],
408+
sourcesContent: [code],
409+
names: [] as string[],
410+
mappings,
411+
sourceRoot: "",
412+
};
413+
}
414+
415+
const INLINE_SOURCE_MAP_RE =
416+
/\n?\/\/# sourceMappingURL=data:application\/json(?:;charset=[^;]+)?;base64,([A-Za-z0-9+/=]+)\s*$/;
417+
418+
// `ssrLoader.load()` returns code with an inline `//# sourceMappingURL=` data
419+
// URL whose `sources` array contains a path relative to the cwd. Without
420+
// fixing this up, stack traces either leak the `\0deno::…` virtual module ID
421+
// (when V8 falls back to the module ID) or display doubled cwd paths like
422+
// `packages/fresh/src/packages/fresh/src/segments.ts` (the caveat described
423+
// in denoland/fresh#3464).
424+
//
425+
// Rewrites the inline source map (if any) so `sources` is the absolute
426+
// specifier with an empty `sourceRoot`. The inline comment itself is kept in
427+
// place so that V8 picks it up natively for stack-trace translation. When the
428+
// loader did not emit a source map, an identity map is appended so the
429+
// virtual ID is still replaced in stack traces. The same map is also
430+
// returned alongside the code so Rollup (production builds) sees consistent
431+
// `sources` during source-map chaining.
432+
export function rewriteLoadedSourceMap(code: string, source: string) {
433+
const match = code.match(INLINE_SOURCE_MAP_RE);
434+
if (match !== null) {
435+
try {
436+
const parsed = JSON.parse(atob(match[1]));
437+
parsed.sources = [source];
438+
parsed.sourceRoot = "";
439+
const start = match.index!;
440+
const reencoded = btoa(JSON.stringify(parsed));
441+
const newCode = code.slice(0, start) +
442+
`\n//# sourceMappingURL=data:application/json;base64,${reencoded}`;
443+
return { code: newCode, map: parsed };
444+
} catch {
445+
// fall through to identity map
446+
}
447+
}
448+
const map = identitySourceMap(source, code);
449+
const encoded = btoa(JSON.stringify(map));
450+
return {
451+
code:
452+
`${code}\n//# sourceMappingURL=data:application/json;base64,${encoded}`,
453+
map,
454+
};
455+
}
456+
457+
// Rewrites just the `sources` of an existing inline `//# sourceMappingURL=`
458+
// comment in JS code, without touching mappings or appending a new comment
459+
// when none exists. Used after Babel has already produced its own source
460+
// map and we only need to fix the specifier.
461+
export function rewriteInlineSourceMapSources(
462+
code: string,
463+
source: string,
464+
): string {
465+
const match = code.match(INLINE_SOURCE_MAP_RE);
466+
if (match === null) return code;
467+
try {
468+
const parsed = JSON.parse(atob(match[1]));
469+
parsed.sources = [source];
470+
parsed.sourceRoot = "";
471+
const reencoded = btoa(JSON.stringify(parsed));
472+
return code.slice(0, match.index!) +
473+
`\n//# sourceMappingURL=data:application/json;base64,${reencoded}`;
474+
} catch {
475+
return code;
476+
}
477+
}
478+
371479
function babelTransform(
372480
options: {
373481
media: MediaType;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect } from "@std/expect/expect";
2+
import { identitySourceMap, rewriteLoadedSourceMap } from "./deno.ts";
3+
4+
Deno.test("identitySourceMap - single line", () => {
5+
const map = identitySourceMap("jsr:@fresh/core/src/render.ts", "foo();");
6+
expect(map.version).toBe(3);
7+
expect(map.sources).toEqual(["jsr:@fresh/core/src/render.ts"]);
8+
expect(map.sourcesContent).toEqual(["foo();"]);
9+
expect(map.sourceRoot).toBe("");
10+
// Single line => single segment, no `;` separators.
11+
expect(map.mappings).toBe("AAAA");
12+
});
13+
14+
Deno.test("identitySourceMap - multi line", () => {
15+
const code = "a;\nb;\nc;";
16+
const map = identitySourceMap("https://example.com/mod.js", code);
17+
// Three lines => `AAAA` + `;AACA` per additional line.
18+
expect(map.mappings).toBe("AAAA;AACA;AACA");
19+
expect(map.sources).toEqual(["https://example.com/mod.js"]);
20+
expect(map.sourceRoot).toBe("");
21+
});
22+
23+
Deno.test("identitySourceMap - keeps full URL in sources to avoid doubled cwd paths", () => {
24+
// Regression test for the caveat from denoland/fresh#3464: Vite resolves
25+
// source-map `sources` entries relative to the (virtual) module location
26+
// and falls back to concatenating the cwd. Using absolute URLs/paths in
27+
// `sources` together with `sourceRoot: ""` prevents the doubling.
28+
const map = identitySourceMap(
29+
"file:///abs/path/to/segments.ts",
30+
"export const x = 1;",
31+
);
32+
expect(map.sources[0]).toBe("file:///abs/path/to/segments.ts");
33+
expect(map.sourceRoot).toBe("");
34+
});
35+
36+
Deno.test("rewriteLoadedSourceMap - rewrites inline map sources to absolute", () => {
37+
const inputMap = {
38+
version: 3,
39+
sources: ["packages/foo/src/bar.ts"],
40+
sourcesContent: ["original;"],
41+
names: [],
42+
mappings: "AAAA",
43+
};
44+
const inlined = btoa(JSON.stringify(inputMap));
45+
const body = "transformed;";
46+
const code =
47+
`${body}\n//# sourceMappingURL=data:application/json;base64,${inlined}`;
48+
49+
const result = rewriteLoadedSourceMap(
50+
code,
51+
"https://jsr.io/@fresh/core/2.1.0/src/render.ts",
52+
);
53+
54+
// The returned map (also embedded inline) has `sources` rewritten.
55+
expect(result.map.sources).toEqual([
56+
"https://jsr.io/@fresh/core/2.1.0/src/render.ts",
57+
]);
58+
expect(result.map.sourceRoot).toBe("");
59+
expect(result.map.mappings).toBe("AAAA");
60+
61+
// The inline `//# sourceMappingURL=` comment is preserved (rewritten) so
62+
// V8 can apply it natively for stack-trace translation.
63+
const m = result.code.match(
64+
/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/,
65+
);
66+
expect(m).not.toBeNull();
67+
const reparsed = JSON.parse(atob(m![1]));
68+
expect(reparsed.sources).toEqual([
69+
"https://jsr.io/@fresh/core/2.1.0/src/render.ts",
70+
]);
71+
expect(reparsed.sourceRoot).toBe("");
72+
// The original transformed code is preserved before the comment.
73+
expect(result.code.startsWith(body)).toBe(true);
74+
});
75+
76+
Deno.test("rewriteLoadedSourceMap - appends identity map when no inline map", () => {
77+
const result = rewriteLoadedSourceMap(
78+
"foo();\nbar();",
79+
"jsr:@fresh/core/src/render.ts",
80+
);
81+
expect(result.map.sources).toEqual(["jsr:@fresh/core/src/render.ts"]);
82+
expect(result.map.sourceRoot).toBe("");
83+
expect(result.map.mappings).toBe("AAAA;AACA");
84+
// The original code is preserved and an inline source map is appended.
85+
expect(result.code.startsWith("foo();\nbar();")).toBe(true);
86+
expect(result.code).toContain(
87+
"//# sourceMappingURL=data:application/json;base64,",
88+
);
89+
});
90+
91+
Deno.test("rewriteLoadedSourceMap - falls back to identity on malformed inline map", () => {
92+
const code = "foo();\n//# sourceMappingURL=data:application/json;base64,!!!";
93+
const result = rewriteLoadedSourceMap(code, "file:///abs.ts");
94+
expect(result.map.sources).toEqual(["file:///abs.ts"]);
95+
});

packages/plugin-vite/tests/build_test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,25 @@ integrationTest("vite build - without routes/ dir", async () => {
128128
);
129129
});
130130

131+
integrationTest(
132+
"vite build - strips deno:: from server stack traces",
133+
async () => {
134+
// Regression test for denoland/fresh#3464. Without identity source
135+
// maps in the deno plugin, server-side stack traces contained the
136+
// virtual module ID `\0deno::{type}::{specifier}` (rendered as
137+
// `deno::N::…`) instead of the original specifier.
138+
await launchProd(
139+
{ cwd: viteResult.tmp },
140+
async (address) => {
141+
const res = await fetch(`${address}/tests/throw`);
142+
const text = await res.text();
143+
expect(text).toContain("FAIL");
144+
expect(text).not.toMatch(/deno::\d+::/);
145+
},
146+
);
147+
},
148+
);
149+
131150
integrationTest("vite build - load json inside npm package", async () => {
132151
await launchProd(
133152
{ cwd: viteResult.tmp },

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,24 @@ integrationTest("vite dev - source mapped stack traces", async () => {
471471
expect(text).toContain("throw.tsx:5:11");
472472
});
473473

474+
integrationTest(
475+
"vite dev - strips deno:: prefix from stack traces",
476+
async () => {
477+
// Regression test for denoland/fresh#3464: the deno plugin's virtual
478+
// module IDs (`\0deno::{type}::{specifier}`) used to leak into stack
479+
// traces. The plugin now rewrites the loader's source maps so frames
480+
// reference the original `jsr:` / `https:` / `file://` specifier.
481+
const res = await fetch(`${demoServer.address()}/tests/throw`);
482+
const text = await res.text();
483+
expect(text).toContain("FAIL");
484+
expect(text).not.toMatch(/deno::\d+::/);
485+
// And no doubled cwd paths from a missing `sourceRoot` (the caveat
486+
// described in the issue).
487+
const cwd = Deno.cwd().replaceAll("\\", "/");
488+
expect(text).not.toContain(`${cwd}${cwd}`);
489+
},
490+
);
491+
474492
integrationTest("vite dev - client side <Head>", async () => {
475493
await withBrowser(async (page) => {
476494
await page.goto(`${demoServer.address()}/tests/head_counter`, {

0 commit comments

Comments
 (0)