A lightweight, type-safe event bus for TypeScript — wildcard patterns, priorities, and a plugin system, with zero dependencies.
- 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 handlers —
emit()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
onErrorcallback - 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
npm install @zandoh/tsbus
# or
bun add @zandoh/tsbusimport { 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();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);
},
});Subscribes to an exact event. Returns an unsubscribe function.
Options: priority (number, default 0, higher runs first) and once (boolean, default false).
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.
Subscribes to an event and automatically unsubscribes after the first execution. Accepts priority.
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.
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.
Removes all listeners for one event, or every listener on the bus when called with no argument.
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 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 |
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.
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.
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 eventExported types: EventBus, EventBusConfig, EventMap, Plugin, ListenerHandler, ListenerInfo, ListenerMap, SubscribeOptions.
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 + declarationsReleases follow Conventional Commits; the changelog is generated with git-cliff.