@event-kit/broadcast
v0.1.2
Published
Zero-dependency, type-safe, cross-tab event bus with pluggable transports (BroadcastChannel, LocalStorage, WebSocket, ...).
Maintainers
Readme
@event-kit/broadcast
Zero-dependency, fully-typed, cross-tab event broadcast for the browser. Ships
with BroadcastChannel and localStorage transports out of the box, and
a single Transport interface for adding your own (WebSocket, SharedWorker, ...).
Install
npm install @event-kit/broadcastQuick start
extends EventMapis required, not decorative.EventMapisRecord<string, unknown>; TypeScript only recognizes your interface as satisfyingT extends EventMaponce it structurally carries an index signature, whichextendssupplies. Leaving it off compiles your interface fine but fails later, atnew Dispatcher<AppEvents>(), with a confusing "does not satisfy the constraint" error.
import { createDispatcher, type EventMap } from '@event-kit/broadcast';
interface AppEvents extends EventMap {
'auth:login': { userId: string; token: string };
'auth:logout': { reason?: 'expired' | 'manual' };
}
const broadcast = createDispatcher<AppEvents>({
channelName: 'my-app-v1', // enables BroadcastChannelTransport
localStoragePrefix: 'app:', // enables LocalStorageTransport fallback
});
const unsubscribe = broadcast.on('auth:login', ({ userId, token }) => {
console.log(userId, token); // fully typed, no casts
});
broadcast.emit('auth:login', { userId: '123', token: 'abc' });
// Later:
unsubscribe();
broadcast.destroy(); // on app teardownEvents emitted in one browser tab are delivered to every other same-origin tab automatically — no polling, no server round-trip.
Telling local events apart from cross-tab events
Every Dispatcher instance has its own sourceId (auto-generated,
overridable via new Dispatcher({ sourceId })). It travels with every
envelope the instance emits and is handed to every listener as a second,
optional meta argument - never merged into the payload:
broadcast.on('auth:login', (payload, meta) => {
meta.sourceId; // which Dispatcher instance originally emitted this
meta.isSelf; // true for this instance's own emit(), false if it
// arrived via a transport from another tab/process
meta.timestamp;
});Handlers that only declare payload keep working unchanged - meta is
purely additive.
API
broadcast.sourceId // this Dispatcher instance's unique id
broadcast.on(event, handler) // -> Unsubscribe, registration-scoped
// handler(payload, meta) - meta.sourceId / meta.isSelf
broadcast.once(event, handler) // -> Unsubscribe, fires once
broadcast.off(event, handler) // reference-based removal (Node/DOM semantics)
broadcast.emit(event, payload) // -> boolean, true if anyone was listening
broadcast.destroy()Dispatcher is intentionally minimal: registration (on/once), removal
(off), and emission (emit), plus destroy() for teardown. Listener
introspection, wildcard subscriptions, and promise-based waiting are not
part of the core API - see docs/audit.md for the reasoning.
See docs/audit.md for the reasoning behind on() vs off()'s
different unsubscribe semantics before relying on off() with a reused
handler reference.
Using more than one localStoragePrefix
If you run multiple independent broadcasts in the same origin, give each a
prefix that isn't a prefix of another (matching is startsWith):
"app:" and "app:v2:" will cross-talk, but "app:v1:" and
"app:v2:" won't.
LocalStorageTransport: cleanup and encryption
Each event's key holds only its latest envelope - useful as a change
signal for other tabs, not useful as history once every tab has reacted
to it. Left alone, those entries would sit in localStorage forever.
LocalStorageTransport runs a periodic cleanup sweep (default: every
60s) that removes entries older than entryTtlMs (default: 5 minutes),
plus any entry under its prefix that fails to parse:
new LocalStorageTransport({
prefix: 'app:',
entryTtlMs: 2 * 60_000, // default: 5 minutes
cleanupIntervalMs: 30_000, // default: 60s; 0 disables the sweep
});localStorage is plaintext and readable by any same-origin script.
codec ({ encode, decode }) lets you transform the serialized
envelope before it's written/read - e.g. to encrypt it. Both sides
default to the identity function when codec is omitted, so this is
entirely opt-in:
new LocalStorageTransport({
codec: {
encode: (json) => myEncrypt(json),
decode: (stored) => myDecrypt(stored), // if this throws, the entry is ignored, not crashed on
},
});maxEnvelopeBytes is measured on the encoded value, since that's what
actually consumes storage quota.
Compatibility
| Environment | Support |
|---|---|
| Modern evergreen browsers (Chrome, Firefox, Edge) | Full - both transports work |
| Safari ≥ 15.4 | Full |
| Safari < 15.4 | BroadcastChannel unsupported - falls back to LocalStorageTransport automatically (feature-detected) |
| Safari Private Browsing | localStorage.setItem throws - writes are swallowed; cross-tab sync silently degrades to same-tab-only, local emit/on still work |
| Node.js ≥ 18 (SSR) | Safe to import; both transports no-op until isBrowser() is true |
This package is published as ESM only ("type": "module", no CJS
require() build). Every environment above, and every modern bundler,
supports import natively; if you need require('@event-kit/broadcast'),
pin to 0.1.x.
Design notes
- Bounded memory under load. The dispatcher's de-duplication cache is
capped by both a time window (
dedupWindowMs) and a hard entry count (maxDedupEntries), so a message flood can't grow memory unboundedly. - Untrusted input is validated. Anything arriving from
localStorageorpostMessageis run through a structural guard (isEnvelopeLike) before being trusted, since both are same-origin channels other scripts/extensions can also write to. - Handler and transport errors are isolated. A throwing listener or a
throwing
transport.sendnever breaks other listeners/transports or crashesemit(); errors are routed to a configurableonErrorsink. - SSR-safe. Every browser API access is guarded by
isBrowser(), so importing the package during server-side rendering never throws. LocalStorageTransportself-cleans. A periodic, prefix-scoped sweep removes stale/malformed entries (see above), so the transport never leaves growing cruft behind inlocalStorage. The sweep is interval-based rather than running on everysend(), keepingsend()cost constant regardless of how many stale keys have accumulated.- No runtime dependencies. Fully auditable, small bundle footprint,
sideEffects: falsefor clean tree-shaking. - ESM only. No CJS
require()build - see Compatibility above. sourceIdis cheap. One string per Dispatcher instance, stamped onto every envelope it emits; computingmeta.isSelfon delivery is a single string comparison, not a new allocation per listener.
