@vyredo/rpc-bus
v0.1.0
Published
Generic event-bus/RPC core for cross-thread communication via BroadcastChannel
Maintainers
Readme
@vyredo/rpc-bus
Request/response RPC across threads, tabs, and workers over BroadcastChannel.
Zero runtime dependencies · ESM-only · tree-shakeable (sideEffects: false).
Status: 0.1.x. The API is usable but not yet frozen; minor versions may change it.
Requirements
BroadcastChannel— available in all modern browsers, Web Workers, and Node 18+.- ESM. This package ships ESM only.
importworks everywhere;require()works on Node 22.12+ (viarequire(esm)) and throwsERR_REQUIRE_ESMon older versions. - Decorators (optional,
@vyredo/rpc-bus/decoratorsonly) — needs"experimentalDecorators": truein yourtsconfig.json.
Installation
npm install @vyredo/rpc-busCore concept: explicit claims
Every handler declares the event types it serves, at registration:
bus.on(channel, callback, ["event-type-1", "event-type-2"]);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ claimsA request matches only if some handler claims that exact event type. If nothing local claims it, the request goes out over the wire instead. A handler that merely runs — but doesn't own the type — never counts as having handled the request.
This makes shadowing impossible: a local handler can't accidentally swallow a request that really belongs to a worker.
⚠️ Claims are effectively required.
claimsis an optional parameter for backwards compatibility, but a handler registered without claims will never match anything. The bus emits alogger.warnwhen this happens.
Quick start
import { createEventBus } from "@vyredo/rpc-bus";
const bus = createEventBus("main", { channelName: "my-app" });
bus.on("math", (event) => {
const e = event as { type: string; a: number; b: number };
return e.a + e.b;
}, ["add"]); // ← this handler owns "add"
const sum = await bus.request<number>("math", { type: "add", a: 1, b: 2 });
// 3Every event must be an object with a type: string discriminator — that's what
claims match against.
Cross-thread
Peers sharing a channelName form one bus network. A request is answered by whichever
peer claims the type:
// === worker.ts ===
const bus = createEventBus("worker", { channelName: "my-app" });
bus.on("ai", async (event) => {
const e = event as { type: string; prompt: string };
return await generate(e.prompt);
}, ["generate"]);
// === main.ts ===
const bus = createEventBus("main", { channelName: "my-app" });
// nothing local claims "generate" → broadcast → worker answers
const text = await bus.request<string>("ai", { type: "generate", prompt: "hello" });Decorator API
@rpc records metadata at class-definition time; registerRpcFacades wires it to a bus,
deriving each handler's claim from the decorator's event:
import { rpc, registerRpcFacades } from "@vyredo/rpc-bus/decorators";
class MathFacade {
@rpc({ channel: "math", event: "add" })
add(params: { a: number; b: number }) {
return params.a + params.b;
}
@rpc({ channel: "math", event: "subtract" })
subtract(params: { a: number; b: number }) {
return params.a - params.b;
}
}
const dispose = registerRpcFacades({ bus, logger }, new MathFacade());
dispose(); // unregisters every handler it boundThe decorated method receives the event minus its type field, as params.
tsconfig.json:
{ "compilerOptions": { "experimentalDecorators": true } }⚠️
registerRpcFacadesis not idempotent. Calling it twice registers duplicate handlers. Always keep the disposer and call it before re-registering.
React
useEffect(() => {
const dispose = registerRpcFacades({ bus }, new MyFacade());
return dispose; // ← required; StrictMode double-mounts
}, []);API reference
createEventBus(name?, options?)
| Parameter | Type | Default | Description |
|---|---|---|---|
| name | string | "main" | Bus name; appears in request IDs and logs |
| options.channelName | string | "rpc-bus" | BroadcastChannel name — peers must match |
| options.logger | BusLogger | no-op | Diagnostics sink |
| options.observer | BusObserver | — | Topology / telemetry hooks |
| options.requestTimeoutMs | number | 15000 | Per-bus request timeout; 0 disables |
bus.on(channel, callback, claims?) → handlerId
Registers a handler. Keep the returned ID for off().
| Parameter | Type | Description |
|---|---|---|
| channel | string | Channel name |
| callback | (event: RpcEvent) => unknown | Sync or async; may return a Promise |
| claims | string[] | Event types owned. Omit and the handler never matches. |
bus.off(channel, handlerId)
Removes one handler. Both arguments are required — passing the wrong channel silently does nothing.
bus.request<T>(channel, event) → Promise<T>
Resolves with the claiming handler's return value, or rejects. Timeout is bus-level; there is no per-request override.
bus.setDispatchInterceptor(fn | null)
Observes every handler invocation on this bus. Useful for record/replay.
bus.setDispatchInterceptor((record) => {
console.log(record.channel, record.event, record.duration);
});bus.dispose()
Clears handlers and pending requests, removes the message listener, closes the channel.
Types
interface RpcEvent { type: string }
interface BusLogger {
debug(msg: string, meta?: Record<string, unknown>): void;
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
setThreadName?(name: string): void;
}
interface BusObserver {
onBind?(channel: string, handlerId: string, eventType?: string): void;
onUnbind?(channel: string, handlerId: string): void;
onRequest?(callId: string, channel: string, eventType: string): void;
onResolve?(callId: string, durationMs: number): void;
onReject?(callId: string, error: string, durationMs: number): void;
}
interface DispatchRecord {
channel: string;
event: RpcEvent;
result: unknown;
error: string | null;
duration: number;
}Dispatch semantics
- Local claim wins. If a handler on this bus claims the type, it is invoked and the
request resolves with its return value — including
undefinedfor void handlers. The request is never broadcast in this case. - Otherwise broadcast. No local claim → out over
BroadcastChannel. - Only claimants answer. Peers that don't own the type stay silent, so a bystander can't race a fast negative answer ahead of the real (usually async) owner.
- Timeout is the failure signal. If nobody answers within
requestTimeoutMs, the promise rejects with a timeoutError.
A handler that throws rejects the caller's promise.
Constraints & gotchas
Payloads must be JSON-safe. Cross-thread messages go through JSON.stringify /
JSON.parse. Date becomes a string, Map/Set become {}, class instances lose
their prototype and methods, undefined properties are dropped, and circular references
throw. Local (same-bus) dispatch does not serialize — so a payload can work locally and
break the moment the handler moves to a worker. Prefer plain, JSON-shaped data.
Errors lose fidelity across threads. A cross-thread rejection is reconstructed from
the error's message only; stack, name, and custom properties do not survive. Encode
anything you need to branch on into the message or a result field.
No BroadcastChannel → silent local-only mode. If the constructor throws, the bus
falls back to local dispatch with no warning. Unclaimed requests then hang until timeout.
Duplicate claims race. If two peers claim the same (channel, eventType), both
answer and the first response wins — non-deterministically. Treat claims as
single-owner; there is no arbitration yet.
Local observer timings are 0. onResolve/onReject report a real duration only
for cross-thread calls; local dispatch passes 0. DispatchRecord.duration measures the
synchronous handler call, so for async handlers it excludes the awaited work.
Testing with multiple peers
Buses are isolated by channelName, so give each test its own to avoid cross-talk. In
Node/jsdom you can supply a mock BroadcastChannel that fans messages out to every other
instance on the same name — see src/event-bus.test.ts for a working one.
License
MIT
