The Node runtime
How `node script.js` runs inside the VM — module loading, the ESM→CJS transform, the completion model, process.exit semantics, and interactive stdin.
The node command is what makes real JavaScript tooling run in a box. It's not
an emulator — it loads your script as JavaScript in the host engine, but wraps it
in a Node-shaped environment: require, module, process, Buffer, timers, a
global object, and the node-compat modules.
Loading a module
Each module's source is wrapped in a function that receives Node's locals and
executed via new Function, with a //# sourceURL= so stack traces point at the
file:
(function (exports, require, module, __filename, __dirname,
console, process, Buffer, setTimeout, …, global,
import.meta.url, import.meta, …, fetch) {
/* your module source */
})- Resolution —
require()resolves built-in shims first, then relative VFS files (with extension andindexresolution), thennode_moduleswalking up the tree, honoringpackage.jsonmain/exports(including wildcard subpath exports). - Cache — a per-run module cache handles circular dependencies.
module.exportsis a live getter/setter, so a module that reassignsmodule.exports = Xmid-cycle (common insemver-style mutually-recursive modules) is seen correctly by others.
ESM without a bundler
Node scripts and packages ship a mix of ESM and CommonJS. Lifo detects ESM
(import/export, "type": "module", .mjs) and transforms it to CommonJS on
the fly (transformEsmToCjs) so both run through the same loader — no build step.
import.meta.url and dynamic import() (with CJS .default interop) are
provided.
The completion model
A CLI's synchronous body finishing doesn't mean it's done — create-expo-app,
for instance, returns from its entry immediately and then fetches, prompts,
scaffolds, and installs asynchronously. A native Node process stays alive while
the event loop has work; the VM has no such loop to observe, so the runtime
approximates one.
After the entry runs, the node command keeps the run alive while meaningful
work is happening, and ends once nothing has moved for a short grace window.
"Meaningful work" is tracked on wall-clock time (not tick counters, which flicker):
- an in-flight
fetch(pendingAsync), - a running child process (
pendingChild— e.g. an in-VMnpm install), - a module still loading (the cache is growing),
- fresh stdin input (the user answering a prompt).
For ESM entries there's also a mainResolved signal (the top-level module
promise settled), which tightens the grace. The result: a finished CLI returns to
the shell prompt on its own, and a long, quiet npm install isn't mistaken for
an idle run.
Gotcha
This model is why timing bugs here are subtle. A CLI that leaves stdin in raw
mode with a stray keypress listener makes isActive()/rawMode flicker; a
consecutive-tick counter would never fire. Tracking real work on wall-clock
time is what makes it robust.
process.exit semantics
Real process.exit() terminates the process immediately — nothing after it runs.
The VM can't halt a JavaScript call stack from the outside, so it does the
faithful next-best thing:
- While the entry is still running synchronously,
exit()throws aProcessExitErrorto unwind it. - Once the run is event-driven (a server is up, or the entry settled),
exit()returns instead of throwing — so a handler like Expo's Ctrl-C path, which callsprocess.exit()at the end of atry, completes cleanly instead of hitting itscatch. - Because returning lets code after
exit()keep running, all output is silenced afterprocess.exit()— matching "the process is dead, nothing prints." This is what keeps aCommandError's message from being re-printed with an internal stack trace.
Interactive stdin
On an interactive terminal, the run gets a single shared stdin backed by the
shell's terminal input. process.stdin supports setRawMode, readline and
keypress events, and process.stdout/stderr report isTTY and implement the
tty.WriteStream cursor API (emitting real ANSI), so spinners, prompts, and
full-screen menus render. All modules in a run share one process.stdin so
keypresses aren't split between competing readers.
The global object
Modules run with a Node-like global (makeNodeGlobal) — Object.create over
the real globalThis with Node's shims as own properties and global.global
self-referencing — so bare-identifier reads (Math, String) and
hasOwnProperty checks that libraries rely on behave as they do in Node.