Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
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
46 changes: 42 additions & 4 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3988,6 +3988,20 @@
slice
}

/// Canonicalize the entry point's path without going through the
/// resolver, whose preserve-symlinks mode shapes its shared caches.
/// `None` when the path can't be opened; the caller falls back to the
/// resolved spelling.
Comment thread
robobun marked this conversation as resolved.
fn realpath_for_main(&mut self, path: &[u8]) -> Option<&'static [u8]> {
let boxed = bun_sys::realpath_by_open(path)?;
// SAFETY: `boxed`'s heap allocation has a stable address for as long
// as the owning `Box` lives in `resolved_path_dups` (drained in
// `destroy()`).
let slice: &'static [u8] = unsafe { core::mem::transmute::<&[u8], &'static [u8]>(&*boxed) };
self.resolved_path_dups.push(boxed);
Some(slice)
}

/// Note: `is_a_file_path` is a runtime
/// arg to avoid duplicating the body for both monomorphizations.
pub(crate) fn _resolve(
Expand Down Expand Up @@ -4082,6 +4096,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). Applied after the
// resolve (see `path_text` below) rather than by flipping
// `opts.preserve_symlinks` for one call: that option shapes cached
// `DirInfo` entries, and the dir cache is keyed by path only.
Comment thread
robobun marked this conversation as resolved.
let main_preserve: Option<bool> = (source == MAIN_FILE_NAME).then(|| {
bun_options_types::context::try_get()
.is_some_and(|c| c.runtime_options.preserve_symlinks_main_enabled())
});

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

View check run for this annotation

Claude / Claude Code Review

--preserve-symlinks-main is not applied to worker entry points despite the '(workers included)' claim

The `(workers included)` claim here doesn't hold for the `--preserve-symlinks-main`-on / `--preserve-symlinks`-off direction: the worker entry path goes through `resolve_entry_point_specifier` (web_worker.rs:1716-1743), which returns `entry_path.text` unconditionally and discards `.pretty`/`.is_symlink` before `_resolve` ever runs — so the `Some(true)` recovery arm can never fire for a symlinked worker entry (the link spelling was thrown away). The `Some(false)` direction does work, since `realp
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 Down Expand Up @@ -4168,10 +4193,23 @@
let result_path = result
.path_const()
.ok_or(crate::CrateError::ModuleNotFound)?;
// SAFETY: `result_path.text` borrows the resolver's arena, which
// outlives `ResolveFunctionResult` (see the struct's lifetime-erasure
// note).
ret.path = unsafe { bun_ptr::detach_lifetime(result_path.text) };
let path_text: &[u8] = match main_preserve {
// The resolver realpathed the entry and `set_realpath` kept the
// link spelling in `pretty`; that spelling is the entry's identity.
Comment thread
robobun marked this conversation as resolved.
Some(true) if result_path.is_symlink && !result_path.pretty.is_empty() => {
result_path.pretty
}
// The preserve-mode resolver kept the entry's link path, but Node
// realpaths the entry when only the general flag is on.
Comment thread
robobun marked this conversation as resolved.
Some(false) if self.transpiler.resolver.opts.preserve_symlinks => self
.realpath_for_main(result_path.text)
.unwrap_or(result_path.text),
_ => result_path.text,
};
// SAFETY: `result_path.text`/`.pretty` borrow the resolver's arena,
// which outlives `ResolveFunctionResult` (see the struct's
// lifetime-erasure note).
ret.path = unsafe { bun_ptr::detach_lifetime(path_text) };
ret.result = Some(result);

Ok(())
Expand Down
10 changes: 10 additions & 0 deletions src/options_types/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,16 @@ pub struct HeapProf {
pub dir: Box<[u8]>,
}

impl RuntimeOptions {
/// `--preserve-symlinks-main` or `NODE_PRESERVE_SYMLINKS_MAIN=1`.
pub fn preserve_symlinks_main_enabled(&self) -> bool {
self.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false)
}
}

