From 9f3d02d50b3c31e33971bf7abe85c8b56026eb6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:25:15 +0000 Subject: [PATCH 01/10] bun build: never derive an output directory from the entry point Without --outdir, a build that produced more than one file (a linked source map is the usual second file) fell back to dirname(entry) as the output directory. `bun build src/x.js --sourcemap` wrote the bundle over src/x.js, and `--outfile dist/app.js --sourcemap` wrote into src/ too. - Bare `--sourcemap` now means linked when the build writes to disk and inline when it prints to stdout, the same rule as `Bun.build({ sourcemap: true })` and esbuild. - `--sourcemap=linked` and `--sourcemap=external` without --outdir or --outfile are an error. External already was; it is now allowed with --outfile. - Without --outdir, `--outfile dir/app.js` is handed to the bundler as outdir=dir with the entry named app.js, so the bundle, its .map and the map's `sources` are all relative to dir. - The dirname(entry) fallback is removed. Fixes #19729 --- docs/bundler/esbuild.mdx | 2 +- docs/bundler/index.mdx | 2 +- src/runtime/cli/Arguments.rs | 13 +++- src/runtime/cli/build_command.rs | 96 ++++++++++++++-------------- test/bundler/cli.test.ts | 103 +++++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 52 deletions(-) diff --git a/docs/bundler/esbuild.mdx b/docs/bundler/esbuild.mdx index 716ae790bd34..9856085af419 100644 --- a/docs/bundler/esbuild.mdx +++ b/docs/bundler/esbuild.mdx @@ -48,7 +48,7 @@ In Bun's CLI, boolean flags like `--minify` take no argument. Flags that take on | `--packages` | `--packages` | No differences | | `--platform` | `--target` | Renamed to `--target` for consistency with tsconfig. Does not support `neutral`. | | `--serve` | n/a | Not applicable | -| `--sourcemap` | `--sourcemap` | Supports `linked` (the default when no value is given), `external`, `inline`, and `none`. Does not support esbuild's `both`. | +| `--sourcemap` | `--sourcemap` | Supports `linked`, `external`, `inline`, and `none`. With no value: `linked` when writing to disk, `inline` when writing to stdout (same as esbuild). Does not support esbuild's `both`. | | `--splitting` | `--splitting` | No differences | | `--target` | n/a | Not supported. Bun's bundler performs no syntactic down-leveling. | | `--watch` | `--watch` | No differences | diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index c8a89aed92f6..0062672b3b11 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -757,7 +757,7 @@ Specifies the type of sourcemap to generate. | Value | Description | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"none"` | Default. No sourcemap is generated. | -| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` to be set. You can customize the base URL in this comment with `--public-path`.

`js
//

//# sourceMappingURL=bundle.js.map
` | +| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` or `--outfile`. You can customize the base URL in this comment with `--public-path`.

`js
//

//# sourceMappingURL=bundle.js.map
` | | `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment.

Generated bundles contain a debug id that can be used to associate a bundle with its corresponding sourcemap. This `debugId` is added as a comment at the bottom of the file.

`js
//

//# debugId=
` | | `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.

`js
//

