@jorvel/event-bus
Lightweight typed publish/subscribe with wildcard support, optional replay-on-subscribe, and a per-bus error handler so a single throwing listener cannot abort iteration. The singleton is pinned to globalThis — every part of the federation graph observes the same bus even if MF share-scope misbehaves.
Design properties
- Typed. Pass an
EventMapgeneric — event names and payloads are checked at the call site. - Last-value cache. Every event remembers its most recent payload so late subscribers can replay synchronously.
- Error isolation. A throwing listener never aborts iteration; the bus routes the error to a configurable handler.
- Wildcards.
onAnysees every event — useful for devtools and analytics. - Cross-tab. Opt into
connectBroadcastto relay throughBroadcastChannel. - Schemas. Opt into
attachSchemaRegistryfor runtime payload validation.
EventBus
type EventMap = Record<string, unknown>;
type Handler<T> = (payload: T) => void;
type WildcardHandler<E extends EventMap> = <K extends keyof E>(event: K, payload: E[K]) => void;
type ErrorHandler = (err: unknown, event: string) => void;
type Unsubscribe = () => void;
class EventBus<E extends EventMap = EventMap> {
constructor(opts?: { errorHandler?: ErrorHandler });
on<K extends keyof E>(event: K, handler: Handler<E[K]>, opts?: { replay?: boolean }): Unsubscribe;
once<K extends keyof E>(event: K, handler: Handler<E[K]>): Unsubscribe;
off<K extends keyof E>(event: K, handler: Handler<E[K]>): void;
onAny(handler: WildcardHandler<E>): Unsubscribe;
emit<K extends keyof E>(event: K, payload: E[K]): void;
replay<K extends keyof E>(event: K, handler: Handler<E[K]>): boolean;
clear<K extends keyof E>(event?: K): void;
listenerCount<K extends keyof E>(event: K): number;
onError(handler: ErrorHandler): void;
}
getEventBus<E extends EventMap = EventMap>(): EventBus<E>;Replay vs. once
replay: true on on() fires the most recent payload synchronously on subscribe — useful when a late-mounting remote needs the current value of auth:session. once() fires on the next emission only, then auto-unsubscribes.Typed events example
interface MyEvents {
'user:login': { userId: string };
'cart:add': { sku: string; qty: number };
'theme:changed': 'light' | 'dark';
}
const bus = getEventBus<MyEvents>();
bus.emit('cart:add', { sku: 'ABC', qty: 2 }); // ok
bus.emit('cart:add', { qty: 2 } as never); // ❌ TS error: missing sku
bus.on('theme:changed', (t) => applyTheme(t), { replay: true });Patterns
Late-mount replay
A remote that mounts after auth flow needs the current user. Use replay: true to receive the most recent emission synchronously.
bus.on('auth:session', (session) => setUser(session), { replay: true });
// fires immediately if a session has been emitted before; otherwise waits.One-shot subscriptions
bus.once('payment:succeeded', (p) => track('purchase', p));
// auto-unsubscribes after the first emission. No memory leak risk.Devtools / logging
onAny sees every event. Pair with the observability adapter to ship a complete audit log in dev.
if (process.env.NODE_ENV !== 'production') {
bus.onAny((event, payload) => console.debug('[bus]', event, payload));
}Custom error handling
A failing listener crash usually shouldn't take down adjacent listeners. The default handler logs to console; override per bus.
bus.onError((err, event) => {
observability.reportError(err, { source: 'bus', context: { event } });
});Schema validation
Federation events crossing trust boundaries deserve runtime validation. Attach a schema registry to reject malformed payloads at the emit site.
type Validator<T> = { parse(input: unknown): T };
type SchemaMap<E extends EventMap> = { [K in keyof E]?: Validator<E[K]> };
attachSchemaRegistry<E extends EventMap>(
bus: EventBus<E>,
schemas: SchemaMap<E>,
opts?: {
onInvalid?: 'throw' | 'drop' | 'warn'; // default 'throw'
log?: (event: keyof E, err: unknown) => void;
},
): SchemaRegistryHandle;
interface SchemaRegistryHandle {
detach(): void;
update<K extends keyof E>(event: K, schema: Validator<E[K]> | undefined): void;
}Usage with Zod
import { z } from 'zod';
import { attachSchemaRegistry, getEventBus } from '@jorvel/event-bus';
const bus = getEventBus<MyEvents>();
const handle = attachSchemaRegistry(bus, {
'cart:add': z.object({ sku: z.string().min(1), qty: z.number().int().positive() }),
}, { onInvalid: 'warn' });
bus.emit('cart:add', { sku: '', qty: -1 }); // warns + dropsCross-tab broadcast
Same-origin tabs that share the bus singleton observe each other's emissions when connected. Implemented via BroadcastChannel; the runtime tags relayed events so they don't loop.
connectBroadcast<E extends EventMap>(
bus: EventBus<E>,
opts?: {
channelName?: string; // default 'jorvel:bus'
filter?: (event: keyof E) => boolean;
},
): BroadcastConnection;
interface BroadcastConnection {
disconnect(): void;
}Filter sensitive events
Cross-tab broadcast trusts every other tab on the same origin. Filter out events that carry sensitive payloads (session tokens, user PII).
connectBroadcast(bus, {
filter: (event) => !event.startsWith('auth:'),
});Federation contract
Pair the bus with a typed federation contract from @jorvel/types to enforce the event vocabulary across host + remote builds:
import { defineFederationContract } from '@jorvel/types';
export const dashboardContract = defineFederationContract({
name: 'dashboard',
emits: {
'dashboard:opened': null,
'dashboard:action': { action: 'string', payload: 'unknown?' },
},
listens: {
'shell:ready': { timestamp: 'number' },
},
});
type DashboardEvents = typeof dashboardContract['emits'] & typeof dashboardContract['listens'];
const bus = getEventBus<DashboardEvents>();Testing
import { _resetEventBus } from '@jorvel/event-bus';
beforeEach(() => { _resetEventBus(); });
it('emits cart:add on Add to Cart', () => {
const spy = vi.fn();
bus.on('cart:add', spy);
render(<Product />);
fireEvent.click(screen.getByText('Add to Cart'));
expect(spy).toHaveBeenCalledWith({ sku: 'BOOK-42', qty: 1 });
});