From 787a7da8547a9904f0f79e63c93715a47e3b2564 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:16:46 +0000 Subject: [PATCH 1/6] bundler: keep onResolve-rewritten path when external: true; clarify publicPath docs --- docs/bundler/index.mdx | 14 +++--- src/bundler/bundle_v2.rs | 60 ++++++++++++++++++++-- test/bundler/bundler_plugin.test.ts | 78 +++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index dcca3906df1..20f580c864f 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -1050,15 +1050,15 @@ With `.` as `root`, the generated file structure looks like this: ### publicPath -A prefix added to any import paths in bundled code. +A prefix added to the file paths the bundler emits into your code. -In many cases, generated bundles contain no import statements; the goal of bundling is to combine all of the code into a single file. In a few cases, though, the generated bundles contain import statements: +In many cases, generated bundles contain no references to other files; the goal of bundling is to combine all of the code into a single file. In a few cases, though, the bundler writes paths to other output files into the bundle: -- **Asset imports** — When importing an unrecognized file type like `*.svg`, the bundler defers to the file loader, which copies the file into `outdir` as is. The import is converted into a variable. -- **External modules** — Files and modules marked as external are not included in the bundle. Instead, the import statement is left in the final bundle. -- **Chunking.** When `splitting` is enabled, the bundler may generate separate "chunk" files that represent code that is shared among multiple entrypoints. +- **Asset imports** — When importing an unrecognized file type like `*.svg`, the bundler defers to the file loader, which copies the file into `outdir` as is. The import is converted into a variable holding the file's path. +- **Chunking** — When `splitting` is enabled, the bundler may generate separate "chunk" files that represent code shared among multiple entrypoints, and emits `import` statements that reference those chunks. +- **Linked source maps** — With `sourcemap: "linked"`, the bundler emits a `//# sourceMappingURL=` comment pointing at the generated `.map` file. -In any of these cases, the final bundles may contain paths to other files. By default these imports are relative. Here is an example of an asset import: +By default these paths are relative. Here is an example of an asset import: @@ -1099,6 +1099,8 @@ The output file would now look something like this. var logo = "https://cdn.example.com/logo-a7305bdef.svg"; ``` +`publicPath` does not rewrite specifiers for [external](#external) modules. An import marked as external is emitted unchanged so it can be resolved at runtime. To rewrite an external specifier to a CDN URL, use an [`onResolve`](/bundler/plugins#onresolve) plugin that returns `{ path, external: true }`. + ### define A map of global identifiers to be replaced at build time. Keys of this object are identifier names, and values are JSON strings that are inlined. diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index d7b1e595590..adfad849897 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -54,10 +54,14 @@ pub use bv2_impl::JSBundleCompletionTask; /// `jsc::api::JSBundler::FileMap` — re-exported from the canonical def below. pub use api::JSBundler::FileMap; -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct PendingImport { pub to_source_index: Index, pub import_record_index: u32, + /// Set when an onResolve plugin returned `{ path, external: true }` before + /// the importer's ast was installed. Applied to `import_record.path` in + /// `patch_import_record_source_indices`; `to_source_index` is unused. + pub external_path: Option>, } pub struct BundleV2<'a> { @@ -4712,7 +4716,45 @@ pub mod bv2_impl { } } else { drop(result.namespace); - drop(result.path); + // Plugin returned `{ path, external: true }`. Keep the + // import external but rewrite its printed specifier to the + // plugin-provided path (esbuild does the same). + if resolve.import_record.kind != ImportKind::EntryPointBuild + && !strings::eql(&result.path, &resolve.import_record.specifier) + { + let source_import_records = + &mut this.graph.ast.items_import_records_mut() + [resolve.import_record.importer_source_index as usize]; + if (source_import_records.len() as u32) + > resolve.import_record.import_record_index + { + // SAFETY: `result.path` is moved into `free_list` + // below and thus outlives `BundleV2`; erase to + // `'static` so the import record can borrow it. + let result_path_static: &'static [u8] = unsafe { + &*std::ptr::from_ref::<[u8]>(result.path.as_ref()) + }; + source_import_records.as_mut_slice() + [resolve.import_record.import_record_index as usize] + .path = bun_paths::fs::Path::init(result_path_static); + this.free_list.push(result.path); + } else { + let entry = this + .resolve_tasks_waiting_for_import_source_index + .get_or_put(resolve.import_record.importer_source_index) + .expect("oom"); + if !entry.found_existing { + *entry.value_ptr = Vec::new(); + } + let _ = entry.value_ptr.push(PendingImport { + to_source_index: Index::INVALID, + import_record_index: resolve.import_record.import_record_index, + external_path: Some(result.path), + }); + } + } else { + drop(result.path); + } } if let Some(source_index) = out_source_index { @@ -4744,6 +4786,7 @@ pub mod bv2_impl { let _ = entry.value_ptr.push(PendingImport { to_source_index: source_index, import_record_index: resolve.import_record.import_record_index, + external_path: None, }); } else { let import_record: &mut ImportRecord = &mut source_import_records @@ -6652,15 +6695,22 @@ pub mod bv2_impl { let (_, value) = self .resolve_tasks_waiting_for_import_source_index .swap_remove_at(idx); - for to_assign in value.slice() { - if save_import_record_source_index + for to_assign in value { + if let Some(external_path) = to_assign.external_path { + // SAFETY: box moved into `free_list` (outlives BundleV2), + // so the `'static` erasure is valid for the import record. + let path_static: &'static [u8] = + unsafe { &*std::ptr::from_ref::<[u8]>(external_path.as_ref()) }; + import_records.as_mut_slice()[to_assign.import_record_index as usize] + .path = bun_paths::fs::Path::init(path_static); + self.free_list.push(external_path); + } else if save_import_record_source_index || input_file_loaders[to_assign.to_source_index.get() as usize].is_css() { import_records.as_mut_slice()[to_assign.import_record_index as usize] .source_index = to_assign.to_source_index; } } - drop(value); } // Inlined `self.path_to_source_index_map(ctx.target)` (== `&mut self.graph.build_graphs[target]`) diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index c81aa651d16..45a7209a291 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -643,6 +643,84 @@ describe("bundler", () => { }, }; }); + // https://github.com/oven-sh/bun/issues/11652 + itBundled("plugin/ResolveExternalRewritesPathESM", ({ root }) => { + return { + files: { + "index.ts": /* ts */ ` + import React from "react"; + import { createRoot } from "react-dom/client"; + export { preact } from "preact"; + export * from "mobx"; + const lazy = await import("lodash"); + console.log(React, createRoot, lazy); + `, + }, + format: "esm", + plugins(builder) { + builder.onResolve({ filter: /^(react|react-dom\/client|preact|mobx|lodash)$/ }, args => { + return { path: "https://esm.sh/" + args.path, external: true }; + }); + }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toContain(`from "https://esm.sh/react"`); + expect(out).toContain(`from "https://esm.sh/react-dom/client"`); + expect(out).toContain(`from "https://esm.sh/preact"`); + expect(out).toContain(`from "https://esm.sh/mobx"`); + expect(out).toContain(`import("https://esm.sh/lodash")`); + expect(out).not.toContain(`"react"`); + expect(out).not.toContain(`"react-dom/client"`); + expect(out).not.toContain(`"preact"`); + expect(out).not.toContain(`"mobx"`); + expect(out).not.toContain(`"lodash"`); + }, + }; + }); + itBundled("plugin/ResolveExternalRewritesPathCJS", ({ root }) => { + return { + files: { + "index.ts": /* ts */ ` + const React = require("react"); + console.log(React); + `, + }, + format: "cjs", + plugins(builder) { + builder.onResolve({ filter: /^react$/ }, args => { + return { path: "https://esm.sh/react", external: true }; + }); + }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toContain(`require("https://esm.sh/react")`); + expect(out).not.toContain(`"react"`); + }, + }; + }); + itBundled("plugin/ResolveExternalSamePathUnchanged", ({ root }) => { + let called = 0; + return { + files: { + "index.ts": /* ts */ ` + import React from "react"; + console.log(React); + `, + }, + format: "esm", + plugins(builder) { + builder.onResolve({ filter: /^react$/ }, args => { + called++; + return { path: args.path, external: true }; + }); + }, + onAfterBundle(api) { + expect(called).toBe(1); + const out = api.readFile("/out.js"); + expect(out).toContain(`from "react"`); + }, + }; + }); itBundled("plugin/ResolveManySegfault", ({ root }) => { let resolveCount = 0; let loadCount = 0; From e78e1b98cba0e27c79620e82b413f36da32d8187 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:29:41 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- src/bundler/bundle_v2.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index adfad849897..c29fa3ed016 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4731,9 +4731,8 @@ pub mod bv2_impl { // SAFETY: `result.path` is moved into `free_list` // below and thus outlives `BundleV2`; erase to // `'static` so the import record can borrow it. - let result_path_static: &'static [u8] = unsafe { - &*std::ptr::from_ref::<[u8]>(result.path.as_ref()) - }; + let result_path_static: &'static [u8] = + unsafe { &*std::ptr::from_ref::<[u8]>(result.path.as_ref()) }; source_import_records.as_mut_slice() [resolve.import_record.import_record_index as usize] .path = bun_paths::fs::Path::init(result_path_static); From 844295c579695619f7ea0eab735a7cf3585c9e33 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:12:08 +0000 Subject: [PATCH 3/6] drop code fix (duplicates #35053); keep publicPath docs clarification --- src/bundler/bundle_v2.rs | 59 ++-------------------- test/bundler/bundler_plugin.test.ts | 78 ----------------------------- 2 files changed, 5 insertions(+), 132 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index c29fa3ed016..d7b1e595590 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -54,14 +54,10 @@ pub use bv2_impl::JSBundleCompletionTask; /// `jsc::api::JSBundler::FileMap` — re-exported from the canonical def below. pub use api::JSBundler::FileMap; -#[derive(Clone)] +#[derive(Clone, Copy)] pub struct PendingImport { pub to_source_index: Index, pub import_record_index: u32, - /// Set when an onResolve plugin returned `{ path, external: true }` before - /// the importer's ast was installed. Applied to `import_record.path` in - /// `patch_import_record_source_indices`; `to_source_index` is unused. - pub external_path: Option>, } pub struct BundleV2<'a> { @@ -4716,44 +4712,7 @@ pub mod bv2_impl { } } else { drop(result.namespace); - // Plugin returned `{ path, external: true }`. Keep the - // import external but rewrite its printed specifier to the - // plugin-provided path (esbuild does the same). - if resolve.import_record.kind != ImportKind::EntryPointBuild - && !strings::eql(&result.path, &resolve.import_record.specifier) - { - let source_import_records = - &mut this.graph.ast.items_import_records_mut() - [resolve.import_record.importer_source_index as usize]; - if (source_import_records.len() as u32) - > resolve.import_record.import_record_index - { - // SAFETY: `result.path` is moved into `free_list` - // below and thus outlives `BundleV2`; erase to - // `'static` so the import record can borrow it. - let result_path_static: &'static [u8] = - unsafe { &*std::ptr::from_ref::<[u8]>(result.path.as_ref()) }; - source_import_records.as_mut_slice() - [resolve.import_record.import_record_index as usize] - .path = bun_paths::fs::Path::init(result_path_static); - this.free_list.push(result.path); - } else { - let entry = this - .resolve_tasks_waiting_for_import_source_index - .get_or_put(resolve.import_record.importer_source_index) - .expect("oom"); - if !entry.found_existing { - *entry.value_ptr = Vec::new(); - } - let _ = entry.value_ptr.push(PendingImport { - to_source_index: Index::INVALID, - import_record_index: resolve.import_record.import_record_index, - external_path: Some(result.path), - }); - } - } else { - drop(result.path); - } + drop(result.path); } if let Some(source_index) = out_source_index { @@ -4785,7 +4744,6 @@ pub mod bv2_impl { let _ = entry.value_ptr.push(PendingImport { to_source_index: source_index, import_record_index: resolve.import_record.import_record_index, - external_path: None, }); } else { let import_record: &mut ImportRecord = &mut source_import_records @@ -6694,22 +6652,15 @@ pub mod bv2_impl { let (_, value) = self .resolve_tasks_waiting_for_import_source_index .swap_remove_at(idx); - for to_assign in value { - if let Some(external_path) = to_assign.external_path { - // SAFETY: box moved into `free_list` (outlives BundleV2), - // so the `'static` erasure is valid for the import record. - let path_static: &'static [u8] = - unsafe { &*std::ptr::from_ref::<[u8]>(external_path.as_ref()) }; - import_records.as_mut_slice()[to_assign.import_record_index as usize] - .path = bun_paths::fs::Path::init(path_static); - self.free_list.push(external_path); - } else if save_import_record_source_index + for to_assign in value.slice() { + if save_import_record_source_index || input_file_loaders[to_assign.to_source_index.get() as usize].is_css() { import_records.as_mut_slice()[to_assign.import_record_index as usize] .source_index = to_assign.to_source_index; } } + drop(value); } // Inlined `self.path_to_source_index_map(ctx.target)` (== `&mut self.graph.build_graphs[target]`) diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 45a7209a291..c81aa651d16 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -643,84 +643,6 @@ describe("bundler", () => { }, }; }); - // https://github.com/oven-sh/bun/issues/11652 - itBundled("plugin/ResolveExternalRewritesPathESM", ({ root }) => { - return { - files: { - "index.ts": /* ts */ ` - import React from "react"; - import { createRoot } from "react-dom/client"; - export { preact } from "preact"; - export * from "mobx"; - const lazy = await import("lodash"); - console.log(React, createRoot, lazy); - `, - }, - format: "esm", - plugins(builder) { - builder.onResolve({ filter: /^(react|react-dom\/client|preact|mobx|lodash)$/ }, args => { - return { path: "https://esm.sh/" + args.path, external: true }; - }); - }, - onAfterBundle(api) { - const out = api.readFile("/out.js"); - expect(out).toContain(`from "https://esm.sh/react"`); - expect(out).toContain(`from "https://esm.sh/react-dom/client"`); - expect(out).toContain(`from "https://esm.sh/preact"`); - expect(out).toContain(`from "https://esm.sh/mobx"`); - expect(out).toContain(`import("https://esm.sh/lodash")`); - expect(out).not.toContain(`"react"`); - expect(out).not.toContain(`"react-dom/client"`); - expect(out).not.toContain(`"preact"`); - expect(out).not.toContain(`"mobx"`); - expect(out).not.toContain(`"lodash"`); - }, - }; - }); - itBundled("plugin/ResolveExternalRewritesPathCJS", ({ root }) => { - return { - files: { - "index.ts": /* ts */ ` - const React = require("react"); - console.log(React); - `, - }, - format: "cjs", - plugins(builder) { - builder.onResolve({ filter: /^react$/ }, args => { - return { path: "https://esm.sh/react", external: true }; - }); - }, - onAfterBundle(api) { - const out = api.readFile("/out.js"); - expect(out).toContain(`require("https://esm.sh/react")`); - expect(out).not.toContain(`"react"`); - }, - }; - }); - itBundled("plugin/ResolveExternalSamePathUnchanged", ({ root }) => { - let called = 0; - return { - files: { - "index.ts": /* ts */ ` - import React from "react"; - console.log(React); - `, - }, - format: "esm", - plugins(builder) { - builder.onResolve({ filter: /^react$/ }, args => { - called++; - return { path: args.path, external: true }; - }); - }, - onAfterBundle(api) { - expect(called).toBe(1); - const out = api.readFile("/out.js"); - expect(out).toContain(`from "react"`); - }, - }; - }); itBundled("plugin/ResolveManySegfault", ({ root }) => { let resolveCount = 0; let loadCount = 0; From 9d9bafc5c90dc8db0312b859f8f2794526ff03ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:33:23 +0000 Subject: [PATCH 4/6] docs: drop onResolve workaround sentence (depends on #35053) --- docs/bundler/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index 20f580c864f..68275f9c3b4 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -1099,7 +1099,7 @@ The output file would now look something like this. var logo = "https://cdn.example.com/logo-a7305bdef.svg"; ``` -`publicPath` does not rewrite specifiers for [external](#external) modules. An import marked as external is emitted unchanged so it can be resolved at runtime. To rewrite an external specifier to a CDN URL, use an [`onResolve`](/bundler/plugins#onresolve) plugin that returns `{ path, external: true }`. +`publicPath` does not rewrite specifiers for [external](#external) modules. An import marked as external is emitted unchanged so it can be resolved at runtime. ### define From c93a39cdb9032c95b01853b7275a554ac9f59cf0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:44:42 +0000 Subject: [PATCH 5/6] align --public-path help text and CLI snippet with updated docs --- docs/snippets/cli/build.mdx | 2 +- src/runtime/cli/Arguments.rs | 2 +- test/bundler/cli.test.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/snippets/cli/build.mdx b/docs/snippets/cli/build.mdx index 56a0d68afcc..14001e10d2b 100644 --- a/docs/snippets/cli/build.mdx +++ b/docs/snippets/cli/build.mdx @@ -79,7 +79,7 @@ bun build - Prefix to be added to import paths in bundled code + Prefix for emitted asset, chunk, and source-map paths diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 65d20886727..811aa540f3a 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -471,7 +471,7 @@ pub(crate) const BUILD_ONLY_PARAMS: &[ParamType] = concat_params!( ), parse_param!("--splitting Enable code splitting"), parse_param!( - "--public-path A prefix to be appended to any import paths in bundled code" + "--public-path A prefix for emitted asset, chunk, and source-map paths" ), parse_param!( "-e, --external ... Exclude module from transpilation (can use * wildcards). ex: -e react" diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index e47d0ae11df..288c5054201 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -6,6 +6,23 @@ import path, { join } from "node:path"; describe.concurrent( "bun build", () => { + // https://github.com/oven-sh/bun/issues/11652 + test("--help describes --public-path by what it prefixes", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--help"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const help = stdout + stderr; + const line = help.split("\n").find(l => l.includes("--public-path")) ?? ""; + expect(line).toMatch(/asset/i); + expect(line).toMatch(/chunk/i); + expect(line).toMatch(/source[- ]?map/i); + expect(exitCode).toBe(0); + }); + test("warnings dont return exit code 1", async () => { const { stderr, exited } = Bun.spawn({ cmd: [bunExe(), "build", path.join(import.meta.dir, "./fixtures/jsx-warning/index.jsx")], From 2c89c08bfaacfb62c7917fe5a4dfb45b9467d862 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:21:33 +0000 Subject: [PATCH 6/6] sync completions/bun-cli.json with --public-path help text --- completions/bun-cli.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 6dfdac75a8f..480fee65b69 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -3337,7 +3337,7 @@ }, { "name": "public-path", - "description": "A prefix to be appended to any import paths in bundled code", + "description": "A prefix for emitted asset, chunk, and source-map paths", "hasValue": true, "valueType": "val", "required": false,