Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/sourcemap/Mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
robobun marked this conversation as resolved.
Resolved

let mut buf = bun_paths::path_buffer_pool::get();
// `platform::Auto` is
Expand Down
22 changes: 10 additions & 12 deletions src/sourcemap/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -926,15 +923,16 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
};
let sources_paths = sources_paths.get();
Comment thread
robobun marked this conversation as resolved.
Resolved

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<Box<[u8]>>` drops automatically on error.
let source_paths_slice: Option<Vec<Box<[u8]>>> = if !source_only {
let mut v: Vec<Box<[u8]>> = Vec::with_capacity(sources_content.items().len());
let mut v: Vec<Box<[u8]>> = 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);
Expand Down Expand Up @@ -1027,9 +1025,9 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
let content_slice: Option<Box<[u8]>> = 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;
};
Expand Down
5 changes: 5 additions & 0 deletions test/aot/runner-suites.programs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion test/js/bun/sourcemap/negative-vlq-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, string>) {
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);
});
});
101 changes: 101 additions & 0 deletions test/js/bun/util/inspect-error.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading