experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 390 lines 9.2 kB view raw
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};