Repository navigation
runtime: Node-API (N-API) host for prebuilt napi addons — tracker #8523
Description
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-arm64318 KB,linux-x64-glibc525 KB,win32-x64533 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 realwatcher.nodebecomes 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.rsand 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_valuerepresentation: an index into a per-napi_envhandle-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 thenapi_register_module_v1/NAPI_MODULE_INITexport;napi_get_version→ declare the NAPI_VERSION target;napi_add_env_cleanup_hookon 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.hentry 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_propertiesdescriptors, 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, thenode_api_*string/symbol extras napi-rs uses
Stage 2 — loader and linking
-
process.dlopen(module, filename)(todayjs_process_dlopenreturnsundefined, node:process — implement process.dlopen #1409) andrequire("…/x.node")→ dlopen, locate the init export, buildmodule.exports - export the napi symbol set from the executable only when an addon is in the graph: macOS currently links
-Wl,-no_exported_symbolson purpose (dyld launch cost,crates/perry/src/commands/compile/link/build_and_run.rs:256-268) — switch to an-exported_symbols_listof the napi set for those builds; Linux--export-dynamic-symbol=napi_*; Windows export table so the addon'swin_delay_load_hookresolves itsnode.exeimports against the host -
native_addon.rsreroutes: a package with a.nodeis 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", …]inpackage.json; never auto-routed, never wins over awell_known_bindings.tomlrow; thePERRY_REQUIRE_FAITHFUL_BINDINGSinteraction written down - pick ONE shipping model and document it: sidecar
.nodenext 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)
- CI loads a real prebuilt Node-API addon and asserts the
napi_*symbols resolved from the host executable, plus a smoke call through it — a green run with zero addon calls is not a pass - differential test: the real
@parcel/watcherwatcher.nodevs the runtime: provide a native @parcel/watcher compatibility facade for full OpenCode #8513 facade on the same fixture tree → identical coalesced event streams. This is what makes runtime: provide a native @parcel/watcher compatibility facade for full OpenCode #8513's "coalescing/rename documented and tested" verifiable against ground truth rather than prose - one napi-rs addon (e.g.
oxc-parseror@swc/core) to cover napi-rs's TSFN / async-work / instance-data patterns - size assertions: hello-world delta is 0 bytes when no addon is in the graph; host delta reported (budget ≤ 0.6 MB) when one is
Risks
- Finalizers and weak refs are new collector surface; scope them carefully or defer.
- Every host table is a GC root the static dominance checker cannot see — register each in
gc_register_mutable_root_scannerin the same commit and classify it inscripts/gc_runtime_root_holders.json. - The unwind regime (ffi: bun:ffi symbol thunks are extern "C" — a use-after-close throw aborts on Linux (cargo-test still red after #8464) #8479): the host's entry points are
extern "C"boundaries by definition; the abort-guard rules apply.
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).
Activity