nodejs/lib.rs
1//! node-js — JavaScript as a fusevm frontend.
2//!
3//! Pipeline: `lexer` → `parser` builds a JS AST → `compiler` lowers it to a
4//! `fusevm::Chunk` (plus a table of function/arrow sub-chunks and try-block
5//! chunks) → fusevm executes it, calling back into the `host` (through
6//! registered builtins and the strict numeric hook) for every JS-specific
7//! operation. There is no bespoke VM or JIT here — execution and codegen live in
8//! fusevm.
9
10pub mod aot;
11pub mod aot_native;
12pub mod arity;
13pub mod ast;
14pub mod banner;
15pub mod builtins;
16pub mod cache;
17pub mod capture;
18pub mod cli;
19pub mod compiler;
20pub mod dap;
21pub mod datefmt;
22pub mod host;
23pub mod lexer;
24pub mod lsp;
25pub mod module;
26pub mod numfmt;
27pub mod parser;
28pub mod proxy;
29pub mod regexp;
30pub mod repl;
31pub mod rust_ffi;
32pub mod slots;
33pub mod stdlib;
34pub mod tiers;
35pub mod tzif;
36pub mod utf16;
37
38pub use fusevm::Value;
39
40/// Stack reserved for the thread JS runs on ([`run_on_js_stack`]).
41///
42/// A JS call is a Rust recursion (`host::run_user_func_nt` → `run_chunk_on` →
43/// a fresh `fusevm::VM` on the stack), so recursion depth is bounded by the
44/// native stack rather than by a frame counter. On the OS default 8 MiB this
45/// bought only 83 frames in a debug build — measured, `node -e 'function
46/// f(n){if(n<=0)return 0;return 1+f(n-1)} f(84)'` aborted — where node v26.7.0
47/// reaches 9901. Reserving 256 MiB is virtual address space, faulted in only as
48/// deep recursion actually uses it, and `host::stack_exhausted` still turns the
49/// far end into a catchable `RangeError` rather than an abort. The reservation
50/// is capped rather than sized to match node's depth exactly so that a runaway
51/// recursion's peak RSS stays bounded; the resulting depth is documented in
52/// BUGS.md.
53pub const JS_STACK_SIZE: usize = 256 * 1024 * 1024;
54
55/// Run `f` on a thread with [`JS_STACK_SIZE`] of stack, falling back to the
56/// calling thread if the reservation is refused (a `ulimit`ed or memory-capped
57/// environment must still run programs, just at a lower recursion ceiling —
58/// `host::stack_exhausted` measures whatever stack it ends up on).
59///
60/// Takes a plain `fn` pointer, not a closure: `Builder::spawn` consumes what it
61/// is given and does not hand it back on failure, and a `fn` is `Copy`, so the
62/// fallback can still call the same entry point.
63pub fn run_on_js_stack(f: fn() -> std::process::ExitCode) -> std::process::ExitCode {
64 match std::thread::Builder::new()
65 .name("node-js".into())
66 .stack_size(JS_STACK_SIZE)
67 .spawn(f)
68 {
69 // A panic on the JS thread has already written its message to stderr;
70 // re-raising keeps the process dying exactly as it would have without
71 // the hop, rather than turning an abort into a quiet exit code.
72 Ok(h) => h.join().unwrap_or_else(|p| std::panic::resume_unwind(p)),
73 Err(_) => f(),
74 }
75}
76
77/// Compile a source string to a runnable program.
78pub fn compile(src: &str) -> Result<compiler::Program, String> {
79 let stmts = parser::parse(src)?;
80 with_source(compiler::compile(&stmts, false), src)
81}
82
83/// Compile leaving the final top-level expression as the program's completion
84/// value (for `vm.runInThisContext` / `eval`).
85pub fn compile_completion(src: &str) -> Result<compiler::Program, String> {
86 compile_completion_strict(src, false)
87}
88
89/// As [`compile_completion`], with the caller's strictness folded in — what a
90/// direct `eval` inherits.
91pub fn compile_completion_strict(
92 src: &str,
93 caller_strict: bool,
94) -> Result<compiler::Program, String> {
95 let stmts = parser::parse(src)?;
96 with_source(
97 compiler::compile_completion_strict(&stmts, false, caller_strict),
98 src,
99 )
100}
101
102/// Compile with per-statement DAP line markers enabled (`node --dap`).
103pub fn compile_debug(src: &str) -> Result<compiler::Program, String> {
104 let stmts = parser::parse(src)?;
105 with_source(compiler::compile(&stmts, true), src)
106}
107
108/// Attach the text a program was parsed from, which its functions' spans
109/// index (`Function.prototype.toString`).
110pub fn with_source(
111 prog: Result<compiler::Program, String>,
112 src: &str,
113) -> Result<compiler::Program, String> {
114 prog.map(|mut p| {
115 p.source = Some(src.into());
116 p
117 })
118}
119
120/// Rebase a freshly compiled program's func/try ids above those already loaded
121/// on the host, install its functions/tries, and return the (rebased) main
122/// chunk to run.
123pub fn load_merged(mut prog: compiler::Program) -> fusevm::Chunk {
124 let (func_off, try_off) = host::with_host(|h| h.program_offsets());
125 compiler::rebase_program(&mut prog, func_off, try_off);
126 let compiler::Program {
127 main,
128 functions,
129 tries,
130 strict,
131 source,
132 } = prog;
133 let funcs: Vec<host::FuncDef> = functions.into_iter().map(|(_, f)| f).collect();
134 host::with_host(|h| {
135 // Each span-carrying function learns which script its span indexes.
136 let mut funcs = funcs;
137 if let Some(text) = source {
138 let script = h.scripts.len() as u32;
139 h.scripts.push(text);
140 for f in funcs.iter_mut().filter(|f| f.span.1 != 0) {
141 f.script = Some(script);
142 }
143 }
144 h.load_program(funcs, tries);
145 // A strict top level marks the frame it is about to run on, so a
146 // refused write throws there the way it does inside a strict function.
147 // A function's strictness rides in its `FuncDef`; the top level had
148 // nowhere to put it, so the module frame stayed sloppy.
149 if strict {
150 h.set_current_strict();
151 }
152 });
153 main
154}
155
156/// Run an already-compiled program on the current host.
157pub fn run_compiled(prog: compiler::Program) -> Result<Value, String> {
158 host::run_main(load_merged(prog))
159}
160
161/// `process.exitCode` as the program left it, or `None` if it was never set.
162///
163/// The binary reads this after a run completes to pick its own status — Node
164/// exits with `process.exitCode` when the loop drains normally, so a script
165/// that signals failure that way (rather than by throwing or calling
166/// `process.exit`) is reported as a failure rather than as success.
167pub fn exit_code() -> Option<i32> {
168 host::with_host(|h| h.exit_code)
169}
170
171/// Run the `exit` event for a program that died on an uncaught exception, and
172/// report the status to leave with.
173///
174/// Node fires `exit` on this path too, and an uncaught exception FORCES the
175/// code to 1 — overriding any `process.exitCode` the script had already set —
176/// while a code the handler itself assigns still wins. Verified on node
177/// v26.7.0: `process.exitCode = 3; process.on('exit', c => console.log(c));
178/// throw new Error('z')` prints `1` and exits 1, and
179/// `process.on('exit', () => { process.exitCode = 9 }); throw new Error('z')`
180/// exits 9.
181pub fn exit_code_after_failure() -> i32 {
182 host::with_host(|h| h.exit_code = Some(1));
183 let _ = stdlib::process::emit_exit_event(1);
184 host::with_host(|h| h.exit_code).unwrap_or(1)
185}
186
187/// Compile `src` and run it on the LIVE host — no reset, no event-loop drain —
188/// in the GLOBAL scope, returning its completion value.
189///
190/// This is the ONE runtime-source evaluator on this frontend. Every construct
191/// that turns a source string into a running program funnels through here:
192/// the CommonJS module wrapper (`module::compile_wrapper`), `vm.runInThisContext`
193/// / `vm.Script` / `vm.compileFunction`, `new Function` / `Function(...)`
194/// (`builtins::dynamic_function`), and the internal JS factories
195/// (`util.promisify`, `stream/promises`, `stream/consumers`,
196/// `performance.timerify`, `module.builtinModules`). Each of those used to carry
197/// its own `compile_completion` → `load_merged` → `run_chunk_on` triple — seven
198/// copies of the same three lines — and every one of them inherited the same
199/// bug: `run_chunk_on` executes on whatever frame is CURRENT, so nested source
200/// saw the calling function's locals. Measured against node v26.7.0,
201/// `function outer(){ let secret = 1; return require('./m.js'); }` with `m.js` =
202/// `module.exports = typeof secret` is `"undefined"` there and was `"number"`
203/// here; `vm.runInThisContext('typeof loc')` likewise. `run_chunk_in_global_scope`
204/// fixes it once, for all of them.
205pub fn eval_in_global_scope(src: &str) -> Result<Value, String> {
206 let prog = compile_completion(src)?;
207 let chunk = load_merged(prog);
208 host::run_chunk_in_global_scope(chunk)
209}
210
211/// Transparent bytecode cache: return the cached compiled `Program` for `src`
212/// (skipping lex/parse/lower entirely), else compile it, store it in the
213/// `~/.node-js/scripts.rkyv` shard, and return it. This runs on EVERY ordinary
214/// `node foo.js` / `node -e` invocation, so scripts are rkyv-cached automatically
215/// — not only under `--build`. Set `NODE_JS_TRACE=1` to log hit/miss to stderr
216/// (silent otherwise; normal runs print nothing).
217pub fn compile_or_load(src: &str) -> Result<compiler::Program, String> {
218 if let Some(prog) = cache::load(src) {
219 if std::env::var_os("NODE_JS_TRACE").is_some() {
220 eprintln!(
221 "node-js: cache HIT ({} ops, {} functions) — skipped lex/parse/lower",
222 prog.main.ops.len(),
223 prog.functions.len()
224 );
225 }
226 return Ok(prog);
227 }
228 let prog = compile(src)?;
229 let _ = cache::store(src, &prog);
230 if std::env::var_os("NODE_JS_TRACE").is_some() {
231 eprintln!(
232 "node-js: cache MISS — compiled + stored ({} ops, {} functions)",
233 prog.main.ops.len(),
234 prog.functions.len()
235 );
236 }
237 Ok(prog)
238}
239
240/// Parse/load, compile, and run a JS source string on a fresh host (rkyv-cached).
241///
242/// This is the `node -e` entry point; [`eval_str_from`] names the other
243/// source-on-the-command-line one, which reports a different `__filename`.
244pub fn eval_str(src: &str) -> Result<Value, String> {
245 eval_str_from(src, "[eval]")
246}
247
248/// [`eval_str`] with the entry-point NAME node reports for it: `[eval]` for
249/// `-e`, `[stdin]` for source piped in. The two are observably different —
250/// `__filename`, `module.id` and a stack frame's file all carry it.
251pub fn eval_str_from(src: &str, origin: &str) -> Result<Value, String> {
252 host::reset_host();
253 // `node -e` resolves top-level `require` from the current working directory.
254 if let Ok(cwd) = std::env::current_dir() {
255 module::set_entry_dir(cwd);
256 }
257 module::install_entry_globals(origin);
258 run_compiled(compile_or_load(src)?)
259}
260
261/// `node -p <src>`: evaluate as `-e` does, then write the program's COMPLETION
262/// value through the `console.log` formatter, exactly as Node's
263/// `--print` does (`node -p '[1,2]'` prints `[ 1, 2 ]`, `node -p '"s"'` prints
264/// the bare `s`). Side effects still happen, so `node -p 'console.log("x")'`
265/// prints `x` and then `undefined`.
266///
267/// Deliberately compiled with [`compile_completion`] rather than through the
268/// source-keyed rkyv cache: the cache is keyed by source TEXT alone, so a
269/// `-p`-shaped chunk and an `-e`-shaped chunk for the same string would alias.
270pub fn eval_str_print(src: &str, origin: &str) -> Result<(), String> {
271 host::reset_host();
272 if let Ok(cwd) = std::env::current_dir() {
273 module::set_entry_dir(cwd);
274 }
275 module::install_entry_globals(origin);
276 let value = run_compiled(compile_completion(src)?)?;
277 // One argument means no directive processing at all (node returns a lone
278 // argument as-is), so this call has nothing that can throw.
279 let line = stdlib::util::format(std::slice::from_ref(&value))?;
280 host::with_host(|h| h.write_out(&format!("{line}\n"), false));
281 Ok(())
282}
283
284/// Run a JS source string on a fresh host with `globals` bound and the
285/// program's output captured in-process, returning the program's outcome
286/// alongside everything it wrote.
287///
288/// This is the entry point for an embedder rather than for the `node` binary,
289/// and it exists because [`eval_str`] cannot serve one: it resets the host
290/// first, which wipes any global installed beforehand, and it lets
291/// `console.log` reach the real stdout, which corrupts a host that owns the
292/// terminal. Both are fixed here — the globals are seeded *after* the reset,
293/// and every write the program makes lands in the returned string.
294///
295/// The outcome and the output are returned separately (rather than the output
296/// only on success) because a program that prints and *then* throws produced
297/// both, and an embedder generally wants to show both.
298///
299/// Globals are given as text and interned as real JS strings here. They are
300/// deliberately *not* `Value`: strings live on this host's heap as
301/// `JsObj::Str`, so a `Value::Str` a caller builds is at best coerced and at
302/// worst method-less. Handing the host text and letting it intern removes that
303/// trap, and matches the sibling runtimes' embedder entry points.
304///
305/// ```no_run
306/// let (result, out) = nodejs::eval_str_captured("console.log(stdin.toUpperCase())", &[("stdin", "hi")]);
307/// assert!(result.is_ok());
308/// assert_eq!(out, "HI\n");
309/// ```
310pub fn eval_str_captured(src: &str, globals: &[(&str, &str)]) -> (Result<Value, String>, String) {
311 host::reset_host();
312 if let Ok(cwd) = std::env::current_dir() {
313 module::set_entry_dir(cwd);
314 }
315 host::with_host(|h| {
316 for (name, text) in globals {
317 let value = h.new_str(*text);
318 h.set_global(name, value);
319 }
320 h.begin_capture();
321 });
322 let result = compile_or_load(src).and_then(run_compiled);
323 let output = host::with_host(|h| h.end_capture());
324 (result, output)
325}
326
327/// Read and run a `.js` file (transparently rkyv-cached — see `compile_or_load`).
328pub fn eval_file(path: &str) -> Result<Value, String> {
329 let src = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
330 host::reset_host();
331 // Top-level `require` in `node app.js` resolves from the entry file's dir.
332 let dir = std::path::Path::new(path)
333 .parent()
334 .filter(|p| !p.as_os_str().is_empty())
335 .map(std::path::Path::to_path_buf)
336 .or_else(|| std::env::current_dir().ok())
337 .unwrap_or_default();
338 let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
339 module::set_entry_dir(dir);
340 // `__filename` is the entry script's REALPATH, not the path that was typed:
341 // Node's loader calls `toRealPath` on the main module, so a script reached
342 // through a symlinked directory reports the link TARGET. (`process.argv[1]`
343 // is the opposite — it keeps the spelling; both measured on node v26.7.0.)
344 let entry = std::fs::canonicalize(path)
345 .map(|p| p.to_string_lossy().into_owned())
346 .unwrap_or_else(|_| stdlib::path::resolve_one(path));
347 module::install_entry_globals(&entry);
348 run_compiled(compile_or_load(&src)?)
349}
350
351/// Read and run a `.js` file under the DAP debugger.
352pub fn eval_file_debug(path: &str) -> Result<Value, String> {
353 let src = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
354 let prog = compile_debug(&src)?;
355 host::reset_host();
356 host::set_debug_mode(true);
357 let r = run_compiled(prog);
358 host::set_debug_mode(false);
359 r
360}