Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1664,7 +1664,7 @@ impl<'a> BundleOptions<'a> {
origin: bun_url::OwnedURL::from_href(Box::default()),
output_dir_handle: None,
root_dir: Box::default(),
preserve_symlinks: false,
preserve_symlinks: transform.preserve_symlinks.unwrap_or(false),
preserve_extensions: false,
production: false,
output_format: Format::Esm,
Expand Down
31 changes: 25 additions & 6 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4082,6 +4082,17 @@
top_level_dir
};

// Node applies --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN
// (not --preserve-symlinks) to the entry point, which is resolved from
// the synthetic main module here (workers included).
Comment thread
robobun marked this conversation as resolved.
Outdated
let main_preserve_override: Option<bool> = (source == MAIN_FILE_NAME).then(|| {
bun_options_types::context::try_get()
.is_some_and(|c| c.runtime_options.preserve_symlinks_main)
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false)
});
Comment thread
robobun marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// A `loop`
// returning the resolver result; `retry_on_not_found` is consumed on
// the first miss.
Expand All @@ -4093,12 +4104,20 @@
bun_ast::ImportKind::Require
};
let global_cache = self.transpiler.resolver.opts.global_cache;
match self.transpiler.resolver.resolve_and_auto_install(
source_to_use,
normalized_specifier,
import_kind,
global_cache,
) {
let resolved = {
let saved_preserve = self.transpiler.resolver.opts.preserve_symlinks;
self.transpiler.resolver.opts.preserve_symlinks =
main_preserve_override.unwrap_or(saved_preserve);
let resolved = self.transpiler.resolver.resolve_and_auto_install(
source_to_use,
normalized_specifier,
import_kind,
global_cache,
);
self.transpiler.resolver.opts.preserve_symlinks = saved_preserve;
resolved

Check failure on line 4118 in src/jsc/VirtualMachine.rs

View check run for this annotation

Claude / Claude Code Review

Temporary preserve_symlinks flip poisons the resolver DirInfo cache

The temporary `opts.preserve_symlinks` flip for the entry-point resolve poisons the resolver's persistent DirInfo cache: `dir_info_uncached` (resolver.rs:6131) reads that flag when deciding whether to compute `info.abs_real_path`, and the result is cached by path with no invalidation. With `--preserve-symlinks-main` on and `--preserve-symlinks` off, the entry's directory chain is cached with `abs_real_path` empty; after the flag is restored, non-main resolves under a symlinked directory hit the
Comment thread
robobun marked this conversation as resolved.
Outdated
};
match resolved {
ResultUnion::Success(r) => break r,
ResultUnion::Failure(e) => return Err(e.into()),
ResultUnion::Pending(_) | ResultUnion::NotFound => {
Expand Down
6 changes: 5 additions & 1 deletion src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1661,7 +1661,11 @@ impl<'a> Resolver<'a> {
module_type_from_ext(name.ext).unwrap_or(options::ModuleType::Unknown);
}

if let Some(entries) = dir.get_entries_ref(self.generation) {
// With --preserve-symlinks, the link path stays the module's
// identity (matching Node), so skip resolving it to the target.
Comment thread
robobun marked this conversation as resolved.
if !self.opts.preserve_symlinks
&& let Some(entries) = dir.get_entries_ref(self.generation)
{
if let Some(query) = entries.get(name.filename) {
// SAFETY: entries_mutex held; rfs points at the process-global RealFS.
let symlink_path =
Expand Down
28 changes: 26 additions & 2 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2861,8 +2861,32 @@ impl RunCommand {
});

// Re-derive the canonical absolute path from the open fd (resolves
// symlinks).
let absolute_script_path: Box<[u8]> = {
// symlinks). With --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN,
// keep the link path as the entry's identity instead (matching Node).
Comment thread
robobun marked this conversation as resolved.
let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false);
let absolute_script_path: Box<[u8]> = if preserve_symlinks_main {
let mut cwd_buf = PathBuffer::uninit();
let Ok(cwd) = bun_core::getcwd(&mut cwd_buf) else {
let _ = bun_sys::close(fd);
return false;
};
let cwd_len = cwd.as_bytes().len();
cwd_buf[cwd_len] = paths::SEP;
let mut abs_buf = PathBuffer::uninit();
let joined = paths::resolve_path::join_abs_string_buf::<paths::platform::Auto>(
&cwd_buf[..cwd_len + 1],
&mut abs_buf.0,
&[&script_name_buf[..open_len]],
);
if joined.is_empty() {
let _ = bun_sys::close(fd);
return false;
}
joined.to_vec().into_boxed_slice()
} else {
let resolved = match bun_sys::get_fd_path(fd, &mut script_name_buf) {
Ok(p) => p,
Err(_) => {
Expand Down
117 changes: 117 additions & 0 deletions test/js/bun/resolve/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,3 +1101,120 @@ it.skipIf(isWindows)("reports a resolution error for an absolute specifier of th
expect(stdout).toBe("ResolveMessage ERR_MODULE_NOT_FOUND\n");
expect(exitCode).toBe(0);
});

describe("--preserve-symlinks", () => {
// A shared library outside the app, symlinked into it. With the flag, the
// link path stays the module's identity (as in Node), so specifiers inside
// the library resolve from the app.
function makeTree() {
const dir = tempDirWithFiles("preserve-symlinks", {
"package.json": `{ "name": "root", "version": "1.0.0" }`,
"shared/lib/relative.mjs": `import dep from "../gen/dep.mjs";\nexport default dep;\n`,
"shared/lib/relative.cjs": `module.exports = require("../gen/dep.cjs");\n`,
"shared/lib/bare.mjs": `import pkg from "somepkg";\nexport default pkg;\n`,
"shared/lib/where.mjs": `export default import.meta.url;\n`,
"shared/main.mjs": `import v from "./gen/dep.mjs";\nconsole.log(v);\n`,
"app/gen/dep.mjs": `export default "RESOLVED-FROM-APP-GEN";\n`,
"app/gen/dep.cjs": `module.exports = "RESOLVED-FROM-APP-GEN";\n`,
"app/node_modules/somepkg/package.json": `{ "name": "somepkg", "version": "1.0.0", "type": "module", "main": "index.mjs" }`,
"app/node_modules/somepkg/index.mjs": `export default "RESOLVED-FROM-APP-NODE-MODULES";\n`,
"app/main-relative.mjs": `import v from "./lib/relative.mjs";\nconsole.log(v);\n`,
"app/main-relative.cjs": `console.log(require("./lib/relative.cjs"));\n`,
"app/main-bare.mjs": `import v from "./lib/bare.mjs";\nconsole.log(v);\n`,
"app/main-where.mjs": `import url from "./lib/where.mjs";\nconsole.log(url);\n`,
});
mkdirSync(join(dir, "app", "lib"));
for (const f of ["relative.mjs", "relative.cjs", "bare.mjs", "where.mjs"]) {
symlinkSync(join(dir, "shared", "lib", f), join(dir, "app", "lib", f), "file");
}
symlinkSync(join(dir, "shared", "main.mjs"), join(dir, "app", "main-sym.mjs"), "file");
return dir;
}

async function run(cwd: string, args: string[], env: Record<string, string | undefined> = bunEnv) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
cwd,
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

it.concurrent("relative specifier in a symlinked ESM module resolves from the link's directory", async () => {
const dir = makeTree();
const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["--preserve-symlinks", "main-relative.mjs"]);
expect(stderr).toBe("");
expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n");
expect(exitCode).toBe(0);
});

it.concurrent("relative specifier in a symlinked CJS module resolves from the link's directory", async () => {
const dir = makeTree();
const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["--preserve-symlinks", "main-relative.cjs"]);
expect(stderr).toBe("");
expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n");
expect(exitCode).toBe(0);
});

it.concurrent("bare specifier in a symlinked module resolves against the consumer's node_modules", async () => {
const dir = makeTree();
const { stdout, stderr, exitCode } = await run(join(dir, "app"), [
"--preserve-symlinks",
"--no-install",
"main-bare.mjs",
]);
expect(stderr).toBe("");
expect(stdout).toBe("RESOLVED-FROM-APP-NODE-MODULES\n");
expect(exitCode).toBe(0);
});

it.concurrent("import.meta.url keeps the link path with the flag and the real path without it", async () => {
const dir = makeTree();
const withFlag = await run(join(dir, "app"), ["--preserve-symlinks", "main-where.mjs"]);
expect(withFlag.stdout.trim()).toEndWith("/app/lib/where.mjs");
expect(withFlag.exitCode).toBe(0);

const withoutFlag = await run(join(dir, "app"), ["main-where.mjs"]);
expect(withoutFlag.stdout.trim()).toEndWith("/shared/lib/where.mjs");
expect(withoutFlag.exitCode).toBe(0);
});

it.concurrent("NODE_PRESERVE_SYMLINKS=1 behaves like the flag", async () => {
const dir = makeTree();
const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["main-relative.mjs"], {
...bunEnv,
NODE_PRESERVE_SYMLINKS: "1",
});
expect(stderr).toBe("");
expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n");
expect(exitCode).toBe(0);
});

it.concurrent("--preserve-symlinks-main keeps a symlinked entry point's link path", async () => {
const dir = makeTree();
const withMain = await run(join(dir, "app"), ["--preserve-symlinks-main", "main-sym.mjs"]);
expect(withMain.stderr).toBe("");
expect(withMain.stdout).toBe("RESOLVED-FROM-APP-GEN\n");
expect(withMain.exitCode).toBe(0);

// Without -main the entry is still realpathed (Node behavior), so its
// relative import resolves from shared/ and fails.
const withoutMain = await run(join(dir, "app"), ["--preserve-symlinks", "main-sym.mjs"]);
expect(withoutMain.exitCode).not.toBe(0);
Comment thread
robobun marked this conversation as resolved.
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it.concurrent("bun build --preserve-symlinks resolves through file symlinks", async () => {
const dir = makeTree();
const { stdout, stderr, exitCode } = await run(join(dir, "app"), [
"build",
"main-relative.mjs",
"--preserve-symlinks",
]);
expect(stderr).toBe("");
expect(stdout).toContain("RESOLVED-FROM-APP-GEN");
expect(exitCode).toBe(0);
});
});
Loading