From 31e2ddc55dad386fc9bf2e940b96d4c62af2bf98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:05:04 +0000 Subject: [PATCH 01/29] bundler: chain inline input sourcemaps through to output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #30536. When Bun.build reads a source file that carries a trailing `//# sourceMappingURL=data:application/json;...` comment (typically produced by an upstream compile step like .vue/.svelte/.mdx/ts-plugin → .js), the bundler already detected the URL at the lexer but dropped it on the floor. The output sourcemap's deepest `sources[]` entry was the intermediate .js file and `sourcesContent[]` carried the intermediate's bytes verbatim — including the literal `sourceMappingURL=` comment — so stack traces surfaced one hop short of the authored source. - `src/sourcemap/InputSourceMap.zig` (new): owns the parsed inner map + per-source contents, cleans up after itself. - `src/bundler/ParseTask.zig`: after parsing the JS, scan the source for a trailing `//# sourceMappingURL=data:...`. Inline data URLs are parsed (base64 and raw); external `.map` references are left for a follow-up. Gated on `source_map != .none` and `loader.canHaveSourceMap()`. - `src/bundler/Graph.zig`, `src/bundler/bundle_v2.zig`: new `InputFile.input_source_map` field; ownership moves from the parse result onto the file on consumption, freed at bundler teardown. - `src/js_printer/js_printer.zig`, `src/sourcemap/Chunk.zig`: threads the parsed map into `Chunk.Builder`. During `addSourceMapping`, translate each (line, column) through the inner map — on a hit, emit `source_index = 1 + inner.source_index` and the inner original (line, column); on a miss, fall back to slot 0 (the intermediate) so unmapped tokens still land in a real file. - `src/bundler/LinkerContext.zig`: each outer source in the output map's `sources[]` expands to `[intermediate, inner_0 .. inner_N-1]`. `sourcesContent[]` matches slot-for-slot: the intermediate's contents first, then each inner source's contents (drawn from the inner map). Chunk stitching uses `base + chunk.end_state.source_index` as the absolute end state so per-chunk mappings that vary source_index across their length compose correctly. - Repro from the issue (entry.ts → inner.js-with-inline-map → inner.ts): output `sources` now contains `../inner.ts`; `sourcesContent[authored_slot]` is the clean authored bytes with no sourceMappingURL comment. - Multi-inner-source maps (e.g. .vue compilers that split template/script) surface all inner sources. - Malformed or unrecognized inline maps fall back gracefully — build succeeds with the old behavior. - External `.map` references unchanged (out of scope here). - `sourcemap = none` builds skip the scan entirely. Five new cases in `test/bundler/bun-build-api.test.ts` covering: the base64 chain, the raw data-URL chain, multi-inner-source surfacing, malformed payload fallback, and external `.map` reference unchanged. --- test/bundler/bun-build-api.test.ts | 174 +++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 8be40eb1b8ae..32e1beb56483 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1646,3 +1646,177 @@ test.skipIf(isWindows)( }, 30_000, ); +// https://github.com/oven-sh/bun/issues/30536 — Bun.build ignores inline +// `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / +// `.ts` file compiled to an intermediate `.js` with an inline sourcemap +// should have its authored sources surface in the final bundle's map. +describe("Bun.build chains inline input sourcemaps", () => { + // Build a tiny intermediate `.js` that carries an inline base64 sourcemap + // pointing at a fake "authored" source, then bundle an entry that imports + // it. The output map's `sources[]` should include the authored source, + // and `sourcesContent[]` should include the inner content verbatim + // (without the trailing `//# sourceMappingURL=` comment). + test("inline data: URL — authored source surfaces in bundled map", async () => { + const authoredSrc = "export const x = 5;\nthrow new Error('authored');\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;AACA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap", { + "intermediate.js": authoredSrc + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored source name must appear somewhere in `sources[]`. + const sourcesJoined = parsed.sources.join("|"); + expect(sourcesJoined).toMatch(/authored\.ts/); + + // `sourcesContent` length must equal `sources` length (spec). + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + // The slot for `authored.ts` must hold the clean authored content, no + // trailing `//# sourceMappingURL=` comment. + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("authored.ts")); + expect(authoredIdx).toBeGreaterThanOrEqual(0); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredSrc); + expect(parsed.sourcesContent[authoredIdx]).not.toMatch(/sourceMappingURL/); + }); + + // Non-base64 `data:application/json,` must work too — some + // toolchains emit the comment in that form. + test("inline data: URL without base64 — authored source surfaces", async () => { + const authoredSrc = "export const y = 1;\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;", + }; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { + "intermediate.js": + authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, + "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); + }); + + // Inner map with multiple sources (e.g. a `.vue` compiler splitting + // template vs script into two virtual sources) — each must round-trip. + test("inline map with multiple inner sources — all surface", async () => { + const scriptSrc = "export const x = 5;\n"; + const templateSrc = "// template part\n"; + const innerMap = { + version: 3, + sources: ["component.vue?script", "component.vue?template"], + sourcesContent: [scriptSrc, templateSrc], + names: [], + mappings: "AAAA;ACAA;", + }; + const intermediate = scriptSrc + templateSrc; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-multi", { + "intermediate.js": intermediate + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + }); + + // A malformed inline map must not break the build — we silently fall + // back to the intermediate as the deepest source. + test("malformed inline map — build succeeds and falls back", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { + "intermediate.js": + "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", + "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + // Must still produce a valid output map — regression guard for the + // "parse failure kills the whole build" path. + const text = await Bun.file(result.outputs[0].path).text(); + expect(text).toMatch(/sourceMappingURL=data:application\/json;base64,/); + }); + + // Non-inline `sourceMappingURL=foo.js.map` references aren't chained + // (external map resolution is out of scope for this change). The build + // must behave exactly as before — the intermediate ends up as the + // deepest source, not a spurious crash. + test("external .map reference — unchanged behavior", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-external", { + "intermediate.js": "export const q = 3;\n//# sourceMappingURL=intermediate.js.map\n", + "entry.ts": `import { q } from './intermediate.js';\nconsole.log(q);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // No inner chain. The intermediate should be in sources[], not some + // phantom "authored.ts". + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); +}); From ee030e8b4620e9c2245e0c1c02dded1a24494acc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:07:15 +0000 Subject: [PATCH 02/29] [autofix.ci] apply automated fixes --- test/bundler/bun-build-api.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 32e1beb56483..044b287998eb 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1714,8 +1714,7 @@ describe("Bun.build chains inline input sourcemaps", () => { }; const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { - "intermediate.js": - authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, + "intermediate.js": authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, }); @@ -1775,8 +1774,7 @@ describe("Bun.build chains inline input sourcemaps", () => { // back to the intermediate as the deepest source. test("malformed inline map — build succeeds and falls back", async () => { const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { - "intermediate.js": - "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", + "intermediate.js": "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, }); From 87f50bc9e5afc16d0af4abcdd8d0a1937e3f0b0a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:19:19 +0000 Subject: [PATCH 03/29] test: cover plugin onLoad with inline sourcemap (#6173) The ParseTask scan runs on `source.contents` regardless of whether the contents came from disk or an `onLoad` plugin return, so the plugin case from #6173 is covered by the same pipeline. Add an explicit regression test so it stays covered if the scanner ever moves. A custom-extension plugin that emits transformed JS with its own `//# sourceMappingURL=data:...` comment should have the pre-transform authored source show up in the final map's `sources[]` / `sourcesContent[]`. Uses a distinct inner-source filename so the slot assertion can tell the plugin intermediate apart from the chained authored source. --- test/bundler/bun-build-api.test.ts | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 044b287998eb..0df15bb467cc 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1817,4 +1817,62 @@ describe("Bun.build chains inline input sourcemaps", () => { // phantom "authored.ts". expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); + + // https://github.com/oven-sh/bun/issues/6173 — a plugin `onLoad` that + // transpiles and returns JS with an inline sourcemap comment should + // have the pre-transform authored source surface in the final map. + // The scanner runs on `source.contents` regardless of origin, so the + // plugin case rides on the same pipeline as the file case. + test("onLoad plugin returning JS with inline sourcemap — authored source surfaces", async () => { + const dir = tempDirWithFiles("bun-build-plugin-chained-sourcemap", { + "src.custom": "export const x = 42;\n", + "entry.ts": `import { x } from './src.custom';\nconsole.log(x);\n`, + }); + + // Use a distinct inner-source name so we can tell which `sources[]` + // slot is the plugin intermediate vs. which is the chained inner. + const authoredContent = "const x_authored_marker = 42;\nexport { x_authored_marker as x };\n"; + const innerMap = { + version: 3, + sources: ["original-authored.custom"], + sourcesContent: [authoredContent], + names: [], + mappings: "AAAA;", + }; + const inlineComment = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + plugins: [ + { + name: "custom-transpiler", + setup(build) { + build.onLoad({ filter: /\.custom$/ }, () => ({ + // Emit transformed JS carrying its own inline sourcemap + // pointing back at the authored `.custom` source. + contents: "export const x = 42;\n" + inlineComment, + loader: "js", + })); + }, + }, + ], + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored-source slot (distinct filename) must be present and + // carry the pre-transform content verbatim. + expect(parsed.sources.some((s: string) => s.endsWith("original-authored.custom"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("original-authored.custom")); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredContent); + }); }); From e84d5c41776936ff82a1aa2410259cec9141ce74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:34:04 +0000 Subject: [PATCH 04/29] address review findings: OOM, leak, test guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] and coderabbit spotted three real issues in the initial landing. One refactor fixes the first two; the third is just more assertions. 1. InputSourceMap.parse: return `?*InputSourceMap` made the `errdefer` blocks dead code — Zig only fires `errdefer` on error returns, and the function had none. Every mid-parse `return null` (most realistically `Mapping.parse` rejecting a malformed VLQ, which passes all the JSON structure checks above it) leaked `source_paths_slice`, `sources_content_slice`, and every `allocator.dupe`d string on `bun.default_allocator` — never reclaimed for the life of the process, nasty in long-running dev-server / watch-mode callers. Split into a public `parse` + internal `parseInternal` that returns `ParseError!*InputSourceMap`. The `errdefer`s now fire on every malformed-payload bail. OOM propagates through the error union and the outer `parse` wraps it in `bun.outOfMemory()` (fatal), while validation failures collapse back to `null` — the original contract callers depend on. Ownership-transfer sites (`psm.external_source_names =`, building the result `InputSourceMap`) neuter the now-redundant `errdefer`s by zeroing `paths_written` / `contents_written` and re-pointing the slices at `&.{}`, so a future `try` added between transfer and return can't double-free. 2. bundle_v2 onParseTaskComplete: the new `input_source_map` slot was written without freeing any prior value, leaking on dev-server / watch-mode reparses where the same source index gets a fresh map. Also, the early-failure path where `runResolutionForParseTask` downgrades `.success` to `.err` was dropping the freshly parsed map without deinit. Both paths now explicitly `deinit()` before overwrite. The success transfer also nulls the result slot so the old layout can't be mistaken for still-owning after the move. 3. Test guards: four call sites pulled `m![1]` without first asserting `m` matched. Added `expect(m).not.toBeNull()` so a future output regression fails the test cleanly instead of surfacing as an unhelpful TypeError on the `m![1]` coercion. Rejected coderabbit's "restrict to file-backed inputs" suggestion: plugin `onLoad` returns are explicitly in scope (closes #6173) and covered by the test committed in 192b3558. A blanket `source.path.isFile()` gate would break that case. Rejected claude[bot]'s suggestion to add a hostile-VLQ regression test: it exposed a pre-existing panic inside `Mapping.parse` (`addScalar` → `fromZeroBased` assert on negative column delta), which is out of scope for this PR. The structural fix above removes the leak on every failure path anyway; the test was documentation- only and didn't need to sit atop a landmine. --- test/bundler/bun-build-api.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 0df15bb467cc..cd19215792b8 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1729,6 +1729,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); }); @@ -1764,6 +1765,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); @@ -1812,6 +1814,7 @@ describe("Bun.build chains inline input sourcemaps", () => { expect(result.success).toBe(true); const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); // No inner chain. The intermediate should be in sources[], not some // phantom "authored.ts". @@ -1865,6 +1868,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); // The authored-source slot (distinct filename) must be present and From ac7b108df56bd30610d032ba0eb43c64a070f48e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:45:31 +0000 Subject: [PATCH 05/29] sourcemap: anchor findSourceMappingURL to the final line coderabbit flag on the inline map scanner: the old `lastIndexOf(source, "\n//# sourceMappingURL=")` would match the needle anywhere in the file, including inside a string / template literal that happens to contain the marker text. Per the Source Map spec the comment MUST sit on the last line, so rewrite `findSourceMappingURL` to trim trailing whitespace first, then only prefix-match the final line. Added two test-coverage improvements in the same commit: - New test "sourceMappingURL marker in body is ignored": crafts an intermediate that embeds a FULLY VALID inline map inside a template literal, then follows it with a plain `export` on the last line. Old scanner would chain through the embedded `hijack.ts` source; new scanner ignores it. - Strengthened "malformed inline map" assertion: decodes the output map and verifies `sources` lists the intermediate (not some fabricated path from the malformed payload) instead of just checking that a sourceMappingURL comment was emitted. Skipping coderabbit's third suggestion ("require `version` field") because Bun's existing `sourcemap.parseJSON` treats `version` as optional too (sourcemap.zig:82-86). Tightening it is worth doing but needs to happen in both places together; out of scope for this PR. --- test/bundler/bun-build-api.test.ts | 57 ++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index cd19215792b8..9202725b96f2 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1788,10 +1788,61 @@ describe("Bun.build chains inline input sourcemaps", () => { sourcemap: "inline", }); expect(result.success).toBe(true); - // Must still produce a valid output map — regression guard for the - // "parse failure kills the whole build" path. + + // Regression guard for the "parse failure kills the whole build" + // path: a valid output map must still be produced, and the deepest + // source must be the intermediate (no spurious chained source from + // the malformed payload). + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // Guard the last-line anchoring — a file that has a fully-valid + // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside + // a template literal / multi-line string) but NO trailing comment must + // not get mis-chained off that in-body text. Without last-line + // anchoring, `lastIndexOf("\n//# sourceMappingURL=")` finds the + // embedded marker and chains through the fake payload — the authored + // "hijack.ts" would show up in the output sources. + test("sourceMappingURL marker in body is ignored (only trailing line counts)", async () => { + const hijackMap = { + version: 3, + sources: ["hijack.ts"], + sourcesContent: ["// i should not appear\n"], + names: [], + mappings: "AAAA;", + }; + const hijackInline = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(hijackMap)).toString("base64")}`; + // Embed the full valid inline comment inside a template literal so + // the file parses as JS, but the real trailing line is the plain + // `export` — no sourcemap comment at end-of-file. + const intermediate = ["export const doc = `", hijackInline, "`;", "export const val = 99;", ""].join("\n"); + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-nohijack", { + "intermediate.js": intermediate, + "entry.ts": `import { val } from './intermediate.js';\nconsole.log(val);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + const text = await Bun.file(result.outputs[0].path).text(); - expect(text).toMatch(/sourceMappingURL=data:application\/json;base64,/); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The in-body marker must not hijack the chain — `hijack.ts` must + // NOT appear as a source in the final map. + expect(parsed.sources.some((s: string) => s.endsWith("hijack.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); // Non-inline `sourceMappingURL=foo.js.map` references aren't chained From 9eca1d462a0d2027e1a434ccc62acbd56ac821c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:49:41 +0000 Subject: [PATCH 06/29] address 3 review findings from claude[bot] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more real bugs in the chaining landing that claude[bot] walked through in detail. Fixing all three: 1. `InputSourceMap.zig`: passing `std.math.maxInt(i32)` as `sources_count` to `Mapping.parse` disabled its `source_index >= sources_count` bounds check. A malformed inline map whose VLQ segment referenced an index past the end of its own `sources[]` would parse successfully. Downstream, the Builder emits `1 + inner.source_index` unclamped and LinkerContext reserves exactly `1 + external_source_names.len` slots per file, so the out-of-range index silently aliases the NEXT input file's slot range in the output — stack traces from file A get misattributed to file B. Pass the real `source_count` so such maps hit the `.fail` path and we fall back cleanly. Regression test: `inline map with out-of-range inner source_index is rejected` constructs a map with VLQ "AAAA;ACAA" (second mapping source_index = 1) against `sources: ["authored.ts"]` (len 1) and verifies `authored.ts` does NOT appear in the output `sources[]` after the fix. 2. `ParseTask.zig`: the dev-server HMR path in `runFromThreadPool` flips a parse-succeeded-with-errors `Success` into an `.err` by value-assigning a fresh `.err` payload and dropping the original `ast`. The prior commit already handled the resolve-error downgrade in `bundle_v2.runResolutionForParseTask`, but this second drop site leaks `ast.input_source_map` on every HMR rebuild of a file that both carries an inline map and logs parse errors. Deinit before the overwrite. 3. `LinkerContext.zig`: the `input_source_map` was plumbed into the print options unconditionally, but Bake's DevServer has its own sourcemap stitcher (`SourceMapStore.joinVLQ` + `PackedMap`) that hard-codes one `sources[]` slot per file and discards `chunk.end_state.source_index`. With `input_source_map` set, chunks now emit non-zero per-chunk `source_index` deltas that the DevServer stitcher would re-rebase across neighboring files' slots, corrupting served browser stack traces for any prebuilt `.js` carrying an inline `data:` sourcemap. Gate the field on `c.dev_server == null` until the DevServer stitcher is taught the slot-expansion layout (follow-up, separate PR). `test/bake/dev/sourcemap.test.ts` still passes; the existing Bun.build (non-dev) suite still passes too. Gate-check: `bun bd test bun-build-api` → 45/45 + 1 todo, `bake sourcemap.test.ts` → 2/2. --- test/bundler/bun-build-api.test.ts | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 9202725b96f2..6312d8d189d1 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1800,6 +1800,51 @@ describe("Bun.build chains inline input sourcemaps", () => { expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); + // Inner map whose VLQ references `source_index >= sources.len` is + // malformed per the spec. Accepting it would alias the next input + // file's slot in the output `sources[]` (Chunk.Builder emits + // `1 + inner.source_index` unclamped; LinkerContext reserves exactly + // `1 + external_source_names.len` slots per file). Pass the real + // source count to `Mapping.parse` so the map gets rejected and we + // fall back to the intermediate. + test("inline map with out-of-range inner source_index is rejected", async () => { + // VLQ "AAAA;ACAA" = line 0: (0, 0, 0, 0); line 1: (0, +1, 0, 0) + // → second mapping references source_index = 1, but sources has + // only one entry. + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: ["// authored\n"], + names: [], + mappings: "AAAA;ACAA", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-oob", { + "intermediate.js": "export const x = 1;\nexport const y = 2;\n" + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The malformed map must be rejected — no `authored.ts` slot + // appears in the output, and no neighboring file's slot got + // aliased away. + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + // Guard the last-line anchoring — a file that has a fully-valid // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside // a template literal / multi-line string) but NO trailing comment must From 47757dc0328720fe762918e459b59ccf37af8478 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 07:27:32 +0000 Subject: [PATCH 07/29] ci: retrigger debian-13-x64-asan-test-bun failed with exit 2 on 706b461 (single shard out of 20 parallel). All other ASAN build lanes passed. Local bun bd (ASAN on by default) is clean on the full bundler suite and integration tests. Can't scrape which test failed from Buildkite anonymously; rolling the dice one time. From a10f2b2904878cd7588973ef49038a5098f5cad4 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 02:32:55 +0000 Subject: [PATCH 08/29] test: gate chained-sourcemap tests behind describe.todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root CLAUDE.md bans new behavior in .zig files — they're kept as a porting reference only. The 8 tests in this block exercise the chained input-sourcemap feature implemented in the .zig tree of this PR, which isn't wired into the live Rust bundler. Wrapping them in describe.todo keeps them as the intended-behavior spec for the eventual Rust port (per the file-by-file plan in the PR description) without red-lining every CI run. Flip back to describe() once InputSourceMap.rs + the Graph.rs / ParseTask.rs / LinkerContext.rs / Chunk.rs changes land. --- test/bundler/bun-build-api.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 6312d8d189d1..83426680afb5 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1650,7 +1650,15 @@ test.skipIf(isWindows)( // `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / // `.ts` file compiled to an intermediate `.js` with an inline sourcemap // should have its authored sources surface in the final bundle's map. -describe("Bun.build chains inline input sourcemaps", () => { +// +// `describe.todo` because the bundler is being ported from Zig to Rust +// (#30412). The feature is implemented end-to-end in the `.zig` tree as +// the porting reference (see PR #30539 description for the file-by-file +// port plan), but `.zig` files no longer compile or ship — the active +// bundler path is Rust and has not been extended yet. These tests pin +// the intended behavior for when the Rust port lands; flip back to +// `describe(...)` at that point. +describe.todo("Bun.build chains inline input sourcemaps", () => { // Build a tiny intermediate `.js` that carries an inline base64 sourcemap // pointing at a fake "authored" source, then bundle an entry that imports // it. The output map's `sources[]` should include the authored source, From 02b8ec87919c2b546110303bea9b8ce8b5558840 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 03:05:40 +0000 Subject: [PATCH 09/29] bundler: chain inline input sourcemaps through to output (Rust port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the Zig design in an earlier commit on this branch to Rust, since the bundler is now implemented in Rust (#30412). Same behavior, same 8 tests, different language. Files touched (Rust): - src/sourcemap/InputSourceMap.rs (new): owns *ParsedSourceMap + per-source contents; `parse` (validates v3 JSON, returns None on malformed), `parse_from_source` (last-line-anchored `//# sourceMappingURL=data:...` scan + base64/raw payload decode). - src/bundler/Graph.rs: `InputFile.input_source_map` column on the MultiArrayList + SoA accessor via `multi_array_columns!`. - src/bundler/ParseTask.rs: after getAST, scan `source.contents` for an inline map; gated on `source_map != None` + `can_have_source_map` + non-empty contents. Field added to `Success`. - src/bundler/ServerComponentParseTask.rs: wrapper Success constructor gets `input_source_map: None` (generated, not authored). - src/bundler/bundle_v2.rs: move from Success into the Graph SoA slot on parse completion; drain slots in `deinit_without_freeing_arena`. - src/bundler/LinkerContext.rs: `generate_source_map_for_chunk` expands outer `sources[]` to [intermediate, inner_0, …, inner_N-1] and mirrors `sourcesContent[]`; stitch the absolute source_index using `base + chunk.end_state.source_index`. Option threaded through to the printer, gated on `dev_server.is_none()` (Bake's SourceMapStore is a separate stitcher with a different layout). - src/sourcemap/Chunk.rs: `Builder.input_source_map`; in `add_source_mapping`, look up the intermediate (line, col) via `find_mapping`. On hit emit `mapped_source_index = 1 + inner.source_index` + inner's (line, col); on miss fall back to slot 0. - src/js_printer/lib.rs: `Options.input_source_map` forwarded into the builder in `get_source_map_builder`. Test suite in test/bundler/bun-build-api.test.ts flipped back from `describe.todo` to `describe` — all 8 pass: - base64 data URL: authored source surfaces - raw data URL: authored source surfaces - multi-inner-source map (e.g. .vue split): all surface - malformed payload: build succeeds, falls back - out-of-range inner source_index: rejected, no slot aliasing - in-body sourceMappingURL marker: ignored, no hijack - external .map reference: unchanged behavior - plugin onLoad inline map: regression guard for #6173 Closes #30536, also fixes #6173. --- src/bundler/Graph.rs | 8 + src/bundler/LinkerContext.rs | 210 ++++++++++++++----- src/bundler/ParseTask.rs | 30 +++ src/bundler/ServerComponentParseTask.rs | 3 + src/bundler/bundle_v2.rs | 22 ++ src/js_printer/lib.rs | 12 ++ src/sourcemap/Chunk.rs | 47 ++++- src/sourcemap/InputSourceMap.rs | 264 ++++++++++++++++++++++++ src/sourcemap/lib.rs | 4 + test/bundler/bun-build-api.test.ts | 10 +- 10 files changed, 542 insertions(+), 68 deletions(-) create mode 100644 src/sourcemap/InputSourceMap.rs diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index aa13706b0826..e7484792553b 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -114,6 +114,13 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, + /// When this file carried an inline `//# sourceMappingURL=data:...` + /// comment, the decoded inner map plus its `sourcesContent` bytes. The + /// linker expands outer `sources[]` / `sourcesContent[]` with these + /// inner entries and the `Chunk::Builder` remaps its mappings through + /// the inner `find_mapping` so final stack traces surface in the + /// authored source. `None` when no chain is available (most inputs). + pub input_source_map: Option>, } impl Default for InputFile { @@ -144,6 +151,7 @@ bun_collections::multi_array_columns! { unique_key_for_additional_file: Box<[u8], AstAlloc>, content_hash_for_additional_file: u64, flags: InputFileFlags, + input_source_map: Option>, } } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index bc47073ee17d..b9b6d89f4ced 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1044,68 +1044,62 @@ impl<'a> LinkerContext<'a> { let sources = self.parse_graph().input_files.items_source(); let quoted_source_map_contents = self.graph.files.items_quoted_source_contents(); + // DevServer uses a separate sourcemap stitcher (`SourceMapStore::join_vlq`) + // that hard-codes one `sources[]` slot per input; threading inner-map + // expansion through there would corrupt its output. `DevServer == None` + // gates the whole feature so the HMR path stays byte-identical. + let input_source_maps: Option<&[Option>]> = + if self.dev_server.is_none() { + Some(self.parse_graph().input_files.items_input_source_map()) + } else { + None + }; // Entries in `results` do not 1:1 map to source files, the mapping // is actually many to one, where a source file can have multiple chunks // in the sourcemap. // - // This hashmap is going to map: + // This hashmap maps: // `source_index` (per compilation) in a chunk // --> - // Which source index in the generated sourcemap, referred to - // as the "mapping source index" within this function to be distinct. + // Base source index in the generated sourcemap (inclusive). When + // the input file did not carry an inline sourcemap, the chunk's + // mappings all use that base. When the input file carried an + // inline `//# sourceMappingURL=`, the chunk's mappings were + // remapped through that inner map at print time and now span + // `base .. base + inner.external_source_names.len - 1`. let mut source_id_map: ArrayHashMap = ArrayHashMap::new(); let source_indices = results.items_source_index(); j.push_static(b"{\n \"version\": 3,\n \"sources\": ["); + let mut next_mapping_source_index: i32 = 0; if !source_indices.is_empty() { - { - let index = source_indices[0]; - let path = &sources[index as usize].path; - source_id_map.put_no_clobber(index, 0)?; - - // Note: the relative path lives in a local owned buffer - // (drops at scope exit). - let rel_path_storage; - let pretty: &[u8] = if path.is_file() { - rel_path_storage = Self::source_map_relative_path(chunk_abs_dir, path.text)?; - &rel_path_storage - } else { - path.pretty - }; - - let mut quote_buf = MutableString::init(pretty.len() + 2)?; - js_printer::quote_for_json(pretty, &mut quote_buf, false)?; - // `to_default_owned` moves the buffer into the joiner - // (joiner owns it until `done`). - j.push_owned(quote_buf.to_default_owned()); - } - - let mut next_mapping_source_index: i32 = 1; - for &index in &source_indices[1..] { + for (chunk_i, &index) in source_indices.iter().enumerate() { let gop = source_id_map.get_or_put(index)?; if gop.found_existing { continue; } *gop.value_ptr = next_mapping_source_index; - next_mapping_source_index += 1; - - let path = &sources[index as usize].path; - - let rel_path_storage; - let pretty: &[u8] = if path.is_file() { - rel_path_storage = Self::source_map_relative_path(chunk_abs_dir, path.text)?; - &rel_path_storage - } else { - path.pretty + // `1` for the intermediate input, plus one slot per inner + // source listed in its `sourceMappingURL`. + let inner: Option<&bun_sourcemap::InputSourceMap> = input_source_maps + .and_then(|m| m[index as usize].as_deref()); + let expansion: i32 = 1 + match inner { + Some(ism) => i32::try_from(ism.map.external_source_names.len()) + .expect("int cast"), + None => 0, }; - - let mut quote_buf = MutableString::init(pretty.len() + ", ".len() + 2)?; - quote_buf.append_assume_capacity(b", "); - js_printer::quote_for_json(pretty, &mut quote_buf, false)?; - j.push_owned(quote_buf.to_default_owned()); + next_mapping_source_index += expansion; + + write_sources_for( + &mut j, + chunk_abs_dir, + &sources[index as usize].path, + inner, + chunk_i > 0, + )?; } } @@ -1113,20 +1107,39 @@ impl<'a> LinkerContext<'a> { let source_indices_for_contents = source_id_map.keys(); if !source_indices_for_contents.is_empty() { - j.push_static(b"\n "); - j.push_static( - quoted_source_map_contents[source_indices_for_contents[0] as usize] - .as_deref() - .unwrap_or(b""), - ); - - for &index in &source_indices_for_contents[1..] { - j.push_static(b",\n "); - j.push_static( - quoted_source_map_contents[index as usize] + let mut emitted_contents: usize = 0; + for &index in source_indices_for_contents.iter() { + // Slot 0: the intermediate input file's contents (already + // JSON-quoted by `compute_quoted_source_contents`). + { + let sep: &[u8] = if emitted_contents == 0 { + b"\n " + } else { + b",\n " + }; + j.push_static(sep); + let content = quoted_source_map_contents[index as usize] .as_deref() - .unwrap_or(b""), - ); + .unwrap_or(b"null"); + j.push_static(if content.is_empty() { b"null" } else { content }); + emitted_contents += 1; + } + // Slots 1..N: inner sources' contents, if any. + if let Some(ism) = + input_source_maps.and_then(|m| m[index as usize].as_deref()) + { + for content in ism.sources_content.iter() { + j.push_static(b",\n "); + if !content.is_empty() { + let mut quote_buf = MutableString::init(content.len() + 2)?; + js_printer::quote_for_json(content, &mut quote_buf, false)?; + j.push_owned(quote_buf.to_default_owned()); + } else { + j.push_static(b"null"); + } + emitted_contents += 1; + } + } } } j.push_static(b"\n ],\n \"mappings\": \""); @@ -1166,7 +1179,12 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - prev_end_state.source_index = mapping_source_index; + // If the input carried an inline map, `chunk.end_state.source_index` + // is the inner source_index of the last mapping within the chunk + // (the Builder emits remapped absolute-within-chunk indices). + // Otherwise it's 0. Either way, the final absolute index is + // `mapping_source_index + chunk.end_state.source_index`. + prev_end_state.source_index = mapping_source_index + chunk.end_state.source_index; prev_column_offset = chunk.final_generated_column; if prev_end_state.generated_line == 0 { @@ -1210,6 +1228,74 @@ impl<'a> LinkerContext<'a> { } } +/// Emit one outer source's quoted path, plus any inner source paths +/// contributed by its `//# sourceMappingURL=` (one slot per inner source, +/// in `external_source_names` order). `leading_comma` is true when this is +/// not the first path appended to the running `sources[]` array — we +/// prefix `", "` before the outer path in that case. +/// +/// Layout matches the one `Chunk::Builder` assumes in `Chunk.rs`: +/// slot 0 → the intermediate input (this outer file) +/// slot 1..N → inner `sources[i]` (chained) +fn write_sources_for( + joiner: &mut StringJoiner, + chunk_abs_dir: &[u8], + outer_path: &bun_paths::fs::Path, + input_map: Option<&bun_sourcemap::InputSourceMap>, + leading_comma: bool, +) -> Result<(), BunError> { + // 1) the intermediate input. + let rel_path_storage; + let pretty: &[u8] = if outer_path.is_file() { + rel_path_storage = + LinkerContext::source_map_relative_path(chunk_abs_dir, outer_path.text)?; + &rel_path_storage + } else { + outer_path.pretty + }; + { + let mut quote_buf = MutableString::init(pretty.len() + ", ".len() + 2)?; + if leading_comma { + quote_buf.append_assume_capacity(b", "); + } + js_printer::quote_for_json(pretty, &mut quote_buf, false)?; + joiner.push_owned(quote_buf.to_default_owned()); + } + + // 2) inner sources, if any. Each inner `sources[i]` is resolved + // relative to the directory of the intermediate file it came from, + // then made relative to `chunk_abs_dir` (the chunk's output dir) for + // the emitted JSON. Absolute inner paths stay absolute before + // relativization. + if let Some(ism) = input_map { + let base_dir = bun_paths::resolve_path::dirname::< + bun_paths::resolve_path::platform::Auto, + >(outer_path.text); + for name in ism.map.external_source_names.iter() { + let name: &[u8] = name.as_ref(); + // Use `join_abs` to produce an absolute inner path (when the + // inner map emitted a relative source name) that can then be + // re-relativized against `chunk_abs_dir`. `join_abs` returns + // a borrow into a thread-local buffer; we copy out immediately + // via `relative_alloc`. + let abs_path: &[u8] = if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + name + } else { + bun_paths::resolve_path::join_abs::( + base_dir, name, + ) + }; + let rel_path = LinkerContext::source_map_relative_path(chunk_abs_dir, abs_path)?; + + let mut quote_buf = MutableString::init(rel_path.len() + ", ".len() + 2)?; + quote_buf.append_assume_capacity(b", "); + js_printer::quote_for_json(&rel_path, &mut quote_buf, false)?; + joiner.push_owned(quote_buf.to_default_owned()); + } + } + Ok(()) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ScanCssImportsResult { Ok, @@ -2193,6 +2279,17 @@ impl<'a> LinkerContext<'a> { // SAFETY: `self.mangled_props` is not mutated during printing; detached borrow // outlives only this call (see above). unsafe { bun_ptr::detach_lifetime_ref(&self.mangled_props) }; + // DevServer uses a separate sourcemap stitcher that hard-codes one + // `sources[]` slot per file; passing `input_source_map` would + // corrupt its output. Gate the whole feature on the Bun.build path. + let input_source_map: Option<&bun_sourcemap::InputSourceMap> = + if self.dev_server.is_none() { + parse_graph.input_files.items_input_source_map() + [source_index.get() as usize] + .as_deref() + } else { + None + }; let print_options = js_printer::Options { bundling: true, @@ -2241,6 +2338,7 @@ impl<'a> LinkerContext<'a> { } else { None }, + input_source_map, mangled_props: Some(mangled_props), module_info, ..Default::default() diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index a4b7cc2acbaf..f10d75f63a52 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -187,6 +187,14 @@ pub(crate) struct Success { /// The package name from package.json, used for barrel optimization. pub(crate) package_name: ast::StoreStr, + + /// Decoded trailing inline `//# sourceMappingURL=data:...` inner map, + /// parsed from the source bytes. `None` when the file had no inline + /// sourcemap comment, when sourcemaps are disabled on the build, or + /// when the inline payload was malformed (caller silently falls back + /// to the raw file bytes). Moved into `graph.input_files.input_source_map` + /// by `on_parse_task_complete`. + pub(crate) input_source_map: Option>, } pub(crate) struct ResultError { @@ -2683,6 +2691,26 @@ pub mod parse_worker { *step = Step::Resolve; + // Chain any inline `//# sourceMappingURL=data:...` map the input + // file carries (e.g. a `.vue`/`.svelte` compiler's trailing + // comment on the intermediate `.js`) into the output sourcemap. + // This scan runs on `source.contents` whether they came from a + // file read or a plugin `onLoad` return, so this covers #6173 + // too. Gated on: + // - source maps enabled on the build (no cost otherwise) + // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) + // - non-empty contents (the scanner would find nothing) + // Malformed payloads return `None` and fall back cleanly. + let input_source_map: Option> = + if topts.source_map != options::SourceMapOption::None + && loader.can_have_source_map() + && !source.contents.is_empty() + { + bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + } else { + None + }; + Ok(Success { ast, source: source.clone(), @@ -2699,6 +2727,8 @@ pub mod parse_worker { } else { 0 }, + + input_source_map, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index b2f10b316297..ecba542b5a98 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -203,6 +203,9 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, + // Server-component wrappers are generated, not authored — no inline + // sourcemap comment to chain through. + input_source_map: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 24999fd82b68..b865f84d8b9d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4955,6 +4955,16 @@ pub mod bv2_impl { // `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its // aliases, so this is the unique drop. { + // `input_source_map` columns hold owned `Box` + // (inner `Arc` + owned `sources_content` Vec) + // allocated from the global heap, not the AST arena. The + // slab-only `MultiArrayList::drop` would strand them, so + // drain explicitly before the slab is released. Matches the + // explicit-drain pattern kept for `css` below. + for m in self.graph.input_files.items_input_source_map_mut() { + drop(m.take()); + } + macro_rules! take_ast_cols { ($ast:expr) => {{ let ast = $ast; @@ -7085,6 +7095,18 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; + // Transfer ownership of any decoded inline input sourcemap + // from the parse result onto the SoA slot. An earlier + // occupant (e.g. incremental reparse of a previously-loaded + // file) is dropped here — the `Box`'s Drop + // releases the inner `Arc` and the owned + // `sources_content` buffers. + { + let slot = &mut this.graph.input_files.items_input_source_map_mut() + [result_source_index]; + *slot = core::mem::take(&mut result.input_source_map); + } + bun_core::scoped_log!( Bundle, "onParse({}, {}) = {} imports, {} exports", diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 68ef9a6b2e9f..892d2dd1d598 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1190,6 +1190,16 @@ pub struct Options<'a> { /// builder as `LineOffsetTables::Borrowed`. pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, + /// When `Some`, the bundler input file carried an inline + /// `//# sourceMappingURL=data:...` comment. The chunk builder + /// remaps each emitted mapping through this inner map so the final + /// output's `source_index`/`(original_line, original_column)` refer + /// to the authored source instead of the intermediate input. + /// `None` for files that don't carry an inline sourcemap, or for + /// the DevServer HMR path (which uses a separate stitcher that + /// hard-codes one `sources[]` slot per file). + pub input_source_map: Option<&'a SourceMap::InputSourceMap>, + pub mangled_props: Option<&'a crate::MangledProps>, } @@ -1243,6 +1253,7 @@ impl<'a> Default for Options<'a> { module_type: bundle_opts::Format::Esm, ts_enums: None, line_offset_tables: None, + input_source_map: None, mangled_props: None, } } @@ -7329,6 +7340,7 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( cover_lines_without_mappings: true, approximate_input_line_count: tree.approximate_newline_count, prepend_count: IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, + input_source_map: opts.input_source_map.take(), line_offset_tables: match opts.line_offset_tables.take() { Some(table) => LineOffsetTables::Borrowed(table), None if generate_source_map == GenerateSourceMap::Lazy => LineOffsetTables::Deferred { diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 899e5671c01f..2cda73ba1ff3 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -375,6 +375,17 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// `line_offset_table_byte_offset_list`. pub line_offset_table_first_non_ascii: RawSlice, + /// When set, the bundler/printer input file carried an inline + /// `//# sourceMappingURL=data:...` comment; `add_source_mapping` will + /// remap each mapping through its inner map so the emitted + /// `source_index` / original `(line, col)` refer to the authored + /// source instead of the bundler's intermediate input. Unset + /// otherwise — the emitted mapping uses the Builder's own + /// `prev_state.source_index` (the outer source's slot). The borrow + /// lives in `Graph::input_files[i].input_source_map` + /// (`Option>`). + pub input_source_map: Option<&'a crate::InputSourceMap>, + // This is a workaround for a bug in the popular "source-map" library: // https://github.com/mozilla/source-map/issues/261. The library will // sometimes return null when querying a source map unless every line @@ -406,6 +417,7 @@ impl Default for NewBuilder<'_, T> { has_prev_state: false, line_offset_table_byte_offset_list: RawSlice::EMPTY, line_offset_table_first_non_ascii: RawSlice::EMPTY, + input_source_map: None, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -706,6 +718,35 @@ impl NewBuilder<'_, VLQSourceMap> { self.update_generated_line_and_column(output); + // Remap through the input's inline sourcemap if present. The + // intermediate input's `(original_line, original_column)` becomes + // the authored source's `(line, col)` via `find_mapping`. On + // hit, the emitted `source_index` is `1 + inner.source_index` — + // the layout `LinkerContext` uses for this file: + // slot 0 → the intermediate input + // 1 + inner_idx → inner `sources[inner_idx]` + // The emitted `source_index` is relative to the chunk's start + // (the Builder always begins with `prev_state.source_index = 0`); + // `LinkerContext` stitches the absolute base in when joining + // chunks. Mappings the inner map doesn't cover fall back to + // slot 0 (the intermediate) so stack traces land in the right + // file rather than silently disappearing. + let mut mapped_source_index: i32 = 0; + let mut mapped_original_line: i32 = original_line.max(0); + let mut mapped_original_column: i32 = original_column.max(0); + if let Some(ism) = self.input_source_map { + if let Some(inner) = ism.map.find_mapping( + crate::Ordinal::from_zero_based(mapped_original_line), + crate::Ordinal::from_zero_based(mapped_original_column), + ) { + mapped_source_index = 1 + inner.source_index; + mapped_original_line = inner.original.lines.zero_based(); + mapped_original_column = inner.original.columns.zero_based(); + } + // else: fall back to the intermediate (slot 0) using the + // (line, col) we already have in the intermediate. + } + // If this line doesn't start with a mapping and we're about to add a mapping // that's not at the start, insert a mapping first so the line starts with one. if self.cover_lines_without_mappings @@ -725,9 +766,9 @@ impl NewBuilder<'_, VLQSourceMap> { self.append_mapping(SourceMapState { generated_line: self.prev_state.generated_line, generated_column: self.generated_column.max(0), - source_index: self.prev_state.source_index, - original_line: original_line.max(0), - original_column: original_column.max(0), + source_index: mapped_source_index, + original_line: mapped_original_line, + original_column: mapped_original_column, }); // This line now has a mapping on it, so don't insert another one diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs new file mode 100644 index 000000000000..bf3014558ebb --- /dev/null +++ b/src/sourcemap/InputSourceMap.rs @@ -0,0 +1,264 @@ +//! Per-input-file sourcemap used by the bundler to chain sourcemaps through +//! upstream compile steps (e.g. `.vue` → `.js`, `.svelte` → `.js`, +//! TypeScript plugins). When `Bun.build` reads an input file that carries +//! an inline `//# sourceMappingURL=data:application/json;...` comment, we +//! parse it into an `InputSourceMap` and store it on the file's +//! `Graph::InputFile`. `LinkerContext` then emits its `sources` / +//! `sourcesContent` in place of the intermediate, and `Chunk::Builder` +//! remaps each mapping through `map.find_mapping` during printing so stack +//! traces surface in the authored source. + +use std::sync::Arc; + +use bun_collections::VecExt; + +use crate::{Mapping, ParsedSourceMap}; + +/// Parsed inner sourcemap + per-source content bytes, owned. +/// +/// `map.external_source_names` holds the chained-in `sources[]`. +/// `sources_content[i]` is the inner file's `sourcesContent[i]`; an empty +/// slot (`b""`) means the inner map did not carry content for that source. +pub struct InputSourceMap { + pub map: Arc, + pub sources_content: Box<[Box<[u8]>]>, +} + +impl InputSourceMap { + /// Parse a sourcemap JSON blob intended to chain through a bundler input + /// file. Returns `None` when the payload is malformed — callers fall back + /// to the raw file bytes. Allocation failures panic via `handle_oom`. + /// + /// `json_bytes` is borrowed; the function copies out what it needs. + pub fn parse(json_bytes: &[u8]) -> Option> { + parse_internal(json_bytes).ok() + } + + /// Locate a trailing `//# sourceMappingURL=data:...` inline comment in + /// `source` and parse the embedded map. Returns `None` when no URL is + /// present, when the URL is not a data URL (e.g. a `.map` filename), or + /// when the payload fails to parse. External `.map` file resolution is + /// the caller's responsibility. + pub fn parse_from_source(source: &[u8]) -> Option> { + let url = find_source_mapping_url(source)?; + parse_data_url(url) + } +} + +/// Malformed input is indistinguishable from "no chain available" — callers +/// treat it as a silent fallback to the raw file bytes. +struct InvalidSourceMap; + +/// Workhorse returning `Result` so `?` fires cleanup on malformed-payload +/// bails — critical because JSON can pass the structural checks but still +/// have a malformed `mappings` VLQ, and we'd otherwise leak everything +/// allocated up to that point. Zig's `errdefer` becomes Rust's automatic +/// drop on early return. +fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourceMap> { + use bun_ast::StoreResetGuard as DataStoreScope; + + let arena = bun_alloc::Arena::new(); + let json_src = bun_ast::Source::init_path_string("sourcemap.json", json_bytes); + let mut log = bun_ast::Log::init(); + + // The JSON parser doesn't respect the supplied allocator for every + // alloc, so reset the AST store on entry and exit. + let _store_scope = DataStoreScope::new(); + + let json = bun_parsers::json::parse::(&json_src, &mut log, &arena) + .map_err(|_| InvalidSourceMap)?; + + if let Some(version) = json.get(b"version") { + match version.data.as_e_number() { + Some(n) if n.value == 3.0 => {} + _ => return Err(InvalidSourceMap), + } + } + + let mappings_str = json.get(b"mappings").ok_or(InvalidSourceMap)?; + let mut mappings_e_string = mappings_str.data.as_e_string().ok_or(InvalidSourceMap)?; + let mappings_slice: &[u8] = mappings_e_string.slice(&arena); + + let sources_paths = json + .get(b"sources") + .ok_or(InvalidSourceMap)? + .data + .as_e_array() + .ok_or(InvalidSourceMap)?; + + // `sourcesContent` is optional; when absent or null every slot is empty. + let sources_content_opt = match json.get(b"sourcesContent") { + None => None, + Some(v) => match v.data.as_e_array() { + Some(arr) => Some(arr), + None => { + // `null` is tolerated; other non-array values are malformed. + if matches!(v.data, bun_ast::ExprData::ENull(_)) { + None + } else { + return Err(InvalidSourceMap); + } + } + }, + }; + + if let Some(arr) = sources_content_opt { + if arr.items.len_u32() != sources_paths.items.len_u32() { + return Err(InvalidSourceMap); + } + } + + let source_count = sources_paths.items.len_u32() as usize; + + // Copy source paths out of the arena into owned storage. + let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); + for item in sources_paths.items.slice() { + let mut estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; + // handle_oom — fatal if OOM + let s = estr.string(&arena).expect("OOM"); + source_paths_slice.push(Box::<[u8]>::from(s)); + } + + // Copy source contents. Non-strings (null, etc.) and empty slots map to `b""`. + let mut sources_content_slice: Vec> = Vec::with_capacity(source_count); + if let Some(arr) = sources_content_opt { + for item in arr.items.slice() { + let slot: Box<[u8]> = if let Some(mut estr) = item.data.as_e_string() { + let s = estr.string(&arena).expect("OOM"); + if s.is_empty() { + Box::<[u8]>::from(&b""[..]) + } else { + Box::<[u8]>::from(s) + } + } else { + Box::<[u8]>::from(&b""[..]) + }; + sources_content_slice.push(slot); + } + } else { + for _ in 0..source_count { + sources_content_slice.push(Box::<[u8]>::from(&b""[..])); + } + } + + // `sources_count` bounds every `source_index` encoded in the VLQ + // mappings. The downstream consumers (`Chunk::Builder` emits + // `1 + inner.source_index`; `LinkerContext` reserves exactly + // `1 + external_source_names.len` slots per file) DON'T defensively + // clamp — out-of-range indices would alias a neighboring input file's + // slot in the output `sources[]`. Pass the real source count so + // malformed maps hit `Fail` and we fall back cleanly. + let sources_count_i32: i32 = i32::try_from(source_count).map_err(|_| InvalidSourceMap)?; + let map_data = match crate::mapping::parse( + mappings_slice, + None, + sources_count_i32, + i32::MAX as usize, + crate::mapping::ParseOptions { + allow_names: false, + sort: true, + }, + ) { + crate::ParseResult::Success(x) => x, + crate::ParseResult::Fail(_) => return Err(InvalidSourceMap), + }; + + let mut psm = map_data; + psm.external_source_names = source_paths_slice; + + Ok(Box::new(InputSourceMap { + map: Arc::new(psm), + sources_content: sources_content_slice.into_boxed_slice(), + })) +} + +/// Find the trailing `//# sourceMappingURL=` comment in a file. Per +/// the Source Map spec the comment MUST be on the last line of the file +/// (see "3. Source Map Format" / "Linking generated code to source maps"), +/// so we anchor to the final line rather than the first `last_index_of` +/// match — a string literal earlier in the file containing that needle +/// must not hijack the lookup. +fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { + // Trim trailing whitespace/newlines so a file that ends with + // `\n//# sourceMappingURL=...\n\n` still resolves to its final line. + let mut end = source.len(); + while end > 0 { + let c = source[end - 1]; + if c == b' ' || c == b'\r' || c == b'\n' || c == b'\t' { + end -= 1; + } else { + break; + } + } + let body = &source[..end]; + if body.is_empty() { + return None; + } + + let last_line_start = match body.iter().rposition(|&b| b == b'\n') { + Some(i) => i + 1, + None => 0, + }; + let last_line = &body[last_line_start..]; + + const NEEDLE: &[u8] = b"//# sourceMappingURL="; + if !last_line.starts_with(NEEDLE) { + return None; + } + let mut url = &last_line[NEEDLE.len()..]; + // Trim trailing whitespace within the line (the final-line trim above + // already handled newlines, but intra-line `\r\n` style endings and + // stray spaces still need trimming). + while let Some(&last) = url.last() { + if last == b' ' || last == b'\r' || last == b'\t' { + url = &url[..url.len() - 1]; + } else { + break; + } + } + Some(url) +} + +/// Decode `data:application/json[;...;base64],...` payloads. Returns `None` +/// when the URL is not a supported data scheme. +fn parse_data_url(url: &[u8]) -> Option> { + const PREFIX: &[u8] = b"data:application/json"; + if !url.starts_with(PREFIX) || url.len() <= PREFIX.len() + 1 { + return None; + } + + // `data:application/json;charset=utf-8;base64,...` is permitted in the + // wild; tolerate any number of `;name[=value]` parameters between the + // prefix and the final `;base64,` / `,` separator. + let mut rest = &url[PREFIX.len()..]; + let mut is_base64 = false; + while !rest.is_empty() && rest[0] == b';' { + let after = &rest[1..]; + // Advance past one parameter up to the next ';' or ','. + let param_end = after.iter().position(|&b| b == b';' || b == b',')?; + let param = &after[..param_end]; + if param == b"base64" { + is_base64 = true; + } + rest = &after[param_end..]; + } + if rest.is_empty() || rest[0] != b',' { + return None; + } + let payload = &rest[1..]; + + if is_base64 { + let decoded_len = bun_base64::decode_len(payload); + let mut buf: Vec = vec![0u8; decoded_len]; + let decoded = bun_base64::decode(&mut buf, payload); + if !decoded.is_successful() { + return None; + } + InputSourceMap::parse(&buf[..decoded.count]) + } else { + // Not base64; treat the payload as the raw JSON text. + InputSourceMap::parse(payload) + } +} + +// ported from: src/sourcemap/InputSourceMap.zig diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 5aaf6e92f0f4..8eaaea1c59de 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -20,6 +20,10 @@ pub mod line_offset_table; pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; +#[path = "InputSourceMap.rs"] +pub mod input_source_map; + +pub use input_source_map::InputSourceMap; pub use bun_base64::vlq; pub use vlq::VLQ; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 83426680afb5..6312d8d189d1 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1650,15 +1650,7 @@ test.skipIf(isWindows)( // `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / // `.ts` file compiled to an intermediate `.js` with an inline sourcemap // should have its authored sources surface in the final bundle's map. -// -// `describe.todo` because the bundler is being ported from Zig to Rust -// (#30412). The feature is implemented end-to-end in the `.zig` tree as -// the porting reference (see PR #30539 description for the file-by-file -// port plan), but `.zig` files no longer compile or ship — the active -// bundler path is Rust and has not been extended yet. These tests pin -// the intended behavior for when the Rust port lands; flip back to -// `describe(...)` at that point. -describe.todo("Bun.build chains inline input sourcemaps", () => { +describe("Bun.build chains inline input sourcemaps", () => { // Build a tiny intermediate `.js` that carries an inline base64 sourcemap // pointing at a fake "authored" source, then bundle an entry that imports // it. The output map's `sources[]` should include the authored source, From 35cbe17d26371e889206b5369a5cd1b4036a8190 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 03:07:37 +0000 Subject: [PATCH 10/29] [autofix.ci] apply automated fixes --- src/bundler/LinkerContext.rs | 33 +++++++++++++++------------------ src/bundler/ParseTask.rs | 18 +++++++++--------- src/sourcemap/lib.rs | 4 ++-- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index b9b6d89f4ced..13591b8d78ac 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1084,11 +1084,12 @@ impl<'a> LinkerContext<'a> { *gop.value_ptr = next_mapping_source_index; // `1` for the intermediate input, plus one slot per inner // source listed in its `sourceMappingURL`. - let inner: Option<&bun_sourcemap::InputSourceMap> = input_source_maps - .and_then(|m| m[index as usize].as_deref()); + let inner: Option<&bun_sourcemap::InputSourceMap> = + input_source_maps.and_then(|m| m[index as usize].as_deref()); let expansion: i32 = 1 + match inner { - Some(ism) => i32::try_from(ism.map.external_source_names.len()) - .expect("int cast"), + Some(ism) => { + i32::try_from(ism.map.external_source_names.len()).expect("int cast") + } None => 0, }; next_mapping_source_index += expansion; @@ -1125,9 +1126,7 @@ impl<'a> LinkerContext<'a> { emitted_contents += 1; } // Slots 1..N: inner sources' contents, if any. - if let Some(ism) = - input_source_maps.and_then(|m| m[index as usize].as_deref()) - { + if let Some(ism) = input_source_maps.and_then(|m| m[index as usize].as_deref()) { for content in ism.sources_content.iter() { j.push_static(b",\n "); if !content.is_empty() { @@ -1268,9 +1267,9 @@ fn write_sources_for( // the emitted JSON. Absolute inner paths stay absolute before // relativization. if let Some(ism) = input_map { - let base_dir = bun_paths::resolve_path::dirname::< - bun_paths::resolve_path::platform::Auto, - >(outer_path.text); + let base_dir = bun_paths::resolve_path::dirname::( + outer_path.text, + ); for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); // Use `join_abs` to produce an absolute inner path (when the @@ -2282,14 +2281,12 @@ impl<'a> LinkerContext<'a> { // DevServer uses a separate sourcemap stitcher that hard-codes one // `sources[]` slot per file; passing `input_source_map` would // corrupt its output. Gate the whole feature on the Bun.build path. - let input_source_map: Option<&bun_sourcemap::InputSourceMap> = - if self.dev_server.is_none() { - parse_graph.input_files.items_input_source_map() - [source_index.get() as usize] - .as_deref() - } else { - None - }; + let input_source_map: Option<&bun_sourcemap::InputSourceMap> = if self.dev_server.is_none() + { + parse_graph.input_files.items_input_source_map()[source_index.get() as usize].as_deref() + } else { + None + }; let print_options = js_printer::Options { bundling: true, diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index f10d75f63a52..debd838ba149 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2701,15 +2701,15 @@ pub mod parse_worker { // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) // - non-empty contents (the scanner would find nothing) // Malformed payloads return `None` and fall back cleanly. - let input_source_map: Option> = - if topts.source_map != options::SourceMapOption::None - && loader.can_have_source_map() - && !source.contents.is_empty() - { - bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) - } else { - None - }; + let input_source_map: Option> = if topts.source_map + != options::SourceMapOption::None + && loader.can_have_source_map() + && !source.contents.is_empty() + { + bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + } else { + None + }; Ok(Success { ast, diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 8eaaea1c59de..8df79757610d 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -12,6 +12,8 @@ pub use error::{Error, Result}; #[path = "Chunk.rs"] pub mod chunk; +#[path = "InputSourceMap.rs"] +pub mod input_source_map; #[path = "InternalSourceMap.rs"] pub mod internal_source_map; #[path = "LineOffsetTable.rs"] @@ -20,8 +22,6 @@ pub mod line_offset_table; pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; -#[path = "InputSourceMap.rs"] -pub mod input_source_map; pub use input_source_map::InputSourceMap; From 4983e19bebf85463b2261698be07bc5fa56dd038 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 19 May 2026 09:51:42 +0000 Subject: [PATCH 11/29] sourcemap: trim whitespace on both sides of the URL after sourceMappingURL= Matches Zig's `bun.strings.trim(_, " \r\t")` at InputSourceMap.zig:219. A leading space after `=` (e.g. `//# sourceMappingURL= data:...`) is spec-invalid but some toolchains emit it, and `parse_data_url`'s prefix check would fail on the leading space without this. Flagged by claude[bot] as a port-fidelity divergence. --- src/sourcemap/InputSourceMap.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index bf3014558ebb..0bb9fcba8d99 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -206,9 +206,18 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } let mut url = &last_line[NEEDLE.len()..]; - // Trim trailing whitespace within the line (the final-line trim above - // already handled newlines, but intra-line `\r\n` style endings and - // stray spaces still need trimming). + // Trim whitespace on both sides within the line. Matches Zig's + // `bun.strings.trim(_, " \r\t")`; a leading space after `=` (e.g. + // `//# sourceMappingURL= data:...`) is spec-invalid but some + // toolchains emit it, and `parse_data_url` would fail on the + // leading space without this. + while let Some(&first) = url.first() { + if first == b' ' || first == b'\r' || first == b'\t' { + url = &url[1..]; + } else { + break; + } + } while let Some(&last) = url.last() { if last == b' ' || last == b'\r' || last == b'\t' { url = &url[..url.len() - 1]; From 6dd5c39dc91f075bfcf36350286d0ea160edb852 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 05:51:59 +0000 Subject: [PATCH 12/29] bundler: init Graph::InputFile.input_source_map in Default impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's #30875 replaced the auto-derived Default impl for InputFile with an explicit one, which I missed in the last rebase — CI caught the missing-field on every build-rust lane. Add the None init. Fixes the build-rust failure in build #56341. --- src/bundler/Graph.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index e7484792553b..ff85b5db6dd9 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -134,6 +134,7 @@ impl Default for InputFile { unique_key_for_additional_file: AstAlloc::vec().into_boxed_slice(), content_hash_for_additional_file: 0, flags: InputFileFlags::default(), + input_source_map: None, } } } From 2b61ec7095d0c77de055ced110728962154b2bf9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 06:25:01 +0000 Subject: [PATCH 13/29] sourcemap: drop unused Mapping import from InputSourceMap.rs Only ParsedSourceMap is referenced; the mapping::parse call is fully-qualified through the lowercase module path. Flagged by claude[bot]. --- src/sourcemap/InputSourceMap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 0bb9fcba8d99..d6e45a8f7211 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use bun_collections::VecExt; -use crate::{Mapping, ParsedSourceMap}; +use crate::ParsedSourceMap; /// Parsed inner sourcemap + per-source content bytes, owned. /// From f5187ac81706711bf84c38214708de8f086b5987 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 06:43:33 +0000 Subject: [PATCH 14/29] bundler: hoist source_map option before get_ast to avoid stacked-borrows UB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit topts is a shared borrow through a raw pointer into *transpiler. get_ast reborrows the same location mutably (also through the raw pointer), which pops topts's SharedReadOnly tag under Stacked Borrows — any subsequent topts.source_map read becomes Miri-detectable UB. Copy source_map out alongside module_type, before the `let _ = topts;` tombstone the prior author left for exactly this invariant. Flagged by claude[bot]. --- src/bundler/ParseTask.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index debd838ba149..38a32acbb69e 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2631,6 +2631,12 @@ pub mod parse_worker { // SAFETY: task.ctx backref valid for the bundle pass (outlives `'r`). let task_ctx = unsafe { task.ctx() }; let module_type = opts.module_type; + // Hoist the `source_map` flag before the tombstone: we need it + // below after `get_ast` runs, but reading `topts.source_map` there + // would touch the invalidated shared borrow (get_ast reborrows + // `(*transpiler).options` mutably via raw pointer, which pops + // `topts`'s tag under Stacked Borrows). Copy it out now. + let source_map_option = topts.source_map; // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. let _ = topts; @@ -2701,7 +2707,7 @@ pub mod parse_worker { // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) // - non-empty contents (the scanner would find nothing) // Malformed payloads return `None` and fall back cleanly. - let input_source_map: Option> = if topts.source_map + let input_source_map: Option> = if source_map_option != options::SourceMapOption::None && loader.can_have_source_map() && !source.contents.is_empty() From 1ae23e155b4a63fb573af9981b68bf11d5beb373 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:51:04 +0000 Subject: [PATCH 15/29] sourcemap: drop redundant mut on estr bindings (-D unused-mut) Main's e_string().string() takes &self now; the `mut` bindings at InputSourceMap.rs:116/126 became unused, which -D unused-mut turns into a hard error on the release build-rust lanes. mappings_e_string keeps its mut (slice() still takes &mut self). --- src/sourcemap/InputSourceMap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index d6e45a8f7211..e8e2e0c08d97 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -113,7 +113,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // Copy source paths out of the arena into owned storage. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items.slice() { - let mut estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; + let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; // handle_oom — fatal if OOM let s = estr.string(&arena).expect("OOM"); source_paths_slice.push(Box::<[u8]>::from(s)); @@ -123,7 +123,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let mut sources_content_slice: Vec> = Vec::with_capacity(source_count); if let Some(arr) = sources_content_opt { for item in arr.items.slice() { - let slot: Box<[u8]> = if let Some(mut estr) = item.data.as_e_string() { + let slot: Box<[u8]> = if let Some(estr) = item.data.as_e_string() { let s = estr.string(&arena).expect("OOM"); if s.is_empty() { Box::<[u8]>::from(&b""[..]) From 8266423353d59278f296cf69eb474354775d854d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:23:06 +0000 Subject: [PATCH 16/29] sourcemap: hint find_line_with_hint from intermediate line, not remapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When input_source_map chaining is active, prev_state.original_line holds the remapped *authored* line — wrong coordinate space for the intermediate file's line-offset table, so the O(1) find_line_with_hint fast path missed on every token and fell to binary search. Track the un-remapped intermediate line in a dedicated prev_intermediate_line field and hint from that. No behavior change (binary search was always the sound fallback); restores the per-token fast path on the chained feature path. Chunk.zig uses plain findLine (no hint), so it's unaffected. Flagged by claude[bot]. --- src/sourcemap/Chunk.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 2cda73ba1ff3..46d0a1e98f02 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -386,6 +386,17 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// (`Option>`). pub input_source_map: Option<&'a crate::InputSourceMap>, + /// Last *intermediate-file* line emitted (before any `input_source_map` + /// remap), kept only to seed `find_line_with_hint`. When chaining is + /// active, `prev_state.original_line` holds the remapped *authored* line, + /// which is the wrong coordinate space for the intermediate's + /// line-offset table — using it as the hint would fail the O(1) fast + /// path on every token and fall through to binary search. This field + /// keeps the hint in the intermediate's space. `0` when no chaining is + /// active (then `prev_state.original_line` is already the intermediate + /// line, but reading this costs nothing). + pub prev_intermediate_line: i32, + // This is a workaround for a bug in the popular "source-map" library: // https://github.com/mozilla/source-map/issues/261. The library will // sometimes return null when querying a source map unless every line @@ -418,6 +429,7 @@ impl Default for NewBuilder<'_, T> { line_offset_table_byte_offset_list: RawSlice::EMPTY, line_offset_table_first_non_ascii: RawSlice::EMPTY, input_source_map: None, + prev_intermediate_line: 0, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -687,14 +699,16 @@ impl NewBuilder<'_, VLQSourceMap> { let byte_offsets = self.line_offset_table_byte_offset_list.slice(); // The printer emits mappings in (mostly) source order, so the previous - // call's `original_line` is the right answer or one/two lines before - // it >95% of the time. Seed `find_line_with_hint` with it; the - // fallback is the same binary search as before. - let original_line = LineOffsetTable::find_line_with_hint( - byte_offsets, - loc, - self.prev_state.original_line as u32, - ); + // call's *intermediate* line is the right answer or one/two lines + // before it >95% of the time. Seed `find_line_with_hint` with it; the + // fallback is the same binary search as before. Hint from + // `prev_intermediate_line` (not `prev_state.original_line`) because the + // latter holds the remapped *authored* line when `input_source_map` is + // active — wrong coordinate space for this (intermediate) table, which + // would poison the fast path. Without chaining the two are equal. + let original_line = + LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_intermediate_line as u32); + self.prev_intermediate_line = original_line.max(0); let idx = original_line.max(0) as usize; // PERF: read the three columns directly instead of `list.get(idx)`. From 60af99850312f481a7c7165e5855bab55407e45c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:25:03 +0000 Subject: [PATCH 17/29] [autofix.ci] apply automated fixes --- src/sourcemap/Chunk.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 46d0a1e98f02..7d13116a02b2 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -706,8 +706,11 @@ impl NewBuilder<'_, VLQSourceMap> { // latter holds the remapped *authored* line when `input_source_map` is // active — wrong coordinate space for this (intermediate) table, which // would poison the fast path. Without chaining the two are equal. - let original_line = - LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_intermediate_line as u32); + let original_line = LineOffsetTable::find_line_with_hint( + byte_offsets, + loc, + self.prev_intermediate_line as u32, + ); self.prev_intermediate_line = original_line.max(0); let idx = original_line.max(0) as usize; From 5b10b6e54a35a13dbe78ff1eb1c25480680a6fa3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:39:41 +0000 Subject: [PATCH 18/29] sourcemap: align comments with main's port-note cleanup --- src/sourcemap/InputSourceMap.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index e8e2e0c08d97..a2a892458a26 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -52,8 +52,8 @@ struct InvalidSourceMap; /// Workhorse returning `Result` so `?` fires cleanup on malformed-payload /// bails — critical because JSON can pass the structural checks but still /// have a malformed `mappings` VLQ, and we'd otherwise leak everything -/// allocated up to that point. Zig's `errdefer` becomes Rust's automatic -/// drop on early return. +/// allocated up to that point (cleanup rides on `Drop` at each early +/// return). fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourceMap> { use bun_ast::StoreResetGuard as DataStoreScope; @@ -206,11 +206,10 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } let mut url = &last_line[NEEDLE.len()..]; - // Trim whitespace on both sides within the line. Matches Zig's - // `bun.strings.trim(_, " \r\t")`; a leading space after `=` (e.g. - // `//# sourceMappingURL= data:...`) is spec-invalid but some - // toolchains emit it, and `parse_data_url` would fail on the - // leading space without this. + // Trim whitespace (` `, `\r`, `\t`) on both sides within the line: a + // leading space after `=` (e.g. `//# sourceMappingURL= data:...`) is + // spec-invalid but some toolchains emit it, and `parse_data_url` + // would fail on the leading space without this. while let Some(&first) = url.first() { if first == b' ' || first == b'\r' || first == b'\t' { url = &url[1..]; @@ -269,5 +268,3 @@ fn parse_data_url(url: &[u8]) -> Option> { InputSourceMap::parse(payload) } } - -// ported from: src/sourcemap/InputSourceMap.zig From ee552a7834792297fd1da4c954fedb1e40a6f519 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:48:06 +0000 Subject: [PATCH 19/29] sourcemap: route write_sources_for paths through source_map_relative_path --- src/bundler/LinkerContext.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 13591b8d78ac..dedbb0bd8a49 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1246,8 +1246,7 @@ fn write_sources_for( // 1) the intermediate input. let rel_path_storage; let pretty: &[u8] = if outer_path.is_file() { - rel_path_storage = - LinkerContext::source_map_relative_path(chunk_abs_dir, outer_path.text)?; + rel_path_storage = LinkerContext::source_map_relative_path(chunk_abs_dir, outer_path.text)?; &rel_path_storage } else { outer_path.pretty From cfe8ba5d7e550e4c43d8fa689e7e8170996bad9b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:49:19 +0000 Subject: [PATCH 20/29] sourcemap: use Number::value() accessor (field now private) --- src/sourcemap/InputSourceMap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index a2a892458a26..e8fd259d1459 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -70,7 +70,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc if let Some(version) = json.get(b"version") { match version.data.as_e_number() { - Some(n) if n.value == 3.0 => {} + Some(n) if n.value() == 3.0 => {} _ => return Err(InvalidSourceMap), } } From b83f1e7139b8522aeb0ec5d270979df751ff0fb6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:33:27 +0000 Subject: [PATCH 21/29] bundler: cap inline-sourcemap source names; isolate chain tests - InputSourceMap: reject maps whose sources[i] exceeds MAX_PATH_BYTES so an adversarial inline map can't panic the fixed-size path buffers; the map falls back cleanly instead. - write_sources_for: resolve inner names via join_abs_string_buf_checked and emit the raw name when the join overflows, rather than the panicking unchecked join. - Move the inline-sourcemap chain suite to its own test file and add an oversized-name regression test. --- src/bundler/LinkerContext.rs | 49 ++- src/sourcemap/InputSourceMap.rs | 9 +- test/bundler/bun-build-api.test.ts | 330 --------------- .../bun-build-inline-sourcemap-chain.test.ts | 390 ++++++++++++++++++ 4 files changed, 429 insertions(+), 349 deletions(-) create mode 100644 test/bundler/bun-build-inline-sourcemap-chain.test.ts diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index dedbb0bd8a49..2954875cfbe9 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1269,26 +1269,39 @@ fn write_sources_for( let base_dir = bun_paths::resolve_path::dirname::( outer_path.text, ); - for name in ism.map.external_source_names.iter() { - let name: &[u8] = name.as_ref(); - // Use `join_abs` to produce an absolute inner path (when the - // inner map emitted a relative source name) that can then be - // re-relativized against `chunk_abs_dir`. `join_abs` returns - // a borrow into a thread-local buffer; we copy out immediately - // via `relative_alloc`. - let abs_path: &[u8] = if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { - name - } else { - bun_paths::resolve_path::join_abs::( - base_dir, name, - ) - }; - let rel_path = LinkerContext::source_map_relative_path(chunk_abs_dir, abs_path)?; - - let mut quote_buf = MutableString::init(rel_path.len() + ", ".len() + 2)?; + // `name` is capped at `MAX_PATH_BYTES` by the parser, so emitting it + // relative (or, on a join that still overflows, verbatim) never + // overflows the fixed-size path buffers. + let emit = |joiner: &mut StringJoiner, p: &[u8]| -> Result<(), BunError> { + let mut quote_buf = MutableString::init(p.len() + ", ".len() + 2)?; quote_buf.append_assume_capacity(b", "); - js_printer::quote_for_json(&rel_path, &mut quote_buf, false)?; + js_printer::quote_for_json(p, &mut quote_buf, false)?; joiner.push_owned(quote_buf.to_default_owned()); + Ok(()) + }; + let mut join_buf = bun_paths::path_buffer_pool::get(); + for name in ism.map.external_source_names.iter() { + let name: &[u8] = name.as_ref(); + if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + let rel = LinkerContext::source_map_relative_path(chunk_abs_dir, name)?; + emit(joiner, &rel)?; + continue; + } + // Relative inner name: join against `base_dir` to get an + // absolute path, then re-relativize to `chunk_abs_dir`. The + // checked join returns `None` when `base_dir + name` exceeds + // the buffer (an adversarial inline map); fall back to the raw + // (spec-valid) name rather than panicking. + match bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::resolve_path::platform::Auto, + >(base_dir, join_buf.as_mut_slice(), &[name]) + { + Some(abs_path) => { + let rel = LinkerContext::source_map_relative_path(chunk_abs_dir, abs_path)?; + emit(joiner, &rel)?; + } + None => emit(joiner, name)?, + } } } Ok(()) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index e8fd259d1459..9acda58b22b0 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -110,12 +110,19 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let source_count = sources_paths.items.len_u32() as usize; - // Copy source paths out of the arena into owned storage. + // Copy source paths out of the arena into owned storage. A `sources[i]` + // longer than `MAX_PATH_BYTES` is rejected (the whole map falls back): + // the name is resolved against the intermediate's dir via the + // fixed-size path buffers in `bun_paths`, which would otherwise panic + // on an oversized entry from an adversarial inline map. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items.slice() { let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; // handle_oom — fatal if OOM let s = estr.string(&arena).expect("OOM"); + if s.len() > bun_paths::MAX_PATH_BYTES { + return Err(InvalidSourceMap); + } source_paths_slice.push(Box::<[u8]>::from(s)); } diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 6312d8d189d1..8be40eb1b8ae 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1646,333 +1646,3 @@ test.skipIf(isWindows)( }, 30_000, ); -// https://github.com/oven-sh/bun/issues/30536 — Bun.build ignores inline -// `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / -// `.ts` file compiled to an intermediate `.js` with an inline sourcemap -// should have its authored sources surface in the final bundle's map. -describe("Bun.build chains inline input sourcemaps", () => { - // Build a tiny intermediate `.js` that carries an inline base64 sourcemap - // pointing at a fake "authored" source, then bundle an entry that imports - // it. The output map's `sources[]` should include the authored source, - // and `sourcesContent[]` should include the inner content verbatim - // (without the trailing `//# sourceMappingURL=` comment). - test("inline data: URL — authored source surfaces in bundled map", async () => { - const authoredSrc = "export const x = 5;\nthrow new Error('authored');\n"; - const innerMap = { - version: 3, - sources: ["authored.ts"], - sourcesContent: [authoredSrc], - names: [], - mappings: "AAAA;AACA;", - }; - const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; - - const dir = tempDirWithFiles("bun-build-chained-sourcemap", { - "intermediate.js": authoredSrc + inline, - "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - - // The authored source name must appear somewhere in `sources[]`. - const sourcesJoined = parsed.sources.join("|"); - expect(sourcesJoined).toMatch(/authored\.ts/); - - // `sourcesContent` length must equal `sources` length (spec). - expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); - - // The slot for `authored.ts` must hold the clean authored content, no - // trailing `//# sourceMappingURL=` comment. - const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("authored.ts")); - expect(authoredIdx).toBeGreaterThanOrEqual(0); - expect(parsed.sourcesContent[authoredIdx]).toBe(authoredSrc); - expect(parsed.sourcesContent[authoredIdx]).not.toMatch(/sourceMappingURL/); - }); - - // Non-base64 `data:application/json,` must work too — some - // toolchains emit the comment in that form. - test("inline data: URL without base64 — authored source surfaces", async () => { - const authoredSrc = "export const y = 1;\n"; - const innerMap = { - version: 3, - sources: ["authored.ts"], - sourcesContent: [authoredSrc], - names: [], - mappings: "AAAA;", - }; - - const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { - "intermediate.js": authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, - "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); - }); - - // Inner map with multiple sources (e.g. a `.vue` compiler splitting - // template vs script into two virtual sources) — each must round-trip. - test("inline map with multiple inner sources — all surface", async () => { - const scriptSrc = "export const x = 5;\n"; - const templateSrc = "// template part\n"; - const innerMap = { - version: 3, - sources: ["component.vue?script", "component.vue?template"], - sourcesContent: [scriptSrc, templateSrc], - names: [], - mappings: "AAAA;ACAA;", - }; - const intermediate = scriptSrc + templateSrc; - const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; - - const dir = tempDirWithFiles("bun-build-chained-sourcemap-multi", { - "intermediate.js": intermediate + inline, - "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); - expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); - expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); - }); - - // A malformed inline map must not break the build — we silently fall - // back to the intermediate as the deepest source. - test("malformed inline map — build succeeds and falls back", async () => { - const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { - "intermediate.js": "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", - "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - // Regression guard for the "parse failure kills the whole build" - // path: a valid output map must still be produced, and the deepest - // source must be the intermediate (no spurious chained source from - // the malformed payload). - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); - }); - - // Inner map whose VLQ references `source_index >= sources.len` is - // malformed per the spec. Accepting it would alias the next input - // file's slot in the output `sources[]` (Chunk.Builder emits - // `1 + inner.source_index` unclamped; LinkerContext reserves exactly - // `1 + external_source_names.len` slots per file). Pass the real - // source count to `Mapping.parse` so the map gets rejected and we - // fall back to the intermediate. - test("inline map with out-of-range inner source_index is rejected", async () => { - // VLQ "AAAA;ACAA" = line 0: (0, 0, 0, 0); line 1: (0, +1, 0, 0) - // → second mapping references source_index = 1, but sources has - // only one entry. - const innerMap = { - version: 3, - sources: ["authored.ts"], - sourcesContent: ["// authored\n"], - names: [], - mappings: "AAAA;ACAA", - }; - const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; - - const dir = tempDirWithFiles("bun-build-chained-sourcemap-oob", { - "intermediate.js": "export const x = 1;\nexport const y = 2;\n" + inline, - "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - // The malformed map must be rejected — no `authored.ts` slot - // appears in the output, and no neighboring file's slot got - // aliased away. - expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(false); - expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); - }); - - // Guard the last-line anchoring — a file that has a fully-valid - // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside - // a template literal / multi-line string) but NO trailing comment must - // not get mis-chained off that in-body text. Without last-line - // anchoring, `lastIndexOf("\n//# sourceMappingURL=")` finds the - // embedded marker and chains through the fake payload — the authored - // "hijack.ts" would show up in the output sources. - test("sourceMappingURL marker in body is ignored (only trailing line counts)", async () => { - const hijackMap = { - version: 3, - sources: ["hijack.ts"], - sourcesContent: ["// i should not appear\n"], - names: [], - mappings: "AAAA;", - }; - const hijackInline = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(hijackMap)).toString("base64")}`; - // Embed the full valid inline comment inside a template literal so - // the file parses as JS, but the real trailing line is the plain - // `export` — no sourcemap comment at end-of-file. - const intermediate = ["export const doc = `", hijackInline, "`;", "export const val = 99;", ""].join("\n"); - - const dir = tempDirWithFiles("bun-build-chained-sourcemap-nohijack", { - "intermediate.js": intermediate, - "entry.ts": `import { val } from './intermediate.js';\nconsole.log(val);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - // The in-body marker must not hijack the chain — `hijack.ts` must - // NOT appear as a source in the final map. - expect(parsed.sources.some((s: string) => s.endsWith("hijack.ts"))).toBe(false); - expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); - }); - - // Non-inline `sourceMappingURL=foo.js.map` references aren't chained - // (external map resolution is out of scope for this change). The build - // must behave exactly as before — the intermediate ends up as the - // deepest source, not a spurious crash. - test("external .map reference — unchanged behavior", async () => { - const dir = tempDirWithFiles("bun-build-chained-sourcemap-external", { - "intermediate.js": "export const q = 3;\n//# sourceMappingURL=intermediate.js.map\n", - "entry.ts": `import { q } from './intermediate.js';\nconsole.log(q);\n`, - }); - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - }); - expect(result.success).toBe(true); - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - // No inner chain. The intermediate should be in sources[], not some - // phantom "authored.ts". - expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); - }); - - // https://github.com/oven-sh/bun/issues/6173 — a plugin `onLoad` that - // transpiles and returns JS with an inline sourcemap comment should - // have the pre-transform authored source surface in the final map. - // The scanner runs on `source.contents` regardless of origin, so the - // plugin case rides on the same pipeline as the file case. - test("onLoad plugin returning JS with inline sourcemap — authored source surfaces", async () => { - const dir = tempDirWithFiles("bun-build-plugin-chained-sourcemap", { - "src.custom": "export const x = 42;\n", - "entry.ts": `import { x } from './src.custom';\nconsole.log(x);\n`, - }); - - // Use a distinct inner-source name so we can tell which `sources[]` - // slot is the plugin intermediate vs. which is the chained inner. - const authoredContent = "const x_authored_marker = 42;\nexport { x_authored_marker as x };\n"; - const innerMap = { - version: 3, - sources: ["original-authored.custom"], - sourcesContent: [authoredContent], - names: [], - mappings: "AAAA;", - }; - const inlineComment = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; - - const result = await Bun.build({ - entrypoints: [join(dir, "entry.ts")], - outdir: join(dir, "out"), - format: "esm", - target: "bun", - sourcemap: "inline", - plugins: [ - { - name: "custom-transpiler", - setup(build) { - build.onLoad({ filter: /\.custom$/ }, () => ({ - // Emit transformed JS carrying its own inline sourcemap - // pointing back at the authored `.custom` source. - contents: "export const x = 42;\n" + inlineComment, - loader: "js", - })); - }, - }, - ], - }); - expect(result.success).toBe(true); - - const text = await Bun.file(result.outputs[0].path).text(); - const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); - expect(m).not.toBeNull(); - const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); - - // The authored-source slot (distinct filename) must be present and - // carry the pre-transform content verbatim. - expect(parsed.sources.some((s: string) => s.endsWith("original-authored.custom"))).toBe(true); - expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); - - const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("original-authored.custom")); - expect(parsed.sourcesContent[authoredIdx]).toBe(authoredContent); - }); -}); diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts new file mode 100644 index 000000000000..eafcb791e6d4 --- /dev/null +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "path"; + +// Regression coverage for https://github.com/oven-sh/bun/issues/30536 and +// https://github.com/oven-sh/bun/issues/6173: the bundler must chain inline +// `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / +// `.ts` file compiled to an intermediate `.js` with an inline sourcemap +// should have its authored sources surface in the final bundle's map. +// +// Kept in a dedicated file (rather than bun-build-api.test.ts) so the suite +// stays fast and deterministic: bun-build-api.test.ts carries a ~160s +// repeated-build stress test whose runtime sits close to its timeout under +// load, which is unrelated to this feature. +describe("Bun.build chains inline input sourcemaps", () => { + // Build a tiny intermediate `.js` that carries an inline base64 sourcemap + // pointing at a fake "authored" source, then bundle an entry that imports + // it. The output map's `sources[]` should include the authored source, + // and `sourcesContent[]` should include the inner content verbatim + // (without the trailing `//# sourceMappingURL=` comment). + test("inline data: URL — authored source surfaces in bundled map", async () => { + const authoredSrc = "export const x = 5;\nthrow new Error('authored');\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;AACA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap", { + "intermediate.js": authoredSrc + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored source name must appear somewhere in `sources[]`. + const sourcesJoined = parsed.sources.join("|"); + expect(sourcesJoined).toMatch(/authored\.ts/); + + // `sourcesContent` length must equal `sources` length (spec). + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + // The slot for `authored.ts` must hold the clean authored content, no + // trailing `//# sourceMappingURL=` comment. + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("authored.ts")); + expect(authoredIdx).toBeGreaterThanOrEqual(0); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredSrc); + expect(parsed.sourcesContent[authoredIdx]).not.toMatch(/sourceMappingURL/); + }); + + // Non-base64 `data:application/json,` must work too — some + // toolchains emit the comment in that form. + test("inline data: URL without base64 — authored source surfaces", async () => { + const authoredSrc = "export const y = 1;\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;", + }; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { + "intermediate.js": authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, + "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); + }); + + // Inner map with multiple sources (e.g. a `.vue` compiler splitting + // template vs script into two virtual sources) — each must round-trip. + test("inline map with multiple inner sources — all surface", async () => { + const scriptSrc = "export const x = 5;\n"; + const templateSrc = "// template part\n"; + const innerMap = { + version: 3, + sources: ["component.vue?script", "component.vue?template"], + sourcesContent: [scriptSrc, templateSrc], + names: [], + mappings: "AAAA;ACAA;", + }; + const intermediate = scriptSrc + templateSrc; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-multi", { + "intermediate.js": intermediate + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + }); + + // A malformed inline map must not break the build — we silently fall + // back to the intermediate as the deepest source. + test("malformed inline map — build succeeds and falls back", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { + "intermediate.js": "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", + "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + // Regression guard for the "parse failure kills the whole build" + // path: a valid output map must still be produced, and the deepest + // source must be the intermediate (no spurious chained source from + // the malformed payload). + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // Inner map whose VLQ references `source_index >= sources.len` is + // malformed per the spec. Accepting it would alias the next input + // file's slot in the output `sources[]` (Chunk.Builder emits + // `1 + inner.source_index` unclamped; LinkerContext reserves exactly + // `1 + external_source_names.len` slots per file). Pass the real + // source count to `Mapping.parse` so the map gets rejected and we + // fall back to the intermediate. + test("inline map with out-of-range inner source_index is rejected", async () => { + // VLQ "AAAA;ACAA" = line 0: (0, 0, 0, 0); line 1: (0, +1, 0, 0) + // → second mapping references source_index = 1, but sources has + // only one entry. + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: ["// authored\n"], + names: [], + mappings: "AAAA;ACAA", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-oob", { + "intermediate.js": "export const x = 1;\nexport const y = 2;\n" + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The malformed map must be rejected — no `authored.ts` slot + // appears in the output, and no neighboring file's slot got + // aliased away. + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // An inner `sources[i]` longer than MAX_PATH_BYTES is resolved against + // the intermediate's directory via fixed-size path buffers; an + // adversarial inline map with a multi-KB source name must be rejected at + // parse time (clean fallback to the intermediate) rather than panicking + // the build in the path normalizer. + test("oversized inner source name — map rejected, build falls back", async () => { + const hugeName = Buffer.alloc(5000, "a").toString() + ".ts"; + const innerMap = { + version: 3, + sources: [hugeName], + sourcesContent: ["// authored\n"], + names: [], + mappings: "AAAA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-huge", { + "intermediate.js": "export const x = 7;\n" + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + // Spawn so an abort in the path normalizer would surface as a nonzero + // exit rather than a thrown JS error. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const r = await Bun.build({ entrypoints: [${JSON.stringify(join(dir, "entry.ts"))}], outdir: ${JSON.stringify(join(dir, "out"))}, format: "esm", target: "bun", sourcemap: "inline" }); + if (!r.success) { console.error("build failed"); process.exit(2); } + const text = await Bun.file(r.outputs[0].path).text(); + const m = text.match(/\\/\\/# sourceMappingURL=data:application\\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m[1], "base64").toString("utf-8")); + console.log(JSON.stringify({ hasHuge: parsed.sources.some(s => s.length > 4096), hasIntermediate: parsed.sources.some(s => s.endsWith("intermediate.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]); + // A crash in the path normalizer aborts the child: empty stdout and a + // nonzero exit. Surfacing stderr here gives a useful message on failure + // (it is not asserted empty — debug/ASAN builds emit warnings). + expect(stdout.trim() === "" ? stderr : "ok").toBe("ok"); + expect(exitCode).toBe(0); + // The oversized map is rejected: no multi-KB source surfaces, and the + // intermediate remains as the deepest source. + expect(JSON.parse(stdout.trim())).toEqual({ hasHuge: false, hasIntermediate: true }); + }); + + // Guard the last-line anchoring — a file that has a fully-valid + // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside + // a template literal / multi-line string) but NO trailing comment must + // not get mis-chained off that in-body text. Without last-line + // anchoring, `lastIndexOf("\n//# sourceMappingURL=")` finds the + // embedded marker and chains through the fake payload — the authored + // "hijack.ts" would show up in the output sources. + test("sourceMappingURL marker in body is ignored (only trailing line counts)", async () => { + const hijackMap = { + version: 3, + sources: ["hijack.ts"], + sourcesContent: ["// i should not appear\n"], + names: [], + mappings: "AAAA;", + }; + const hijackInline = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(hijackMap)).toString("base64")}`; + // Embed the full valid inline comment inside a template literal so + // the file parses as JS, but the real trailing line is the plain + // `export` — no sourcemap comment at end-of-file. + const intermediate = ["export const doc = `", hijackInline, "`;", "export const val = 99;", ""].join("\n"); + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-nohijack", { + "intermediate.js": intermediate, + "entry.ts": `import { val } from './intermediate.js';\nconsole.log(val);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The in-body marker must not hijack the chain — `hijack.ts` must + // NOT appear as a source in the final map. + expect(parsed.sources.some((s: string) => s.endsWith("hijack.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // Non-inline `sourceMappingURL=foo.js.map` references aren't chained + // (external map resolution is out of scope for this change). The build + // must behave exactly as before — the intermediate ends up as the + // deepest source, not a spurious crash. + test("external .map reference — unchanged behavior", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-external", { + "intermediate.js": "export const q = 3;\n//# sourceMappingURL=intermediate.js.map\n", + "entry.ts": `import { q } from './intermediate.js';\nconsole.log(q);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // No inner chain. The intermediate should be in sources[], not some + // phantom "authored.ts". + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // https://github.com/oven-sh/bun/issues/6173 — a plugin `onLoad` that + // transpiles and returns JS with an inline sourcemap comment should + // have the pre-transform authored source surface in the final map. + // The scanner runs on `source.contents` regardless of origin, so the + // plugin case rides on the same pipeline as the file case. + test("onLoad plugin returning JS with inline sourcemap — authored source surfaces", async () => { + const dir = tempDirWithFiles("bun-build-plugin-chained-sourcemap", { + "src.custom": "export const x = 42;\n", + "entry.ts": `import { x } from './src.custom';\nconsole.log(x);\n`, + }); + + // Use a distinct inner-source name so we can tell which `sources[]` + // slot is the plugin intermediate vs. which is the chained inner. + const authoredContent = "const x_authored_marker = 42;\nexport { x_authored_marker as x };\n"; + const innerMap = { + version: 3, + sources: ["original-authored.custom"], + sourcesContent: [authoredContent], + names: [], + mappings: "AAAA;", + }; + const inlineComment = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + plugins: [ + { + name: "custom-transpiler", + setup(build) { + build.onLoad({ filter: /\.custom$/ }, () => ({ + // Emit transformed JS carrying its own inline sourcemap + // pointing back at the authored `.custom` source. + contents: "export const x = 42;\n" + inlineComment, + loader: "js", + })); + }, + }, + ], + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored-source slot (distinct filename) must be present and + // carry the pre-transform content verbatim. + expect(parsed.sources.some((s: string) => s.endsWith("original-authored.custom"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("original-authored.custom")); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredContent); + }); +}); From 37dad95f60b23d44f061b5a08b952bd374f587af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:56:25 +0000 Subject: [PATCH 22/29] test: run inline-sourcemap chain suite concurrently --- test/bundler/bun-build-inline-sourcemap-chain.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts index eafcb791e6d4..6b0d8b7b3624 100644 --- a/test/bundler/bun-build-inline-sourcemap-chain.test.ts +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -12,7 +12,7 @@ import { join } from "path"; // stays fast and deterministic: bun-build-api.test.ts carries a ~160s // repeated-build stress test whose runtime sits close to its timeout under // load, which is unrelated to this feature. -describe("Bun.build chains inline input sourcemaps", () => { +describe.concurrent("Bun.build chains inline input sourcemaps", () => { // Build a tiny intermediate `.js` that carries an inline base64 sourcemap // pointing at a fake "authored" source, then bundle an entry that imports // it. The output map's `sources[]` should include the authored source, From 3a6030db607e325d9f99898de59d6f586ab5db0d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:19:08 +0000 Subject: [PATCH 23/29] test: size oversized-name case past the largest platform MAX_PATH_BYTES Windows MAX_PATH_BYTES is ~96 KB, so a 5 KB name was under the cap there and surfaced instead of being rejected. Use a 128 KB name so the map is rejected on every platform. --- test/bundler/bun-build-inline-sourcemap-chain.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts index 6b0d8b7b3624..ae1d9b0125c7 100644 --- a/test/bundler/bun-build-inline-sourcemap-chain.test.ts +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -209,11 +209,12 @@ describe.concurrent("Bun.build chains inline input sourcemaps", () => { // An inner `sources[i]` longer than MAX_PATH_BYTES is resolved against // the intermediate's directory via fixed-size path buffers; an - // adversarial inline map with a multi-KB source name must be rejected at + // adversarial inline map with such a source name must be rejected at // parse time (clean fallback to the intermediate) rather than panicking - // the build in the path normalizer. + // the build in the path normalizer. MAX_PATH_BYTES is platform-dependent + // (4096 on Linux, ~96 KB on Windows), so use a name past the largest. test("oversized inner source name — map rejected, build falls back", async () => { - const hugeName = Buffer.alloc(5000, "a").toString() + ".ts"; + const hugeName = Buffer.alloc(128 * 1024, "a").toString() + ".ts"; const innerMap = { version: 3, sources: [hugeName], From c9447b732d3ee2f5a2f38b9921c75939dce4bb63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:58:11 +0000 Subject: [PATCH 24/29] sourcemap: adapt InputSourceMap to renamed json parse and Result-based mapping::parse --- src/sourcemap/InputSourceMap.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 9acda58b22b0..10b8dab9df28 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -65,7 +65,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // alloc, so reset the AST store on entry and exit. let _store_scope = DataStoreScope::new(); - let json = bun_parsers::json::parse::(&json_src, &mut log, &arena) + let json = bun_parsers::json::parse_json_into_arena(&json_src, &mut log, &arena) .map_err(|_| InvalidSourceMap)?; if let Some(version) = json.get(b"version") { @@ -156,7 +156,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // slot in the output `sources[]`. Pass the real source count so // malformed maps hit `Fail` and we fall back cleanly. let sources_count_i32: i32 = i32::try_from(source_count).map_err(|_| InvalidSourceMap)?; - let map_data = match crate::mapping::parse( + let map_data = crate::mapping::parse( mappings_slice, None, sources_count_i32, @@ -165,10 +165,8 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc allow_names: false, sort: true, }, - ) { - crate::ParseResult::Success(x) => x, - crate::ParseResult::Fail(_) => return Err(InvalidSourceMap), - }; + ) + .map_err(|_| InvalidSourceMap)?; let mut psm = map_data; psm.external_source_names = source_paths_slice; From 5f22626766cfac5ec2a7add74e18123e96427e1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:21:48 +0000 Subject: [PATCH 25/29] sourcemap: read inline maps through the tape-based JSON accessors --- src/sourcemap/InputSourceMap.rs | 70 +++++++++++++++------------------ 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 10b8dab9df28..cf0aea9ce268 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -10,8 +10,6 @@ use std::sync::Arc; -use bun_collections::VecExt; - use crate::ParsedSourceMap; /// Parsed inner sourcemap + per-source content bytes, owned. @@ -65,50 +63,52 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // alloc, so reset the AST store on entry and exit. let _store_scope = DataStoreScope::new(); - let json = bun_parsers::json::parse_json_into_arena(&json_src, &mut log, &arena) + let root = bun_parsers::json::parse_json_into_arena(&json_src, &mut log, &arena) .map_err(|_| InvalidSourceMap)?; + // The tape-based JSON parser represents containers as `EObjectJSON` / + // `EArrayJSON` rows; read through the tape accessors rather than + // materializing `Expr`s per element. + let obj: &bun_ast::E::ObjectJSON = match &root.data { + bun_ast::ExprData::EObjectJSON(o) => o.get(), + _ => return Err(InvalidSourceMap), + }; + use bun_ast::E::JsonValue; - if let Some(version) = json.get(b"version") { - match version.data.as_e_number() { - Some(n) if n.value() == 3.0 => {} + if let Some(version) = obj.get(b"version") { + match version { + JsonValue::Number(n) if n.value() == 3.0 => {} _ => return Err(InvalidSourceMap), } } - let mappings_str = json.get(b"mappings").ok_or(InvalidSourceMap)?; - let mut mappings_e_string = mappings_str.data.as_e_string().ok_or(InvalidSourceMap)?; - let mappings_slice: &[u8] = mappings_e_string.slice(&arena); + let mappings_slice: &[u8] = obj + .get(b"mappings") + .and_then(|v| v.as_str()) + .ok_or(InvalidSourceMap)?; - let sources_paths = json + let sources_paths = obj .get(b"sources") - .ok_or(InvalidSourceMap)? - .data - .as_e_array() + .and_then(|v| v.as_array()) .ok_or(InvalidSourceMap)?; // `sourcesContent` is optional; when absent or null every slot is empty. - let sources_content_opt = match json.get(b"sourcesContent") { + let sources_content_opt = match obj.get(b"sourcesContent") { None => None, - Some(v) => match v.data.as_e_array() { + Some(v) => match v.as_array() { Some(arr) => Some(arr), - None => { - // `null` is tolerated; other non-array values are malformed. - if matches!(v.data, bun_ast::ExprData::ENull(_)) { - None - } else { - return Err(InvalidSourceMap); - } - } + // `null` is tolerated; other non-array values are malformed. + None if matches!(v, JsonValue::Null) => None, + None => return Err(InvalidSourceMap), }, }; if let Some(arr) = sources_content_opt { - if arr.items.len_u32() != sources_paths.items.len_u32() { + if arr.items().len() != sources_paths.items().len() { return Err(InvalidSourceMap); } } - let source_count = sources_paths.items.len_u32() as usize; + let source_count = sources_paths.items().len(); // Copy source paths out of the arena into owned storage. A `sources[i]` // longer than `MAX_PATH_BYTES` is rejected (the whole map falls back): @@ -116,10 +116,8 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // fixed-size path buffers in `bun_paths`, which would otherwise panic // on an oversized entry from an adversarial inline map. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); - for item in sources_paths.items.slice() { - let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; - // handle_oom — fatal if OOM - let s = estr.string(&arena).expect("OOM"); + for item in sources_paths.items() { + let s = item.as_str().ok_or(InvalidSourceMap)?; if s.len() > bun_paths::MAX_PATH_BYTES { return Err(InvalidSourceMap); } @@ -129,16 +127,10 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // Copy source contents. Non-strings (null, etc.) and empty slots map to `b""`. let mut sources_content_slice: Vec> = Vec::with_capacity(source_count); if let Some(arr) = sources_content_opt { - for item in arr.items.slice() { - let slot: Box<[u8]> = if let Some(estr) = item.data.as_e_string() { - let s = estr.string(&arena).expect("OOM"); - if s.is_empty() { - Box::<[u8]>::from(&b""[..]) - } else { - Box::<[u8]>::from(s) - } - } else { - Box::<[u8]>::from(&b""[..]) + for item in arr.items() { + let slot: Box<[u8]> = match item.as_str() { + Some(s) => Box::<[u8]>::from(s), + None => Box::<[u8]>::from(&b""[..]), }; sources_content_slice.push(slot); } From 7d009dd5f4375f45197b3adaa12ade25a3f15f5b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:30:46 +0000 Subject: [PATCH 26/29] sourcemap: trim explanatory comments to their load-bearing core --- src/bundler/Graph.rs | 9 +-- src/bundler/LinkerContext.rs | 64 +++++------------- src/bundler/ParseTask.rs | 30 +++------ src/bundler/ServerComponentParseTask.rs | 3 +- src/bundler/bundle_v2.rs | 15 ++--- src/js_printer/lib.rs | 11 +--- src/sourcemap/Chunk.rs | 56 +++++----------- src/sourcemap/InputSourceMap.rs | 86 ++++++++----------------- 8 files changed, 79 insertions(+), 195 deletions(-) diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index ff85b5db6dd9..5dd24675c2ca 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -114,12 +114,9 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, - /// When this file carried an inline `//# sourceMappingURL=data:...` - /// comment, the decoded inner map plus its `sourcesContent` bytes. The - /// linker expands outer `sources[]` / `sourcesContent[]` with these - /// inner entries and the `Chunk::Builder` remaps its mappings through - /// the inner `find_mapping` so final stack traces surface in the - /// authored source. `None` when no chain is available (most inputs). + /// Decoded inline `//# sourceMappingURL=data:...` map for this file; + /// the linker expands `sources[]`/`sourcesContent[]` with its entries + /// and `Chunk::Builder` remaps mappings through it. Usually `None`. pub input_source_map: Option>, } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 2954875cfbe9..0772129cc7eb 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1044,10 +1044,8 @@ impl<'a> LinkerContext<'a> { let sources = self.parse_graph().input_files.items_source(); let quoted_source_map_contents = self.graph.files.items_quoted_source_contents(); - // DevServer uses a separate sourcemap stitcher (`SourceMapStore::join_vlq`) - // that hard-codes one `sources[]` slot per input; threading inner-map - // expansion through there would corrupt its output. `DevServer == None` - // gates the whole feature so the HMR path stays byte-identical. + // DevServer's stitcher (`SourceMapStore::join_vlq`) assumes one + // `sources[]` slot per input, so chaining is gated to `Bun.build`. let input_source_maps: Option<&[Option>]> = if self.dev_server.is_none() { Some(self.parse_graph().input_files.items_input_source_map()) @@ -1055,19 +1053,10 @@ impl<'a> LinkerContext<'a> { None }; - // Entries in `results` do not 1:1 map to source files, the mapping - // is actually many to one, where a source file can have multiple chunks - // in the sourcemap. - // - // This hashmap maps: - // `source_index` (per compilation) in a chunk - // --> - // Base source index in the generated sourcemap (inclusive). When - // the input file did not carry an inline sourcemap, the chunk's - // mappings all use that base. When the input file carried an - // inline `//# sourceMappingURL=`, the chunk's mappings were - // remapped through that inner map at print time and now span - // `base .. base + inner.external_source_names.len - 1`. + // Many-to-one: a source file can own several chunks. Maps each + // compilation `source_index` to its base index in the generated + // `sources[]`; a file with an inline map spans + // `base ..= base + external_source_names.len`. let mut source_id_map: ArrayHashMap = ArrayHashMap::new(); let source_indices = results.items_source_index(); @@ -1178,11 +1167,8 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - // If the input carried an inline map, `chunk.end_state.source_index` - // is the inner source_index of the last mapping within the chunk - // (the Builder emits remapped absolute-within-chunk indices). - // Otherwise it's 0. Either way, the final absolute index is - // `mapping_source_index + chunk.end_state.source_index`. + // `chunk.end_state.source_index` is chunk-relative (0 without an + // inline map); rebase it onto this file's slot base. prev_end_state.source_index = mapping_source_index + chunk.end_state.source_index; prev_column_offset = chunk.final_generated_column; @@ -1227,15 +1213,9 @@ impl<'a> LinkerContext<'a> { } } -/// Emit one outer source's quoted path, plus any inner source paths -/// contributed by its `//# sourceMappingURL=` (one slot per inner source, -/// in `external_source_names` order). `leading_comma` is true when this is -/// not the first path appended to the running `sources[]` array — we -/// prefix `", "` before the outer path in that case. -/// -/// Layout matches the one `Chunk::Builder` assumes in `Chunk.rs`: -/// slot 0 → the intermediate input (this outer file) -/// slot 1..N → inner `sources[i]` (chained) +/// Emit one outer source's quoted path plus its chained inner paths, in +/// the slot layout `Chunk::Builder` emits against: slot 0 = the outer +/// file, slots 1..N = inner `sources[i]`. fn write_sources_for( joiner: &mut StringJoiner, chunk_abs_dir: &[u8], @@ -1260,18 +1240,12 @@ fn write_sources_for( joiner.push_owned(quote_buf.to_default_owned()); } - // 2) inner sources, if any. Each inner `sources[i]` is resolved - // relative to the directory of the intermediate file it came from, - // then made relative to `chunk_abs_dir` (the chunk's output dir) for - // the emitted JSON. Absolute inner paths stay absolute before - // relativization. + // 2) inner sources: resolve each against the intermediate's dir, then + // re-relativize to `chunk_abs_dir` for the emitted JSON. if let Some(ism) = input_map { let base_dir = bun_paths::resolve_path::dirname::( outer_path.text, ); - // `name` is capped at `MAX_PATH_BYTES` by the parser, so emitting it - // relative (or, on a join that still overflows, verbatim) never - // overflows the fixed-size path buffers. let emit = |joiner: &mut StringJoiner, p: &[u8]| -> Result<(), BunError> { let mut quote_buf = MutableString::init(p.len() + ", ".len() + 2)?; quote_buf.append_assume_capacity(b", "); @@ -1287,11 +1261,8 @@ fn write_sources_for( emit(joiner, &rel)?; continue; } - // Relative inner name: join against `base_dir` to get an - // absolute path, then re-relativize to `chunk_abs_dir`. The - // checked join returns `None` when `base_dir + name` exceeds - // the buffer (an adversarial inline map); fall back to the raw - // (spec-valid) name rather than panicking. + // The checked join returns `None` on overflow (adversarial + // map); emit the raw, spec-valid name instead of panicking. match bun_paths::resolve_path::join_abs_string_buf_checked::< bun_paths::resolve_path::platform::Auto, >(base_dir, join_buf.as_mut_slice(), &[name]) @@ -2290,9 +2261,8 @@ impl<'a> LinkerContext<'a> { // SAFETY: `self.mangled_props` is not mutated during printing; detached borrow // outlives only this call (see above). unsafe { bun_ptr::detach_lifetime_ref(&self.mangled_props) }; - // DevServer uses a separate sourcemap stitcher that hard-codes one - // `sources[]` slot per file; passing `input_source_map` would - // corrupt its output. Gate the whole feature on the Bun.build path. + // DevServer's stitcher assumes one `sources[]` slot per file; + // chaining is gated to the `Bun.build` path. let input_source_map: Option<&bun_sourcemap::InputSourceMap> = if self.dev_server.is_none() { parse_graph.input_files.items_input_source_map()[source_index.get() as usize].as_deref() diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 38a32acbb69e..1b18193557cf 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -188,12 +188,9 @@ pub(crate) struct Success { /// The package name from package.json, used for barrel optimization. pub(crate) package_name: ast::StoreStr, - /// Decoded trailing inline `//# sourceMappingURL=data:...` inner map, - /// parsed from the source bytes. `None` when the file had no inline - /// sourcemap comment, when sourcemaps are disabled on the build, or - /// when the inline payload was malformed (caller silently falls back - /// to the raw file bytes). Moved into `graph.input_files.input_source_map` - /// by `on_parse_task_complete`. + /// Decoded trailing inline `//# sourceMappingURL=data:...` map; `None` + /// when absent, disabled, or malformed. Moved into + /// `graph.input_files.input_source_map` by `on_parse_task_complete`. pub(crate) input_source_map: Option>, } @@ -2631,11 +2628,9 @@ pub mod parse_worker { // SAFETY: task.ctx backref valid for the bundle pass (outlives `'r`). let task_ctx = unsafe { task.ctx() }; let module_type = opts.module_type; - // Hoist the `source_map` flag before the tombstone: we need it - // below after `get_ast` runs, but reading `topts.source_map` there - // would touch the invalidated shared borrow (get_ast reborrows - // `(*transpiler).options` mutably via raw pointer, which pops - // `topts`'s tag under Stacked Borrows). Copy it out now. + // Copy `source_map` out before the tombstone: get_ast reborrows + // `(*transpiler).options` mutably, invalidating `topts` under + // Stacked Borrows. let source_map_option = topts.source_map; // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. @@ -2697,16 +2692,9 @@ pub mod parse_worker { *step = Step::Resolve; - // Chain any inline `//# sourceMappingURL=data:...` map the input - // file carries (e.g. a `.vue`/`.svelte` compiler's trailing - // comment on the intermediate `.js`) into the output sourcemap. - // This scan runs on `source.contents` whether they came from a - // file read or a plugin `onLoad` return, so this covers #6173 - // too. Gated on: - // - source maps enabled on the build (no cost otherwise) - // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) - // - non-empty contents (the scanner would find nothing) - // Malformed payloads return `None` and fall back cleanly. + // Scan for an inline `//# sourceMappingURL=data:...` map to chain + // into the output sourcemap. Runs on `source.contents` regardless + // of origin (file read or plugin `onLoad`, covering #6173). let input_source_map: Option> = if source_map_option != options::SourceMapOption::None && loader.can_have_source_map() diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index ecba542b5a98..610fe18f3691 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -203,8 +203,7 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, - // Server-component wrappers are generated, not authored — no inline - // sourcemap comment to chain through. + // Generated wrapper: nothing to chain. input_source_map: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index b865f84d8b9d..460893ee373b 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4955,12 +4955,9 @@ pub mod bv2_impl { // `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its // aliases, so this is the unique drop. { - // `input_source_map` columns hold owned `Box` - // (inner `Arc` + owned `sources_content` Vec) - // allocated from the global heap, not the AST arena. The + // `input_source_map` slots are global-heap `Box`es; the // slab-only `MultiArrayList::drop` would strand them, so - // drain explicitly before the slab is released. Matches the - // explicit-drain pattern kept for `css` below. + // drain explicitly (same pattern as `css` below). for m in self.graph.input_files.items_input_source_map_mut() { drop(m.take()); } @@ -7095,12 +7092,8 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; - // Transfer ownership of any decoded inline input sourcemap - // from the parse result onto the SoA slot. An earlier - // occupant (e.g. incremental reparse of a previously-loaded - // file) is dropped here — the `Box`'s Drop - // releases the inner `Arc` and the owned - // `sources_content` buffers. + // Move the decoded inline sourcemap onto the SoA slot, + // dropping any earlier occupant (incremental reparse). { let slot = &mut this.graph.input_files.items_input_source_map_mut() [result_source_index]; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 892d2dd1d598..c1b53eefd781 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1190,14 +1190,9 @@ pub struct Options<'a> { /// builder as `LineOffsetTables::Borrowed`. pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, - /// When `Some`, the bundler input file carried an inline - /// `//# sourceMappingURL=data:...` comment. The chunk builder - /// remaps each emitted mapping through this inner map so the final - /// output's `source_index`/`(original_line, original_column)` refer - /// to the authored source instead of the intermediate input. - /// `None` for files that don't carry an inline sourcemap, or for - /// the DevServer HMR path (which uses a separate stitcher that - /// hard-codes one `sources[]` slot per file). + /// Inline `//# sourceMappingURL=data:...` map carried by the input + /// file; the chunk builder remaps emitted mappings through it. `None` + /// for the DevServer path (its stitcher assumes one slot per file). pub input_source_map: Option<&'a SourceMap::InputSourceMap>, pub mangled_props: Option<&'a crate::MangledProps>, diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 7d13116a02b2..8e874f1701c1 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -375,26 +375,15 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// `line_offset_table_byte_offset_list`. pub line_offset_table_first_non_ascii: RawSlice, - /// When set, the bundler/printer input file carried an inline - /// `//# sourceMappingURL=data:...` comment; `add_source_mapping` will - /// remap each mapping through its inner map so the emitted - /// `source_index` / original `(line, col)` refer to the authored - /// source instead of the bundler's intermediate input. Unset - /// otherwise — the emitted mapping uses the Builder's own - /// `prev_state.source_index` (the outer source's slot). The borrow - /// lives in `Graph::input_files[i].input_source_map` - /// (`Option>`). + /// Inline `//# sourceMappingURL=data:...` map carried by the input + /// file; `add_source_mapping` remaps each mapping through it so the + /// emitted coordinates refer to the authored source. pub input_source_map: Option<&'a crate::InputSourceMap>, - /// Last *intermediate-file* line emitted (before any `input_source_map` - /// remap), kept only to seed `find_line_with_hint`. When chaining is - /// active, `prev_state.original_line` holds the remapped *authored* line, - /// which is the wrong coordinate space for the intermediate's - /// line-offset table — using it as the hint would fail the O(1) fast - /// path on every token and fall through to binary search. This field - /// keeps the hint in the intermediate's space. `0` when no chaining is - /// active (then `prev_state.original_line` is already the intermediate - /// line, but reading this costs nothing). + /// Last intermediate-file line, seeding `find_line_with_hint`. Kept + /// separately because `prev_state.original_line` holds the remapped + /// *authored* line when chaining — the wrong coordinate space for the + /// intermediate's line-offset table. pub prev_intermediate_line: i32, // This is a workaround for a bug in the popular "source-map" library: @@ -698,14 +687,10 @@ impl NewBuilder<'_, VLQSourceMap> { } let byte_offsets = self.line_offset_table_byte_offset_list.slice(); - // The printer emits mappings in (mostly) source order, so the previous - // call's *intermediate* line is the right answer or one/two lines - // before it >95% of the time. Seed `find_line_with_hint` with it; the - // fallback is the same binary search as before. Hint from - // `prev_intermediate_line` (not `prev_state.original_line`) because the - // latter holds the remapped *authored* line when `input_source_map` is - // active — wrong coordinate space for this (intermediate) table, which - // would poison the fast path. Without chaining the two are equal. + // Mappings arrive in (mostly) source order, so the previous call's + // intermediate line usually hits the O(1) fast path. Hint from + // `prev_intermediate_line`, not `prev_state.original_line`: the + // latter is the remapped authored line when chaining. let original_line = LineOffsetTable::find_line_with_hint( byte_offsets, loc, @@ -735,19 +720,10 @@ impl NewBuilder<'_, VLQSourceMap> { self.update_generated_line_and_column(output); - // Remap through the input's inline sourcemap if present. The - // intermediate input's `(original_line, original_column)` becomes - // the authored source's `(line, col)` via `find_mapping`. On - // hit, the emitted `source_index` is `1 + inner.source_index` — - // the layout `LinkerContext` uses for this file: - // slot 0 → the intermediate input - // 1 + inner_idx → inner `sources[inner_idx]` - // The emitted `source_index` is relative to the chunk's start - // (the Builder always begins with `prev_state.source_index = 0`); - // `LinkerContext` stitches the absolute base in when joining - // chunks. Mappings the inner map doesn't cover fall back to - // slot 0 (the intermediate) so stack traces land in the right - // file rather than silently disappearing. + // Remap through the inline map if present, emitting chunk-relative + // `source_index` in the layout `LinkerContext` stitches: slot 0 = + // the intermediate, `1 + inner_idx` = inner `sources[inner_idx]`. + // Mappings the inner map doesn't cover fall back to slot 0. let mut mapped_source_index: i32 = 0; let mut mapped_original_line: i32 = original_line.max(0); let mut mapped_original_column: i32 = original_column.max(0); @@ -760,8 +736,6 @@ impl NewBuilder<'_, VLQSourceMap> { mapped_original_line = inner.original.lines.zero_based(); mapped_original_column = inner.original.columns.zero_based(); } - // else: fall back to the intermediate (slot 0) using the - // (line, col) we already have in the intermediate. } // If this line doesn't start with a mapping and we're about to add a mapping diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index cf0aea9ce268..9a286af9116c 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -1,57 +1,38 @@ -//! Per-input-file sourcemap used by the bundler to chain sourcemaps through -//! upstream compile steps (e.g. `.vue` → `.js`, `.svelte` → `.js`, -//! TypeScript plugins). When `Bun.build` reads an input file that carries -//! an inline `//# sourceMappingURL=data:application/json;...` comment, we -//! parse it into an `InputSourceMap` and store it on the file's -//! `Graph::InputFile`. `LinkerContext` then emits its `sources` / -//! `sourcesContent` in place of the intermediate, and `Chunk::Builder` -//! remaps each mapping through `map.find_mapping` during printing so stack -//! traces surface in the authored source. +//! Inline `//# sourceMappingURL=data:...` sourcemap carried by a bundler +//! input file, stored on `Graph::InputFile`. `LinkerContext` expands its +//! `sources`/`sourcesContent` and `Chunk::Builder` remaps mappings through +//! it so the output map points at the authored source. use std::sync::Arc; use crate::ParsedSourceMap; -/// Parsed inner sourcemap + per-source content bytes, owned. -/// -/// `map.external_source_names` holds the chained-in `sources[]`. -/// `sources_content[i]` is the inner file's `sourcesContent[i]`; an empty -/// slot (`b""`) means the inner map did not carry content for that source. +/// `map.external_source_names` holds the chained-in `sources[]`; +/// `sources_content[i]` is `sourcesContent[i]` (`b""` when absent). pub struct InputSourceMap { pub map: Arc, pub sources_content: Box<[Box<[u8]>]>, } impl InputSourceMap { - /// Parse a sourcemap JSON blob intended to chain through a bundler input - /// file. Returns `None` when the payload is malformed — callers fall back - /// to the raw file bytes. Allocation failures panic via `handle_oom`. - /// - /// `json_bytes` is borrowed; the function copies out what it needs. + /// `None` on malformed payloads — callers fall back to the raw file + /// bytes. Copies what it needs out of `json_bytes`. pub fn parse(json_bytes: &[u8]) -> Option> { parse_internal(json_bytes).ok() } - /// Locate a trailing `//# sourceMappingURL=data:...` inline comment in - /// `source` and parse the embedded map. Returns `None` when no URL is - /// present, when the URL is not a data URL (e.g. a `.map` filename), or - /// when the payload fails to parse. External `.map` file resolution is - /// the caller's responsibility. + /// Parse the map from a trailing inline comment in `source`. `None` + /// for no/non-`data:` URL (external `.map` resolution is the + /// caller's) or a malformed payload. pub fn parse_from_source(source: &[u8]) -> Option> { let url = find_source_mapping_url(source)?; parse_data_url(url) } } -/// Malformed input is indistinguishable from "no chain available" — callers -/// treat it as a silent fallback to the raw file bytes. +/// Malformed input behaves exactly like "no chain available". struct InvalidSourceMap; -/// Workhorse returning `Result` so `?` fires cleanup on malformed-payload -/// bails — critical because JSON can pass the structural checks but still -/// have a malformed `mappings` VLQ, and we'd otherwise leak everything -/// allocated up to that point (cleanup rides on `Drop` at each early -/// return). fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourceMap> { use bun_ast::StoreResetGuard as DataStoreScope; @@ -65,9 +46,8 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let root = bun_parsers::json::parse_json_into_arena(&json_src, &mut log, &arena) .map_err(|_| InvalidSourceMap)?; - // The tape-based JSON parser represents containers as `EObjectJSON` / - // `EArrayJSON` rows; read through the tape accessors rather than - // materializing `Expr`s per element. + // Containers come back as `EObjectJSON`/`EArrayJSON` tape rows; read + // them through the tape accessors. let obj: &bun_ast::E::ObjectJSON = match &root.data { bun_ast::ExprData::EObjectJSON(o) => o.get(), _ => return Err(InvalidSourceMap), @@ -110,11 +90,9 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let source_count = sources_paths.items().len(); - // Copy source paths out of the arena into owned storage. A `sources[i]` - // longer than `MAX_PATH_BYTES` is rejected (the whole map falls back): - // the name is resolved against the intermediate's dir via the - // fixed-size path buffers in `bun_paths`, which would otherwise panic - // on an oversized entry from an adversarial inline map. + // A `sources[i]` longer than `MAX_PATH_BYTES` rejects the whole map: + // the linker resolves it through fixed-size path buffers that panic on + // oversized (adversarial) input. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items() { let s = item.as_str().ok_or(InvalidSourceMap)?; @@ -140,13 +118,9 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc } } - // `sources_count` bounds every `source_index` encoded in the VLQ - // mappings. The downstream consumers (`Chunk::Builder` emits - // `1 + inner.source_index`; `LinkerContext` reserves exactly - // `1 + external_source_names.len` slots per file) DON'T defensively - // clamp — out-of-range indices would alias a neighboring input file's - // slot in the output `sources[]`. Pass the real source count so - // malformed maps hit `Fail` and we fall back cleanly. + // Pass the real source count: downstream slot math doesn't clamp, so + // an out-of-range VLQ `source_index` must reject the map here instead + // of aliasing a neighboring file's `sources[]` slot. let sources_count_i32: i32 = i32::try_from(source_count).map_err(|_| InvalidSourceMap)?; let map_data = crate::mapping::parse( mappings_slice, @@ -169,12 +143,9 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc })) } -/// Find the trailing `//# sourceMappingURL=` comment in a file. Per -/// the Source Map spec the comment MUST be on the last line of the file -/// (see "3. Source Map Format" / "Linking generated code to source maps"), -/// so we anchor to the final line rather than the first `last_index_of` -/// match — a string literal earlier in the file containing that needle -/// must not hijack the lookup. +/// Find the trailing `//# sourceMappingURL=` comment. Anchored to the +/// final line (spec: the comment MUST be the last line) so a string literal +/// containing the needle can't hijack the lookup. fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { // Trim trailing whitespace/newlines so a file that ends with // `\n//# sourceMappingURL=...\n\n` still resolves to its final line. @@ -203,10 +174,8 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } let mut url = &last_line[NEEDLE.len()..]; - // Trim whitespace (` `, `\r`, `\t`) on both sides within the line: a - // leading space after `=` (e.g. `//# sourceMappingURL= data:...`) is - // spec-invalid but some toolchains emit it, and `parse_data_url` - // would fail on the leading space without this. + // Trim spaces/tabs/CR around the URL: `= data:...` is spec-invalid but + // some toolchains emit it. while let Some(&first) = url.first() { if first == b' ' || first == b'\r' || first == b'\t' { url = &url[1..]; @@ -232,9 +201,8 @@ fn parse_data_url(url: &[u8]) -> Option> { return None; } - // `data:application/json;charset=utf-8;base64,...` is permitted in the - // wild; tolerate any number of `;name[=value]` parameters between the - // prefix and the final `;base64,` / `,` separator. + // Tolerate any `;name[=value]` parameters (e.g. `;charset=utf-8`) + // before the final `;base64,` / `,` separator. let mut rest = &url[PREFIX.len()..]; let mut is_base64 = false; while !rest.is_empty() && rest[0] == b';' { From 27ec2ea8107bf4b6400a64c33b4b7d027d387b17 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:48:26 +0000 Subject: [PATCH 27/29] sourcemap: use bun_core::strings helpers for byte search (source lint) --- src/sourcemap/InputSourceMap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 9a286af9116c..a50cf2a391f3 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -163,7 +163,7 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } - let last_line_start = match body.iter().rposition(|&b| b == b'\n') { + let last_line_start = match bun_core::strings::last_index_of_char(body, b'\n') { Some(i) => i + 1, None => 0, }; @@ -208,7 +208,7 @@ fn parse_data_url(url: &[u8]) -> Option> { while !rest.is_empty() && rest[0] == b';' { let after = &rest[1..]; // Advance past one parameter up to the next ';' or ','. - let param_end = after.iter().position(|&b| b == b';' || b == b',')?; + let param_end = bun_core::strings::index_of_any(after, b";,")?; let param = &after[..param_end]; if param == b"base64" { is_base64 = true; From 39e136072bae8cf1f9ebe91cc217194341e07df8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:57:36 +0000 Subject: [PATCH 28/29] bundler: skip inline-map scan under DevServer; pass URL-schemed sources through The DevServer stitcher never consumes the parsed map, so the per-reparse scan was dead work on the HMR path. URL-schemed inner sources[] entries (webpack:///...) must not be path-joined; emit them verbatim per spec. --- src/bundler/LinkerContext.rs | 6 ++++ src/bundler/ParseTask.rs | 10 +++--- .../bun-build-inline-sourcemap-chain.test.ts | 36 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 0772129cc7eb..bc32663ff3e7 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1256,6 +1256,12 @@ fn write_sources_for( let mut join_buf = bun_paths::path_buffer_pool::get(); for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); + // The spec allows URLs in `sources[]` (e.g. `webpack:///src/a.ts`); + // path-joining would destroy the scheme, so pass them through. + if bun_core::strings::index_of(name, b"://").is_some() { + emit(joiner, name)?; + continue; + } if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { let rel = LinkerContext::source_map_relative_path(chunk_abs_dir, name)?; emit(joiner, &rel)?; diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 1b18193557cf..94eda26d88a8 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2628,10 +2628,11 @@ pub mod parse_worker { // SAFETY: task.ctx backref valid for the bundle pass (outlives `'r`). let task_ctx = unsafe { task.ctx() }; let module_type = opts.module_type; - // Copy `source_map` out before the tombstone: get_ast reborrows + // Copy these out before the tombstone: get_ast reborrows // `(*transpiler).options` mutably, invalidating `topts` under // Stacked Borrows. let source_map_option = topts.source_map; + let has_dev_server = topts.has_dev_server(); // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. let _ = topts; @@ -2694,9 +2695,10 @@ pub mod parse_worker { // Scan for an inline `//# sourceMappingURL=data:...` map to chain // into the output sourcemap. Runs on `source.contents` regardless - // of origin (file read or plugin `onLoad`, covering #6173). - let input_source_map: Option> = if source_map_option - != options::SourceMapOption::None + // of origin (file read or plugin `onLoad`, covering #6173). Skipped + // under DevServer: its stitcher never consumes the result. + let input_source_map: Option> = if !has_dev_server + && source_map_option != options::SourceMapOption::None && loader.can_have_source_map() && !source.contents.is_empty() { diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts index ae1d9b0125c7..6dd151b3044d 100644 --- a/test/bundler/bun-build-inline-sourcemap-chain.test.ts +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -134,6 +134,42 @@ describe.concurrent("Bun.build chains inline input sourcemaps", () => { expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); }); + // The Source Map spec allows `sources[i]` to be a URL + // (e.g. webpack's `webpack:///./src/x.ts`); path-joining such a name + // would destroy the scheme, so it must pass through verbatim. + test("URL-schemed inner source name passes through verbatim", async () => { + const authoredSrc = "export const w = 9;\n"; + const innerMap = { + version: 3, + sources: ["webpack:///./src/original.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-url", { + "intermediate.js": authoredSrc + inline, + "entry.ts": `import { w } from './intermediate.js';\nconsole.log(w);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The scheme-prefixed name survives untouched (no join/relativize). + expect(parsed.sources).toContain("webpack:///./src/original.ts"); + }); + // A malformed inline map must not break the build — we silently fall // back to the intermediate as the deepest source. test("malformed inline map — build succeeds and falls back", async () => { From 2d79d79009761e55b809fe261c69bbb0b280de16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:03:34 +0000 Subject: [PATCH 29/29] bundler: emit inner source names verbatim for virtual-namespace modules A plugin onResolve custom-namespace path has no on-disk directory, so joining inner names against dirname(text) produced bogus labels. --- src/bundler/LinkerContext.rs | 14 ++++-- .../bun-build-inline-sourcemap-chain.test.ts | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index bc32663ff3e7..f722a1384939 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1243,9 +1243,6 @@ fn write_sources_for( // 2) inner sources: resolve each against the intermediate's dir, then // re-relativize to `chunk_abs_dir` for the emitted JSON. if let Some(ism) = input_map { - let base_dir = bun_paths::resolve_path::dirname::( - outer_path.text, - ); let emit = |joiner: &mut StringJoiner, p: &[u8]| -> Result<(), BunError> { let mut quote_buf = MutableString::init(p.len() + ", ".len() + 2)?; quote_buf.append_assume_capacity(b", "); @@ -1253,6 +1250,17 @@ fn write_sources_for( joiner.push_owned(quote_buf.to_default_owned()); Ok(()) }; + // A non-file intermediate (plugin virtual module) has no directory + // to resolve against; emit inner names verbatim. + if !outer_path.is_file() { + for name in ism.map.external_source_names.iter() { + emit(joiner, name.as_ref())?; + } + return Ok(()); + } + let base_dir = bun_paths::resolve_path::dirname::( + outer_path.text, + ); let mut join_buf = bun_paths::path_buffer_pool::get(); for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts index 6dd151b3044d..f08111abaaf5 100644 --- a/test/bundler/bun-build-inline-sourcemap-chain.test.ts +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -424,4 +424,51 @@ describe.concurrent("Bun.build chains inline input sourcemaps", () => { const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("original-authored.custom")); expect(parsed.sourcesContent[authoredIdx]).toBe(authoredContent); }); + + // A virtual module (onResolve custom namespace) has no on-disk directory + // to resolve inner names against; they must surface verbatim instead of + // being joined with a bogus base. + test("virtual-namespace module with inline sourcemap keeps inner names verbatim", async () => { + const dir = tempDirWithFiles("bun-build-virtual-chained-sourcemap", { + "entry.ts": `import { v } from 'virt:mod';\nconsole.log(v);\n`, + }); + + const authoredContent = "export const v = 7;\n"; + const innerMap = { + version: 3, + sources: ["virtual-authored.src"], + sourcesContent: [authoredContent], + names: [], + mappings: "AAAA;", + }; + const inlineComment = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + plugins: [ + { + name: "virtual", + setup(build) { + build.onResolve({ filter: /^virt:/ }, args => ({ namespace: "virt", path: args.path.slice(5) })); + build.onLoad({ filter: /.*/, namespace: "virt" }, () => ({ + contents: "export const v = 7;\n" + inlineComment, + loader: "js", + })); + }, + }, + ], + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The inner name survives untouched (no join against a bogus base). + expect(parsed.sources).toContain("virtual-authored.src"); + }); });