|
| 1 | +/** |
| 2 | + * DeployStack Global Event Bus |
| 3 | + * |
| 4 | + * Core event management system built on Node.js EventEmitter. |
| 5 | + * Provides type-safe event emission and plugin listener management. |
| 6 | + */ |
| 7 | + |
| 8 | +import { EventEmitter } from 'events'; |
| 9 | +import { type FastifyBaseLogger } from 'fastify'; |
| 10 | +import { type EventName, type EventData, type EventContext, type EventHandler } from './types'; |
| 11 | + |
| 12 | +/** |
| 13 | + * DeployStack Event Bus - Central event management system |
| 14 | + * |
| 15 | + * Features: |
| 16 | + * - Type-safe event emission with compile-time validation |
| 17 | + * - Plugin listener registration and cleanup |
| 18 | + * - Error isolation (failed listeners don't affect others) |
| 19 | + * - Memory efficient (fire-and-forget processing) |
| 20 | + * - Audit trail for plugin listener registrations |
| 21 | + */ |
| 22 | +export class DeployStackEventBus extends EventEmitter { |
| 23 | + private logger?: FastifyBaseLogger; |
| 24 | + private pluginListeners: Map<string, Set<string>> = new Map(); |
| 25 | + |
| 26 | + constructor(logger?: FastifyBaseLogger) { |
| 27 | + super(); |
| 28 | + this.logger = logger; |
| 29 | + |
| 30 | + // Support many plugins listening to events |
| 31 | + this.setMaxListeners(100); |
| 32 | + |
| 33 | + // Prevent crashes from unhandled errors in event listeners |
| 34 | + this.on('error', (error) => { |
| 35 | + this.logger?.error('EventBus error:', error); |
| 36 | + }); |
| 37 | + } |
| 38 | + |
| 39 | + /** |
| 40 | + * Emit an event with typed data and context |
| 41 | + * |
| 42 | + * @param eventName - Type-safe event name constant |
| 43 | + * @param data - Strongly-typed event data |
| 44 | + * @param context - Event context with database, logger, user info |
| 45 | + * @returns true if event had listeners, false otherwise |
| 46 | + */ |
| 47 | + emitWithContext<T extends EventName>( |
| 48 | + eventName: T, |
| 49 | + data: EventData<T>, |
| 50 | + context: EventContext |
| 51 | + ): boolean { |
| 52 | + try { |
| 53 | + this.logger?.debug(`Emitting event: ${eventName}`); |
| 54 | + |
| 55 | + // Emit the event with data and context |
| 56 | + const hasListeners = this.emit(eventName, data, context); |
| 57 | + |
| 58 | + if (!hasListeners) { |
| 59 | + this.logger?.debug(`No listeners for event: ${eventName}`); |
| 60 | + } |
| 61 | + |
| 62 | + return hasListeners; |
| 63 | + } catch (error) { |
| 64 | + this.logger?.error(`Failed to emit event ${eventName}: ${error}`); |
| 65 | + // Don't throw - event emission failures should not break core operations |
| 66 | + return false; |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Register a plugin event listener |
| 72 | + * |
| 73 | + * @param pluginId - Unique plugin identifier |
| 74 | + * @param eventName - Event name to listen for |
| 75 | + * @param handler - Event handler function |
| 76 | + */ |
| 77 | + registerPluginListener<T extends EventName>( |
| 78 | + pluginId: string, |
| 79 | + eventName: T, |
| 80 | + handler: EventHandler<T> |
| 81 | + ): void { |
| 82 | + try { |
| 83 | + // Track plugin listeners for cleanup |
| 84 | + if (!this.pluginListeners.has(pluginId)) { |
| 85 | + this.pluginListeners.set(pluginId, new Set()); |
| 86 | + } |
| 87 | + this.pluginListeners.get(pluginId)!.add(eventName); |
| 88 | + |
| 89 | + // Wrap handler with error isolation |
| 90 | + const wrappedHandler = async (data: EventData<T>, context: EventContext) => { |
| 91 | + try { |
| 92 | + await handler(data, context); |
| 93 | + } catch (error) { |
| 94 | + this.logger?.error(`Plugin ${pluginId} event handler failed for ${eventName}: ${error}`); |
| 95 | + // Don't re-throw - plugin failures should not affect other plugins or core operations |
| 96 | + } |
| 97 | + }; |
| 98 | + |
| 99 | + // Register the wrapped listener |
| 100 | + this.on(eventName, wrappedHandler); |
| 101 | + |
| 102 | + this.logger?.info(`Plugin ${pluginId} registered for event: ${eventName}`); |
| 103 | + } catch (error) { |
| 104 | + this.logger?.error(`Failed to register plugin listener for ${pluginId}: ${error}`); |
| 105 | + throw error; // This is a setup error, should be thrown |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + /** |
| 110 | + * Unregister all event listeners for a plugin |
| 111 | + * |
| 112 | + * @param pluginId - Plugin identifier to clean up |
| 113 | + */ |
| 114 | + unregisterPlugin(pluginId: string): void { |
| 115 | + try { |
| 116 | + const events = this.pluginListeners.get(pluginId); |
| 117 | + if (!events) { |
| 118 | + this.logger?.debug(`No listeners found for plugin: ${pluginId}`); |
| 119 | + return; |
| 120 | + } |
| 121 | + |
| 122 | + let removedCount = 0; |
| 123 | + events.forEach(eventName => { |
| 124 | + const listenerCount = this.listenerCount(eventName); |
| 125 | + this.removeAllListeners(eventName); |
| 126 | + removedCount += listenerCount; |
| 127 | + }); |
| 128 | + |
| 129 | + this.pluginListeners.delete(pluginId); |
| 130 | + |
| 131 | + this.logger?.info(`Unregistered ${removedCount} listeners for plugin: ${pluginId}`); |
| 132 | + } catch (error) { |
| 133 | + this.logger?.error(`Failed to unregister plugin ${pluginId}: ${error}`); |
| 134 | + // Don't throw - cleanup failures should not break plugin unloading |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + /** |
| 139 | + * Get statistics about registered listeners |
| 140 | + * |
| 141 | + * @returns Event bus statistics |
| 142 | + */ |
| 143 | + getStats(): { |
| 144 | + totalPlugins: number; |
| 145 | + totalListeners: number; |
| 146 | + eventCounts: Record<string, number>; |
| 147 | + } { |
| 148 | + const eventCounts: Record<string, number> = {}; |
| 149 | + |
| 150 | + // Get listener counts for each event |
| 151 | + this.eventNames().forEach(eventName => { |
| 152 | + if (typeof eventName === 'string') { |
| 153 | + eventCounts[eventName] = this.listenerCount(eventName); |
| 154 | + } |
| 155 | + }); |
| 156 | + |
| 157 | + return { |
| 158 | + totalPlugins: this.pluginListeners.size, |
| 159 | + totalListeners: Object.values(eventCounts).reduce((sum, count) => sum + count, 0), |
| 160 | + eventCounts |
| 161 | + }; |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * Check if a plugin has registered listeners |
| 166 | + * |
| 167 | + * @param pluginId - Plugin identifier to check |
| 168 | + * @returns true if plugin has registered listeners |
| 169 | + */ |
| 170 | + hasPluginListeners(pluginId: string): boolean { |
| 171 | + return this.pluginListeners.has(pluginId); |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * Get list of events a plugin is listening to |
| 176 | + * |
| 177 | + * @param pluginId - Plugin identifier |
| 178 | + * @returns Set of event names the plugin is listening to |
| 179 | + */ |
| 180 | + getPluginEvents(pluginId: string): Set<string> { |
| 181 | + return this.pluginListeners.get(pluginId) || new Set(); |
| 182 | + } |
| 183 | + |
| 184 | + /** |
| 185 | + * Shutdown the event bus and clean up all listeners |
| 186 | + */ |
| 187 | + shutdown(): void { |
| 188 | + try { |
| 189 | + this.logger?.info('Shutting down EventBus...'); |
| 190 | + |
| 191 | + // Remove all listeners |
| 192 | + this.removeAllListeners(); |
| 193 | + |
| 194 | + // Clear plugin tracking |
| 195 | + this.pluginListeners.clear(); |
| 196 | + |
| 197 | + this.logger?.info('EventBus shutdown complete'); |
| 198 | + } catch (error) { |
| 199 | + this.logger?.error(`Error during EventBus shutdown: ${error}`); |
| 200 | + } |
| 201 | + } |
| 202 | +} |
0 commit comments