Rules for rivetkit-typescript/packages/rivetkit-napi/. The bridge is pure plumbing — all load-bearing logic belongs in rivetkit-core. These notes capture current conventions and known foot-guns; they are not design principles. For the layer-boundary rule itself, see the root CLAUDE.md.
- The N-API addon lives at
@rivetkit/rivetkit-napiinrivetkit-typescript/packages/rivetkit-napi. Keep Docker build targets, publish metadata, examples, and workspace package references in sync when renaming or moving it. - TypeScript actor vars are JS-runtime-only in
registry/native.ts. Do not reintroduceActorVarsinrivetkit-coreor addActorContext.vars/setVarsto NAPI.
- Keep the receive-loop adapter callback registry centralized in
rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs. Extend its TSF slots, payload builders, and bridge error helpers there instead of scattering ad hoc JS conversion logic across new dispatch code. - Keep
rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rsas the receive-loop execution boundary.actor_factory.rsstays focused on TSF binding setup and bridge helpers, not event-loop control flow.
- N-API actor-runtime wrappers expose
ActorContextsub-objects as first-class classes, keep raw payloads asBuffer, and wrap queue messages as classes so completable receives can callcomplete()back into Rust.
- NAPI callback bridges pass a single request object through
ThreadsafeFunction. Promise results that cross back into Rust deserialize into#[napi(object)]structs instead ofJsObjectso the callback future staysSend. - N-API
ThreadsafeFunctioncallbacks usingErrorStrategy::CalleeHandledfollow Node's error-first JS signature. Internal wrappers must accept(err, payload)and rethrow non-null errors explicitly. - NAPI websocket async handlers hold one
WebSocketCallbackRegiontoken per promise-returning handler so concurrent handlers cannot release each other's sleep guard.
#[napi(object)]bridge payloads stay plain-data only. If TypeScript needs to cancel native work, use primitives or JS-side polling instead of trying to pass a#[napi]class instance through an object field.- N-API structured errors cross the JS<->Rust boundary by prefix-encoding
{ group, code, message, metadata }intonapi::Error.reason, then normalizing that prefix back into aRivetErroron the other side. - N-API bridge debug logs use stable
kindplus compact payload summaries, never raw buffers or full request bodies.
ActorContextSharedinstances are cached byactor_id. Every freshrun_adapter_loopmust callreset_runtime_shared_state()before reattaching abort/run/task hooks or sleep→wake cycles inherit staleend_reason/ lifecycle flags and drop post-wake events.- Receive-loop
SerializeStatehandling stays inline innapi_actor_events.rs, reuses the sharedstate_deltas_from_payload(...)converter fromactor_context.rs, and only cancels the adapter abort token onDestroyor final adapter teardown, not onSleep. - Receive-loop NAPI optional callbacks preserve the TypeScript runtime defaults: missing
onBeforeSubscribeallows the subscription, missing workflow callbacks replyNone, and missing connection lifecycle hooks still accept the connection while leaving the existing empty conn state untouched.
ActorContextShared::runtime_statestores a N-APIRef<()>for the JS-only actor runtime state bag.Ref::unref(env)and reference deletion require anEnv, butreset_runtime_state()runs from receive-loop worker paths andDrop for ActorContextSharedmay run without an active JS callback frame.- The current
mem::forgetfallback inactor_context.rskeeps debug and release behavior aligned when noEnvis available, but it leaks one JS object reference per actor wake cycle that created runtime state. - The intended fix is to create an actor-shared cleanup
ThreadsafeFunctionthe first timeruntime_state(env)has anEnv. StaleRef<()>values should be wrapped in a payload whoseDropforgets the reference only if it was never successfully unreffed, then queued to that TSF fromreset_runtime_state()andDrop. - The TSF callback must run on the JS thread, call
ref.unref(ctx.env), and avoid invoking user callbacks. The TSF itself should be unreffed from the event loop so it does not keep Node alive. - Shutdown is the hard edge: if the TSF is closing or Node supplies a null
Envduring addon teardown, the payload must fall back to the existing bounded process-lifetime leak instead of dropping a liveRef<()>and tripping napi-rs debug assertions. - Before replacing the fallback, add a NAPI integration test that creates runtime state across many actor wake/destroy cycles, waits for the cleanup TSF to drain, and verifies native reference counts return to zero.
- For non-idempotent native waits like
queue.enqueueAndWait(), bridge JSAbortSignalthrough a standalone nativeCancellationToken. Timeout-slicing is only safe for receive-style polling calls likewaitForNames(). - Native queue receive waits observe the actor abort token.
enqueue_and_waitcompletion waits ignore actor abort and rely on the tracked user task for shutdown cancellation. - Core queue receive waits need the
ActorContext-owned abortCancellationToken, cancelled frommark_destroy_requested(). External JS cancel tokens alone will not makec.queue.next()abort during destroy.
- In
rivetkit-typescript/packages/rivetkit/src/registry/native.ts, lateregisterTask(...)calls during sleep/finalize teardown can legitimately hitactor task registration is closed/not configured. Swallow only that specific bridge error so workflow cleanup does not crash the runtime. - Bare-workflow
no_envoysfailures should be investigated as possible runtime crashes before being chased as engine scheduling misses. Check actor stderr for lateregisterTask(...)/ adapter panics first. - Native actor runner settings in
rivetkit-typescript/packages/rivetkit/src/registry/native.tscome fromdefinition.config.options, not top-level actor config fields.