JORVEL

@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 EventMap generic — 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. onAny sees every event — useful for devtools and analytics.
  • Cross-tab. Opt into connectBroadcast to relay through BroadcastChannel.
  • Schemas. Opt into attachSchemaRegistry for runtime payload validation.

EventBus

ts
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

ts
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.

ts
bus.on('auth:session', (session) => setUser(session), { replay: true });
// fires immediately if a session has been emitted before; otherwise waits.

One-shot subscriptions

ts
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.

ts
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.

ts
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.

ts
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

ts
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 + drops

Cross-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.

ts
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).

ts
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:

ts
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

ts
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 });
});