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(