From 96bb0919fe529eb19120ea82294c982793a028eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:11:19 +0000 Subject: [PATCH 1/2] bundler: keep the symlink spelling of entry-point output paths When an entry point is reached through a symlink (e.g. a workspace package under node_modules), the resolver swaps in the real path and the linker's per-chunk open+get_fd_path canonicalization strips the symlink again, so the output lands at dist/_.._/packages/mypkg/index.js instead of dist/node_modules/mypkg/index.js. Record the pre-symlink absolute path in entry_point_original_names (the same map plugin-resolved virtual entries use), and have computeChunks skip the get_fd_path round-trip when a plain relative() already sits under root_dir. The canonicalization fallback is kept for short-name/symlinked-cwd spellings that need it to find a common prefix. Fixes #8467 Fixes #13365 --- src/bundler/bundle_v2.rs | 11 +++++ src/bundler/linker_context/computeChunks.rs | 16 ++++++- test/bundler/bun-build-api.test.ts | 49 +++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 49d092acb299..b14088302655 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2589,6 +2589,17 @@ pub mod bv2_impl { .loader(&self.transpiler.options.loaders) .unwrap_or(Loader::File); + // When the resolver followed a symlink, `path.pretty` still holds the + // pre-symlink absolute path (`set_realpath` moved the old `text` there). + // Keep it for output naming so `outdir` mirrors the entry-point spelling + // the user wrote, matching esbuild. `path_with_pretty_initialized` below + // overwrites `pretty`. + if is_entry_point && path.is_symlink && path.is_file() && !path.pretty.is_empty() { + self.graph + .entry_point_original_names + .put(source_index.get(), path.pretty)?; + } + // SAFETY: `path_with_pretty_initialized` allocates into `self.graph.heap`, which // outlives the bundle pass; erase the arena lifetime back to the resolver's // `Path<'static>` alias so `path` doesn't keep `self` borrowed. diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index be942b837711..d2de3f318df1 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -640,8 +640,23 @@ pub fn compute_chunks(this: &mut LinkerContext, unique_key: u64) -> crate::Resul } else { b"." }; + let root_dir = &this.resolver().opts.root_dir; let mut real_path_buf = PathBuffer::uninit(); let dir: &[u8] = 'dir: { + // When `dir_path` already sits under `root_dir`, skip the + // open+get_fd_path round-trip: that canonicalization would + // discard the symlink spelling an entry point was reached + // through (#8467). Fall through when the plain relativization + // walks above root so a short-name/symlinked cwd still finds a + // common prefix. + if bun_paths::is_absolute(dir_path) + && !resolve_path::relative_platform::( + root_dir, dir_path, + ) + .starts_with(b"..") + { + break 'dir dir_path; + } let Ok(dir_file) = bun_sys::File::openat( bun_sys::Fd::cwd(), dir_path, @@ -672,7 +687,6 @@ pub fn compute_chunks(this: &mut LinkerContext, unique_key: u64) -> crate::Resul } }; - let root_dir = &this.resolver().opts.root_dir; chunk.template.placeholder.dir = resolve_path::relative_alloc(root_dir, dir)?; } } diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 2b7fa720b654..9a07afdc85f6 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -527,6 +527,55 @@ describe("Bun.build", () => { }, ); + // https://github.com/oven-sh/bun/issues/8467 + test.concurrent("output paths keep the symlink spelling of an entry point", async () => { + using dir = tempDir("build-symlink-entry-output", { + "packages/mypkg/index.ts": `export const a = 1;\n`, + "packages/mypkg/other.ts": `export const b = 2;\n`, + "app/src/local.ts": `export const c = 3;\n`, + "app/build.ts": ` + const result = await Bun.build({ + entrypoints: ["./node_modules/mypkg/index.ts", "./node_modules/mypkg/other.ts", "./src/local.ts"], + outdir: "./dist", + }); + if (!result.success) { + for (const m of result.logs) console.error(String(m)); + process.exit(1); + } + for (const out of result.outputs) { + console.log(out.kind + " " + out.path.slice(process.cwd().length + 1).replaceAll("\\\\", "/")); + } + `, + }); + mkdirSync(join(String(dir), "app", "node_modules"), { recursive: true }); + symlinkSync( + join(String(dir), "packages", "mypkg"), + join(String(dir), "app", "node_modules", "mypkg"), + isWindows ? "junction" : "dir", + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.ts"], + env: bunEnv, + cwd: join(String(dir), "app"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout.trim().split("\n").sort(); + expect({ lines, stderr }).toEqual({ + lines: [ + "entry-point dist/node_modules/mypkg/index.js", + "entry-point dist/node_modules/mypkg/other.js", + "entry-point dist/src/local.js", + ], + stderr: "", + }); + expect(stdout).not.toContain("_.._"); + expect(exitCode).toBe(0); + }); + test.concurrent("errors are returned as an array", async () => { const x = await buildNoThrow({ entrypoints: [join(import.meta.dir, "does-not-exist.ts")], From 81b9ef93ed441062ff92a8f238deb04c2fae8ec6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:35:02 +0000 Subject: [PATCH 2/2] address review: cover plugin-NoMatch fallback and file-level symlinks The onResolve NoMatch fallback was overwriting the absolute pre-symlink path that enqueue_entry_item recorded with the relative specifier, so any onResolve plugin whose filter matched the entry and returned undefined still produced _.._/ paths. That put is now redundant (enqueue_entry_item records the absolute pre-symlink path itself) and dropped. Test extended to cover both the no-plugin and no-op-onResolve paths plus a file-level symlink with a differing basename, which pins [name] as well as [dir]. Comment blocks collapsed to one line each. --- src/bundler/bundle_v2.rs | 18 +--- src/bundler/linker_context/computeChunks.rs | 7 +- test/bundler/bun-build-api.test.ts | 103 +++++++++++--------- 3 files changed, 61 insertions(+), 67 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index b14088302655..85f8d105ce74 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2589,11 +2589,7 @@ pub mod bv2_impl { .loader(&self.transpiler.options.loaders) .unwrap_or(Loader::File); - // When the resolver followed a symlink, `path.pretty` still holds the - // pre-symlink absolute path (`set_realpath` moved the old `text` there). - // Keep it for output naming so `outdir` mirrors the entry-point spelling - // the user wrote, matching esbuild. `path_with_pretty_initialized` below - // overwrites `pretty`. + // #8467: stash the pre-symlink spelling (in `pretty` until overwritten below) for output naming; matches esbuild. if is_entry_point && path.is_symlink && path.is_file() && !path.pretty.is_empty() { self.graph .entry_point_original_names @@ -4502,19 +4498,9 @@ pub mod bv2_impl { return; }; let mut resolved = resolved; - let Ok(source_index) = - this.enqueue_entry_item(&mut resolved, true, target) - else { + let Ok(_) = this.enqueue_entry_item(&mut resolved, true, target) else { return; }; - - // Store the original entry point name for virtual entries that fall back to file resolution - if let Some(idx) = source_index { - let _ = this - .graph - .entry_point_original_names - .put(idx, &resolve.import_record.specifier); - } return; } diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index d2de3f318df1..a18829dd20ad 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -643,12 +643,7 @@ pub fn compute_chunks(this: &mut LinkerContext, unique_key: u64) -> crate::Resul let root_dir = &this.resolver().opts.root_dir; let mut real_path_buf = PathBuffer::uninit(); let dir: &[u8] = 'dir: { - // When `dir_path` already sits under `root_dir`, skip the - // open+get_fd_path round-trip: that canonicalization would - // discard the symlink spelling an entry point was reached - // through (#8467). Fall through when the plain relativization - // walks above root so a short-name/symlinked cwd still finds a - // common prefix. + // #8467: skip get_fd_path when dir_path is already under root_dir (canonicalizing drops the symlink spelling); fall through so a short-name/symlinked cwd still finds a prefix. if bun_paths::is_absolute(dir_path) && !resolve_path::relative_platform::( root_dir, dir_path, diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 9a07afdc85f6..1ccdc9f5663b 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -528,53 +528,66 @@ describe("Bun.build", () => { ); // https://github.com/oven-sh/bun/issues/8467 - test.concurrent("output paths keep the symlink spelling of an entry point", async () => { - using dir = tempDir("build-symlink-entry-output", { - "packages/mypkg/index.ts": `export const a = 1;\n`, - "packages/mypkg/other.ts": `export const b = 2;\n`, - "app/src/local.ts": `export const c = 3;\n`, - "app/build.ts": ` - const result = await Bun.build({ - entrypoints: ["./node_modules/mypkg/index.ts", "./node_modules/mypkg/other.ts", "./src/local.ts"], - outdir: "./dist", - }); - if (!result.success) { - for (const m of result.logs) console.error(String(m)); - process.exit(1); - } - for (const out of result.outputs) { - console.log(out.kind + " " + out.path.slice(process.cwd().length + 1).replaceAll("\\\\", "/")); - } - `, - }); - mkdirSync(join(String(dir), "app", "node_modules"), { recursive: true }); - symlinkSync( - join(String(dir), "packages", "mypkg"), - join(String(dir), "app", "node_modules", "mypkg"), - isWindows ? "junction" : "dir", - ); + test.concurrent.each([false, true])( + "output paths keep the symlink spelling of an entry point (onResolve plugin: %p)", + async withPlugin => { + using dir = tempDir("build-symlink-entry-output", { + "packages/mypkg/index.ts": `export const a = 1;\n`, + "packages/mypkg/other.ts": `export const b = 2;\n`, + "app/src/real-impl.ts": `export const c = 3;\n`, + "app/build.ts": ` + const result = await Bun.build({ + entrypoints: [ + "./node_modules/mypkg/index.ts", + "./node_modules/mypkg/other.ts", + "./src/alias.ts", + ], + outdir: "./dist", + plugins: ${withPlugin} + ? [{ name: "noop", setup(b) { b.onResolve({ filter: /.*/ }, () => undefined); } }] + : [], + }); + if (!result.success) { + for (const m of result.logs) console.error(String(m)); + process.exit(1); + } + for (const out of result.outputs) { + console.log(out.kind + " " + out.path.slice(process.cwd().length + 1).replaceAll("\\\\", "/")); + } + `, + }); + mkdirSync(join(String(dir), "app", "node_modules"), { recursive: true }); + symlinkSync( + join(String(dir), "packages", "mypkg"), + join(String(dir), "app", "node_modules", "mypkg"), + isWindows ? "junction" : "dir", + ); + // File-level symlink with a different basename, target inside root: pins [name]. + symlinkSync("real-impl.ts", join(String(dir), "app", "src", "alias.ts"), "file"); - await using proc = Bun.spawn({ - cmd: [bunExe(), "build.ts"], - env: bunEnv, - cwd: join(String(dir), "app"), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.ts"], + env: bunEnv, + cwd: join(String(dir), "app"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const lines = stdout.trim().split("\n").sort(); - expect({ lines, stderr }).toEqual({ - lines: [ - "entry-point dist/node_modules/mypkg/index.js", - "entry-point dist/node_modules/mypkg/other.js", - "entry-point dist/src/local.js", - ], - stderr: "", - }); - expect(stdout).not.toContain("_.._"); - expect(exitCode).toBe(0); - }); + const lines = stdout.trim().split("\n").sort(); + expect({ lines, stderr }).toEqual({ + lines: [ + "entry-point dist/node_modules/mypkg/index.js", + "entry-point dist/node_modules/mypkg/other.js", + "entry-point dist/src/alias.js", + ], + stderr: "", + }); + expect(stdout).not.toContain("_.._"); + expect(stdout).not.toContain("real-impl"); + expect(exitCode).toBe(0); + }, + ); test.concurrent("errors are returned as an array", async () => { const x = await buildNoThrow({