//# sourceMappingURL=data:application/json;base64,
` | diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index a735bf36abdc..22abbaa2d8d5 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -2633,8 +2633,17 @@ fn parse_build_command_options( if let Some(setting) = args.option(b"--sourcemap") { if setting.is_empty() { - // In the future, Bun is going to make this default to .linked - opts.source_map = Some(api::SourceMapMode::Linked); + // Same rule as `Bun.build({ sourcemap: true })`: a separate .map + // file when the build writes to disk, an inline map when the + // bundle goes to stdout. + let writes_to_disk = ctx.bundler_options.compile + || !ctx.bundler_options.outdir.is_empty() + || !ctx.bundler_options.outfile.is_empty(); + opts.source_map = Some(if writes_to_disk { + api::SourceMapMode::Linked + } else { + api::SourceMapMode::Inline + }); } else if setting == b"inline" { opts.source_map = Some(api::SourceMapMode::Inline); } else if setting == b"none" { diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 7afc02a2a3c8..7c74349afa17 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -160,21 +160,26 @@ impl BuildCommand { options::CompileMode::None }; - if this_transpiler.options.source_map == options::SourceMapOption::External - && ctx.bundler_options.outdir.is_empty() - && !ctx.bundler_options.compile - { - bun_core::pretty_errorln!( - "error: cannot use an external source map without --outdir" - ); - Global::exit(1); - } - let mut outfile: &[u8] = &ctx.bundler_options.outfile; let output_to_stdout = !ctx.bundler_options.compile && outfile.is_empty() && ctx.bundler_options.outdir.is_empty(); + if output_to_stdout { + let kind = match this_transpiler.options.source_map { + options::SourceMapOption::External => Some("an external"), + options::SourceMapOption::Linked => Some("a linked"), + options::SourceMapOption::Inline | options::SourceMapOption::None => None, + }; + if let Some(kind) = kind { + bun_core::pretty_errorln!( + "error: cannot use {} source map without --outdir or --outfile (use --sourcemap=inline to print it to stdout)", + kind + ); + Global::exit(1); + } + } + this_transpiler.options.supports_multiple_outputs = !(output_to_stdout || !outfile.is_empty()); @@ -394,6 +399,25 @@ impl BuildCommand { } } + // For the bundler, `--outfile dir/app.js` is `--outdir dir` with the + // entry chunk (and so its .map) named after the file. `--compile` and + // `--no-bundle` keep the output in memory and write `outfile` below. + if ctx.bundler_options.outdir.is_empty() + && !outfile.is_empty() + && !ctx.bundler_options.compile + && !ctx.bundler_options.transform_only + { + let mut entry_naming = Vec::::new(); + write!( + &mut entry_naming, + "./{}", + bstr::BStr::new(bun_paths::basename(outfile)) + ) + .expect("unreachable"); + this_transpiler.options.entry_naming = entry_naming.into_boxed_slice(); + this_transpiler.options.output_dir = bun_core::dirname(outfile).unwrap_or(b".").into(); + } + let mut src_root_dir_buf = bun_paths::path_buffer_pool::get(); let src_root_dir: &[u8] = 'brk1: { let path: &[u8] = 'brk2: { @@ -642,25 +666,6 @@ impl BuildCommand { break 'brk result.output_files.into_vec(); } - if ctx.bundler_options.outdir.is_empty() - && !outfile.is_empty() - && !ctx.bundler_options.compile - { - let mut entry_naming = Vec::::new(); - write!( - &mut entry_naming, - "./{}", - bstr::BStr::new(bun_paths::basename(outfile)) - ) - .expect("unreachable"); - this_transpiler.options.entry_naming = entry_naming.into_boxed_slice(); - if let Some(dir) = bun_core::dirname(outfile) { - ctx.bundler_options.outdir = dir.into(); - } - // resolver.opts.entry_naming — field does not exist on the - // resolver subset; bundler-side `entry_naming` is sufficient. - } - // Stack-owned Mini event loop so its tasks/concurrent_tasks queues // drop at scope exit; the arena bulk-free skips Drop. Outlives the // BACKREF passed to `generate_from_cli`. @@ -791,19 +796,19 @@ impl BuildCommand { let writer = Output::writer_buffered(); let mut output_dir: &[u8] = &opt_output_dir; - let will_be_one_file = - // --outdir is not supported with --compile - // but you can still use --outfile - // in which case, we should set the output dir to the dirname of the outfile - // https://github.com/oven-sh/bun/issues/8697 - ctx.bundler_options.compile - || (output_files.len() == 1 - && matches!(output_files[0].value, options::OutputFileValue::Buffer { .. })); - - if output_dir.is_empty() && !outfile.is_empty() && will_be_one_file { + // `--compile` and `--no-bundle` keep the `--outfile` output in memory + // until here. It goes into the outfile's directory. + // https://github.com/oven-sh/bun/issues/8697 + if output_dir.is_empty() && !outfile.is_empty() { output_dir = bun_core::dirname(outfile).unwrap_or(b"."); // With --compile, the bundler already named the entry point's chunk after the outfile. - if !ctx.bundler_options.compile { + if !ctx.bundler_options.compile + && output_files.len() == 1 + && matches!( + output_files[0].value, + options::OutputFileValue::Buffer { .. } + ) + { output_files[0].dest_path = bun_paths::basename(outfile).into(); } } @@ -822,21 +827,16 @@ impl BuildCommand { } } - let mut root_path: &[u8] = output_dir; - if root_path.is_empty() && ctx.args.entry_points.len() == 1 { - root_path = bun_core::dirname(&ctx.args.entry_points[0]).unwrap_or(b"."); - } - - let root_dir = if root_path.is_empty() || root_path == b"." { + let root_dir = if output_dir.is_empty() || output_dir == b"." { bun_sys::Dir::cwd() } else { - match bun_sys::Dir::cwd().make_open_path(root_path, Default::default()) { + match bun_sys::Dir::cwd().make_open_path(output_dir, Default::default()) { Ok(d) => d, Err(err) => { Output::err( err, "could not open output directory {}", - (bun_fmt::quote(root_path),), + (bun_fmt::quote(output_dir),), ); exit_or_watch(1, ctx.debug.hot_reload == HotReload::Watch); } diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index a3923eb89ebc..0368eb591a0a 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -351,6 +351,109 @@ test("you can use --outfile=... and --sourcemap", async () => { `); }); +describe.concurrent("--sourcemap never writes next to the entry point", () => { + // The entry lives in src/, the build runs from work/. Nothing may appear in + // src/ or work/ unless it was asked for. + const src = { + "dep.js": `export const dep = 42;\n`, + "entry.js": `import { dep } from "./dep.js";\nconsole.log(dep);\n`, + "entry.ts": `import { dep } from "./dep.js";\nexport const out: number = dep;\nconsole.log(out);\n`, + "index.html": `\n`, + }; + const fixture = { + ...Object.fromEntries(Object.entries(src).map(([name, contents]) => [`src/${name}`, contents])), + "work/.keep": "", + }; + + function tree(dir: string) { + const out: Record = {}; + for (const rel of fs.readdirSync(dir, { recursive: true, encoding: "utf8" }).sort()) { + const file = join(dir, rel); + if (fs.statSync(file).isFile()) out[rel.replaceAll("\\", "/")] = fs.readFileSync(file, "utf8"); + } + return out; + } + + async function build(dir: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", ...args], + env: bunEnv, + cwd: join(dir, "work"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.each(["entry.js", "entry.ts"])("bare --sourcemap prints %s with an inline map to stdout", async entry => { + using dir = tempDir("build-sourcemap-stdout", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), `../src/${entry}`, "--sourcemap"); + expect(stderr).toBe(""); + expect(stdout).toContain("console.log("); + expect(stdout).toContain("//# sourceMappingURL=data:application/json;base64,"); + expect(tree(String(dir))).toEqual(fixture); + expect(exitCode).toBe(0); + }); + + test.each(["linked", "external"])("--sourcemap=%s without --outdir or --outfile is an error", async kind => { + using dir = tempDir("build-sourcemap-stdout-error", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), "../src/entry.js", `--sourcemap=${kind}`); + expect(stdout).toBe(""); + expect(stderr).toBe( + `error: cannot use ${kind === "external" ? "an external" : "a linked"} source map without --outdir or --outfile (use --sourcemap=inline to print it to stdout)\n`, + ); + expect(tree(String(dir))).toEqual(fixture); + expect(exitCode).toBe(1); + }); + + // https://github.com/oven-sh/bun/issues/19729 + test.each(["linked", "external"])("--outfile in another directory with --sourcemap=%s writes there", async kind => { + using dir = tempDir("build-sourcemap-outfile-dir", fixture); + const { stdout, stderr, exitCode } = await build( + String(dir), + "../src/entry.js", + "--outfile=../dist/js/app.js", + `--sourcemap=${kind}`, + ); + expect(stderr).toBe(""); + expect(stdout).toContain("app.js.map"); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual(["dist/js/app.js", "dist/js/app.js.map", ...Object.keys(fixture)]); + expect(after["dist/js/app.js"]).toContain("console.log("); + if (kind === "linked") { + expect(after["dist/js/app.js"]).toContain("//# sourceMappingURL=app.js.map"); + } else { + expect(after["dist/js/app.js"]).not.toContain("sourceMappingURL"); + } + // Relative to the map in dist/js/, not to the cwd. + expect(JSON.parse(after["dist/js/app.js.map"]).sources).toEqual(["../../src/dep.js", "../../src/entry.js"]); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + expect(exitCode).toBe(0); + }); + + test("a standalone html build with --sourcemap and no --outdir writes to the cwd", async () => { + using dir = tempDir("build-sourcemap-standalone-html", fixture); + const { stdout, stderr, exitCode } = await build( + String(dir), + "../src/index.html", + "--compile", + "--target=browser", + "--sourcemap", + ); + expect(stderr).toBe(""); + expect(stdout).toContain("index.html"); + + const after = tree(String(dir)); + const written = Object.keys(after).filter(file => !(file in fixture)); + expect(written).toEqual([expect.stringMatching(/^work\/index(-\w+)?\.js\.map$/), "work/index.html"]); + expect(after["work/index.html"]).toContain("console.log("); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + expect(exitCode).toBe(0); + }); +}); + test("some log cases", async () => { const tmpdir = tmpdirSync(); const inputFile = path.join(tmpdir, "input.js"); From a771cc0c022d9bfe36e03ac24c4f9282775ad6d3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:20:50 +0000 Subject: [PATCH 02/10] test: cover the full --sourcemap matrix with --outfile, same basename as the entry --- test/bundler/cli.test.ts | 61 ++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 0368eb591a0a..01a90e148806 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -407,31 +407,42 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { expect(exitCode).toBe(1); }); - // https://github.com/oven-sh/bun/issues/19729 - test.each(["linked", "external"])("--outfile in another directory with --sourcemap=%s writes there", async kind => { - using dir = tempDir("build-sourcemap-outfile-dir", fixture); - const { stdout, stderr, exitCode } = await build( - String(dir), - "../src/entry.js", - "--outfile=../dist/js/app.js", - `--sourcemap=${kind}`, - ); - expect(stderr).toBe(""); - expect(stdout).toContain("app.js.map"); - - const after = tree(String(dir)); - expect(Object.keys(after)).toEqual(["dist/js/app.js", "dist/js/app.js.map", ...Object.keys(fixture)]); - expect(after["dist/js/app.js"]).toContain("console.log("); - if (kind === "linked") { - expect(after["dist/js/app.js"]).toContain("//# sourceMappingURL=app.js.map"); - } else { - expect(after["dist/js/app.js"]).not.toContain("sourceMappingURL"); - } - // Relative to the map in dist/js/, not to the cwd. - expect(JSON.parse(after["dist/js/app.js.map"]).sources).toEqual(["../../src/dep.js", "../../src/entry.js"]); - for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); - expect(exitCode).toBe(0); - }); + // https://github.com/oven-sh/bun/issues/19729. The outfile has the entry's + // basename on purpose: that spelling used to replace src/entry.js with its + // own bundle. + test.each(["--sourcemap", "--sourcemap=linked", "--sourcemap=external", "--sourcemap=inline"])( + "--outfile in another directory with %s writes there", + async flag => { + using dir = tempDir("build-sourcemap-outfile-dir", fixture); + const { stdout, stderr, exitCode } = await build( + String(dir), + "../src/entry.js", + "--outfile=../dist/js/entry.js", + flag, + ); + expect(stderr).toBe(""); + expect(stdout).toContain("entry.js"); + + const after = tree(String(dir)); + const bundle = after["dist/js/entry.js"]; + expect(bundle).toContain("console.log("); + if (flag === "--sourcemap=inline") { + expect(Object.keys(after)).toEqual(["dist/js/entry.js", ...Object.keys(fixture)]); + expect(bundle).toContain("//# sourceMappingURL=data:application/json;base64,"); + } else { + expect(Object.keys(after)).toEqual(["dist/js/entry.js", "dist/js/entry.js.map", ...Object.keys(fixture)]); + if (flag === "--sourcemap=external") { + expect(bundle).not.toContain("sourceMappingURL"); + } else { + expect(bundle).toContain("//# sourceMappingURL=entry.js.map"); + } + // Relative to the map in dist/js/, not to the cwd. + expect(JSON.parse(after["dist/js/entry.js.map"]).sources).toEqual(["../../src/dep.js", "../../src/entry.js"]); + } + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + expect(exitCode).toBe(0); + }, + ); test("a standalone html build with --sourcemap and no --outdir writes to the cwd", async () => { using dir = tempDir("build-sourcemap-standalone-html", fixture); From 8ba61793db6f7805b36591a05eeb6880348d0b8a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:30:31 +0000 Subject: [PATCH 03/10] keep the --outfile basename byte-exact in entry_naming; run the --outfile rows without a leading .. (Windows mkdir of ../x is a separate bug) --- src/runtime/cli/build_command.rs | 10 ++-------- test/bundler/cli.test.ts | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 7c74349afa17..81906dd0251b 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -407,14 +407,8 @@ impl BuildCommand { && !ctx.bundler_options.compile && !ctx.bundler_options.transform_only { - let mut entry_naming = Vec::::new(); - write!( - &mut entry_naming, - "./{}", - bstr::BStr::new(bun_paths::basename(outfile)) - ) - .expect("unreachable"); - this_transpiler.options.entry_naming = entry_naming.into_boxed_slice(); + this_transpiler.options.entry_naming = + strings::concat(&[b"./", bun_paths::basename(outfile)]); this_transpiler.options.output_dir = bun_core::dirname(outfile).unwrap_or(b".").into(); } diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 01a90e148806..523e2c06549f 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -352,8 +352,9 @@ test("you can use --outfile=... and --sourcemap", async () => { }); describe.concurrent("--sourcemap never writes next to the entry point", () => { - // The entry lives in src/, the build runs from work/. Nothing may appear in - // src/ or work/ unless it was asked for. + // The entry lives in src/, the build runs from work/ (or from the root for + // the --outfile rows). Nothing may appear in src/ or work/ unless it was + // asked for. const src = { "dep.js": `export const dep = 42;\n`, "entry.js": `import { dep } from "./dep.js";\nconsole.log(dep);\n`, @@ -374,11 +375,11 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { return out; } - async function build(dir: string, ...args: string[]) { + async function build(cwd: string, ...args: string[]) { await using proc = Bun.spawn({ cmd: [bunExe(), "build", ...args], env: bunEnv, - cwd: join(dir, "work"), + cwd, stdout: "pipe", stderr: "pipe", }); @@ -388,7 +389,7 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { test.each(["entry.js", "entry.ts"])("bare --sourcemap prints %s with an inline map to stdout", async entry => { using dir = tempDir("build-sourcemap-stdout", fixture); - const { stdout, stderr, exitCode } = await build(String(dir), `../src/${entry}`, "--sourcemap"); + const { stdout, stderr, exitCode } = await build(join(String(dir), "work"), `../src/${entry}`, "--sourcemap"); expect(stderr).toBe(""); expect(stdout).toContain("console.log("); expect(stdout).toContain("//# sourceMappingURL=data:application/json;base64,"); @@ -398,7 +399,11 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { test.each(["linked", "external"])("--sourcemap=%s without --outdir or --outfile is an error", async kind => { using dir = tempDir("build-sourcemap-stdout-error", fixture); - const { stdout, stderr, exitCode } = await build(String(dir), "../src/entry.js", `--sourcemap=${kind}`); + const { stdout, stderr, exitCode } = await build( + join(String(dir), "work"), + "../src/entry.js", + `--sourcemap=${kind}`, + ); expect(stdout).toBe(""); expect(stderr).toBe( `error: cannot use ${kind === "external" ? "an external" : "a linked"} source map without --outdir or --outfile (use --sourcemap=inline to print it to stdout)\n`, @@ -408,18 +413,13 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { }); // https://github.com/oven-sh/bun/issues/19729. The outfile has the entry's - // basename on purpose: that spelling used to replace src/entry.js with its - // own bundle. + // basename on purpose: `bun build src/entry.js --outfile dist/js/entry.js + // --sourcemap` used to replace src/entry.js with its own bundle. test.each(["--sourcemap", "--sourcemap=linked", "--sourcemap=external", "--sourcemap=inline"])( "--outfile in another directory with %s writes there", async flag => { using dir = tempDir("build-sourcemap-outfile-dir", fixture); - const { stdout, stderr, exitCode } = await build( - String(dir), - "../src/entry.js", - "--outfile=../dist/js/entry.js", - flag, - ); + const { stdout, stderr, exitCode } = await build(String(dir), "src/entry.js", "--outfile=dist/js/entry.js", flag); expect(stderr).toBe(""); expect(stdout).toContain("entry.js"); @@ -447,7 +447,7 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { test("a standalone html build with --sourcemap and no --outdir writes to the cwd", async () => { using dir = tempDir("build-sourcemap-standalone-html", fixture); const { stdout, stderr, exitCode } = await build( - String(dir), + join(String(dir), "work"), "../src/index.html", "--compile", "--target=browser", From bf1e9f3970e117a925a2f5eb96cdf038ce425bb7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:47:14 +0000 Subject: [PATCH 04/10] shorter comments --- src/runtime/cli/Arguments.rs | 4 +--- src/runtime/cli/build_command.rs | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 22abbaa2d8d5..da4a947a0642 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -2633,9 +2633,7 @@ fn parse_build_command_options( if let Some(setting) = args.option(b"--sourcemap") { if setting.is_empty() { - // Same rule as `Bun.build({ sourcemap: true })`: a separate .map - // file when the build writes to disk, an inline map when the - // bundle goes to stdout. + // Same rule as `Bun.build({ sourcemap: true })`. let writes_to_disk = ctx.bundler_options.compile || !ctx.bundler_options.outdir.is_empty() || !ctx.bundler_options.outfile.is_empty(); diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 81906dd0251b..dbf6d996851e 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -399,9 +399,7 @@ impl BuildCommand { } } - // For the bundler, `--outfile dir/app.js` is `--outdir dir` with the - // entry chunk (and so its .map) named after the file. `--compile` and - // `--no-bundle` keep the output in memory and write `outfile` below. + // Bundle `--outfile dir/app.js` as `--outdir dir` with the entry named `app.js`. if ctx.bundler_options.outdir.is_empty() && !outfile.is_empty() && !ctx.bundler_options.compile @@ -790,9 +788,7 @@ impl BuildCommand { let writer = Output::writer_buffered(); let mut output_dir: &[u8] = &opt_output_dir; - // `--compile` and `--no-bundle` keep the `--outfile` output in memory - // until here. It goes into the outfile's directory. - // https://github.com/oven-sh/bun/issues/8697 + // `--compile` and `--no-bundle` output for `--outfile` is still in memory here (#8697). if output_dir.is_empty() && !outfile.is_empty() { output_dir = bun_core::dirname(outfile).unwrap_or(b"."); // With --compile, the bundler already named the entry point's chunk after the outfile. From be9c3a4d5c810180d137c2e2bc392bf9bbc1f477 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:11:56 +0000 Subject: [PATCH 05/10] docs: linked and external source maps need --outdir or --outfile --- docs/bundler/esbuild.mdx | 2 +- docs/bundler/index.mdx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/bundler/esbuild.mdx b/docs/bundler/esbuild.mdx index 9856085af419..784b9e07d04f 100644 --- a/docs/bundler/esbuild.mdx +++ b/docs/bundler/esbuild.mdx @@ -48,7 +48,7 @@ In Bun's CLI, boolean flags like `--minify` take no argument. Flags that take on | `--packages` | `--packages` | No differences | | `--platform` | `--target` | Renamed to `--target` for consistency with tsconfig. Does not support `neutral`. | | `--serve` | n/a | Not applicable | -| `--sourcemap` | `--sourcemap` | Supports `linked`, `external`, `inline`, and `none`. With no value: `linked` when writing to disk, `inline` when writing to stdout (same as esbuild). Does not support esbuild's `both`. | +| `--sourcemap` | `--sourcemap` | Supports `linked`, `external`, `inline`, and `none`. With no value: `linked` when writing to disk, `inline` when writing to stdout (same as esbuild). `linked` and `external` require `--outdir` or `--outfile`. Does not support esbuild's `both`. | | `--splitting` | `--splitting` | No differences | | `--target` | n/a | Not supported. Bun's bundler performs no syntactic down-leveling. | | `--watch` | `--watch` | No differences | diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index 0062672b3b11..f1179abb2e92 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -754,12 +754,12 @@ Specifies the type of sourcemap to generate. -| Value | Description | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `"none"` | Default. No sourcemap is generated. | -| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` or `--outfile`. You can customize the base URL in this comment with `--public-path`.

`js
//

//# sourceMappingURL=bundle.js.map
` | -| `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment.

Generated bundles contain a debug id that can be used to associate a bundle with its corresponding sourcemap. This `debugId` is added as a comment at the bottom of the file.

`js
//

//# debugId=
` | -| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.

`js
//

//# sourceMappingURL=data:application/json;base64,
` | +| Value | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `"none"` | Default. No sourcemap is generated. | +| `"linked"` | A separate `*.js.map` file is created alongside each `*.js` bundle using a `//# sourceMappingURL` comment to link the two. Requires `--outdir` or `--outfile`. You can customize the base URL in this comment with `--public-path`.

`js
//

//# sourceMappingURL=bundle.js.map
` | +| `"external"` | A separate `*.js.map` file is created alongside each `*.js` bundle without inserting a `//# sourceMappingURL` comment. Requires `--outdir` or `--outfile`.

Generated bundles contain a debug id that can be used to associate a bundle with its corresponding sourcemap. This `debugId` is added as a comment at the bottom of the file.

`js
//

//# debugId=
` | +| `"inline"` | A sourcemap is generated and appended to the end of the generated bundle as a base64 payload.

`js
//

//# sourceMappingURL=data:application/json;base64,
` | The associated `*.js.map` sourcemap is a JSON file containing an equivalent `debugId` property. From a7286ce3892cff76f75c8e15eac91770e3f38391 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:27:53 +0000 Subject: [PATCH 06/10] test: assert the exit code before reading the output tree --- test/bundler/cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 523e2c06549f..01db44397baa 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -422,6 +422,7 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { const { stdout, stderr, exitCode } = await build(String(dir), "src/entry.js", "--outfile=dist/js/entry.js", flag); expect(stderr).toBe(""); expect(stdout).toContain("entry.js"); + expect(exitCode).toBe(0); const after = tree(String(dir)); const bundle = after["dist/js/entry.js"]; @@ -440,7 +441,6 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { expect(JSON.parse(after["dist/js/entry.js.map"]).sources).toEqual(["../../src/dep.js", "../../src/entry.js"]); } for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); - expect(exitCode).toBe(0); }, ); @@ -455,13 +455,13 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { ); expect(stderr).toBe(""); expect(stdout).toContain("index.html"); + expect(exitCode).toBe(0); const after = tree(String(dir)); const written = Object.keys(after).filter(file => !(file in fixture)); expect(written).toEqual([expect.stringMatching(/^work\/index(-\w+)?\.js\.map$/), "work/index.html"]); expect(after["work/index.html"]).toContain("console.log("); for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); - expect(exitCode).toBe(0); }); }); From 571bea530ba7bea6bc8f76dfb6c964938cff44dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:03:26 +0000 Subject: [PATCH 07/10] bun build: put the --sourcemap=inline hint on a note line; pin --no-bundle --outfile in a directory --- src/runtime/cli/build_command.rs | 2 +- test/bundler/cli.test.ts | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index ddf84f8f7ab3..f0afb704bbef 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -196,7 +196,7 @@ impl BuildCommand { }; if let Some(kind) = kind { bun_core::pretty_errorln!( - "error: cannot use {} source map without --outdir or --outfile (use --sourcemap=inline to print it to stdout)", + "error: cannot use {} source map without --outdir or --outfile\nnote: use --sourcemap=inline to print the source map to stdout", kind ); Global::exit(1); diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index db717a041e95..72a0ab8d8c92 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -406,7 +406,8 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { ); expect(stdout).toBe(""); expect(stderr).toBe( - `error: cannot use ${kind === "external" ? "an external" : "a linked"} source map without --outdir or --outfile (use --sourcemap=inline to print it to stdout)\n`, + `error: cannot use ${kind === "external" ? "an external" : "a linked"} source map without --outdir or --outfile\n` + + `note: use --sourcemap=inline to print the source map to stdout\n`, ); expect(tree(String(dir))).toEqual(fixture); expect(exitCode).toBe(1); @@ -444,6 +445,24 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { }, ); + test("--no-bundle --outfile in another directory writes there", async () => { + using dir = tempDir("build-no-bundle-outfile-dir", fixture); + const { stdout, stderr, exitCode } = await build( + String(dir), + "src/entry.js", + "--no-bundle", + "--outfile=dist/js/entry.js", + ); + expect(stderr).toBe(""); + expect(stdout).toContain("entry.js"); + expect(exitCode).toBe(0); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual(["dist/js/entry.js", ...Object.keys(fixture)]); + expect(after["dist/js/entry.js"]).toContain("console.log(dep)"); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + }); + test("a standalone html build with --sourcemap and no --outdir writes to the cwd", async () => { using dir = tempDir("build-sourcemap-standalone-html", fixture); const { stdout, stderr, exitCode } = await build( From 828064d4c5ee457cbc1640fcf967deddaaf24338 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:37:46 +0000 Subject: [PATCH 08/10] bun build: take the --outfile name as written, not as a naming template `--outfile 'dist/[name].js'` wrote `dist/app.js`. The basename went into `entry_naming`, which is a path template, so `[name]`, `[hash]`, `[dir]`, `[ext]` and `[target]` in it were expanded. The basename now reaches the bundler as `entry_point_file_name`, the option that `--compile` already used (renamed from `compile_entry_point_name`). `compute_chunks` gives the one output file the template `./[name]` with the name as the placeholder value. The `--no-bundle` namer returns the name directly, so the CLI no longer renames an output file after the build. - Released Bun expanded the name in one case: `--outfile` with a linked source map (the rename was skipped for two outputs, and the files went next to the entry). For a templated name, use `--outdir --entry-naming ''`. - `--outfile /` has no file name. It is an error before the build starts. - `--outfile` with a second output that takes the entry naming (the CSS of a JS entry, or the page of a `--target=bun` or `--target=node` entry that imports an `.html` file) still exits 1 and writes nothing. The message is now `cannot write multiple output files without an output directory`. - The debugId of an `--outfile` build with a source map changes once: the chunk hash read the file name as its template, and no longer does. --- src/bundler/linker_context/computeChunks.rs | 38 +-- src/bundler/options.rs | 9 +- src/bundler/transpiler.rs | 4 + src/runtime/api/js_bundle_completion_task.rs | 2 +- src/runtime/cli/build_command.rs | 31 ++- .../bundler_promiseall_deadcode.test.ts | 4 +- test/bundler/cli.test.ts | 220 ++++++++++++++++-- 7 files changed, 250 insertions(+), 58 deletions(-) diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index bde0bdb1e091..f3191b989ef0 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -572,8 +572,17 @@ pub(crate) fn compute_chunks( let output_paths = this.graph.entry_points.items_output_path(); // re-borrow after `find_all_imported_parts_in_js_order` released `&mut this`. let ast_targets = this.graph.ast.items_target(); - let mut compile_entry_point_pending = - !bv2.transpiler().options.compile_entry_point_name.is_empty(); + let is_executable = this.options.compile_mode.is_executable(); + let mut entry_point_file_name_pending = + !bv2.transpiler().options.entry_point_file_name.is_empty(); + // The chunks that `--entry-naming` names. + let takes_entry_naming = |chunk: &Chunk| { + chunk.entry_point.is_entry_point() + && (matches!(chunk.content, chunk::Content::Html) + || (kinds[chunk.entry_point.source_index() as usize] + == crate::EntryPoint::Kind::UserSpecified + && !chunk.flags.contains(chunk::Flags::HAS_HTML_CHUNK))) + }; for (chunk_id, chunk) in chunks.iter_mut().enumerate() { // Assign a unique key to each chunk. This key encodes the index directly so // we can easily recover it later without needing to look it up in a map. The @@ -608,28 +617,29 @@ pub(crate) fn compute_chunks( this.unique_key_prefix = chunk.unique_key[..prefix_len].into(); } - // The same file that `StandaloneModuleGraph::to_bytes` embeds as the entry point. - if compile_entry_point_pending - && this.chunk_output_kind(chunk) == OutputKind::EntryPoint - && this.chunk_side(chunk) == Side::Server + if entry_point_file_name_pending + && if is_executable { + // The same file that `StandaloneModuleGraph::to_bytes` embeds as the entry point. + this.chunk_output_kind(chunk) == OutputKind::EntryPoint + && this.chunk_side(chunk) == Side::Server + } else { + // `--outfile`: a build that writes one file has one such chunk. + takes_entry_naming(chunk) + } { - compile_entry_point_pending = false; + entry_point_file_name_pending = false; + // The name is a placeholder value, so a `[name]` or `[hash]` in it is never expanded. chunk.template = PathTemplate { data: Box::from(&b"./[name]"[..]), placeholder: Placeholder { - name: bv2.transpiler().options.compile_entry_point_name.clone(), + name: bv2.transpiler().options.entry_point_file_name.clone(), ..Default::default() }, }; continue; } - if chunk.entry_point.is_entry_point() - && (matches!(chunk.content, chunk::Content::Html) - || (kinds[chunk.entry_point.source_index() as usize] - == crate::EntryPoint::Kind::UserSpecified - && !chunk.flags.contains(chunk::Flags::HAS_HTML_CHUNK))) - { + if takes_entry_naming(chunk) { // Use fileWithTarget template if there are HTML imports and user hasn't manually set naming if has_server_html_imports && bv2.transpiler().options.entry_naming.is_empty() { chunk.template = PathTemplate::FILE_WITH_TARGET.into(); diff --git a/src/bundler/options.rs b/src/bundler/options.rs index cdd1c7b8c25b..969fe1b75b8a 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1374,8 +1374,8 @@ pub struct BundleOptions<'a> { pub debugger: bool, pub compile_mode: CompileMode, - /// `--compile`: the name of the entry point's chunk, `/$bunfs/root/` in the executable. - pub compile_entry_point_name: Box<[u8]>, + /// The file name `--outfile` or `--compile` gives the entry point's chunk, as written: it is not a naming template. + pub entry_point_file_name: Box<[u8]>, pub metafile: bool, /// Path to write JSON metafile (for Bun.build API) pub metafile_json_path: Box<[u8]>, @@ -1577,7 +1577,8 @@ impl<'a> BundleOptions<'a> { code_coverage: self.code_coverage, debugger: self.debugger, compile_mode: self.compile_mode, - compile_entry_point_name: self.compile_entry_point_name.clone(), + // Only the parent names output files (`compute_chunks`, `transform_only_dest_path`). + entry_point_file_name: Box::default(), metafile: self.metafile, metafile_json_path: self.metafile_json_path.clone(), metafile_markdown_path: self.metafile_markdown_path.clone(), @@ -1829,7 +1830,7 @@ impl<'a> BundleOptions<'a> { code_coverage: false, debugger: false, compile_mode: CompileMode::None, - compile_entry_point_name: Box::default(), + entry_point_file_name: Box::default(), metafile: false, metafile_json_path: Box::default(), metafile_markdown_path: Box::default(), diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 3821fc09304a..137b30e1eb50 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -2967,6 +2967,10 @@ impl<'a> Transpiler<'a> { loader: options::Loader, output: &[u8], ) -> Box<[u8]> { + if !self.options.entry_point_file_name.is_empty() { + return self.options.entry_point_file_name.clone(); + } + let rel_to_root = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Auto, false, diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 5ca0266322e0..265dd16f2edd 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -1117,7 +1117,7 @@ impl CompletionStruct for JSBundleCompletionTask { } if let Some(compile) = &config.compile { let executable = executable_path(config, compile); - transpiler.options.compile_entry_point_name = + transpiler.options.entry_point_file_name = Box::from(executable_entry_point_name(&executable)); } // `BundleOptions.{banner,footer}` are `Cow<'static, [u8]>`; clone into diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index f0afb704bbef..cfff3b9d3340 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -392,7 +392,7 @@ impl BuildCommand { Global::exit(1); } - this_transpiler.options.compile_entry_point_name = + this_transpiler.options.entry_point_file_name = bun_paths::basename(compile_outfile(outfile)).into(); } } @@ -427,15 +427,24 @@ impl BuildCommand { } } - // Bundle `--outfile dir/app.js` as `--outdir dir` with the entry named `app.js`. + // `--outfile dir/app.js` names the one output `app.js`. A bundle is written as with `--outdir dir`. if ctx.bundler_options.outdir.is_empty() && !outfile.is_empty() && !ctx.bundler_options.compile - && !ctx.bundler_options.transform_only { - this_transpiler.options.entry_naming = - strings::concat(&[b"./", bun_paths::basename(outfile)]); - this_transpiler.options.output_dir = bun_core::dirname(outfile).unwrap_or(b".").into(); + let name = bun_paths::basename(outfile); + if name.is_empty() { + bun_core::pretty_errorln!( + "error: --outfile {} does not name a file", + bun_fmt::quote(outfile), + ); + Global::exit(1); + } + this_transpiler.options.entry_point_file_name = name.into(); + if !ctx.bundler_options.transform_only { + this_transpiler.options.output_dir = + bun_core::dirname(outfile).unwrap_or(b".").into(); + } } let mut src_root_dir_buf = bun_paths::path_buffer_pool::get(); @@ -812,16 +821,6 @@ impl BuildCommand { // `--compile` and `--no-bundle` output for `--outfile` is still in memory here (#8697). if output_dir.is_empty() && !outfile.is_empty() { output_dir = bun_core::dirname(outfile).unwrap_or(b"."); - // With --compile, the bundler already named the entry point's chunk after the outfile. - if !ctx.bundler_options.compile - && output_files.len() == 1 - && matches!( - output_files[0].value, - options::OutputFileValue::Buffer { .. } - ) - { - output_files[0].dest_path = bun_paths::basename(outfile).into(); - } } if !ctx.bundler_options.compile { diff --git a/test/bundler/bundler_promiseall_deadcode.test.ts b/test/bundler/bundler_promiseall_deadcode.test.ts index c5960784c32a..17fc0c198ca3 100644 --- a/test/bundler/bundler_promiseall_deadcode.test.ts +++ b/test/bundler/bundler_promiseall_deadcode.test.ts @@ -150,7 +150,7 @@ describe("bundler", () => { await Promise.resolve(); AsyncEntryPoint(); - //# debugId=B9EF7E5F2ACBD11D64756E2164756E21 + //# debugId=AAA2C75337D0736364756E2164756E21 //# sourceMappingURL=out.js.map " `); @@ -379,7 +379,7 @@ describe("bundler", () => { await Promise.resolve(); AsyncEntryPoint(); - //# debugId=6678C3B13A630A4064756E2164756E21 + //# debugId=E55E87653A91E6C264756E2164756E21 //# sourceMappingURL=out.js.map " `); diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 72a0ab8d8c92..940dc2007e7a 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -351,6 +351,28 @@ test("you can use --outfile=... and --sourcemap", async () => { `); }); +/** Every file under `dir`, keyed by its `/`-separated relative path. */ +function tree(dir: string) { + const out: Record = {}; + for (const rel of fs.readdirSync(dir, { recursive: true, encoding: "utf8" }).sort()) { + const file = join(dir, rel); + if (fs.statSync(file).isFile()) out[rel.replaceAll("\\", "/")] = fs.readFileSync(file, "utf8"); + } + return out; +} + +async function build(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + describe.concurrent("--sourcemap never writes next to the entry point", () => { // The entry lives in src/, the build runs from work/ (or from the root for // the --outfile rows). Nothing may appear in src/ or work/ unless it was @@ -366,27 +388,6 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { "work/.keep": "", }; - function tree(dir: string) { - const out: Record = {}; - for (const rel of fs.readdirSync(dir, { recursive: true, encoding: "utf8" }).sort()) { - const file = join(dir, rel); - if (fs.statSync(file).isFile()) out[rel.replaceAll("\\", "/")] = fs.readFileSync(file, "utf8"); - } - return out; - } - - async function build(cwd: string, ...args: string[]) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "build", ...args], - env: bunEnv, - cwd, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr, exitCode }; - } - test.each(["entry.js", "entry.ts"])("bare --sourcemap prints %s with an inline map to stdout", async entry => { using dir = tempDir("build-sourcemap-stdout", fixture); const { stdout, stderr, exitCode } = await build(join(String(dir), "work"), `../src/${entry}`, "--sourcemap"); @@ -484,6 +485,183 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { }); }); +describe.concurrent("--outfile takes the file name as written", () => { + // `[name]`, `[hash]`, `[dir]`, `[ext]` and `[target]` are placeholders in + // --entry-naming. In --outfile they are characters of the file name. + const page = `\n`; + const fixture = { + "src/[ext].html": page, + "src/app.css": `body { color: red; }\n`, + "src/app.js": `console.log("app");\n`, + "src/index.html": page, + "src/server.js": `import page from "./index.html";\nconsole.log(page);\n`, + "src/styled.js": `import "./app.css";\nconsole.log("styled");\n`, + }; + + test.each([ + "plain.js", + "[name].js", + "[hash].js", + "[hash8].js", + "[dir].js", + "[ext].js", + "[target].js", + "a-[name]-b.js", + "[id].js", + "[...all].js", + "a[b.js", + ])("--outfile dist/%s", async name => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), "src/app.js", "--outfile", `dist/${name}`); + expect(stderr).toBe(""); + expect(stdout).toContain(name); + expect(exitCode).toBe(0); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual([`dist/${name}`, ...Object.keys(fixture)]); + expect(after[`dist/${name}`]).toContain(`console.log("app")`); + }); + + test.each([ + { + name: "a linked source map", + args: ["src/app.js", "--outfile", "dist/[name]-[hash].js", "--sourcemap=linked"], + written: ["dist/[name]-[hash].js", "dist/[name]-[hash].js.map"], + contains: "//# sourceMappingURL=[name]-[hash].js.map", + }, + { + name: "an external source map", + args: ["src/app.js", "--outfile", "dist/[name]-[hash].js", "--sourcemap=external"], + written: ["dist/[name]-[hash].js", "dist/[name]-[hash].js.map"], + contains: "//# debugId=", + }, + { + name: "an inline source map", + args: ["src/app.js", "--outfile", "dist/[name]-[hash].js", "--sourcemap=inline"], + written: ["dist/[name]-[hash].js"], + contains: "//# sourceMappingURL=data:application/json;base64,", + }, + { + name: "a css entry", + args: ["src/app.css", "--outfile", "dist/[name].css"], + written: ["dist/[name].css"], + contains: "color: red", + }, + { + name: "a standalone html build", + args: ["src/index.html", "--compile", "--target=browser", "--outfile", "dist/[name].html"], + written: ["dist/[name].html"], + contains: `console.log("app")`, + }, + { + // No --outfile: the page takes the name of its entry. + name: "a standalone html build of [ext].html", + args: ["src/[ext].html", "--compile", "--target=browser"], + written: ["[ext].html"], + contains: `console.log("app")`, + }, + { + name: "--no-bundle", + args: ["src/app.js", "--no-bundle", "--outfile", "dist/[name].js"], + written: ["dist/[name].js"], + contains: `console.log("app")`, + }, + { + name: "--entry-naming", + args: ["src/app.js", "--outfile", "dist/x.js", "--entry-naming", "[name]-[hash].[ext]"], + written: ["dist/x.js"], + contains: `console.log("app")`, + }, + { + name: "--entry-naming and --no-bundle", + args: ["src/app.js", "--no-bundle", "--outfile", "dist/x.js", "--entry-naming", "[name]-[hash].[ext]"], + written: ["dist/x.js"], + contains: `console.log("app")`, + }, + ])("with $name", async ({ args, written, contains }) => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), ...args); + expect(stderr).toBe(""); + for (const file of written) expect(stdout).toContain(path.basename(file)); + expect(exitCode).toBe(0); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual([...written, ...Object.keys(fixture)]); + expect(after[written[0]]).toContain(contains); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + }); + + test("the metafile names the file that is written", async () => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build( + String(dir), + "src/app.js", + "--outfile", + "dist/[name].js", + "--metafile=meta.json", + ); + expect(stderr).toBe(""); + expect(stdout).toContain("[name].js"); + expect(exitCode).toBe(0); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual(["dist/[name].js", "meta.json", ...Object.keys(fixture)]); + expect(Object.keys(JSON.parse(after["meta.json"]).outputs)).toEqual(["./[name].js"]); + }); + + // The build runs in src/, so a name that expands to the entry's own name would replace the entry. + test.each([ + { outfile: "[name].js", sourcemap: "none" }, + { outfile: "[name].js", sourcemap: "linked" }, + { outfile: "bundle.[hash].js", sourcemap: "linked" }, + ])("$outfile next to the entry with --sourcemap=$sourcemap", async ({ outfile, sourcemap }) => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build( + join(String(dir), "src"), + "app.js", + "--outfile", + outfile, + `--sourcemap=${sourcemap}`, + ); + expect(stderr).toBe(""); + expect(stdout).toContain(outfile); + expect(exitCode).toBe(0); + + const written = sourcemap === "linked" ? [`src/${outfile}`, `src/${outfile}.map`] : [`src/${outfile}`]; + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual([...Object.keys(fixture), ...written].sort()); + if (sourcemap === "linked") expect(after[`src/${outfile}`]).toContain(`//# sourceMappingURL=${outfile}.map`); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + }); + + test.each([ + { cwd: ".", cmd: "src/app.js" }, + { cwd: "src", cmd: "app.js" }, + { cwd: ".", cmd: "src/app.js --no-bundle" }, + ])("bun build $cmd --outfile / in $cwd is an error", async ({ cwd, cmd }) => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build(join(String(dir), cwd), ...cmd.split(" "), "--outfile", "/"); + expect(stdout).toBe(""); + expect(stderr).toBe(`error: --outfile "/" does not name a file\n`); + expect(tree(String(dir))).toEqual(fixture); + expect(exitCode).toBe(1); + }); + + // One entry, two outputs that take the entry naming: the css of a js entry, the page of a server entry. + test.each([ + "src/styled.js --outfile dist/out.js", + "src/styled.js --outfile dist/[name].js", + "src/server.js --target=bun --outfile dist/out.js", + ])("bun build %s writes nothing", async cmd => { + using dir = tempDir("build-outfile-name", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), ...cmd.split(" ")); + expect(stdout).toBe(""); + expect(stderr).toBe("error: cannot write multiple output files without an output directory\n"); + expect(tree(String(dir))).toEqual(fixture); + expect(exitCode).toBe(1); + }); +}); + test("some log cases", async () => { const tmpdir = tmpdirSync(); const inputFile = path.join(tmpdir, "input.js"); From 357e9a458d7a5e3c06b2bb9af5e90cf01c875720 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:42:33 +0000 Subject: [PATCH 09/10] test: bun build --outfile writes the source map before the bundle With --outfile the bundler writes the files itself, map first, as it does for --outdir. A reader that reloads when the bundle changes (bun --hot) never finds the new bundle beside the map of the build before. The rows observe the order through a map path that cannot be written: the build fails there and the old bundle stays. --- test/bundler/cli.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 3b71c94c7961..93f29f845e75 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -485,6 +485,23 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { }); }); +// `bun --hot app.js` reloads when the bundle changes, so the bundle must never +// sit beside the source map of the build before it. A map path that cannot be +// written shows the order: the build fails there and the old bundle stays. +test.concurrent.each(["linked", "external"])("--outfile writes the %s source map before the bundle", async kind => { + const before = "the bundle of the build before\n"; + using dir = tempDir("build-outfile-map-first", { + "entry.js": `console.log("entry");\n`, + "app.js": before, + "app.js.map/.keep": "", + }); + const { stdout, stderr, exitCode } = await build(String(dir), "entry.js", "--outfile=app.js", `--sourcemap=${kind}`); + expect(fs.readFileSync(join(String(dir), "app.js"), "utf8")).toBe(before); + expect(stderr).toContain("writing sourcemap for chunk"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); +}); + describe.concurrent("--outfile takes the file name as written", () => { // `[name]`, `[hash]`, `[dir]`, `[ext]` and `[target]` are placeholders in // --entry-naming. In --outfile they are characters of the file name. From 4e01f5e9567210169cabb0d991f21a6fa3bc7fd6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:57:02 +0000 Subject: [PATCH 10/10] test: pin bare --sourcemap with --outdir as a linked map The default of a bare --sourcemap reads three options: --compile, --outdir and --outfile. The --outdir state had no row. --- test/bundler/cli.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index ad45bc92afcb..f6412f8d5d63 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -398,6 +398,19 @@ describe.concurrent("--sourcemap never writes next to the entry point", () => { expect(exitCode).toBe(0); }); + test("bare --sourcemap with --outdir writes a linked map", async () => { + using dir = tempDir("build-sourcemap-outdir", fixture); + const { stdout, stderr, exitCode } = await build(String(dir), "src/entry.js", "--outdir=dist", "--sourcemap"); + expect(stderr).toBe(""); + expect(stdout).toContain("entry.js.map"); + expect(exitCode).toBe(0); + + const after = tree(String(dir)); + expect(Object.keys(after)).toEqual(["dist/entry.js", "dist/entry.js.map", ...Object.keys(fixture)]); + expect(after["dist/entry.js"]).toContain("//# sourceMappingURL=entry.js.map"); + for (const file of Object.keys(fixture)) expect(after[file]).toBe(fixture[file]); + }); + test.each(["linked", "external"])("--sourcemap=%s without --outdir or --outfile is an error", async kind => { using dir = tempDir("build-sourcemap-stdout-error", fixture); const { stdout, stderr, exitCode } = await build(