Skip to content

runtime: Node-API (N-API) host for prebuilt napi addons — tracker #8523

Description

@proggeramlug

Why

Perry's model for npm packages that ship a Node native addon is a hand-written Rust facade per package (perry-ext-better-sqlite3, perry-ext-sharp, perry-ext-bcrypt, perry-ext-argon2, …, served through well_known_bindings.toml; #4961 made everything else a hard compile error). That model is right for high-value packages — a facade writes straight into Perry's shapes and can beat Node — but it does not scale to the long tail, and every facade carries compat = "partial" until audited (#5716). Meanwhile the modern JS toolchain ships as Node-API addons built with napi-rs / node-addon-api (@swc/core, oxc-parser, rolldown, lightningcss, @tailwindcss/oxide, @napi-rs/*, @parcel/watcher, better-sqlite3, sharp, fsevents, …).

A Node-API host — Perry's executable exporting the napi_* C surface so a prebuilt .node addon can be dlopened and run unmodified — is the structural answer: zero per-package work, faithful by construction, and no JS engine involved (it satisfies #8506's "no runtime JavaScript engine" requirement: the addon is native code, just not LTO'd with the rest of the binary).

This is a platform bet, not a blocker for any current target. In particular it is not how #8513 (@parcel/watcher) gets done — see below.

What this is not for

  • runtime: provide a native @parcel/watcher compatibility facade for full OpenCode #8513 / the OpenCode watcher. For a file watcher the Node-API boundary cost is noise (a handful of C calls per event batch); the performance story there is OS events vs Perry's current 25 ms polling fs.watch, and that is identical under either approach. Size-wise the facade costs ~0.2 MB and only when linked; the Node-API route costs the exported host (~0.3–0.6 MB) plus a 0.3–0.5 MB sidecar dylib per platform (@parcel/watcher-darwin-arm64 318 KB, linux-x64-glibc 525 KB, win32-x64 533 KB), and loses single-static-binary and every cross-compile target. runtime: provide a native @parcel/watcher compatibility facade for full OpenCode #8513 ships as a facade; the real watcher.node becomes this tracker's differential-test fixture instead (Stage 4).
  • Replacing existing facades. Node-API must never become the default route for a package that has a perry-ext-* binding — that would be a size/distribution regression for everyone.
  • NAN / V8 C++ addons. Impossible without V8. Detected via the existing markers in crates/perry/src/commands/compile/collect_modules/native_addon.rs and rejected with a diagnostic naming the reason.
  • Addons that import uv_* directly. Node exports libuv; Perry has no libuv. Reject at load with an error naming the unresolved symbol; a minimal shim is a separate decision.
  • Mobile / embedded targets (iOS, tvOS, watchOS, visionOS, Android, HarmonyOS). npm prebuilts don't exist for them and loose-dylib loading is not an option there. Desktop + server only.

What already exists (don't rebuild)

need have
raw data pointers into buffers (napi_get_buffer_info, napi_get_typedarray_info) under a moving GC buffers / typed arrays are non-moving, born tenured — pinning contract in crates/perry-runtime/src/bun_ffi/mod.rs
external backing stores (napi_create_external_arraybuffer) toArrayBuffer / toBuffer in bun_ffi/memory.rs
dlopen + symbol lookup bun_ffi/dlopen.rs
threadsafe functions (napi_call_threadsafe_function from any thread) perry-ffi event pump: notify_main_thread + per-module process_pending tick (crates/perry-ffi/src/event_pump.rs)
napi_create_async_work / napi_queue_async_work perry_ffi::spawn_blocking + main-thread completion
strong references (napi_create_reference, refcount > 0) gc_register_mutable_root_scanner + handle registry (crates/perry-ffi/src/handle.rs)
the C type surface on the addon side crates/perry-runtime/src/ohos_napi.rs (Perry as a HarmonyOS napi addon — consumer, not host, but the structs are there)

Prerequisite

#6562 stage 3 (JSCallback: native→JS calls + exception mapping across extern "C"). Every napi_call_function, property getter/setter trampoline, finalizer and TSFN callback is that primitive. Building it twice would be a mistake, and the unwind regime was expensive to learn (#8479, #8480, #8488). Sequence this tracker after it.

Stages

Stage 0 — design note (docs/src/internals/node-api-host.md)

  • napi_value representation: an index into a per-napi_env handle-scope stack that the collector scans and rewrites (seed: crates/perry-ffi/src/transient_roots.rs). Never a raw heap address — the Silent wrong answers: hot relational comparison with an object operand returns false after ~726 iterations (default GC config) #8393 class (side table keyed by raw address goes stale after the first copying minor) applies to every table this host keeps.
  • napi_ref: strong = rooted handle; weak = needs a collector hook that clears the slot on sweep/evacuation. Decide whether weak refs are v1.
  • napi_wrap / napi_add_finalizer / externals: needs a finalization hook from the collector for native-owned payloads. Decide the shape (per-object finalizer table swept after mark, finalizers run on the main thread at a safepoint — Node never runs them inside GC).
  • Pending-exception model: status codes, napi_is_exception_pending / napi_get_and_clear_last_exception; every entry point catches a Perry throw and converts it — nothing unwinds across the addon.
  • napi_define_class + napi_new_instance + napi_instanceof: native-defined constructors must register in the class-ID chain (known-weak area: native base-class subclassing).
  • Module init: napi_module_register (legacy) and the napi_register_module_v1 / NAPI_MODULE_INIT export; napi_get_version → declare the NAPI_VERSION target; napi_add_env_cleanup_hook on exit.
  • Threading rules: which napi_* are legal off-thread (only TSFN call/acquire/release) and how the host detects misuse.
  • Surface inventory: every js_native_api.h + node_api.h entry marked v1 / later / never, with the reason.

Stage 1 — host core

  • handle scopes (open/close/escapable); value create/get/typeof/coerce for primitives, objects, arrays, strings (utf8/utf16/latin1), bigint, symbol, date
  • property access/definition incl. napi_define_properties descriptors, getters/setters, napi_get_property_names
  • functions: napi_create_function, napi_call_function, napi_get_cb_info, napi_get_new_target
  • errors: create/throw (error, type, range, syntax), pending-exception state, napi_fatal_error
  • references, wrap/unwrap/remove_wrap, externals, finalizers (per Stage 0 decision)
  • buffers, typed arrays, arraybuffers (incl. external + detach), dataview
  • promises, async work, threadsafe functions; napi_get_uv_event_loop → error status (documented: no libuv)
  • napi_adjust_external_memory, instance data, cleanup hooks, the node_api_* string/symbol extras napi-rs uses

Stage 2 — loader and linking

  • process.dlopen(module, filename) (today js_process_dlopen returns undefined, node:process — implement process.dlopen #1409) and require("…/x.node") → dlopen, locate the init export, build module.exports
  • export the napi symbol set from the executable only when an addon is in the graph: macOS currently links -Wl,-no_exported_symbols on purpose (dyld launch cost, crates/perry/src/commands/compile/link/build_and_run.rs:256-268) — switch to an -exported_symbols_list of the napi set for those builds; Linux --export-dynamic-symbol=napi_*; Windows export table so the addon's win_delay_load_hook resolves its node.exe imports against the host
  • native_addon.rs reroutes: a package with a .node is no longer a hard error when it is listed in the opt-in below; NAN/V8/uv_* markers stay hard errors with a diagnostic naming the reason

Stage 3 — distribution and policy

  • opt-in only: perry.nativeAddons: ["@swc/core", …] in package.json; never auto-routed, never wins over a well_known_bindings.toml row; the PERRY_REQUIRE_FAITHFUL_BINDINGS interaction written down
  • pick ONE shipping model and document it: sidecar .node next to the executable vs embed-and-extract at first run. Cover macOS codesign/notarization of a separately loaded Mach-O, read-only install dirs, quarantine, and cross-compiles fetching the target's platform package
  • build-cache fingerprint includes the addon file hash and the policy

Stage 4 — gate (must be able to go red, must prove the subject ran)

Risks

Related

#6562 (prerequisite), #8513 (facade; fixture for Stage 4), #4961 (current hard error + detection), #5716 (facade governance), #8506 (OpenCode tracker — not a blocker), #1409 (process.dlopen stub), #8393 (raw-address keyed tables).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew capability or improvementparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions