You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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
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)
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
one napi-rs addon (e.g. oxc-parser or @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_scanner in the same commit and classify it in scripts/gc_runtime_root_holders.json.
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 throughwell_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 carriescompat = "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.nodeaddon can bedlopened 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
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).perry-ext-*binding — that would be a size/distribution regression for everyone.crates/perry/src/commands/compile/collect_modules/native_addon.rsand rejected with a diagnostic naming the reason.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.What already exists (don't rebuild)
napi_get_buffer_info,napi_get_typedarray_info) under a moving GCcrates/perry-runtime/src/bun_ffi/mod.rsnapi_create_external_arraybuffer)toArrayBuffer/toBufferinbun_ffi/memory.rsdlopen+ symbol lookupbun_ffi/dlopen.rsnapi_call_threadsafe_functionfrom any thread)perry-ffievent pump:notify_main_thread+ per-moduleprocess_pendingtick (crates/perry-ffi/src/event_pump.rs)napi_create_async_work/napi_queue_async_workperry_ffi::spawn_blocking+ main-thread completionnapi_create_reference, refcount > 0)gc_register_mutable_root_scanner+ handle registry (crates/perry-ffi/src/handle.rs)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 acrossextern "C"). Everynapi_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).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).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.napi_*are legal off-thread (only TSFN call/acquire/release) and how the host detects misuse.js_native_api.h+node_api.hentry marked v1 / later / never, with the reason.Stage 1 — host core
napi_define_propertiesdescriptors, getters/setters,napi_get_property_namesnapi_create_function,napi_call_function,napi_get_cb_info,napi_get_new_targetnapi_fatal_errornapi_get_uv_event_loop→ error status (documented: no libuv)napi_adjust_external_memory, instance data, cleanup hooks, thenode_api_*string/symbol extras napi-rs usesStage 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-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 hostnative_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 reasonStage 3 — distribution and policy
perry.nativeAddons: ["@swc/core", …]inpackage.json; never auto-routed, never wins over awell_known_bindings.tomlrow; thePERRY_REQUIRE_FAITHFUL_BINDINGSinteraction written down.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 packageStage 4 — gate (must be able to go red, must prove the subject ran)
napi_*symbols resolved from the host executable, plus a smoke call through it — a green run with zero addon calls is not a pass@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 proseoxc-parseror@swc/core) to cover napi-rs's TSFN / async-work / instance-data patternsRisks
gc_register_mutable_root_scannerin the same commit and classify it inscripts/gc_runtime_root_holders.json.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.dlopenstub), #8393 (raw-address keyed tables).