Native addons (N-API)

Cruft runs compiled .node npm addons (better-sqlite3, sharp, bcrypt) by supplying N-API, the C interface they were built against, on its own engine. Native handles are table indices rather than heap pointers, so the garbage collector stays in charge. A loaded addon is trusted native code; sealed modes refuse to load one.

Some npm packages ship compiled machine code: a .node file built in C, C++, or Rust against Node's native-addon interface. better-sqlite3, sharp, bcrypt, the @napi-rs family, and aws-crt are all in this category, reaching for native speed or binding to an existing C library. Running them on Cruft means supplying the interface they were compiled against, N-API, on an engine that is not V8.

Node implements N-API over V8. Cruft has no V8, so it implements the same C interface over its own object model, in Cruft's own Rust. The goal is that an addon compiled for Node loads and runs unmodified. This page covers how that works and the one place Cruft's isolation story has a hard limit.

Alpha (0.0.10). Cruft implements roughly 145 of the N-API entry points and loads real prebuilt addons on Unix. A loaded .node addon is arbitrary native code running in your process. Do not load one you do not trust; sealed modes refuse to load native addons for exactly this reason.

What N-API is

N-API is a stable C interface between an addon and the runtime. The addon calls functions like napi_create_string_utf8 and napi_get_property, and the runtime supplies them. The addon never touches a JavaScript value directly; it holds an opaque napi_value and passes it back to the runtime for anything it wants to do. Stability is the point: because the addon depends on the interface and not on the engine behind it, a single compiled binary keeps working across Node versions.

That same indirection is what lets a different engine supply the interface. As long as Cruft answers the calls with the semantics N-API specifies, a binary built for Node cannot tell that a different object model is on the other side.

Handles are tickets, not pointers

The load-bearing design decision is how a napi_value relates to a JavaScript value. It is not a pointer into the heap. It is an index into a table the runtime keeps for that addon: native code holds a ticket, and to read or change the value it hands the ticket back and Cruft looks it up.

Two things follow, and both matter to a reader deciding whether native addons are safe to run.

  • The garbage collector stays in charge. JavaScript values remain owned and traced by Trash Panda, Cruft's collector. Every value an addon is currently holding, through the handle table, a longer-lived reference, or a pending exception, is enrolled as a root, so it is not collected out from under the native code. The collector never has to understand C pointers, because none point into the heap.
  • The object graph cannot be corrupted through the interface. A ticket used after its scope has closed, or a ticket an addon simply invents by passing an arbitrary integer, resolves to a bounds-checked table miss. It comes back as an error code or undefined, not a use-after-free or a type confusion.

Handle scopes bound how long a ticket stays valid, the stack of napi_open_handle_scope/napi_close_handle_scope calls an addon makes. A reference (napi_ref) keeps a value alive past its scope, with a reference count, for state an addon holds across calls.

What Cruft implements

The surface is broad. Roughly 145 of the napi_* entry points are present, the large majority fully functional, with a few stubs noted under Limitations.

Values
create and read numbersstrings (utf8latin1utf16)symbolsbooleans
Objects
propertiesdefine_propertiesproperty namestype checkscoercion
Errors
throwpending exceptionextended error info
Lifetime
handle scopesescapable scopesreferences with refcounts
Binary
ArrayBuffertyped arraysDataViewBuffersexternal values
Values, harder
BigInt (both directions)DatePromisewrapped native objectsclasses
Concurrency
threadsafe functionsasync work

Two of these carry real weight for packages that do more than wrap a pure function:

  • Threadsafe functions let native code call back into JavaScript from a thread other than the one running the engine, which a raw callback cannot do safely. Cruft implements them with the per-consumer reference counting the interface specifies.
  • Async work runs a blocking native task off the main thread and delivers its result back to JavaScript. Cruft runs the work on a real thread and hands the completion to the engine between job turns, so the main thread is never blocked.

Cruft reports process.versions.napi as "9", which is the value prebuilt-addon selectors (node-gyp-build, prebuild-install, @napi-rs) read to choose which binary to install.

Loading a .node file

.node is a first-class module extension. Whether it arrives through require("./addon.node"), import * as m from "./addon.node", or a dynamic import(), resolution takes the native path before any source text is read:

  1. The runtime opens the shared library.
  2. The addon's registration entry point runs, receiving a fresh environment and an exports object it fills in through the interface.
  3. That exports object is cached and returned as the module's namespace. For a native module, the exports object is the whole namespace.

The library then stays resident for the life of the process, because its function pointers must stay valid. Loading is Unix-only today; a Windows loader is not built yet, so .node addons do not load on Windows.

The trust boundary

Native addons are the one place Cruft's isolation has a hard limit, and it is worth being exact about where the limit falls.

A loaded .node addon is arbitrary machine code in your process. Once the library is mapped and its entry point has run, it can read any memory and make any syscall directly. Those syscalls do not pass through Cruft's capability layer, and native code cannot be placed inside a Compartment, because it reaches the operating system directly, outside any JavaScript realm. The handle table protects the engine's object graph from corruption through the interface; it does nothing about what native code does around the interface.

The decision to load, however, is itself a capability, and this is where Cruft draws the line it can enforce:

  • Under --sealed or --sealed-deps, a .node addon is refused. It does not load at all, with a message that native addons execute arbitrary host code and must be run outside sealed modes.
  • Under --audit, it loads and the load is recorded: opening the library shows up in the audit log as a native-addon load of that path.
  • Under the default mode, it loads silently.

So the guarantee sits at the load gate. Sealed modes keep native code out of the process entirely, and the audit log makes the grant visible. What Cruft cannot do is contain an addon after it has loaded in a permissive mode. In practice, treat any dependency that ships a .node file as trusted native code, a decision made when you install it. If your threat model includes untrusted dependencies, run under --sealed-deps, which refuses native addons and boxes the I/O of your pure-JavaScript dependencies, or do not install the package.

Limitations

  • Unix only. There is no Windows loader yet, so .node addons do not load on Windows.
  • External finalizers do not run. napi_create_external, napi_create_external_buffer, and napi_add_finalizer accept a finalize callback but do not call it, so native state attached that way is leaked. The napi_wrap finalizer, which frees native state tied to a JavaScript object, does run. An addon that relies on external finalizers to release resources should be treated as unsupported for now.
  • Opaque handles are the addon's responsibility. A napi_ref, and the threadsafe and async handles, is a raw box. A double-free or use-after-free by the addon is a genuine crash. This is the same opaque-pointer contract Node itself has, not a weaker one.
  • A few functions are stubs. napi_object_freeze and napi_object_seal currently do nothing. Individual functions or addon-specific idioms may remain incomplete.
  • The version query disagrees with itself by one. process.versions.napi reports 9 for build selection, while the C-level napi_get_version returns 8.
  • The safety property is reasoned, not battle-proven. The handle design is safe by construction and the rooting is exercised, but no test yet loads a real addon and drives stale-handle, forged-handle, or double-free misuse.
  • Per-package compatibility varies. Whether a specific addon works depends on that package and the entry points it uses. Verify the one you depend on rather than assuming the whole ecosystem loads.