/** * @module @dotdo/postgres-shared/event-emitter-safe * * Safe event emitter implementation that prevents memory leaks. * * Features: * - Maximum listener limit with configurable warning/error behavior * - Optional WeakRef pattern for automatic cleanup of garbage-collected listeners * - Safe iteration during emit (handles listener modifications) * - Error isolation (one listener error doesn't block others) * - Full TypeScript support with typed events * * This addresses memory leak issues documented in PLAN-TDD-ISSUES.md: * - postgres-mem1: Memory leak in event listeners * - Extract to shared EventEmitterSafe class */ // ============================================================================= // Types // ============================================================================= /** * Listener function type */ export type Listener = (event: T) => void /** * Error handler for listener errors */ export type ListenerErrorHandler = (error: unknown, event: string, listener: Listener) => void /** * Configuration options for EventEmitterSafe */ export interface EventEmitterSafeConfig { /** * Maximum number of listeners per event. * Set to 0 for unlimited (not recommended). * @default 100 */ maxListeners?: number /** * Behavior when max listeners is reached. * - 'warn': Log a warning but allow the listener * - 'error': Throw an error * - 'ignore': Silently ignore * @default 'warn' */ maxListenersBehavior?: 'warn' | 'error' | 'ignore' /** * Enable WeakRef pattern for listeners. * When enabled, listeners can be garbage collected if no other references exist. * Note: This requires the listener to be referenced elsewhere to work properly. * @default false */ useWeakRef?: boolean /** * Custom error handler for listener errors. * If not provided, errors are silently caught to prevent blocking other listeners. */ onListenerError?: ListenerErrorHandler /** * Enable debug logging * @default false */ debug?: boolean } /** * Internal representation of a listener with metadata */ interface ListenerEntry { /** The listener function or WeakRef to it */ listener: Listener | WeakRef> /** Whether this is a one-time listener */ once: boolean /** Whether this entry uses WeakRef */ isWeak: boolean } /** * Statistics about the event emitter */ export interface EventEmitterStats { /** Total number of events registered */ eventCount: number /** Total number of listeners across all events */ totalListeners: number /** Breakdown of listeners per event */ listenersPerEvent: Record /** Number of listeners cleaned up (weak refs that were garbage collected) */ cleanedUpListeners: number /** Number of times max listeners warning was triggered */ maxListenersWarnings: number } // ============================================================================= // Default Configuration // ============================================================================= const DEFAULT_CONFIG: Required = { maxListeners: 100, maxListenersBehavior: 'warn', useWeakRef: false, onListenerError: () => {}, // Silent by default debug: false, } // ============================================================================= // EventEmitterSafe Class // ============================================================================= /** * A safe event emitter that prevents memory leaks and provides better error handling. * * @example Basic usage * ```typescript * const emitter = new EventEmitterSafe<{ * 'user:login': { userId: string } * 'user:logout': { userId: string } * }>() * * const unsubscribe = emitter.on('user:login', (event) => { * console.log(`User ${event.userId} logged in`) * }) * * emitter.emit('user:login', { userId: '123' }) * * // Clean up * unsubscribe() * ``` * * @example With max listeners protection * ```typescript * const emitter = new EventEmitterSafe({ * maxListeners: 10, * maxListenersBehavior: 'error', * }) * ``` * * @example With WeakRef for automatic cleanup * ```typescript * const emitter = new EventEmitterSafe({ useWeakRef: true }) * * // If the listener is garbage collected, it will be automatically removed * let handler: Listener | null = (event) => console.log(event) * emitter.on('event', handler) * * handler = null // Now eligible for garbage collection * ``` */ export class EventEmitterSafe = Record> { private config: Required private listeners: Map>> = new Map() private cleanedUpListeners = 0 private maxListenersWarnings = 0 constructor(config: EventEmitterSafeConfig = {}) { this.config = { ...DEFAULT_CONFIG, ...config } } /** * Register a listener for an event. * Returns an unsubscribe function. * * @param event - The event name to listen for * @param listener - The listener function * @returns Unsubscribe function */ on(event: K, listener: Listener): () => void { return this.addListener(event, listener, false) } /** * Register a one-time listener for an event. * The listener will be automatically removed after being called once. * * @param event - The event name to listen for * @param listener - The listener function * @returns Unsubscribe function */ once(event: K, listener: Listener): () => void { return this.addListener(event, listener, true) } /** * Remove a specific listener from an event. * * @param event - The event name * @param listener - The listener function to remove * @returns true if the listener was found and removed */ off(event: K, listener: Listener): boolean { const eventListeners = this.listeners.get(event) if (!eventListeners) { return false } for (const entry of eventListeners) { const resolvedListener = this.resolveListener(entry) if (resolvedListener === listener) { eventListeners.delete(entry) if (eventListeners.size === 0) { this.listeners.delete(event) } return true } } return false } /** * Emit an event to all registered listeners. * * @param event - The event name * @param data - The event data * @returns Number of listeners that were called */ emit(event: K, data: Events[K]): number { const eventListeners = this.listeners.get(event) if (!eventListeners || eventListeners.size === 0) { return 0 } // Create a snapshot to handle modifications during iteration const entries = Array.from(eventListeners) const toRemove: ListenerEntry[] = [] let calledCount = 0 for (const entry of entries) { const listener = this.resolveListener(entry) // Handle garbage collected WeakRefs if (listener === null) { toRemove.push(entry) this.cleanedUpListeners++ continue } // Handle once listeners if (entry.once) { toRemove.push(entry) } // Call the listener with error isolation try { listener(data) calledCount++ } catch (error) { this.config.onListenerError(error, String(event), listener as Listener) calledCount++ // Still count as called even if it errored } } // Remove once listeners and garbage collected weak refs for (const entry of toRemove) { eventListeners.delete(entry) } // Clean up empty event sets if (eventListeners.size === 0) { this.listeners.delete(event) } return calledCount } /** * Remove all listeners for a specific event. * * @param event - The event name * @returns Number of listeners removed */ removeAllListeners(event: K): number { const eventListeners = this.listeners.get(event) if (!eventListeners) { return 0 } const count = eventListeners.size this.listeners.delete(event) return count } /** * Remove all listeners for all events. * * @returns Total number of listeners removed */ clear(): number { let total = 0 for (const eventListeners of this.listeners.values()) { total += eventListeners.size } this.listeners.clear() return total } /** * Get the number of listeners for a specific event. * * @param event - The event name * @returns Number of listeners */ listenerCount(event: K): number { const eventListeners = this.listeners.get(event) if (!eventListeners) { return 0 } // Clean up garbage collected weak refs while counting let count = 0 const toRemove: ListenerEntry[] = [] for (const entry of eventListeners) { const listener = this.resolveListener(entry) if (listener === null) { toRemove.push(entry) this.cleanedUpListeners++ } else { count++ } } // Remove garbage collected entries for (const entry of toRemove) { eventListeners.delete(entry) } if (eventListeners.size === 0) { this.listeners.delete(event) } return count } /** * Get all event names that have listeners. * * @returns Array of event names */ eventNames(): (keyof Events)[] { return Array.from(this.listeners.keys()) } /** * Check if there are any listeners for an event. * * @param event - The event name * @returns true if there are listeners */ hasListeners(event: K): boolean { return this.listenerCount(event) > 0 } /** * Get statistics about the event emitter. * * @returns Statistics object */ getStats(): EventEmitterStats { const listenersPerEvent: Record = {} let totalListeners = 0 for (const [event, eventListeners] of this.listeners) { const count = eventListeners.size listenersPerEvent[String(event)] = count totalListeners += count } return { eventCount: this.listeners.size, totalListeners, listenersPerEvent, cleanedUpListeners: this.cleanedUpListeners, maxListenersWarnings: this.maxListenersWarnings, } } /** * Get the current configuration. * * @returns Configuration object */ getConfig(): Readonly> { return { ...this.config } } /** * Update configuration options. * Note: Changing useWeakRef only affects new listeners. * * @param config - Partial configuration to update */ setConfig(config: Partial): void { Object.assign(this.config, config) } // ============================================================================= // Private Methods // ============================================================================= /** * Internal method to add a listener */ private addListener( event: K, listener: Listener, once: boolean ): () => void { // Get or create the listener set for this event let eventListeners = this.listeners.get(event) if (!eventListeners) { eventListeners = new Set() this.listeners.set(event, eventListeners) } // Check max listeners limit if (this.config.maxListeners > 0 && eventListeners.size >= this.config.maxListeners) { switch (this.config.maxListenersBehavior) { case 'error': throw new Error( `MaxListenersExceeded: Event "${String(event)}" has reached the maximum of ${this.config.maxListeners} listeners. ` + `This may indicate a memory leak. Consider removing unused listeners or increasing maxListeners.` ) case 'warn': this.maxListenersWarnings++ if (this.config.debug) { console.warn( `MaxListenersWarning: Event "${String(event)}" has ${eventListeners.size} listeners, ` + `which exceeds the recommended maximum of ${this.config.maxListeners}. ` + `This may indicate a memory leak.` ) } break case 'ignore': // Do nothing break } } // Create the listener entry const entry: ListenerEntry = { listener: this.config.useWeakRef ? new WeakRef(listener as Listener) : listener as Listener, once, isWeak: this.config.useWeakRef, } eventListeners.add(entry) if (this.config.debug) { console.log( `EventEmitterSafe: Added ${once ? 'once ' : ''}listener for "${String(event)}" ` + `(total: ${eventListeners.size})` ) } // Return unsubscribe function return () => { eventListeners?.delete(entry) if (eventListeners?.size === 0) { this.listeners.delete(event) } if (this.config.debug) { console.log( `EventEmitterSafe: Removed listener for "${String(event)}" ` + `(remaining: ${eventListeners?.size ?? 0})` ) } } } /** * Resolve a listener from an entry, handling WeakRefs * Returns null if the WeakRef has been garbage collected */ private resolveListener(entry: ListenerEntry): Listener | null { if (entry.isWeak) { return (entry.listener as WeakRef>).deref() ?? null } return entry.listener as Listener } } // ============================================================================= // Factory Function // ============================================================================= /** * Create a new EventEmitterSafe instance. * * @param config - Optional configuration * @returns New EventEmitterSafe instance */ export function createEventEmitter = Record>( config?: EventEmitterSafeConfig ): EventEmitterSafe { return new EventEmitterSafe(config) } // ============================================================================= // Utility Types // ============================================================================= /** * Extract event data type from an EventEmitterSafe instance */ export type EventData>, K extends string> = E extends EventEmitterSafe ? K extends keyof Events ? Events[K] : never : never /** * Extract all event names from an EventEmitterSafe instance */ export type EventNames>> = E extends EventEmitterSafe ? keyof Events : never