/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/node_compat_shim.rs
393 строки
13 KB
Bartek Iwańczuk
feat(cli): provide a `node` on PATH when Node.js is not installed (#34969)
17 июн 2026, 10:30
Не верифицирован
17 июн 2026, 10:30
f2f8666
Код
Авторство
О чём код?
// 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); } }