diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 49d092acb299..85f8d105ce74 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2589,6 +2589,13 @@ pub mod bv2_impl { .loader(&self.transpiler.options.loaders) .unwrap_or(Loader::File); + // #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 + .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. @@ -4491,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 be942b837711..a18829dd20ad 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -640,8 +640,18 @@ 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: { + // #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, + ) + .starts_with(b"..") + { + break 'dir dir_path; + } let Ok(dir_file) = bun_sys::File::openat( bun_sys::Fd::cwd(), dir_path, @@ -672,7 +682,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..1ccdc9f5663b 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -527,6 +527,68 @@ describe("Bun.build", () => { }, ); + // https://github.com/oven-sh/bun/issues/8467 + 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]); + + 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({ entrypoints: [join(import.meta.dir, "does-not-exist.ts")],