@event-kit/bus
v0.1.1
Published
Zero-dependency, type-safe, cross-tab event bus with pluggable transports (BroadcastChannel, LocalStorage, WebSocket, ...).
Maintainers
Readme
@event-kit/bus
Zero-dependency, fully-typed, cross-tab event bus 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/busQuick 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/bus';
interface AppEvents extends EventMap {
'auth:login': { userId: string; token: string };
'auth:logout': { reason?: 'expired' | 'manual' };
}
const bus = createDispatcher<AppEvents>({
channelName: 'my-app-v1', // enables BroadcastChannelTransport
localStoragePrefix: 'app:', // enables LocalStorageTransport fallback
});
const unsubscribe = bus.on('auth:login', ({ userId, token }) => {
console.log(userId, token); // fully typed, no casts
});
bus.emit('auth:login', { userId: '123', token: 'abc' });
// Later:
unsubscribe();
bus.destroy(); // on app teardownEvents emitted in one browser tab are delivered to every other same-origin tab automatically — no polling, no server round-trip.
API
bus.on(event, handler) // -> Unsubscribe, registration-scoped
bus.once(event, handler) // -> Unsubscribe, fires once
bus.off(event, handler) // reference-based removal (Node/DOM semantics)
bus.removeAllListeners(event?) // clear one event, or everything
bus.listenerCount(event)
bus.eventNames()
bus.onAny((event, payload) => {}) // -> Unsubscribe, fires for every event
bus.offAny(handler)
bus.waitFor(event, { timeoutMs?, signal? }) // -> Promise<Payload>
bus.emit(event, payload) // -> boolean, true if anyone was listening
bus.hasListeners(event)
bus.destroy()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 buses 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.
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 |
| Node.js require() | Supported via the dist/index.cjs build |
Not yet verified against real browsers or a real npm install by the
maintainers of this fork - see docs/audit.md for exactly what has
and hasn't been executed. Run npm test and check a couple of target
browsers before depending on this in production.
Status
This package has been type-checked exhaustively but has not yet been
run (no npm test execution, no real-browser testing) as part of
building it out. It is not a drop-in replacement for a battle-tested
library like broadcast-channel
(cross-tab sync, actively maintained, years of cross-browser edge cases
fixed) or mitt/nanoevents
(pure local typed emitters) until it has gone through the same. Use
this when the pluggable multi-transport architecture is specifically
what you want; reach for one of those otherwise.
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. - No runtime dependencies. Fully auditable, small bundle footprint,
sideEffects: falsefor clean tree-shaking.
