RPC
The RozeniteDevToolsClient you get from getRozeniteDevToolsClient (see the
Plugin Development Guide) is fire-and-forget:
send(type, payload) and onMessage(type, listener). That's the right tool
for events, but it isn't an answer to "call this and give me a result."
Every plugin that needs a result back from the other side ends up
reinventing the same thing by hand: a correlation id, a matching response
event type, a Promise stored in a map, and an ad-hoc timeout. And none of
that hand-rolled scaffolding protects you from the case that actually causes
support tickets — the panel isn't mounted yet, or the device peer has died,
and your caller just hangs forever with no Promise ever settling.
@rozenite/plugin-bridge ships createRozeniteRpc so you don't have to
build this yourself. It gives you an awaitable call with acknowledgement,
heartbeats, timeouts, and cancellation on top of your existing client.
The abstraction is symmetric: both the device and the panel can register
handlers with handle() and call methods on the same rpc instance.
Reserved message type
RPC rides on top of your existing RozeniteDevToolsClient — there's no
separate channel to set up. It reserves a single message type,
'rozenite:rpc', for its own use. Don't use 'rozenite:rpc' as an event
type in your own plugin's event map, or it will collide with the RPC layer.
Declaring methods
Methods are declared function-shaped, as in the example above, so params and result are both inferred from a single type. A method that takes no params is declared with no arguments.
Registering a handler
Registering a second handler for the same method throws immediately, at registration time — a method has exactly one handler on a given peer.
Calling a method
Calling is a two-step handle: method() names the method and takes the
call's options, and invoke() takes the params.
This is the only call form. A handle holds nothing but the method name and its options — no subscription, no state — so creating one per call is free, and reusing one across multiple calls is equally fine:
Why a single timeout isn't enough
A single constant timeout is the wrong tool here: a legitimately slow handler would trip it, while a handler that's actually gone is indistinguishable from one that's just slow. RPC splits liveness from duration — the receiver acknowledges a request immediately, then sends heartbeats while it executes. "Slow but alive" and "gone" become different, observable states.
The three timers and their defaults
Every invoke() call is guarded by three independent, caller-side timers:
heartbeatMs (default 2_000) is set by the caller via method()'s
options; it controls how often the receiver sends a heartbeat while handling
that call.
Retries
retries (default 1) applies only to ACK_TIMEOUT — the one failure
where the handler provably never ran. STALLED and TIMEOUT never retry,
because by the time either fires the handler may already have committed side
effects, and a handler error never retries by construction.
Because the retry budget is consumed before timeoutMs is re-armed for the
retried attempt, retries: 1 can cost up to ackTimeoutMs + timeoutMs in
the worst case: the first attempt burns a full ackTimeoutMs before giving
up, and the retry then gets its own full timeoutMs window.
Cancellation
Pass an AbortSignal to cancel a call in flight. Every caller-side give-up —
STALLED, TIMEOUT, or your own AbortSignal — aborts the handler's
ctx.signal on the other side, so long-running work can stop instead of
running to no purpose:
The caller rejects immediately on cancellation and drops any late result that arrives afterwards — a handler that settles after cancellation is silently discarded, with no error and no dangling frame.
Known limitation: heartbeats only prove the event loop is alive
A heartbeat proves the peer's event loop is alive, not that the handler
is making progress. Synchronous work blocks the heartbeat timer too, so a
10-second synchronous loop on either side looks exactly like a dead peer.
This design cannot detect a synchronous long-running block — make sure
staleTimeoutMs comfortably exceeds the longest synchronous stretch you
expect on either side, and raise it per-call for handlers you know will
block.
Errors
Errors come in two shapes, discriminated by kind:
RozeniteProtocolError(kind: 'protocol') — the call itself failed, not your handler.error.codeis one of:ACK_TIMEOUT— nobody acknowledged the request in time; the handler never ran.STALLED— the peer stopped sending any sign of life.TIMEOUT— the call didn't settle within the absolute cap.CANCELLED— the caller'sAbortSignalfired.METHOD_NOT_FOUND— no handler is registered for that method on the peer.SERIALIZATION_ERROR— the result (or errordata) couldn't survive the transport.CLIENT_CLOSED—close()was called while the call was in flight.
RozeniteHandlerError(kind: 'handler') — the remote handler ran and threw.error.messageis`${method} failed: ${remote.message}`anderror.stackis your own call stack, so you always see who invoked the call. The remote's ownname,message,stack(development builds only), and any handler-supplieddatalive undererror.remote.
Narrow on kind, not instanceof — a plugin's device code and panel code
are separate bundles, so instanceof only happens to work when the error
was constructed by the bundle doing the check. Use the exported type guards
instead:
API reference
InvokeOptions.signal?: AbortSignalInvokeOptions.ackTimeoutMs?: number— default5_000InvokeOptions.heartbeatMs?: number— default2_000InvokeOptions.staleTimeoutMs?: number— default6_000InvokeOptions.timeoutMs?: number— default30_000, passInfinityto opt outInvokeOptions.retries?: number— default1,ACK_TIMEOUTonly
Calling close() removes the underlying message subscription and rejects
every in-flight call with CLIENT_CLOSED.
