Skip to content

zandoh/tsbus

Repository files navigation

tsbus logo

tsbus

A lightweight, type-safe event bus for TypeScript — wildcard patterns, priorities, and a plugin system, with zero dependencies.

npm version CI status license

Features

  • Type-safe — events and payloads are checked end-to-end via a typed event map
  • Wildcard patterns — subscribe to families of events with user:* or everything with *
  • Priority ordering — handlers run in priority order (higher first, FIFO within the same priority)
  • Sync and async handlersemit() returns a promise that resolves when every handler has completed
  • Plugin system — observe the bus lifecycle (init, subscribe, emit, errors) with isolated, parallel hooks
  • Error isolation — a throwing handler never breaks the emit or other handlers; route errors to an onError callback
  • Zero dependencies — no runtime-specific APIs; works in browsers, Node.js, Bun, and Deno
  • Small — about 2 kB gzipped, tree-shakeable, ships ESM and CJS with full type declarations

Installation

npm install @zandoh/tsbus
# or
bun add @zandoh/tsbus

Quick start

import { createEventBus } from "@zandoh/tsbus";

// Define your event map: event name -> payload type
interface AppEvents {
  "user:login": { id: string; email: string };
  "user:logout": { id: string };
  "app:init": Record<string, never>;
}

const bus = createEventBus<AppEvents>();

// Subscribe (returns an unsubscribe function)
const unsubscribe = bus.on("user:login", (payload) => {
  console.log("User logged in:", payload.email);
});

// Subscribe to a wildcard pattern
bus.onPattern("user:*", (payload) => {
  console.log("User event:", payload);
});

// Higher priority runs first
bus.on("user:login", auditHandler, { priority: 10 });

// One-time listener
bus.once("app:init", () => {
  console.log("App initialized");
});

// Emit — resolves after all matching handlers complete
await bus.emit("user:login", { id: "123", email: "user@example.com" });

unsubscribe();

API

createEventBus<TEventMap>(config?)

Creates a new EventBus instance.

Option Type Default Description
plugins Plugin[] [] Plugins with lifecycle hooks — see Plugins
trackStats boolean false Track execution durations; without it, avgDuration in getListeners() and the duration passed to plugin onAfterEmit hooks are always 0 (executionCount is tracked either way)
onError (event, payload, error, listenerId) => void Called when a handler throws or rejects. Handler errors never propagate out of emit(); without this callback (or a plugin onError hook) they are dropped silently
onPluginError (pluginName, hookName, error) => void Called when a plugin lifecycle hook throws or rejects; without it, plugin errors are logged to console.error
const bus = createEventBus<AppEvents>({
  trackStats: true,
  onError: (event, payload, error, listenerId) => {
    console.error(`Handler for ${event} failed:`, error);
  },
});

bus.on(event, handler, options?)

Subscribes to an exact event. Returns an unsubscribe function.

Options: priority (number, default 0, higher runs first) and once (boolean, default false).

bus.onPattern(pattern, handler, options?)

Subscribes to every event matching a wildcard pattern. * matches any sequence of characters, including separators — user:* matches user:login as well as user:profile:update, and "*" alone matches every event. Because a pattern can match many events, the handler's payload is typed unknown.

bus.once(event, handler, options?)

Subscribes to an event and automatically unsubscribes after the first execution. Accepts priority.

bus.emit(event, payload)

Emits an event to all matching listeners. Handlers execute sequentially in priority order (FIFO within the same priority); async handlers are awaited before the next one runs. Returns a Promise<void> that resolves once every handler has completed. Handler errors are captured, not thrown — see Error handling.

bus.off(listenerId)

Removes a single listener by its symbol ID. Listener IDs are exposed through getListeners(); for the common case, prefer calling the unsubscribe function returned by on / onPattern / once.

bus.offAll(event?)

Removes all listeners for one event, or every listener on the bus when called with no argument.

bus.getListeners(event?)

Returns a Map of subscription patterns to listener metadata (id, priority, once, pattern, addedAt, executionCount, avgDuration), optionally filtered to listeners matching one event. Useful for debugging and introspection.

Plugins

Plugins observe the bus lifecycle. All hooks are optional and may be sync or async; hooks for a given moment run in parallel across plugins, and a hook that throws or rejects is isolated — it never interrupts the emit or other plugins. By default, a failing hook is logged to console.error; pass onPluginError to createEventBus to route these failures elsewhere instead.

import type { Plugin } from "@zandoh/tsbus";

const loggingPlugin: Plugin<AppEvents> = {
  name: "logger",
  onInit: () => console.log("EventBus initialized"),
  onBeforeEmit: (event, payload) => console.log(`Emitting ${String(event)}`),
  onAfterEmit: (event, payload, duration, handlerCount) => {
    console.log(`${String(event)} completed in ${duration}ms (${handlerCount} handlers)`);
  },
  onError: (event, payload, error) => console.error(`Error in ${String(event)}:`, error),
};

const bus = createEventBus<AppEvents>({ plugins: [loggingPlugin] });
Hook Signature Fires when
onInit () The bus is created
onSubscribe (event, listenerId) A listener is added
onUnsubscribe (event, listenerId) A listener is removed — manually, or when a once listener expires after running
onBeforeEmit (event, payload) Before matching handlers run
onAfterEmit (event, payload, duration, handlerCount) After all handlers complete
onError (event, payload, error, listenerId?) A handler throws or rejects

DevTools

A browser extension in extension/ adds a tsbus panel to Chrome DevTools: a live log of every emit (with payloads, durations, handler counts, and errors), listener inspection, and the ability to dispatch or replay events from the panel. Instrument your bus with the bundled plugin:

import { createEventBus } from "@zandoh/tsbus";
import { createDevtoolsPlugin } from "@zandoh/tsbus/devtools";

const devtools = createDevtoolsPlugin({ name: "app" });
const bus = devtools.connect(createEventBus<AppEvents>({ plugins: [devtools.plugin] }));

The plugin is SSR-safe and inert when the extension isn't installed. See the extension README for install and usage details.

Error handling

emit() never rejects because of a handler: errors are caught per listener, the remaining handlers still run, and the error is routed to the onError config callback and any plugin onError hooks. If neither is registered, errors are dropped silently — register at least one in production. Listener stats (executionCount, avgDuration) count only successful runs.

TypeScript

The event map makes both event names and payloads compile-time checked:

interface AppEvents {
  "user:login": { id: string; email: string };
  "user:logout": { id: string };
}

const bus = createEventBus<AppEvents>();

await bus.emit("user:login", { id: "123", email: "user@example.com" }); // ok
await bus.emit("user:login", { id: 123 }); // type error: wrong payload
await bus.emit("unknown:event", {}); // type error: unknown event

Exported types: EventBus, EventBusConfig, EventMap, Plugin, ListenerHandler, ListenerInfo, ListenerMap, SubscribeOptions.

Development

This repo uses Bun for package management and scripts:

bun install
bun run check   # lint, format check, knip, type-check
bun run test    # vitest
bun run bench   # benchmarks vs. eventemitter3 and mitt
bun run build   # tsup — ESM + CJS + declarations

Releases follow Conventional Commits; the changelog is generated with git-cliff.

License

MIT

About

Lightweight, type-safe event bus for TypeScript - wildcard patterns, priorities, and plugins, with zero dependencies

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages