experiments in a post-browser web
1/**
2 * Component Communication Bus
3 *
4 * A lightweight pubsub system for cross-component communication.
5 * Works across Shadow DOM boundaries and supports typed events.
6 *
7 * @example
8 * import { bus, on, emit, channel } from './events.js';
9 *
10 * // Subscribe to events
11 * const unsubscribe = on('user:login', (user) => {
12 * console.log('User logged in:', user);
13 * });
14 *
15 * // Publish events
16 * emit('user:login', { name: 'Alice', id: 123 });
17 *
18 * // Create typed channels
19 * const userChannel = channel('user');
20 * userChannel.on('login', handler);
21 * userChannel.emit('login', userData);
22 */
23
24/**
25 * @typedef {Object} Subscription
26 * @property {() => void} unsubscribe - Stop receiving events
27 * @property {boolean} active - Whether subscription is active
28 */
29
30/**
31 * Event bus instance
32 */
33class EventBus {
34 constructor() {
35 /** @type {Map<string, Set<Function>>} */
36 this._handlers = new Map();
37 /** @type {Map<string, *>} */
38 this._lastValues = new Map();
39 }
40
41 /**
42 * Subscribe to an event
43 * @param {string} event - Event name (supports wildcards: 'user:*')
44 * @param {Function} handler - Event handler
45 * @param {Object} [options]
46 * @param {boolean} [options.once=false] - Only handle once then unsubscribe
47 * @param {boolean} [options.replay=false] - Replay last value immediately if available
48 * @returns {Subscription}
49 */
50 on(event, handler, options = {}) {
51 const { once = false, replay = false } = options;
52
53 let wrappedHandler = handler;
54 let active = true;
55
56 if (once) {
57 wrappedHandler = (...args) => {
58 if (active) {
59 active = false;
60 this._removeHandler(event, wrappedHandler);
61 handler(...args);
62 }
63 };
64 }
65
66 if (!this._handlers.has(event)) {
67 this._handlers.set(event, new Set());
68 }
69 this._handlers.get(event).add(wrappedHandler);
70
71 // Replay last value if requested and available
72 if (replay && this._lastValues.has(event)) {
73 queueMicrotask(() => {
74 if (active) {
75 wrappedHandler(this._lastValues.get(event));
76 }
77 });
78 }
79
80 return {
81 unsubscribe: () => {
82 active = false;
83 this._removeHandler(event, wrappedHandler);
84 },
85 get active() {
86 return active;
87 }
88 };
89 }
90
91 /**
92 * Subscribe to event, receiving only the next occurrence
93 * @param {string} event
94 * @param {Function} handler
95 * @returns {Subscription}
96 */
97 once(event, handler) {
98 return this.on(event, handler, { once: true });
99 }
100
101 /**
102 * Emit an event to all subscribers
103 * @param {string} event - Event name
104 * @param {*} [data] - Event data
105 * @param {Object} [options]
106 * @param {boolean} [options.retain=false] - Store value for replay
107 */
108 emit(event, data, options = {}) {
109 const { retain = false } = options;
110
111 if (retain) {
112 this._lastValues.set(event, data);
113 }
114
115 // Exact match handlers
116 const handlers = this._handlers.get(event);
117 if (handlers) {
118 for (const handler of [...handlers]) {
119 try {
120 handler(data);
121 } catch (error) {
122 console.error(`[EventBus] Error in handler for '${event}':`, error);
123 }
124 }
125 }
126
127 // Wildcard handlers (e.g., 'user:*' matches 'user:login')
128 for (const [pattern, patternHandlers] of this._handlers) {
129 if (pattern.endsWith(':*')) {
130 const prefix = pattern.slice(0, -1);
131 if (event.startsWith(prefix) && pattern !== event) {
132 for (const handler of [...patternHandlers]) {
133 try {
134 handler(data, event);
135 } catch (error) {
136 console.error(`[EventBus] Error in wildcard handler for '${event}':`, error);
137 }
138 }
139 }
140 }
141 }
142 }
143
144 /**
145 * Remove all handlers for an event
146 * @param {string} event
147 */
148 off(event) {
149 this._handlers.delete(event);
150 this._lastValues.delete(event);
151 }
152
153 /**
154 * Remove all handlers and clear state
155 */
156 clear() {
157 this._handlers.clear();
158 this._lastValues.clear();
159 }
160
161 /**
162 * Get count of handlers for an event
163 * @param {string} event
164 * @returns {number}
165 */
166 listenerCount(event) {
167 return this._handlers.get(event)?.size ?? 0;
168 }
169
170 _removeHandler(event, handler) {
171 const handlers = this._handlers.get(event);
172 if (handlers) {
173 handlers.delete(handler);
174 if (handlers.size === 0) {
175 this._handlers.delete(event);
176 }
177 }
178 }
179}
180
181/**
182 * Global event bus instance
183 */
184export const bus = new EventBus();
185
186/**
187 * Subscribe to an event on the global bus
188 * @param {string} event
189 * @param {Function} handler
190 * @param {Object} [options]
191 * @returns {Subscription}
192 */
193export function on(event, handler, options) {
194 return bus.on(event, handler, options);
195}
196
197/**
198 * Subscribe once to an event
199 * @param {string} event
200 * @param {Function} handler
201 * @returns {Subscription}
202 */
203export function once(event, handler) {
204 return bus.once(event, handler);
205}
206
207/**
208 * Emit an event on the global bus
209 * @param {string} event
210 * @param {*} [data]
211 * @param {Object} [options]
212 */
213export function emit(event, data, options) {
214 bus.emit(event, data, options);
215}
216
217/**
218 * Create a namespaced channel
219 *
220 * @param {string} namespace - Channel namespace (e.g., 'user', 'editor')
221 * @returns {Channel}
222 *
223 * @example
224 * const userChannel = channel('user');
225 * userChannel.on('login', (user) => console.log(user));
226 * userChannel.emit('login', { name: 'Alice' });
227 * // Equivalent to: bus.emit('user:login', { name: 'Alice' })
228 */
229export function channel(namespace) {
230 return {
231 on(event, handler, options) {
232 return bus.on(`${namespace}:${event}`, handler, options);
233 },
234 once(event, handler) {
235 return bus.once(`${namespace}:${event}`, handler);
236 },
237 emit(event, data, options) {
238 bus.emit(`${namespace}:${event}`, data, options);
239 },
240 off(event) {
241 bus.off(`${namespace}:${event}`);
242 },
243 /**
244 * Subscribe to all events in this channel
245 */
246 onAny(handler, options) {
247 return bus.on(`${namespace}:*`, handler, options);
248 }
249 };
250}
251
252/**
253 * Create a typed event emitter for a specific event
254 *
255 * @template T
256 * @param {string} event - Event name
257 * @returns {{ emit: (data: T) => void, on: (handler: (data: T) => void) => Subscription }}
258 *
259 * @example
260 * const userLogin = typedEvent('user:login');
261 * userLogin.on((user) => console.log(user.name));
262 * userLogin.emit({ name: 'Alice', id: 123 });
263 */
264export function typedEvent(event) {
265 return {
266 emit(data, options) {
267 bus.emit(event, data, options);
268 },
269 on(handler, options) {
270 return bus.on(event, handler, options);
271 },
272 once(handler) {
273 return bus.once(event, handler);
274 }
275 };
276}
277
278/**
279 * Wait for an event (Promise-based)
280 *
281 * @param {string} event - Event to wait for
282 * @param {Object} [options]
283 * @param {number} [options.timeout] - Timeout in ms (rejects if exceeded)
284 * @param {Function} [options.filter] - Only resolve if filter returns true
285 * @returns {Promise<*>}
286 *
287 * @example
288 * const userData = await waitFor('user:login', { timeout: 5000 });
289 */
290export function waitFor(event, options = {}) {
291 const { timeout, filter } = options;
292
293 return new Promise((resolve, reject) => {
294 let timeoutId;
295 let subscription;
296
297 const cleanup = () => {
298 if (timeoutId) clearTimeout(timeoutId);
299 if (subscription) subscription.unsubscribe();
300 };
301
302 subscription = bus.on(event, (data) => {
303 if (!filter || filter(data)) {
304 cleanup();
305 resolve(data);
306 }
307 });
308
309 if (timeout) {
310 timeoutId = setTimeout(() => {
311 cleanup();
312 reject(new Error(`Timeout waiting for event: ${event}`));
313 }, timeout);
314 }
315 });
316}
317
318/**
319 * Mixin that adds event bus methods to a component
320 *
321 * @param {typeof LitElement} Base
322 * @returns {typeof LitElement}
323 *
324 * @example
325 * class MyComponent extends EventBusMixin(LitElement) {
326 * connectedCallback() {
327 * super.connectedCallback();
328 * this.subscribe('data:update', this.handleUpdate);
329 * }
330 *
331 * handleUpdate = (data) => {
332 * this.data = data;
333 * }
334 * }
335 */
336export function EventBusMixin(Base) {
337 return class extends Base {
338 constructor() {
339 super();
340 this._busSubscriptions = [];
341 }
342
343 /**
344 * Subscribe to an event (auto-cleanup on disconnect)
345 */
346 subscribe(event, handler, options) {
347 const sub = bus.on(event, handler, options);
348 this._busSubscriptions.push(sub);
349 return sub;
350 }
351
352 /**
353 * Emit an event
354 */
355 publish(event, data, options) {
356 bus.emit(event, data, options);
357 }
358
359 disconnectedCallback() {
360 super.disconnectedCallback();
361 // Clean up all subscriptions
362 for (const sub of this._busSubscriptions) {
363 sub.unsubscribe();
364 }
365 this._busSubscriptions = [];
366 }
367 };
368}
369
370/**
371 * Create a new isolated event bus instance
372 * Useful for testing or isolated component trees
373 *
374 * @returns {EventBus}
375 */
376export function createBus() {
377 return new EventBus();
378}
379
380export default {
381 bus,
382 on,
383 once,
384 emit,
385 channel,
386 typedEvent,
387 waitFor,
388 EventBusMixin,
389 createBus
390};