Node compatibility layer

Cruft supplies Node's standard library, node:fs, node:http, and the rest, in its own Rust. Each module is a thin node:* adapter over a cruft:* primitive that the capability system hooks. This page covers the resolver, that two-layer split, how complete each module is, and the native-addon boundary.

Node's standard library is baked into the binary that runs your code: node:fs, node:http, and the rest are C++ and JavaScript compiled into the same process as V8. Cruft is a different engine running that same code, so it supplies that surface itself. Every node:* and bare specifier resolves to a host module in Cruft's own Rust, and each is built in two layers: a cruft:* primitive that names the raw host capability, and a node:* adapter that wraps it in Node's exact idiom. This page walks the resolver, that two-layer split, the depth tiers each module is held to, the compatibility exceptions where Node and ECMA-262 disagree, and the native-addon boundary. It builds on the concepts-level Node compatibility and the module system.

The resolver

All Node-flavored imports, node:fs, bare fs, node:fs/promises, and the rest, resolve through a single host surface: the built-in module resolver installed at startup. It maps each specifier family onto module objects the host constructed when the realm was assembled. The engine's module graph sees ordinary modules; the resolver decides what they contain.

The surface divides into families:

FamilyRepresentative specifiers
Filesystemfs, fs/promises
Path and OSpath (+ posix/win32), os
Processprocess, child_process
Networkinghttp, https, http2, net, tls, dns, dgram
Streams and eventsstream (+ web/promises/consumers), events, timers
Utilities and binaryassert, util, buffer, querystring, zlib, punycode
Context and diagnosticsvm, console, perf_hooks, diagnostics_channel, v8, inspector
Compatibility stubstty, readline, async_hooks, worker_threads, cluster, repl, …

Both the node:-prefixed and bare forms resolve for every family, in ESM and CJS alike, because packages in the wild use every combination.

Primitives and adapters

For most host families there are two module shapes over one implementation:

  • The cruft:* primitive names the minimal host capability with a clean contract: what reading a file, or resolving a path, fundamentally is.
  • The node:* adapter wraps that primitive in Node's exact idiom: callback-last signatures, errno-style error objects with code properties, the historical quirks packages actually test for.

Behavior lives in the primitive; shape lives in the adapter. The adapter layer is deliberately thin, and three consequences follow:

  • A Node quirk fix touches the adapter without contaminating the primitive.
  • The capability system hooks the primitive. Every filesystem entry routes through one filesystem capability check, so sealed and audit modes cover the Node surface automatically; there is no separate Node hole to seal.
  • A future non-Node compatibility target, or a script written against the primitive surface, reuses the primitives unchanged.

Depth tiers

Modules sit at one of three depths, and the discipline is that each module's depth is stated, per module, on its owning page:

  1. Focused: substantive, package-serving implementations.
  2. Partial: present and relied on for common paths, with named gaps.
  3. Import-time stubs: exist so require("tty") and feature detection succeed in package initialization code that would otherwise crash before reaching the parts of the package that matter.

The focused modules, with fs covering promises, glob, watch, fd-level I/O, and real Uint8Array binary reads, child_process at its sync lanes, and http/https as both server and client:

Focused
fspathosprocesschild_processhttphttpsnettlsdnsdgramstreameventsbufferutilzlibcryptovmassert

Anything below focused follows one rule: fail explicitly at the method boundary. A stub that pretends an operation completed turns an explicit error into silent corruption downstream, and that is the standard every partial surface is held to. One place it currently falls short: the node:zlib zstd functions (zstdCompressSync and friends) are present as named exports but throw when called rather than compressing, so feature detection that only checks typeof is fooled. The sync child_process lanes, by contrast, honor env, input, and timeout correctly.

Partiality is often scoped to a usage pattern rather than to a whole module. node:stream, for example, is focused for the common readable-drain pattern (Readable.from() delivering to data then end listeners, in Node's registration order) because that is the pattern real packages overwhelmingly use. Describing it accurately means naming which patterns are covered, so the module is neither "done" nor "stubbed".

Compatibility exceptions to the spec

Cruft's engine is spec-canonical, so Node-isms that contradict ECMA-262 cannot be implemented in place. They are handled as named compatibility exceptions at the host layer:

  • Legacy Function.prototype.caller/arguments. The spec says these poison accessors throw (%ThrowTypeError%); V8 and Node carry a legacy behavior. Cruft ships the spec-pure core, passing the test262 rows V8 deliberately fails, and models the legacy accessor as an opt-in compatibility exception for packages that need it.
  • Node package resolution. node_modules walking, exports maps, and extension inference are an algorithm Node defines, not the language. Cruft implements it as a defined compatibility exception over its own module resolution.
  • Node-observable formatting differences (number and date edge formatting and similar) are classified as compatibility signals and tracked, not silently absorbed into the engine.

The pattern is uniform: the pure behavior is the default, and the compatibility exception is a bounded, documented object, never an unmarked patch inside the engine. That is what keeps the conformance story (99.9% zero-skip test262) and the compatibility story simultaneously true.

Native addons: the N-API boundary

Packages that load .node native addons cross a genuinely different boundary, and the layer gives it its own architecture. The full treatment is on the native addons page; the shape of it:

  • Handles into a runtime-owned table. napi_value is an index into a runtime-owned handle table, never a raw pointer into the heap. JavaScript values stay owned and traced by the runtime; native code holds opaque tickets. The GC and identity contracts from Cruft Core survive foreign code by construction.
  • The bridge is Cruft's own. The C-ABI entry points, and the machinery behind them, the loader, environment, handle table, callback trampoline, and reference and scope machinery, are Cruft's own Rust.
  • The trust boundary. A loaded addon is arbitrary machine code. The bridge preserves value safety, it cannot corrupt the JS heap through the API, but no bridge makes foreign code subject to the capability system. Addon loading is a host-authority decision, and the sandboxing story treats it as such rather than claiming containment it cannot deliver.

Limitations

  • The zstd exports throw. node:zlib's zstdCompressSync and its siblings are present as named exports but throw when called, so typeof-only feature detection is misled.
  • Stubs are import-time only. Import-time stubs exist to survive package initialization; they do not implement the operations behind them, and calling into one fails at the method boundary rather than pretending to succeed.
  • Native code is not contained after load. The N-API bridge protects the engine's object graph, but a loaded .node addon runs arbitrary machine code outside the capability system. Sealed modes refuse to load addons; the default mode loads and trusts them.