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 */
})
  • Resolutionrequire() resolves built-in shims first, then relative VFS files (with extension and index resolution), then node_modules walking up the tree, honoring package.json main/exports (including wildcard subpath exports).
  • Cache — a per-run module cache handles circular dependencies. module.exports is a live getter/setter, so a module that reassigns module.exports = X mid-cycle (common in semver-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 donecreate-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-VM npm 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 a ProcessExitError to 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 calls process.exit() at the end of a try, completes cleanly instead of hitting its catch.
  • Because returning lets code after exit() keep running, all output is silenced after process.exit() — matching "the process is dead, nothing prints." This is what keeps a CommandError'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.