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",