Files
deno/cli/node_compat_shim.rs
Bartek Iwańczuk f2f8666fad feat(cli): provide a node on PATH when Node.js is not installed (#34969)
Some native tools resolve and spawn a `node` child process via a raw OS
PATH lookup, which bypasses the `node` interception Deno does at the
shell and `child_process` levels. The motivating case is Next.js 16:
Turbopack's native addon spawns a pool of `node` workers to run
webpack-style JS loaders (PostCSS/Tailwind, `next/font`), so
`deno task dev` fails CSS compilation with "spawning node pooled process
- No such file or directory" whenever no `node` binary is installed.

To make `node` unnecessary, Deno now stands in for it. When the deno
binary is invoked through a file named `node` it translates the Node CLI
arguments to Deno arguments and runs as if `deno node ...`; and on
startup it creates such a `node` executable under DENO_DIR (a symlink on
unix, a hardlink or copy on windows) and prepends that directory to its
own PATH so child processes, including native NAPI spawns, inherit it.
Both are best-effort and only activate when a real `node` is not already
on PATH, so existing Node installs are never shadowed; set
DENO_DISABLE_NODE_SHIM=1 to turn the behavior off.

Reported via denoland/docs#3185.
2026-06-17 09:30:08 +02:00

394 lines
13 KiB
Rust

// Copyright 2018-2026 the Deno authors. MIT license.
//! Lets Deno stand in for `node` when no real Node.js binary is available.
//!
//! Two complementary mechanisms:
//!
//! 1. Arg0 dispatch ([`maybe_rewrite_node_arg0`]): when the deno binary is
//! invoked through a file named `node` (a symlink/hardlink created below),
//! translate the Node.js CLI arguments to Deno arguments and run as if
//! `deno node ...` had been invoked.
//!
//! 2. PATH injection ([`ensure_node_on_path`]): create a `node` executable
//! pointing back at the current deno binary in a cache directory and prepend
//! that directory to the process's own `PATH`, so that child processes
//! (including native ones such as Next.js Turbopack, which spawn `node` via
//! a raw OS PATH lookup that bypasses Deno's JS-level interception) can find
//! a `node` to run.
//!
//! Both are best-effort and only kick in when a real `node` is not already
//! available on `PATH`, so existing Node.js setups are never shadowed. The
//! behavior can be disabled entirely with `DENO_DISABLE_NODE_SHIM=1`.
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
/// Env var that disables the whole feature when set to a truthy value.
const DISABLE_ENV_VAR: &str = "DENO_DISABLE_NODE_SHIM";
/// Internal marker set once the shim has been put on PATH, used to avoid the
/// shim recursively validating against itself when a spawned `node` (which is
/// really deno) re-enters this code.
const ACTIVE_ENV_VAR: &str = "DENO_NODE_SHIM_ACTIVE";
/// Name of the directory under DENO_DIR that holds the `node` shim.
const SHIM_DIR_NAME: &str = "node_compat_bin";
fn is_truthy(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn env_disabled() -> bool {
std::env::var(DISABLE_ENV_VAR)
.map(|v| is_truthy(&v))
.unwrap_or(false)
}
/// Whether a subcommand can execute user code that might spawn a native `node`
/// child process. Used to keep the `node` PATH scan (in [`ensure_node_on_path`])
/// off the cold-start path of commands like `fmt`/`lint`/`check`/`lsp` that
/// never spawn one.
pub fn subcommand_may_spawn_node(
subcommand: &crate::args::DenoSubcommand,
) -> bool {
use crate::args::DenoSubcommand::*;
matches!(
subcommand,
Run(_) | Task(_) | Test(_) | Bench(_) | Eval(_) | Repl(_) | Serve(_)
)
}
/// Returns whether `arg0`'s file name is exactly `node` (or `node.exe` on
/// Windows). Deliberately an exact match, not a suffix match, so names like
/// `anode` or `mynode` do not trigger dispatch.
fn is_node_arg0(arg0: &OsString) -> bool {
let Some(file_name) = Path::new(arg0).file_name() else {
return false;
};
// Never misfire on the deno binary itself.
if file_name.eq_ignore_ascii_case("deno")
|| file_name.eq_ignore_ascii_case("deno.exe")
{
return false;
}
if file_name == "node" {
return true;
}
cfg!(windows) && file_name.eq_ignore_ascii_case("node.exe")
}
/// If the process was invoked as `node`, translate the Node.js CLI args into
/// Deno args and return the rewritten argv. Otherwise returns `args` unchanged.
///
/// Must be called before any threads are spawned, as it may mutate process
/// environment variables (`NODE_OPTIONS`, `DENO_TLS_CA_STORE`, ...).
pub fn maybe_rewrite_node_arg0(args: Vec<OsString>) -> Vec<OsString> {
let Some(arg0) = args.first() else {
return args;
};
if !is_node_arg0(arg0) {
return args;
}
// Respect the opt-out: a stale `node` shim left on PATH from a previous run
// should pass through unchanged when the feature is disabled.
if env_disabled() {
return args;
}
// node_shim operates on Strings. Node never passes non-UTF8 flags, and any
// entrypoint path is re-resolved by deno's own `run` resolution.
let node_args: Vec<String> = args[1..]
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let parsed = match node_shim::parse_args(node_args) {
Ok(parsed) => parsed,
Err(errors) => {
// This runs before logging is initialized; mirror the standalone shim.
#[allow(clippy::print_stderr, reason = "node shim arg parse error")]
{
if errors.len() == 1 {
eprintln!("Error: {}", errors[0]);
} else if errors.len() > 1 {
eprintln!("Errors: {}", errors.join(", "));
}
}
deno_runtime::exit(1);
}
};
let options = node_shim::TranslateOptions::for_node_cli();
let result = node_shim::translate_to_deno_args(parsed, &options);
apply_env_side_effects(&result);
let mut deno_args = result.deno_args;
// Resolve the entrypoint for the run path the same way the standalone shim
// does, sharing a single implementation. `current_exe` resolves to the real
// deno binary (not the `node` symlink), so the resolution subprocess runs as
// plain `deno eval` without re-entering arg0 dispatch.
#[allow(clippy::disallowed_methods, reason = "resolving the node shim exe")]
let current_exe =
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("deno"));
node_shim::resolve_run_entrypoint(&current_exe, &mut deno_args);
// `deno_args[0]` is the program-name slot ("node") that deno's flag parser
// skips; `deno_args[1..]` carry the real subcommand. Returning `deno_args`
// verbatim is exactly what the standalone shim feeds to deno.
deno_args.into_iter().map(OsString::from).collect()
}
fn apply_env_side_effects(result: &node_shim::TranslatedArgs) {
if result.use_system_ca {
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var("DENO_TLS_CA_STORE", "system") };
}
if !result.node_options.is_empty() {
let options = result.node_options.join(" ");
let merged = match std::env::var("NODE_OPTIONS") {
Ok(existing) if !existing.is_empty() => {
format!("{existing} {options}")
}
_ => options,
};
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var("NODE_OPTIONS", merged) };
}
if !result.trace_event_categories.is_empty() {
// SAFETY: called before any threads are spawned.
unsafe {
std::env::set_var(
"DENO_NODE_TRACE_EVENT_CATEGORIES",
&result.trace_event_categories,
)
};
}
}
/// Ensure a `node` executable that points back at the current deno binary is
/// available on `PATH`, so child processes that spawn `node` natively can find
/// one. Best-effort and no-op when a real `node` already exists, when disabled,
/// or when the shim is already active.
///
/// Must be called before any threads are spawned (it mutates `PATH`).
pub fn ensure_node_on_path(deno_dir_root: &Path) -> std::io::Result<()> {
if env_disabled() {
return Ok(());
}
// Re-entry guard: if a previously-prepended shim `node` re-invoked us, the
// shim itself is now on PATH; don't recurse or re-validate against ourselves.
if std::env::var(ACTIVE_ENV_VAR)
.map(|v| is_truthy(&v))
.unwrap_or(false)
{
return Ok(());
}
// Don't shadow a real Node.js install.
if which::which("node").is_ok() {
return Ok(());
}
#[allow(clippy::disallowed_methods, reason = "resolving the node shim exe")]
let current_exe = std::env::current_exe()?;
let current_exe =
crate::util::fs::canonicalize_path(&current_exe).unwrap_or(current_exe);
let shim_dir = deno_dir_root.join(SHIM_DIR_NAME);
let shim_name = if cfg!(windows) { "node.exe" } else { "node" };
let shim_path = shim_dir.join(shim_name);
if !shim_is_valid(&shim_path, &current_exe) {
std::fs::create_dir_all(&shim_dir)?;
create_shim(&shim_path, &current_exe)?;
}
prepend_self_path(&shim_dir);
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var(ACTIVE_ENV_VAR, "1") };
Ok(())
}
/// Whether an existing shim at `shim_path` already points at `current_exe`.
fn shim_is_valid(shim_path: &Path, current_exe: &Path) -> bool {
#[cfg(unix)]
{
match std::fs::read_link(shim_path) {
Ok(target) => {
target == current_exe
|| crate::util::fs::canonicalize_path(&target).ok().as_deref()
== Some(current_exe)
}
Err(_) => false,
}
}
#[cfg(windows)]
{
same_file::is_same_file(shim_path, current_exe).unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (shim_path, current_exe);
false
}
}
#[cfg(unix)]
fn create_shim(shim_path: &Path, current_exe: &Path) -> std::io::Result<()> {
// Symlink to a unique temp name first, then atomically `rename` it into
// place. This keeps a valid `node` visible at all times, so two concurrent
// deno invocations (e.g. editor + terminal) can't race into a window where
// the shim is momentarily missing.
let tmp_path =
shim_path.with_extension(format!("tmp-{}", std::process::id()));
// A leftover temp file from a crashed previous run would make `symlink`
// fail with AlreadyExists; clear it first.
let _ = std::fs::remove_file(&tmp_path);
std::os::unix::fs::symlink(current_exe, &tmp_path)?;
match std::fs::rename(&tmp_path, shim_path) {
Ok(()) => Ok(()),
Err(err) => {
let _ = std::fs::remove_file(&tmp_path);
Err(err)
}
}
}
#[cfg(windows)]
fn create_shim(shim_path: &Path, current_exe: &Path) -> std::io::Result<()> {
// Native CreateProcess / Rust's Command PATH lookup only execute `.exe`, not
// `.cmd`, so the shim must be a real executable. A hardlink shares the deno
// binary's bytes with no extra disk; fall back to a copy across volumes.
if shim_path.exists() {
let _ = std::fs::remove_file(shim_path);
}
match std::fs::hard_link(current_exe, shim_path) {
Ok(()) => Ok(()),
Err(_) => {
// Copy via a unique temp file then atomically rename into place.
let tmp_path =
shim_path.with_extension(format!("exe.tmp-{}", std::process::id()));
std::fs::copy(current_exe, &tmp_path)?;
std::fs::rename(&tmp_path, shim_path)
}
}
}
#[cfg(not(any(unix, windows)))]
fn create_shim(_shim_path: &Path, _current_exe: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"node shim is not supported on this platform",
))
}
/// Prepend `dir` to the process's own `PATH` (idempotently), so spawned
/// children inherit it.
fn prepend_self_path(dir: &Path) {
let sep = if cfg!(windows) { ';' } else { ':' };
let current = std::env::var_os("PATH").unwrap_or_default();
// Idempotency: don't grow PATH if the dir is already present.
let already_present = std::env::split_paths(&current).any(|p| p == dir);
if already_present {
return;
}
let mut new_path = OsString::from(dir);
if !current.is_empty() {
new_path.push(sep.to_string());
new_path.push(&current);
}
// SAFETY: called before any threads are spawned.
unsafe { std::env::set_var("PATH", new_path) };
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_node_arg0() {
assert!(is_node_arg0(&OsString::from("node")));
assert!(is_node_arg0(&OsString::from("/usr/local/bin/node")));
assert!(!is_node_arg0(&OsString::from("anode")));
assert!(!is_node_arg0(&OsString::from("/path/to/mynode")));
assert!(!is_node_arg0(&OsString::from("deno")));
assert!(!is_node_arg0(&OsString::from("/usr/bin/deno")));
}
#[test]
#[cfg(windows)]
fn detects_node_exe_arg0_on_windows() {
assert!(is_node_arg0(&OsString::from("node.exe")));
assert!(is_node_arg0(&OsString::from("NODE.EXE")));
assert!(!is_node_arg0(&OsString::from("deno.exe")));
}
#[test]
fn non_node_arg0_passes_through_unchanged() {
let args = vec![
OsString::from("/usr/bin/deno"),
OsString::from("run"),
OsString::from("main.ts"),
];
let result = maybe_rewrite_node_arg0(args.clone());
assert_eq!(result, args);
}
#[test]
fn truthy_values() {
assert!(is_truthy("1"));
assert!(is_truthy("true"));
assert!(is_truthy("TRUE"));
assert!(is_truthy("True"));
assert!(is_truthy("tRuE"));
assert!(is_truthy("yes"));
assert!(is_truthy("on"));
assert!(!is_truthy("0"));
assert!(!is_truthy("false"));
assert!(!is_truthy(""));
}
#[test]
fn create_shim_round_trip() {
// Exercises create_shim + shim_is_valid on every platform. On Windows this
// is the only coverage of the hardlink/copy path, which the unix-only spec
// tests can't reach.
let base = std::env::temp_dir()
.join(format!("deno_node_shim_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// Stand-in for the deno binary the shim points back at. Keep it in the same
// directory tree as the shim so a Windows hardlink stays on one volume.
let fake_exe = base.join(if cfg!(windows) { "deno.exe" } else { "deno" });
std::fs::write(&fake_exe, b"binary").unwrap();
let shim_dir = base.join(SHIM_DIR_NAME);
std::fs::create_dir_all(&shim_dir).unwrap();
let shim_name = if cfg!(windows) { "node.exe" } else { "node" };
let shim_path = shim_dir.join(shim_name);
// Fresh creation, then idempotent recreation over an existing shim: both
// must yield a shim that validates against the target.
for _ in 0..2 {
create_shim(&shim_path, &fake_exe).unwrap();
assert!(shim_path.exists());
assert!(shim_is_valid(&shim_path, &fake_exe));
}
// A shim pointing at a different binary must be reported invalid (this is
// what triggers recreation after a deno upgrade).
let other_exe = base.join("other");
std::fs::write(&other_exe, b"binary").unwrap();
assert!(!shim_is_valid(&shim_path, &other_exe));
let _ = std::fs::remove_dir_all(&base);
}
}