Skip to content

feat: Node-API (napi) surface for plugin developers - #437

Draft
edusperoni wants to merge 6 commits into
mainfrom
feat/node-api
Draft

feat: Node-API (napi) surface for plugin developers#437
edusperoni wants to merge 6 commits into
mainfrom
feat/node-api

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

Exposes a standard Node-API (napi_*) surface to plugin authors, alongside the existing JSI facade — plugins can be written against the Node-API C ABI instead of raw V8.

  • Vendored upstream: Node.js's engine-independent NAPI implementation (js_native_api_v8.cc + public headers) from nodejs/node v26.7.0 (b4f23d36), byte-identical; provenance, license (MIT/OpenJS NOTICE), and a 3-line re-sync procedure live in NativeScript/napi/vendor/README.md. A small shim (NativeScript/napi/shim/) supplies the handful of idioms upstream expects from Node internals.
  • One napi_env per runtime (main + each Worker), created at the end of Runtime::Init, destroyed under the teardown Locker before ObjectManager::DisposeAllRegistered (env ref lists hold v8::Globals). NAPI refs/finalizers stay on stock v8::Global+SetWeak, byte-compatible with upstream — deliberately independent of the planned cppgc migration of runtime wrappers.
  • Async surface implemented (not stubs): threadsafe functions with Node's queue semantics (bounded queue, blocking/nonblocking, acquire/release, abort → napi_closing) where every JS-side step is posted to the owning runtime's runloop — producer threads never take the isolate's Locker; napi_async_work executes on a GCD global queue and completes on the owning runloop; async contexts, napi_make_callback, callback scopes, and env/async cleanup hooks (LIFO, hooks may add/remove hooks while running).
  • Plugin access: #include <NativeScript/NapiRuntime.h>NativeScriptNapiEnv() (mirrors JSIRuntime). Headers ship in the framework automatically.
  • Module loading: addons register via constructor-based napi_module_register (plus a node_module_register alias for napi-ios addon compatibility) and load from JS with require("name") — bare specifiers only, consulted after the builtin fast path, exports cached per env (Workers get their own instance).
  • PromiseProxy made engine-transparent: the construct trap now returns the real V8 promise (the runloop marshaling lives in the wrapped executor and is unchanged), so napi_is_promise and other engine-level checks hold for promises made via the global Promise, and rejection events carry the same object user code holds.
  • Finalizers are queued from V8 weak callbacks and drained on the next runloop turn (Node's SetImmediate placement), never during GC.
  • Version surface: NAPI version 10 (napi_get_version); envs use module API version 8 (Node's default), with the implications documented.
  • Docs: docs/node-api.md — plugin-author quickstart (working addon + JS caller, registration, loading, threading contract, finalizer timing, version story) plus the full divergence reference.

Divergences from Node

Everything in js_native_api.h is upstream, compiled unmodified. The differences are confined to the node_api.h surface, where Node's implementation depends on libuv, node::Buffer, or its module loader — All are written up in docs/node-api.md.

  • napi_get_uv_event_loop and node_api_get_module_file_name return napi_generic_failure. There is no uv_loop_t — the runtime drives a CFRunLoop — and addons are linked into the app binary rather than loaded from a file, so nothing identifies the calling module.
  • Buffers are Uint8Arrays. There is no node::Buffer, so napi_is_buffer is exactly "is this a Uint8Array". napi_create_external_buffer is still zero-copy; its finalizer runs from V8's backing-store deleter, and is skipped (leaking the data) if the isolate is already disposing.
  • TSFN ref/unref are no-ops. The runloop belongs to the app or the worker; it does not exit because an addon released its last reference. The flag is tracked so calls pair up, and gates nothing.
  • napi_call_threadsafe_function returns napi_would_deadlock instead of blocking, when a blocking call is made from the env's own thread with a full queue — the thread that would drain the queue is the caller. Node blocks regardless and never returns this status; wedging the runloop is worse than a status an addon may not expect.
  • napi_delete_async_work refuses queued or executing work (napi_generic_failure) rather than deleting it and leaving the queue holding a dangling pointer.
  • Async cleanup hooks run at teardown but are not awaited, because the teardown thread is the one that would have to run the completion. Their handles stay valid, so a late napi_remove_async_cleanup_hook is safe.
  • napi_fatal_exception reports and continues through the runtime's error handlers; napi_fatal_error still aborts, as upstream.
  • The NAPI_MODULE macro is not the entry point. It only emits the symbols a dlopen loader would scan for, and there is no such loader here; addons register from a constructor calling napi_module_register.

node_api.h is the whole native surface — no process, no fs, no libuv handles, no node.h/v8.h/uv.h access.

Testing

  • Full TestRunner suite: 1,158 passing / 0 failing (baseline was 1,063; +95 Node-API specs across NapiTests.js and NapiCoverageTests.js).
  • Coverage ported from Node's test/js-native-api suites: string encodings/truncation/NUL semantics, number conversion sentinel tables, symbols, typed arrays + dataviews (bounds, alignment, detach), promises, errors with codes, references (weak/strong transitions, GC-driven), exceptions, coercions, property definition — plus TSFN ordering/backpressure/reentry/abort, async work + cancel, cleanup hooks, and Worker isolation.
  • nm on the built framework (Debug simulator + Release device): 145 napi_* symbols + NativeScriptNapiEnv exported.

Follow-ups (not in this PR)

  • Optional require("<path>.node") dylib loading (dlopen + dlsym("napi_register_module_v1"), as napi-ios does).
  • Verify -fmodules consumers: NapiRuntime.h includes non-modular napi/vendor/*.h headers; likely moot since the framework sets DEFINES_MODULE = NO, but worth confirming with a real plugin build.
  • napi_create_external_buffer's backing-store deleter holds a raw napi_env; guarded against teardown races (leaks instead of dangling), a full fix would mirror Node's v8impl::Reference ownership.

Six files copied byte-identical from nodejs/node tag v26.7.0
(b4f23d3619c98bed09af93a21192f6080197a8c6): the engine-independent
Node-API headers, the V8 implementation, and its impl header.
node_api.cc is intentionally not vendored; its role is filled by the
runtime-side embed layer added separately. vendor/README.md records
provenance, build accommodations, and the re-sync procedure; NOTICE
carries the Node.js license.
One napi_env per runtime (main and each Worker), created at the end of
Runtime::Init and destroyed under the teardown Locker before
DisposeAllRegistered, since its reference lists hold v8::Globals.

The shim supplies the idioms js_native_api_v8.cc expects from Node's
internal headers, keeping the vendored files byte-identical.
NodeApiEmbed covers the node_api.h surface Node implements in
node_api.cc: constructor-based module registration (with a
node_module_register alias for napi-ios addon compatibility), version
queries, fatal errors, and buffers over Uint8Array; async and
threadsafe-function entry points are stubs returning
napi_generic_failure until the async layer lands.

Plugins reach the env through NativeScriptNapiEnv() (NapiRuntime.h,
mirroring JSIRuntime), and registered addons load through
require(name): bare specifiers only, consulted after the builtin
fast path, with exports cached per env.

Finalizers queued from V8 weak callbacks drain on the next runloop
turn, matching Node's SetImmediate placement.
Exercises the napitestmodule fixture: value round-trips, property
definition, error propagation with code, napi_wrap/unwrap, references,
per-env exports caching, and the finalizer draining on the runloop turn
after collection.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d7ec3e1d-0c19-4e3a-ad7c-87724e0b336b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Replaces the Phase-1 stubs with the full async surface. Threadsafe
functions follow Node's queue semantics (bounded queue, blocking and
nonblocking call modes, acquire/release, abort -> napi_closing) with
every JS-side step posted to the owning runtime's runloop — producer
threads never take the isolate's Locker, which is the same invariant
the Worker class-initialization deadlock taught us. napi_async_work
executes on a GCD global queue and completes on the owning runloop,
with napi_cancelled reported when cancel wins the race.

Env teardown now enters the isolate (the destructor holds the Locker
but no scopes), then runs cleanup hooks (one LIFO list, drained
against the live registry so hooks may add and remove hooks), aborts
surviving threadsafe functions — releasing blocked producers and
handing undelivered items to the callback with a null env — and only
then finalizes references, since each TSFN holds a ref to its JS
callback.

Known divergences, documented inline: ref/unref of a TSFN gates
nothing (the app runloop never exits because of NAPI), a JS-thread
call into its own full blocking queue returns napi_would_deadlock
rather than blocking, and napi_delete_async_work refuses while the
work is in flight.
Ports a high-value subset of Node's test/js-native-api assertions
(strings with exact truncation and NUL semantics, number conversions
including the int32/int64 sentinel tables, symbols, typed arrays and
dataviews with bounds and detach, promises, errors with codes,
references across the weak/strong boundary, exceptions, coercions,
property definition) into a second fixture module and 70+ specs.

Shared fixture helpers move to NapiTestSupport.h, which also fixes
error reporting: the pending-exception probe clears the last error
code, so the real message has to be captured before it.

One ported expectation was wrong for this runtime and is now asserted
as the actual behavior and documented: the global Promise is replaced
by a Proxy (promise-proxy.js), so napi_is_promise reports false for
promises constructed through it, while napi_create_promise and
async-function promises report true.

docs/node-api.md is the plugin-author guide: quickstart addon,
registration and require() loading, threading contract, finalizer
timing, version story, and the divergences-from-Node table.
The cross-thread resolution marshaling lives entirely in the wrapped
executor; the per-instance Proxy only rebound then/catch/finally to
the underlying promise, so removing it changes no scheduling behavior
while making constructed promises real V8 promises again. Engine-level
checks now hold for them — napi_is_promise reports true (divergence
entry removed from docs/node-api.md), and the promise user code holds
is the same object the unhandledrejection/rejectionhandled events
carry, which the late-handler test now asserts directly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant