mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void>((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<void>((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",
|
||||
|
||||
Reference in New Issue
Block a user