From 45734771b3c11e127be158c54bbe9f5a6e7b17ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:28:51 +0000 Subject: [PATCH 1/4] bundler: make sourcemap sources relative to the map file, not outdir When a build emits chunks into subdirectories of outdir (multiple entrypoints with a root, or a [dir] naming template), the sourcemap sources array was computed relative to the outdir instead of the directory containing the .map file. Per the sourcemap spec (and esbuild), sources entries are resolved relative to the map file itself, so debuggers following them ended up at nonexistent out// paths. Compute chunk_abs_dir as output_dir + dirname(template) at post-process time. The content hash is not yet known there, but the hash formatter never emits a path separator, so a dummy value gives the correct directory depth. Fixes #3332 --- src/bundler/LinkerContext.rs | 20 +++ .../linker_context/postProcessCSSChunk.rs | 8 +- .../linker_context/postProcessJSChunk.rs | 7 +- src/bundler/options.rs | 21 +++ test/regression/issue/03332.test.ts | 132 ++++++++++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 test/regression/issue/03332.test.ts diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 99d5286eef3a..848b80612dc1 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1015,6 +1015,26 @@ impl<'a> LinkerContext<'a> { Ok(rel) } + /// Absolute directory of a chunk's output file, for use as the base of + /// source map `sources` entries (which the spec defines as relative to the + /// map file itself). `chunk.final_rel_path` is not yet known at + /// post-process time, so this is derived from `output_dir` + + /// `chunk.template.rel_dir()`. + pub(crate) fn source_map_chunk_abs_dir( + output_dir: &[u8], + template: &crate::options::PathTemplate, + sanitize_parent_dirs: bool, + ) -> Box<[u8]> { + let rel_dir = template.rel_dir(sanitize_parent_dirs); + if rel_dir.is_empty() || &*rel_dir == b"." || output_dir.is_empty() { + return Box::from(output_dir); + } + let mut buf = bun_paths::path_buffer_pool::get(); + Box::from(bun_paths::resolve_path::join_abs_string_buf::< + bun_paths::platform::Auto, + >(output_dir, &mut buf, &[&rel_dir])) + } + pub(crate) fn generate_source_map_for_chunk( &mut self, isolated_hash: u64, diff --git a/src/bundler/linker_context/postProcessCSSChunk.rs b/src/bundler/linker_context/postProcessCSSChunk.rs index 0cd4822a0cf7..81db2ac96171 100644 --- a/src/bundler/linker_context/postProcessCSSChunk.rs +++ b/src/bundler/linker_context/postProcessCSSChunk.rs @@ -160,12 +160,16 @@ pub(crate) fn post_process_css_chunk( // borrows the local, not `c`, avoiding the split-borrow with // `c.generate_source_map_for_chunk(&mut self, …)` below. let resolver = c.resolver.expect("resolver set in load()"); - let output_dir = &resolver.opts.output_dir; + let chunk_abs_dir = crate::LinkerContext::source_map_chunk_abs_dir( + &resolver.opts.output_dir, + &chunk.template, + !c.options.compile, + ); chunk.output_source_map = c.generate_source_map_for_chunk( chunk.isolated_hash, worker, &compile_results_for_source_map, - output_dir, + &chunk_abs_dir, can_have_shifts, )?; } diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index 7a73067f3afb..62bcce8dfa6d 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -809,11 +809,16 @@ pub(crate) fn post_process_js_chunk( // local, not `c`, avoiding the split-borrow with // `c.generate_source_map_for_chunk(&mut self, …)`. let resolver = c.resolver.expect("resolver set in load()"); + let chunk_abs_dir = LinkerContext::source_map_chunk_abs_dir( + &resolver.opts.output_dir, + &chunk.template, + !c.options.compile, + ); chunk.output_source_map = c.generate_source_map_for_chunk( chunk.isolated_hash, worker, &compile_results_for_source_map, - &resolver.opts.output_dir, + &chunk_abs_dir, can_have_shifts, )?; } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 0470a8e38238..ab56443df3b2 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2376,6 +2376,27 @@ impl PathTemplate { sanitize_parent_dirs, ) } + + /// The chunk-relative directory this template expands into, with `[hash]` + /// (not yet known during post-process) substituted by a dummy value. The + /// hash formatter never emits a path separator, so any value yields the + /// correct directory depth. Result uses `/` separators. + pub(crate) fn rel_dir(&self, sanitize_parent_dirs: bool) -> Box<[u8]> { + let mut rel = Vec::::new(); + path_template_print( + &mut rel, + &self.data, + &self.placeholder.dir, + &self.placeholder.name, + &self.placeholder.ext, + self.placeholder.hash.or(Some(0)), + &self.placeholder.target, + sanitize_parent_dirs, + ) + .expect("write to Vec"); + bun_paths::resolve_path::platform_to_posix_in_place::(&mut rel); + Box::from(bun_paths::resolve_path::dirname::(&rel)) + } } #[derive(Debug, Clone, Default)] diff --git a/test/regression/issue/03332.test.ts b/test/regression/issue/03332.test.ts new file mode 100644 index 000000000000..f0afd39c694a --- /dev/null +++ b/test/regression/issue/03332.test.ts @@ -0,0 +1,132 @@ +// https://github.com/oven-sh/bun/issues/3332 +// Sourcemap `sources` entries must be relative to the .map file's own +// directory (per the sourcemap spec), not to the build outdir. +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { tempDir } from "harness"; + +describe("issue 3332: sourcemap sources are relative to the map file", () => { + test("nested outdir layout (monorepo-style root)", async () => { + using dir = tempDir("issue-3332", { + "xxx/package.json": JSON.stringify({ name: "xxx", dependencies: { "tiny-dep": "1.0.0" } }), + "xxx/node_modules/tiny-dep/package.json": JSON.stringify({ + name: "tiny-dep", + version: "1.0.0", + main: "index.js", + }), + "xxx/node_modules/tiny-dep/index.js": `module.exports = { dep: "tiny" };`, + "xxx/repos/livex/store/src/util.ts": `export const util = () => "util";`, + "xxx/repos/livex/web/src/index.ts": ` + import { util } from "../../store/src/util"; + import dep from "tiny-dep"; + console.log(util(), dep); + `, + "xxx/repos/livex/server/src/bundler/foo.ts": ` + import { util } from "../../../store/src/util"; + import dep from "tiny-dep"; + console.log("foo", util(), dep); + `, + }); + + const root = path.join(String(dir), "xxx"); + const outdir = path.join(String(dir), "out"); + + const result = await Bun.build({ + root, + entrypoints: [ + path.join(root, "repos/livex/web/src/index.ts"), + path.join(root, "repos/livex/server/src/bundler/foo.ts"), + ], + outdir, + sourcemap: "external", + }); + expect(result.success).toBe(true); + + const maps = result.outputs.filter(o => o.path.endsWith(".map")); + expect(maps.length).toBe(2); + + for (const map of maps) { + // Each map lands in a subdirectory of outdir mirroring the source tree, + // so its directory differs from outdir. That is the case the bug broke. + const mapDir = path.dirname(map.path); + expect(mapDir).not.toBe(outdir); + + const json = JSON.parse(readFileSync(map.path, "utf8")); + expect(Array.isArray(json.sources)).toBe(true); + expect(json.sources.length).toBeGreaterThanOrEqual(3); + + for (const src of json.sources as string[]) { + const resolved = path.resolve(mapDir, src); + // Every source entry must resolve to a real file from the map's own + // directory. Before the fix these resolved under out/repos/.../xxx/... + // which does not exist. + expect(existsSync(resolved), `source ${JSON.stringify(src)} in ${map.path} -> ${resolved}`).toBe(true); + } + } + }); + + test("flat outdir (single entrypoint in outdir root) still resolves", async () => { + using dir = tempDir("issue-3332-flat", { + "src/entry.ts": ` + import { helper } from "./lib/helper"; + console.log(helper()); + `, + "src/lib/helper.ts": `export const helper = () => "hi";`, + }); + + const outdir = path.join(String(dir), "dist"); + const result = await Bun.build({ + entrypoints: [path.join(String(dir), "src/entry.ts")], + outdir, + sourcemap: "external", + }); + expect(result.success).toBe(true); + + const [map] = result.outputs.filter(o => o.path.endsWith(".map")); + expect(map).toBeDefined(); + // With a single entrypoint and no `root`, the map sits directly in outdir. + expect(path.dirname(map.path)).toBe(outdir); + + const json = JSON.parse(readFileSync(map.path, "utf8")); + for (const src of json.sources as string[]) { + const resolved = path.resolve(path.dirname(map.path), src); + expect(existsSync(resolved), `source ${JSON.stringify(src)} -> ${resolved}`).toBe(true); + } + }); + + test("inline sourcemap in a nested chunk resolves from the map file", async () => { + using dir = tempDir("issue-3332-inline", { + "xxx/repos/app/src/util.ts": `export const util = () => 1;`, + "xxx/repos/app/src/entry.ts": ` + import { util } from "./util"; + console.log(util()); + `, + "xxx/repos/other/stub.ts": `export {};`, + }); + + const root = path.join(String(dir), "xxx"); + const outdir = path.join(String(dir), "out"); + const result = await Bun.build({ + root, + entrypoints: [path.join(root, "repos/app/src/entry.ts"), path.join(root, "repos/other/stub.ts")], + outdir, + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const entry = result.outputs.find(o => o.path.endsWith("entry.js")); + expect(entry).toBeDefined(); + const jsDir = path.dirname(entry!.path); + expect(jsDir).not.toBe(outdir); + + const code = readFileSync(entry!.path, "utf8"); + const m = code.match(/sourceMappingURL=data:application\/json;[^,]+,([A-Za-z0-9+/=]+)/); + expect(m).toBeTruthy(); + const json = JSON.parse(Buffer.from(m![1], "base64").toString("utf8")); + for (const src of json.sources as string[]) { + const resolved = path.resolve(jsDir, src); + expect(existsSync(resolved), `source ${JSON.stringify(src)} -> ${resolved}`).toBe(true); + } + }); +}); From 3c8d467c660b248d8f99bbde91683062d173cba2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:31:25 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/regression/issue/03332.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/regression/issue/03332.test.ts b/test/regression/issue/03332.test.ts index f0afd39c694a..1e2628feb6d0 100644 --- a/test/regression/issue/03332.test.ts +++ b/test/regression/issue/03332.test.ts @@ -2,9 +2,9 @@ // Sourcemap `sources` entries must be relative to the .map file's own // directory (per the sourcemap spec), not to the build outdir. import { describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; -import { tempDir } from "harness"; describe("issue 3332: sourcemap sources are relative to the map file", () => { test("nested outdir layout (monorepo-style root)", async () => { From 851a27bd3aba057df5338bc67f31a8d1a980333a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:33:14 +0000 Subject: [PATCH 3/4] trim doc comments on rel_dir / source_map_chunk_abs_dir --- src/bundler/LinkerContext.rs | 8 +++----- src/bundler/options.rs | 7 +++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 848b80612dc1..af8959bfe856 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1015,11 +1015,9 @@ impl<'a> LinkerContext<'a> { Ok(rel) } - /// Absolute directory of a chunk's output file, for use as the base of - /// source map `sources` entries (which the spec defines as relative to the - /// map file itself). `chunk.final_rel_path` is not yet known at - /// post-process time, so this is derived from `output_dir` + - /// `chunk.template.rel_dir()`. + /// `output_dir` joined with the chunk template's directory: the base that + /// source-map `sources` entries are relative to. `final_rel_path` is not + /// yet set when the source map is generated, so the template stands in. pub(crate) fn source_map_chunk_abs_dir( output_dir: &[u8], template: &crate::options::PathTemplate, diff --git a/src/bundler/options.rs b/src/bundler/options.rs index ab56443df3b2..cb57bad15a42 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2377,10 +2377,9 @@ impl PathTemplate { ) } - /// The chunk-relative directory this template expands into, with `[hash]` - /// (not yet known during post-process) substituted by a dummy value. The - /// hash formatter never emits a path separator, so any value yields the - /// correct directory depth. Result uses `/` separators. + /// `/`-separated directory portion of the expanded template. A missing + /// `[hash]` is filled with a dummy: the hash output never contains `/`, + /// so the directory is the same regardless of the eventual value. pub(crate) fn rel_dir(&self, sanitize_parent_dirs: bool) -> Box<[u8]> { let mut rel = Vec::::new(); path_template_print( From 7fdb4fa938e13ec5163a69017ad50940b30c844b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:00:38 +0000 Subject: [PATCH 4/4] absolutize relative outdir before joining chunk rel_dir join_abs_string_buf requires an absolute cwd; Bun.build stores a relative outdir verbatim. Anchor the join at FileSystem top_level_dir so a relative outdir is resolved the same way relative_platform_buf already does for the flat case. Adds a subprocess test that builds with outdir: 'dist' and asserts sources resolve from the map dir. Also guard the flat/inline tests against vacuously passing on an empty sources array. --- src/bundler/LinkerContext.rs | 6 +++- test/regression/issue/03332.test.ts | 48 ++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index af8959bfe856..38c83958680d 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1027,10 +1027,14 @@ impl<'a> LinkerContext<'a> { if rel_dir.is_empty() || &*rel_dir == b"." || output_dir.is_empty() { return Box::from(output_dir); } + // `join_abs_string_buf` requires an absolute cwd. `output_dir` may be + // relative (Bun.build `outdir: "dist"`), so anchor at `top_level_dir`; + // an absolute `output_dir` resets the join base on its own. + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; let mut buf = bun_paths::path_buffer_pool::get(); Box::from(bun_paths::resolve_path::join_abs_string_buf::< bun_paths::platform::Auto, - >(output_dir, &mut buf, &[&rel_dir])) + >(top_level_dir, &mut buf, &[output_dir, &rel_dir])) } pub(crate) fn generate_source_map_for_chunk( diff --git a/test/regression/issue/03332.test.ts b/test/regression/issue/03332.test.ts index 1e2628feb6d0..054ed7e9046a 100644 --- a/test/regression/issue/03332.test.ts +++ b/test/regression/issue/03332.test.ts @@ -2,7 +2,7 @@ // Sourcemap `sources` entries must be relative to the .map file's own // directory (per the sourcemap spec), not to the build outdir. import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -89,6 +89,7 @@ describe("issue 3332: sourcemap sources are relative to the map file", () => { expect(path.dirname(map.path)).toBe(outdir); const json = JSON.parse(readFileSync(map.path, "utf8")); + expect(json.sources.length).toBeGreaterThanOrEqual(2); for (const src of json.sources as string[]) { const resolved = path.resolve(path.dirname(map.path), src); expect(existsSync(resolved), `source ${JSON.stringify(src)} -> ${resolved}`).toBe(true); @@ -124,9 +125,54 @@ describe("issue 3332: sourcemap sources are relative to the map file", () => { const m = code.match(/sourceMappingURL=data:application\/json;[^,]+,([A-Za-z0-9+/=]+)/); expect(m).toBeTruthy(); const json = JSON.parse(Buffer.from(m![1], "base64").toString("utf8")); + expect(json.sources.length).toBeGreaterThanOrEqual(2); for (const src of json.sources as string[]) { const resolved = path.resolve(jsDir, src); expect(existsSync(resolved), `source ${JSON.stringify(src)} -> ${resolved}`).toBe(true); } }); + + test("relative outdir with nested chunks", async () => { + // `Bun.build` stores a relative `outdir` verbatim; the sources-base join + // must still produce an absolute chunk directory. Run in a subprocess so + // the test can chdir without touching the harness cwd. + using dir = tempDir("issue-3332-reloutdir", { + "proj/a/util.ts": `export const u = 1;`, + "proj/a/entry.ts": `import { u } from "./util"; console.log(u);`, + "proj/b/entry.ts": `console.log("b");`, + "build.ts": ` + import path from "node:path"; + import { readFileSync, existsSync } from "node:fs"; + const root = path.join(import.meta.dir, "proj"); + const res = await Bun.build({ + root, + entrypoints: [path.join(root, "a/entry.ts"), path.join(root, "b/entry.ts")], + outdir: "dist", + sourcemap: "external", + }); + if (!res.success) { console.error(res.logs); process.exit(1); } + const map = res.outputs.find(o => o.path.endsWith(path.join("a", "entry.js.map"))); + if (!map) { console.error("no map for a/entry"); process.exit(1); } + const mapDir = path.dirname(map.path); + const json = JSON.parse(readFileSync(map.path, "utf8")); + if (json.sources.length < 2) { console.error("too few sources", json.sources); process.exit(1); } + for (const s of json.sources) { + const r = path.resolve(mapDir, s); + if (!existsSync(r)) { console.error("MISS", s, "->", r); process.exit(1); } + } + console.log("OK " + json.sources.length); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("OK 2"); + expect(exitCode).toBe(0); + }); });