npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vyredo/rpc-bus

v0.1.0

Published

Generic event-bus/RPC core for cross-thread communication via BroadcastChannel

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. import works everywhere; require() works on Node 22.12+ (via require(esm)) and throws ERR_REQUIRE_ESM on older versions.
  • Decorators (optional, @vyredo/rpc-bus/decorators only) — needs "experimentalDecorators": true in your tsconfig.json.

Installation

npm install @vyredo/rpc-bus

Core concept: explicit claims

Every handler declares the event types it serves, at registration:

bus.on(channel, callback, ["event-type-1", "event-type-2"]);
//                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ claims

A 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. claims is an optional parameter for backwards compatibility, but a handler registered without claims will never match anything. The bus emits a logger.warn when 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 });
// 3

Every 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 bound

The decorated method receives the event minus its type field, as params.

tsconfig.json:

{ "compilerOptions": { "experimentalDecorators": true } }

⚠️ registerRpcFacades is 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

  1. Local claim wins. If a handler on this bus claims the type, it is invoked and the request resolves with its return value — including undefined for void handlers. The request is never broadcast in this case.
  2. Otherwise broadcast. No local claim → out over BroadcastChannel.
  3. 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.
  4. Timeout is the failure signal. If nobody answers within requestTimeoutMs, the promise rejects with a timeout Error.

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