From aa257ea4b16f2d2a35c12df63359d650b48b1154 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:29:51 +0800 Subject: [PATCH] fix(browser): decode source-map response framing Share the ECMA-426 JSON parse view across fetched maps and local point tracing while retaining exact response text, bytes, and digest. Handle BOM and HTTP newline variants without treating unterminated prefixes as valid JSON. Validation: 89 source-map tests passed; source/runtime build and check:fast passed. Real Chrome 154 CLI and stdio MCP browser verification passed on macOS ARM64. --- src/browser/WebSourceMapFetcher.ts | 7 +- src/javascript/sourceMaps/SourceMapFormat.ts | 19 +++- .../sourceMaps/TraceSourceMap.test.ts | 16 ++++ .../browser/webSourceMapFetcher.test.ts | 90 +++++++++++++++++++ 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 9a16e5d41..96e959527 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -21,6 +21,7 @@ import { safeParseJson } from "../domain/safeJson.js"; import { jsonParts } from "../domain/jsonSerialization.js"; import { inspectSourceMapValue, + sourceMapJsonText, SourceMapFormatFailure, type SourceMapLeaf, } from "../javascript/sourceMaps/SourceMapFormat.js"; @@ -364,7 +365,9 @@ const readBoundedText = async ( offset += chunk.byteLength; } try { - return new TextDecoder("utf-8", { fatal: true }).decode(joined); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + joined, + ); } catch { throw new SourceMapEncodingError(); } @@ -455,7 +458,7 @@ const normalizeSourceMap = ( fetchedUrl: string, context: SourceMapDecodeContext, ): SourceMapItem => { - const parsedJson = safeParseJson(text); + const parsedJson = safeParseJson(sourceMapJsonText(text)); if (!parsedJson.ok) return emptySourceMapItem( request, diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index c1816d51e..bebde0973 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -279,6 +279,20 @@ const inspectLeaf = ( return leaf; }; +/** + * Return the JSON view of source-map text without changing the source evidence. + * ECMA-426 ยง11.2.1 permits HTTP(S) responses to start with `)]}'`; the line + * through the first HTTP newline is transport framing, not map content. Local + * point tracing shares this parser and has historically accepted the same + * framing. https://tc39.es/ecma426/#sec-fetchsourcemap + */ +export const sourceMapJsonText = (text: string): string => { + const withoutBom = text.replace(/^\uFEFF/u, ""); + if (!withoutBom.startsWith(")]}'")) return withoutBom; + const newline = withoutBom.search(/[\r\n]/u); + return newline === -1 ? "" : withoutBom.slice(newline); +}; + /** Validate structure before allocation, retaining raw declarations and section boundaries. */ export const inspectSourceMap = ( text: string, @@ -289,10 +303,7 @@ export const inspectSourceMap = ( readonly declarations: readonly SourceMapSourceDeclaration[]; readonly leaves: readonly SourceMapLeaf[]; } => { - const withoutBom = text.replace(/^\uFEFF/u, ""); - const jsonText = withoutBom.startsWith(")]}'") - ? withoutBom.slice(withoutBom.indexOf("\n") + 1) - : withoutBom; + const jsonText = sourceMapJsonText(text); const value: unknown = JSON.parse(jsonText); const inspected = inspectSourceMapValue(value); const normalized = diff --git a/src/javascript/sourceMaps/TraceSourceMap.test.ts b/src/javascript/sourceMaps/TraceSourceMap.test.ts index 81d9ba76a..0f3c6abdf 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.test.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.test.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { describe, expect, it } from "vitest"; import { traceSourceMap } from "./TraceSourceMap.js"; @@ -13,6 +15,20 @@ const leaf = { const trace = (map: unknown, position = point) => traceSourceMap(JSON.stringify(map), url, position); +it("traces BOM-prefixed maps with a source-map transport prefix ending in CR", () => { + const text = `\uFEFF)]}'transport prefix\r${JSON.stringify(leaf)}`; + const report = traceSourceMap(text, url, point); + expect(report).toMatchObject({ + source_map_sha256: createHash("sha256").update(text).digest("hex"), + matches: [ + { + reported_source: "app.ts", + content: { text: "original" }, + }, + ], + }); +}); + describe("upstream source-map point evidence", () => { it("preserves every duplicate-column/source-URL candidate and its own embedded bytes", () => { const report = trace({ diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 2ce7903a3..fd6deb952 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; @@ -28,6 +29,95 @@ const expectInvalidIncludedSourceMaps = (result: WebSourceMaps): void => { expectInvalidSourceMaps({ ...result, status: "unavailable" }); }; +it("parses ECMA-426 HTTP source-map prefixes while retaining exact response evidence", async () => { + const map = JSON.stringify({ + version: 3, + names: [], + sources: ["src/main.ts"], + sourcesContent: ["import './dependency.ts';"], + mappings: "AAAA", + }); + const prefix = ")]}'transport prefix"; + const texts = [ + map, + `\uFEFF${prefix}\n${map}`, + `${prefix}\n${map}`, + `${prefix}\r\n${map}`, + `${prefix}\r${map}`, + `${prefix}${map}`, + `${prefix}\n{`, + ]; + const server = createServer((incoming, response) => { + const index = Number(incoming.url?.slice(1)); + response.setHeader("content-type", "application/source-map+json"); + response.end(texts[index]); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new TypeError("Expected a TCP listener address"); + const localOrigin = `http://127.0.0.1:${String(address.port)}`; + const result = await fetchWebSourceMaps( + texts.map((_, index) => ({ + ...request, + fetchUrl: `${localOrigin}/${index}`, + declaredUrl: `${localOrigin}/${index}`, + })), + input({ allowed_origins: [localOrigin] }), + ); + expect(result.items.map(({ status }) => status)).toEqual([ + "included", + "included", + "included", + "included", + "included", + "invalid", + "invalid", + ]); + for (const [index, text] of texts.slice(0, 5).entries()) + expect(result.items[index]).toMatchObject({ + artifact: { + text, + bytes: Buffer.byteLength(text), + sha256: createHash("sha256").update(text).digest("hex"), + }, + original_sources: [{ source: `${localOrigin}/src/main.ts` }], + mappings: [ + { + generated_line: 1, + generated_column: 0, + source: `${localOrigin}/src/main.ts`, + original_line: 1, + original_column: 0, + }, + ], + original_module_edges: [ + { + from_source: `${localOrigin}/src/main.ts`, + specifier: "./dependency.ts", + resolved_source: `${localOrigin}/src/dependency.ts`, + }, + ], + }); + expect(result.items.slice(5)).toMatchObject([ + { artifact: null, mappings: [], original_sources: [] }, + { artifact: null, mappings: [], original_sources: [] }, + ]); + } finally { + server.closeAllConnections(); + if (server.listening) + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } +}); + describe("source-map redirect URL resolution", () => { it.each(["/maps/current", "/assets/v2/app.js.map"])( "resolves relative sources against the delivered map at %s",