impl Default for RuntimeOptions {
// See `ContextData::default` — folded into the single startup call site.
#[inline(always)]
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
66 changes: 49 additions & 17 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2550,18 +2550,11 @@ impl RunCommand {
bstr::BStr::new(target_name),
bstr::BStr::new(fs_top_level_dir),
);
// Temporarily honor `--preserve-symlinks-main` / NODE_PRESERVE_SYMLINKS_MAIN
// for this one resolve.
let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main_enabled();
let resolution: ::core::result::Result<bun_resolver::Result, bun_resolver::Error> = {
let saved_preserve = this_transpiler.resolver.opts.preserve_symlinks;
this_transpiler.resolver.opts.preserve_symlinks =
ctx.runtime_options.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false);
// SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime.
let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir };
let resolved = match this_transpiler.resolver.resolve(
match this_transpiler.resolver.resolve(
top_level_dir,
target_name,
bun_ast::ImportKind::EntryPointRun,
Expand All @@ -2576,9 +2569,7 @@ impl RunCommand {
bun_ast::ImportKind::EntryPointRun,
)
}
};
this_transpiler.resolver.opts.preserve_symlinks = saved_preserve;
resolved
}
};
// (path, loader) — captured if the resolve hit a real file whose
// loader Bun cannot execute (e.g. `.css`); used by the `log_errors`
Expand All @@ -2597,9 +2588,29 @@ impl RunCommand {
.unwrap_or(Loader::Tsx);
if loader.can_be_run_by_bun() || loader == Loader::Html || loader == Loader::Md {
bun_core::scoped_log!(RUN_LOG, "Resolved to: `{}`", bstr::BStr::new(path.text));
// borrowck — `boot_and_handle_error` takes
// `&mut ctx`; copy `path.text` out of the resolver borrow.
let text: Box<[u8]> = path.text.to_vec().into_boxed_slice();
// Node applies --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN
// (not --preserve-symlinks) to the entry point. Adjust the
// resolved spelling here instead of flipping
// `opts.preserve_symlinks` for one resolve: that option
// shapes the process-lifetime DirInfo cache, which is
// keyed by path only.
// (Also copies `path.text` out of the resolver borrow for
// the `&mut ctx` call below.)
Comment thread
robobun marked this conversation as resolved.
let text: Box<[u8]> =
if preserve_symlinks_main && path.is_symlink && !path.pretty.is_empty() {
// The resolver realpathed the entry; `set_realpath`
// kept the link spelling in `pretty`.
Comment thread
robobun marked this conversation as resolved.
path.pretty.to_vec().into_boxed_slice()
} else if !preserve_symlinks_main
&& this_transpiler.resolver.opts.preserve_symlinks
{
// The preserve-mode resolver kept the link path,
// but the entry must be realpathed.
Comment thread
robobun marked this conversation as resolved.
bun_sys::realpath_by_open(path.text)
.unwrap_or_else(|| path.text.to_vec().into_boxed_slice())
} else {
path.text.to_vec().into_boxed_slice()
};
return Ok(Self::boot_and_handle_error(ctx, &text, Some(loader)));
} else {
bun_core::scoped_log!(
Expand Down Expand Up @@ -2861,8 +2872,29 @@ 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_enabled();
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
19 changes: 19 additions & 0 deletions src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7684,6 +7684,25 @@ fn get_fd_path_freebsd_linuxulator<'a>(
Ok(&mut out.0[..len])
}

/// Canonicalize `path` by opening it read-only and deriving the fd's
/// absolute path via [`get_fd_path`]. `None` when the path is too long or
/// cannot be opened.
Comment thread
robobun marked this conversation as resolved.
pub fn realpath_by_open(path: &[u8]) -> Option<Box<[u8]>> {
let mut buf = bun_paths::path_buffer_pool::get();
if path.len() >= buf.len() {
return None;
}
buf[..path.len()].copy_from_slice(path);
buf[path.len()] = 0;
// SAFETY: `buf[path.len()] == 0` written above.
let z = ZStr::from_buf(&buf[..], path.len());
let fd = open(z, O::RDONLY, 0).ok()?;
let mut out = bun_paths::path_buffer_pool::get();
let resolved = get_fd_path(fd, &mut out);
let _ = close(fd);
resolved.ok().map(|p| p.to_vec().into_boxed_slice())
}

/// fd → absolute path. Linux: readlink `/proc/self/fd/N`;
/// macOS: `fcntl(F_GETPATH)`; Windows: `GetFinalPathNameByHandle`.
pub fn get_fd_path<'a>(fd: Fd, out: &'a mut bun_paths::PathBuffer) -> Maybe<&'a mut [u8]> {
Expand Down
157 changes: 157 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,160 @@ 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`,
"shared/mainjs.js": `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");
symlinkSync(join(dir, "shared", "mainjs.js"), join(dir, "app", "mainsym.js"), "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);

// `bun run` with an extensionless target goes through the module
// resolution fallback rather than the direct-file fast path.
const fallback = await run(join(dir, "app"), ["run", "--preserve-symlinks-main", "./mainsym"]);
expect(fallback.stderr).toBe("");
expect(fallback.stdout).toBe("RESOLVED-FROM-APP-GEN\n");
expect(fallback.exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Same fallback path without -main: the entry is realpathed even though
// --preserve-symlinks kept the resolver in preserve mode.
const fallbackWithoutMain = await run(join(dir, "app"), ["run", "--preserve-symlinks", "./mainsym"]);
expect(fallbackWithoutMain.stderr).toContain("Cannot find module './gen/dep.mjs'");
expect(fallbackWithoutMain.stdout).toBe("");
expect(fallbackWithoutMain.exitCode).not.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.stderr).toContain("Cannot find module './gen/dep.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(
"-main only: entry under a symlinked directory keeps its link path, other modules are realpathed",
async () => {
// The entry's resolve must not leak preserve-mode entries into the shared
// dir cache: foo.cjs below has to be realpathed even though the entry is
// not.
const dir = tempDirWithFiles("preserve-symlinks-main-dir", {
"real/app/bin/entry.cjs": `const foo = require("../lib/foo.cjs");\nconsole.log(__filename);\nconsole.log(foo);\n`,
"real/app/lib/foo.cjs": `module.exports = __filename;\n`,
});
symlinkSync(join(dir, "real"), join(dir, "opt"), "dir");
const { stdout, stderr, exitCode } = await run(dir, [
"--preserve-symlinks-main",
join(dir, "opt", "app", "bin", "entry.cjs"),
]);
expect(stderr).toBe("");
const [entryLine, fooLine] = stdout.replaceAll("\\", "/").trim().split("\n");
expect(entryLine).toBe(join(dir, "opt", "app", "bin", "entry.cjs").replaceAll("\\", "/"));
expect(fooLine).toBe(join(realpathSync(dir), "real", "app", "lib", "foo.cjs").replaceAll("\\", "/"));
expect(exitCode).toBe(0);
},
);

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