diff --git a/src/sourcemap/Mapping.rs b/src/sourcemap/Mapping.rs index 89653cc5dd64..e9582071940e 100644 --- a/src/sourcemap/Mapping.rs +++ b/src/sourcemap/Mapping.rs @@ -383,6 +383,10 @@ impl Lookup { } let name: &[u8] = &source_map.external_source_names[index]; + // A source with no name is not a file. Joined to a module URL that has no directory, it is an empty path. + if name.is_empty() { + return None; + } let mut buf = bun_paths::path_buffer_pool::get(); // `platform::Auto` is diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index e4ba62b09d68..e7d0533ee641 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -906,15 +906,12 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu return Err(crate::Error::InvalidSourceMap); }; - let sources_content = match json - .get(b"sourcesContent") - .ok_or(crate::Error::InvalidSourceMap)? - .data - { - bun_ast::ExprData::EArrayJSON(arr) => arr, - _ => return Err(crate::Error::InvalidSourceMap), + let sources_content = match json.get(b"sourcesContent").map(|contents| contents.data) { + Some(bun_ast::ExprData::EArrayJSON(arr)) => Some(arr), + Some(bun_ast::ExprData::ENull(_)) | None => None, + Some(_) => return Err(crate::Error::InvalidSourceMap), }; - let sources_content = sources_content.get(); + let sources_content = sources_content.as_ref().map(|arr| arr.get().items()); let sources_paths = match json .get(b"sources") @@ -926,15 +923,16 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu }; let sources_paths = sources_paths.get(); - if sources_content.items().len() != sources_paths.items().len() { + if sources_content.is_some_and(|contents| contents.len() != sources_paths.items().len()) { return Err(crate::Error::InvalidSourceMap); } + let sources_content = sources_content.unwrap_or_default(); let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_)); // `Vec>` drops automatically on error. let source_paths_slice: Option>> = if !source_only { - let mut v: Vec> = Vec::with_capacity(sources_content.items().len()); + let mut v: Vec> = Vec::with_capacity(sources_paths.items().len()); for item in sources_paths.items() { let Some(s) = item.as_str() else { return Err(crate::Error::InvalidSourceMap); @@ -1027,9 +1025,9 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu let content_slice: Option> = match source_index { Some(idx) if !matches!(hint, ParseUrlResultHint::MappingsOnly) - && (idx as usize) < sources_content.items().len() => + && (idx as usize) < sources_content.len() => 'content: { - let item = &sources_content.items()[idx as usize]; + let item = &sources_content[idx as usize]; let Some(str) = item.as_str() else { break 'content None; }; diff --git a/test/aot/runner-suites.programs.txt b/test/aot/runner-suites.programs.txt index 538ec862c9a2..85b5d06cc261 100644 --- a/test/aot/runner-suites.programs.txt +++ b/test/aot/runner-suites.programs.txt @@ -188,7 +188,12 @@ test/bundler/transpiler/es-decorators.test.ts: ES decorators lowering matrix > . test/bundler/transpiler/es-decorators.test.ts: ES decorators lowering matrix > .ts > twoClasses test/js/bun/util/inspect-error.test.js: AggregateError whose errors cannot be walked > the error a second load of a module that failed to build rejects with test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > a `cause` getter that loads the throwing file again +test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > call sites of an external map that has no sourcesContent +test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > code frame of an external map that has no sourcesContent +test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > external map that has no sourcesContent test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > external map whose original source is unavailable +test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > inline map with an empty source name in a blob module: sourcesContent [null] +test/js/bun/util/inspect-error.test.js: source map remapping of the printed stack > inline map with an empty source name in a blob module: sourcesContent absent test/js/node/v8/capture-stack-trace.test.js: CallFrame.p.getFunction does not expose the module loader reaction test/js/node/v8/capture-stack-trace.test.js: CallFrame.p.getFunction hides internal callees from sloppy code test/js/node/v8/capture-stack-trace.test.js: a stack that a collection materializes reads the same in a Worker diff --git a/test/js/bun/sourcemap/negative-vlq-mapping.test.ts b/test/js/bun/sourcemap/negative-vlq-mapping.test.ts index 746ff863712c..bc6503edbcba 100644 --- a/test/js/bun/sourcemap/negative-vlq-mapping.test.ts +++ b/test/js/bun/sourcemap/negative-vlq-mapping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -import { join } from "node:path"; +import { join, sep } from "node:path"; // A `// @bun`-pragma'd file is not re-transpiled, so the sidecar / inline // source map is parsed as-is when error.stack is formatted. A negative @@ -169,3 +169,49 @@ describe.concurrent("sourcemap: negative VLQ delta in mappings is rejected, not expect(exitCode).toBe(0); }); }); + +// `sourcesContent` is only the text of a code frame. A map that has none, or `null`, still remaps a frame. +describe.concurrent("sourcemap: sourcesContent is optional", () => { + // Generated line 3, column 2 -> sources[0], line 5, column 3. + const base = { version: 3, sources: ["orig.ts"], names: [], mappings: ";;EAIE;" }; + const mapped = { stdout: "at t (orig.ts:5:3)\n", stderr: "", exitCode: 0 }; + + async function run(files: Record) { + using dir = tempDir("sourcemap-no-content", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.replaceAll(String(dir) + sep, ""), stderr, exitCode }; + } + + test.each([ + ["absent", base], + ["null", { ...base, sourcesContent: null }], + ])("external .map: sourcesContent %s", async (_, map) => { + const files = { "entry.js": body + `//# sourceMappingURL=entry.js.map\n`, "entry.js.map": JSON.stringify(map) }; + expect(await run(files)).toEqual(mapped); + }); + + // What `bun build --sourcemap=external` writes: a `debugId` comment and no `sourceMappingURL` comment. + test("external .map of a file that has a debugId comment only: sourcesContent absent", async () => { + const files = { + "entry.js": body + `//# debugId=0123456789ABCDEF64756E2164756E21\n`, + "entry.js.map": JSON.stringify({ ...base, debugId: "0123456789ABCDEF64756E2164756E21" }), + }; + expect(await run(files)).toEqual(mapped); + }); + + // The inline map is the one that is used, also beside a `.map` that decodes. + test("inline data-URL map: sourcesContent absent", async () => { + const files = { + "entry.js": body + `//# sourceMappingURL=data:application/json;base64,${btoa(JSON.stringify(base))}\n`, + "entry.js.map": JSON.stringify({ ...base, sources: ["sidecar.ts"], sourcesContent: [""] }), + }; + expect(await run(files)).toEqual(mapped); + }); +}); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 5020cdb87ca6..b6b67d0f2ae2 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -359,6 +359,107 @@ describe("source map remapping of the printed stack", () => { expect(exitCode).toBe(1); }); + // The same artifact when `sourcesContent` is left out. There is still nothing + // to show and the frames are still remapped. The second decode of the map, + // for the code frame, reports nothing either. + test.concurrent("external map that has no sourcesContent", async () => { + const main = [ + "// @bun", + 'function thrower() { throw new Error("NOCONTENT"); }', + "const out = {};", + "try { thrower(); } catch (e) { out.inspect = Bun.inspect(e); }", + "try { thrower(); } catch (e) { out.stack = e.stack; }", + "console.log(JSON.stringify(out));", + "thrower();", + ]; + // The mappings of the test above. + const map = { version: 3, sources: ["orig.ts"], names: [], mappings: ";AAUI;;AAUA;AAUA;;AAUA" }; + const { dir, out, stderr, exitCode } = await run({ + "main.js": main.join("\n") + "\n//# sourceMappingURL=main.js.map\n", + "main.js.map": JSON.stringify(map), + }); + + const files = ["orig.ts", "main.js"]; + expect({ + stack: frames(out.stack, dir, files), + inspect: frames(out.inspect, dir, files), + uncaught: frames(stderr, dir, files), + warnings: stderr.split("\n").filter(line => line.startsWith("warn:")), + }).toEqual({ + stack: ["at thrower (orig.ts:11:5)", "at orig.ts:31:5"], + inspect: ["at thrower (orig.ts:11:5)", "at orig.ts:21:5"], + uncaught: ["at thrower (orig.ts:11:5)", "at orig.ts:41:5"], + warnings: [], + }); + expect(exitCode).toBe(1); + }); + + // With no `sourcesContent` the code frame is read from the source on disk. + test.concurrent("code frame of an external map that has no sourcesContent", async () => { + const { dir, out, stderr, exitCode } = await run({ + "main.js": [ + "// @bun", + 'try { throw new Error("FRAME"); } catch (e) { console.log(JSON.stringify(Bun.inspect(e))); }', + "//# sourceMappingURL=main.js.map", + ].join("\n"), + // Line 2 -> orig.ts:1:5. + "main.js.map": JSON.stringify({ version: 3, sources: ["orig.ts"], names: [], mappings: ";AAAI" }), + "orig.ts": "const fromDisk = 1;\n", + }); + expect({ + firstLine: out.split("\n")[0], + frames: frames(out, dir, ["orig.ts", "main.js"]), + stderr, + }).toEqual({ firstLine: "1 | const fromDisk = 1;", frames: ["at orig.ts:1:5"], stderr: "" }); + expect(exitCode).toBe(0); + }); + + // A source with no name is not a file to read. A module imported from a `blob:` URL has no directory either. + test.concurrent.each([ + ["absent", {}], + ["[null]", { sourcesContent: [null] }], + ])("inline map with an empty source name in a blob module: sourcesContent %s", async (_, content) => { + const map = { version: 3, sources: [""], ...content, names: [], mappings: ";;AACI" }; + const { out, stderr, exitCode } = await run({ + "module.txt": [ + "// @bun", + "export function thrower() {", + ' throw new Error("BLOB");', + "}", + "//# sourceMappingURL=data:application/json;base64," + btoa(JSON.stringify(map)), + ].join("\n"), + "main.js": [ + 'const code = await Bun.file(import.meta.dir + "/module.txt").text();', + 'const { thrower } = await import(URL.createObjectURL(new Blob([code], { type: "text/javascript" })));', + "try { thrower(); } catch (e) { console.log(JSON.stringify(Bun.inspect(e))); }", + ].join("\n"), + }); + expect({ error: out.split("\n").filter(line => line.startsWith("error:")), stderr, exitCode }).toEqual({ + error: ["error: BLOB"], + stderr: "", + exitCode: 0, + }); + }); + + // Error.prepareStackTrace reads the same map through the call sites. + test.concurrent("call sites of an external map that has no sourcesContent", async () => { + const { dir, out, stderr, exitCode } = await run({ + "main.js": [ + "// @bun", + "Error.prepareStackTrace = (_, sites) => sites.map(s => [s.getFileName(), s.getLineNumber(), s.getColumnNumber()]);", + 'console.log(JSON.stringify(new Error("SITES").stack[0]));', + "//# sourceMappingURL=main.js.map", + ].join("\n"), + // Line 2 -> orig.ts:11:5. + "main.js.map": JSON.stringify({ version: 3, sources: ["orig.ts"], names: [], mappings: ";;AAUI" }), + }); + expect({ site: [out[0].replaceAll("\\", "/"), out[1], out[2]], stderr }).toEqual({ + site: [dir.replaceAll("\\", "/") + "/orig.ts", 11, 5], + stderr: "", + }); + expect(exitCode).toBe(0); + }); + // Modules bun transpiled itself. `present.ts` stays on disk; `deleted.ts` is // removed after it was loaded, so the code frame can no longer be read back. // Reading error.stack first makes the printer start from the already