Module system
The machinery between an import specifier string and an evaluated module with live exports. This page covers how a file is classified as ESM or CommonJS, the ESM module-record graph and live-binding cells, CommonJS interop with static export detection, the host bridge for built-ins like node:fs, top-level await, dynamic import, and the seam with the package manager.
The module system is the machinery between a specifier string (import x from "./y.js") and an evaluated module with live exports. This page covers classification, the ESM graph, CommonJS interop, the host bridge for built-ins, top-level await, and the resolution seams. It assumes the concepts-level Modules and packages.
Classification: which world is this file in?
JavaScript has two module systems with different semantics, and every loaded file must be assigned to exactly one before anything else can happen. Cruft classifies from the resolved URL and the nearest package.json marker, the way Node does:
| Signal | Classification |
|---|---|
.mjs | ESM |
.cjs | CJS |
.js under "type": "module" | ESM |
.js under "type": "commonjs", or no marker | CJS |
| markerless loose file reached by an ESM graph edge | may be treated as ESM |
The classification is consequential: it decides whether the file enters the spec's module-graph evaluation or the CommonJS wrapper path, which in turn decides its scoping (import.meta vs. __dirname), its export semantics (live bindings vs. a snapshot value), and its timing (graph evaluation vs. synchronous require).
The ESM graph
Module records
Every ES module gets a module record, keyed by resolved URL, in a process-wide module cache. The record carries the compiled module, its status (fetched → linked → evaluating → evaluated), its namespace object, its local export cells, its async static dependencies, and, importantly, any evaluation error, so a module that threw once reports the same error to every later importer instead of re-evaluating.
Keying by resolved URL is what gives ESM its singleton property: two imports that resolve to the same URL get the same record, the same evaluation, the same namespace.
Live bindings: exports are cells
The spec's most misunderstood ESM feature is that exports are live bindings, not copied values. Cruft implements this directly: a module's local exports are backed by shared cells, and an importer's binding reads through the cell. When the exporting module later reassigns the variable, the importer observes the new value:
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from "./counter.mjs";
increment();
console.log(count); // 1 — the binding is live, not a copy of 0
Namespace objects (import * as ns) read through the same cells, so ns.count also observes mutation.
Graph evaluation order
Static import requests are walked in source order; dependencies evaluate before the modules that request them. Re-exports and namespace exports resolve through the module cache, and can produce a deferred result when a transitive module hasn't entered the cache yet, which is how cycles are tolerated: a cycle participant can observe a not-yet-initialized binding (and gets the spec's TDZ error if it reads too early) rather than deadlocking the graph.
CommonJS interop
The wrapper
A CJS file evaluates inside the classic wrapper, exports, module, and require in scope, and the final module.exports value is stored on the module record. require is synchronous: the requested module evaluates to completion during the call, the historical semantics the CJS ecosystem depends on.
ESM importing CJS: static export detection
When an ES module imports a CJS module, there is no spec namespace to import from, CJS has one export value, decided at runtime. Cruft does what Node does, with runtime support of its own: it statically scans the CJS source for recognizable exports.name = … and module.exports.name = … writes and projects those names as named imports:
// lib.cjs
exports.greet = (n) => "hi " + n;
// main.mjs — named import from CJS works:
import { greet } from "./lib.cjs";
The detection is deliberately a heuristic over recognizable shapes: dynamic export patterns fall back to the default-import mapping (import lib from "./lib.cjs", then lib.greet). This matches the ecosystem's expectations because Node's own cjs-module-lexer sets them.
Built-ins: the host bridge
The runtime does not hard-code node:fs. Instead the engine exposes host hooks: a small interface the embedding host supplies for built-in resolution, namespace finalization, IO polling, and non-JavaScript source loading. The Cruft host implements those hooks with its built-in resolver, which maps node:* and bare built-in specifiers onto the module objects the host installed at startup.
The division of labor: the host owns what node:fs contains; the engine owns that it is a module, its record, its graph status, its namespace semantics. This keeps the engine embeddable and spec-pure while the Node surface stays a host concern, and it is the seam a future non-Node compatibility surface would plug into.
Top-level await
A module using top-level await suspends its module frame and resumes via the job queue (the same rooted-microtask machinery as any async function, see the jobs subsystem when written). The graph handles the async cases: a module waits for its async static dependencies, enqueues its body-start job when they're ready, and on failure propagates the error both to dependent modules and to any import() waiters. The "rooted" in rooted microtasks matters: a suspended module frame is registered with the GC so a long-pending await can't have its state collected out from under it.
Dynamic import
import() is available in both worlds, including inside CJS, where it is the one bridge to async ESM loading. It resolves through the same cache and graph as static imports; the promise settles with the namespace (or the recorded evaluation error).
TypeScript at the loader
.ts/.mts/.cts paths dispatch through the TypeScript resolver, which erases types and hands plain JavaScript to the same classification and evaluation machinery above (.mts → ESM, .cts → CJS, .ts by package marker). The module system proper never sees TypeScript; erasure happened at the loading seam.
The package seam
The module loader begins where the package manager ends: the loader consumes the installed tree and package graph that cruft install materialized. The two systems are deliberately separated, install/lockfile/store semantics on one side, resolution/evaluation semantics on the other, with the source path and package graph as the handoff. Node's package-resolution algorithm (node_modules walking, exports maps, extension resolution) is implemented as a defined compatibility-exception surface over Cruft's own resolution rather than baked into the engine, consistent with the spec-first stance described in Node compatibility.