@absoluteui/services/bus
Typed event bus, in-tab and across tabs.
import { ... } from '@absoluteui/services/bus';How to use
Section titled “How to use”Typed publish/subscribe for decoupled parts of an application, within one tab or across every same-origin tab through BroadcastChannel, plus leader election so only one tab runs a job. Use a bus when unrelated screens must react to the same event (an employee was saved, the user signed out) without importing each other; for state that must persist, use @absoluteui/services/storage instead.
How do I publish typed events inside the page?
Section titled “How do I publish typed events inside the page?”Declare the event map once; on, once, and emit are checked against it. on returns the unsubscribe function.
import { createEventBus } from '@absoluteui/services/bus';
type HrEvents = { 'employee:saved': { id: number; name: string }; 'leave:approved': { requestId: string; days: number };};const events = createEventBus<HrEvents>();
const toasts: string[] = [];const off = events.on('employee:saved', ({ name }) => { toasts.push(`${name} saved`); });events.once('leave:approved', ({ days }) => { toasts.push(`${String(days)} days approved`); });events.emit('employee:saved', { id: 42, name: 'Ada Lovelace' });events.emit('leave:approved', { requestId: 'LR-1042', days: 3 });events.emit('leave:approved', { requestId: 'LR-1043', days: 1 }); // once: not deliveredoff();toasts; // => ['Ada Lovelace saved', '3 days approved']events.listenerCount(); // => 0How do I observe every event, for example for audit logging?
Section titled “How do I observe every event, for example for audit logging?”Subscribe to '*'; the listener receives the type and the payload.
import { createEventBus } from '@absoluteui/services/bus';
const events = createEventBus<{ 'employee:saved': { id: number }; 'employee:deleted': { id: number } }>();const audit: string[] = [];events.on('*', (type, payload) => { audit.push(`${type} ${JSON.stringify(payload)}`); });events.emit('employee:saved', { id: 42 });events.emit('employee:deleted', { id: 7 });audit; // => ['employee:saved {"id":42}', 'employee:deleted {"id":7}']How do I send events to other tabs?
Section titled “How do I send events to other tabs?”createCrossTabBus delivers to every same-origin tab and worker that uses the same channel name, and to this tab (unless local: false). Payloads must be plain, structured-cloneable data. Validate incoming payloads: another tab may run an older release.
import { createCrossTabBus } from '@absoluteui/services/bus';
type SessionEvents = { 'session:ended': { reason: 'logout' | 'timeout' } };const isEnded = (type: string, p: unknown): boolean => type === 'session:ended' && typeof p === 'object' && p !== null && 'reason' in p && (p.reason === 'logout' || p.reason === 'timeout');
const thisTab = createCrossTabBus<SessionEvents>('peoplehub-session', { validate: isEnded });const otherTab = createCrossTabBus<SessionEvents>('peoplehub-session', { validate: isEnded }); // stands in for a second tabconst received = new Promise<string>((resolve) => { otherTab.once('session:ended', ({ reason }) => { resolve(reason); }); });thisTab.emit('session:ended', { reason: 'logout' });await received; // => 'logout'thisTab.crossTab; // => truethisTab.close();otherTab.close();How do I run a job in only one tab?
Section titled “How do I run a job in only one tab?”createLeaderElection picks one tab per name (Web Locks when available, a heartbeat otherwise); leadership moves to another tab when the leader closes. Poll or refresh tokens only in the leader, and share results with a cross-tab bus.
import { createCrossTabBus, createLeaderElection } from '@absoluteui/services/bus';
const updates = createCrossTabBus<{ 'notifications:count': number }>('peoplehub-notifications');const election = createLeaderElection('peoplehub-notifications');await election.whenLeader();const timer = setInterval(() => { void fetch('/api/notifications/count').then(async (r) => { updates.emit('notifications:count', Number(await r.text())); });}, 60_000);election.onChange((leader) => { if (!leader) clearInterval(timer); });resign() gives leadership up, for example on sign-out.
What goes wrong?
Section titled “What goes wrong?”- Declare the event map with
type, notinterface: interfaces have no index signature, so TypeScript rejects them as anEventMap. - A listener that throws does not stop the others; the error goes to
onError(defaultreportError). - Functions, DOM nodes, and class instances with methods cannot cross tabs:
emitthrowsSVC_BUS_UNCLONEABLE.'*'cannot be emitted (SVC_BUS_INVALID_TYPE). - Without
BroadcastChannel,crossTabisfalseand events stay in the current tab.
import { createCrossTabBus } from '@absoluteui/services/bus';
const bus = createCrossTabBus<{ 'report:ready': { open: () => void } }>('peoplehub-reports');bus.emit('report:ready', { open: () => undefined }); // => throws SVC_BUS_UNCLONEABLEbus.close();API reference
Section titled “API reference”Functions
Section titled “Functions”createCrossTabBus
Typed bus shared by every same-origin tab/worker that uses the same channelName (BroadcastChannel is
same-origin by design, so no origin check is needed). Payloads must be structured-cloneable (plain data,
no functions or DOM nodes): otherwise emit throws SVC_BUS_UNCLONEABLE. Falls back to in-tab delivery.
function createCrossTabBus<E extends EventMap>(channelName: string, options?: CrossTabBusOptions): CrossTabBus<E>;Example:
const bus = createCrossTabBus<{ logout: undefined; theme: 'light' | 'dark' }>('app');bus.on('logout', () => location.assign('/signed-out'));bus.emit('logout', undefined);createEventBus
Creates a typed in-memory event bus with listener error isolation.
function createEventBus<E extends EventMap>(options?: EventBusOptions): EventBus<E>;Example:
const bus = createEventBus<{ saved: { id: number }; closed: undefined }>();const saved: number[] = [];const off = bus.on('saved', ({ id }) => { saved.push(id); });bus.emit('saved', { id: 1 });off();bus.emit('saved', { id: 2 });saved; // => [1]createLeaderElection
Elects one leader among same-origin tabs for name (for example, only the leader refreshes tokens or polls).
Uses the Web Locks API when present (leadership passes automatically when the leader tab closes), otherwise
a BroadcastChannel heartbeat; without either, this tab is always leader.
function createLeaderElection(name: string, options?: LeaderElectionOptions): LeaderElection;Example:
const election = createLeaderElection('token-refresh');await election.whenLeader();const timer = setInterval(() => { void fetch('/api/token/refresh', { method: 'POST' }); }, 240_000); // only one tab refreshesInterfaces
Section titled “Interfaces”CrossTabBus
Cross-tab bus returned by {@link createCrossTabBus}.
interface CrossTabBus<E extends EventMap> extends EventBus<E> { readonly crossTab: boolean; close(): void;}CrossTabBusOptions
Options for {@link createCrossTabBus}.
interface CrossTabBusOptions extends EventBusOptions { readonly local?: boolean; readonly validate?: (type: string, payload: unknown) => boolean;}EventBus
Typed event bus returned by {@link createEventBus}.
interface EventBus<E extends EventMap> { on<K extends keyof E & string>(type: K, listener: EventListener<E[K]>): () => void; on(type: '*', listener: WildcardListener<E>): () => void; off<K extends keyof E & string>(type: K, listener: EventListener<E[K]>): void; off(type: '*', listener: WildcardListener<E>): void; once<K extends keyof E & string>(type: K, listener: EventListener<E[K]>): () => void; emit<K extends keyof E & string>(type: K, payload: E[K]): void; listenerCount(type?: (keyof E & string) | '*'): number; clear(): void;}EventBusOptions
Options for {@link createEventBus}.
interface EventBusOptions { readonly onError?: (error: unknown, type: string) => void;}LeaderElection
Handle returned by {@link createLeaderElection}.
interface LeaderElection { readonly isLeader: boolean; whenLeader(): Promise<void>; onChange(callback: (isLeader: boolean) => void): () => void; resign(): void;}LeaderElectionOptions
Options for {@link createLeaderElection}.
interface LeaderElectionOptions { readonly strategy?: 'auto' | 'locks' | 'broadcast'; readonly heartbeatMs?: number;}EventListener
Listener for one event type.
type EventListener<P> = (payload: P) => void;EventMap
Event name -> payload type map.
type EventMap = Record<string, unknown>;WildcardListener
Listener for every event ('*').
type WildcardListener<E extends EventMap> = <K extends keyof E & string>(type: K, payload: E[K]) => void;