mirror of
https://github.com/aphrody-labs/deno.git
synced 2026-10-14 06:19:28 +00:00
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.
394 lines
13 KiB
Rust
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(¤t_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(¤t_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, ¤t_exe) {
|
|
std::fs::create_dir_all(&shim_dir)?;
|
|
create_shim(&shim_path, ¤t_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(¤t).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(¤t);
|
|
}
|
|
// 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);
|
|
}
|
|
}
|