/** * Platform EventBus — in-process pub/sub for decoupled service integration. * * Design: * - Uses Node.js EventEmitter (single process, zero serialization overhead) * - Typed events via PlatformEventMap (compile-time safety) * - Wildcard subscriptions: subscribe('shift.*') catches all shift events * - Error isolation: each listener wraps its handler in try-catch * - Stats tracking: per-event and per-listener counters for observability * * Usage: * // Publish (from any service) * eventBus.publish('shift.signup.created', { shiftId, userName, ... }); * * // Subscribe (from listeners registered at startup) * eventBus.subscribe('shift.signup.created', async (payload) => { ... }); * eventBus.subscribe('shift.*', async (payload) => { ... }); // wildcard */ import { EventEmitter } from 'events'; import { logger } from '../utils/logger'; import type { PlatformEventMap, PlatformEventName, EventPayload } from '../types/events'; type EventHandler = (payload: EventPayload) => void | Promise; interface ListenerRegistration { name: string; pattern: string; handler: (event: string, payload: unknown) => void | Promise; } interface EventStats { published: number; lastPublishedAt: Date | null; } class EventBus { private emitter = new EventEmitter(); private listeners: ListenerRegistration[] = []; private eventStats = new Map(); private listenerStats = new Map(); constructor() { // Allow many listeners (we'll have multiple per event) this.emitter.setMaxListeners(100); } /** * Publish a typed event. All matching subscribers are called asynchronously. * This is fire-and-forget — errors in listeners do NOT propagate to the publisher. */ publish(event: E, payload: EventPayload): void { // Update stats const stats = this.eventStats.get(event) ?? { published: 0, lastPublishedAt: null }; stats.published++; stats.lastPublishedAt = new Date(); this.eventStats.set(event, stats); // Emit to exact subscribers this.emitter.emit(event, payload); // Emit to wildcard subscribers for (const reg of this.listeners) { if (reg.pattern.endsWith('.*')) { const prefix = reg.pattern.slice(0, -2); if (event.startsWith(prefix + '.') && event !== reg.pattern) { this.safeCall(reg.name, () => reg.handler(event, payload)); } } } logger.debug(`EventBus: ${event}`, { event }); } /** * Subscribe to a specific event with a named listener. * The name is used for stats tracking and debugging. */ subscribe( event: E, name: string, handler: EventHandler, ): void { const wrappedHandler = (payload: EventPayload) => { this.safeCall(name, () => handler(payload)); }; this.emitter.on(event, wrappedHandler); this.listeners.push({ name, pattern: event, handler: (_event: string, payload: unknown) => handler(payload as EventPayload), }); this.listenerStats.set(name, { handled: 0, errors: 0 }); } /** * Subscribe to all events matching a wildcard pattern (e.g., 'shift.*'). * Handler receives both the event name and payload. */ subscribePattern( pattern: string, name: string, handler: (event: string, payload: unknown) => void | Promise, ): void { this.listeners.push({ name, pattern, handler }); this.listenerStats.set(name, { handled: 0, errors: 0 }); } /** * Call a handler with error isolation and stats tracking. */ private safeCall(listenerName: string, fn: () => void | Promise): void { const stats = this.listenerStats.get(listenerName); try { const result = fn(); if (result instanceof Promise) { result .then(() => { if (stats) stats.handled++; }) .catch((err) => { if (stats) { stats.handled++; stats.errors++; } logger.debug(`EventBus listener "${listenerName}" error:`, err); }); } else { if (stats) stats.handled++; } } catch (err) { if (stats) { stats.handled++; stats.errors++; } logger.debug(`EventBus listener "${listenerName}" sync error:`, err); } } /** * Get stats for observability dashboard. */ getStats(): { totalEventsPublished: number; eventCounts: Record; listenerCounts: Record; registeredListeners: { name: string; pattern: string }[]; } { let total = 0; const eventCounts: Record = {}; for (const [name, stats] of this.eventStats) { total += stats.published; eventCounts[name] = { published: stats.published, lastPublishedAt: stats.lastPublishedAt?.toISOString() ?? null, }; } const listenerCounts: Record = {}; for (const [name, stats] of this.listenerStats) { listenerCounts[name] = { ...stats }; } return { totalEventsPublished: total, eventCounts, listenerCounts, registeredListeners: this.listeners.map(l => ({ name: l.name, pattern: l.pattern })), }; } /** * Remove all listeners (for testing or shutdown). */ removeAllListeners(): void { this.emitter.removeAllListeners(); this.listeners = []; } } export const eventBus = new EventBus();