clean-jsdoc-theme TypeDoc APIv0.0.0
docs-site/typedoc-src/services/EventEmitter.tstypescript
/**
 * Event Emitter Service
 *
 * @remarks
 * A type-safe event emitter implementation supporting
 * synchronous and asynchronous event handling.
 *
 * @packageDocumentation
 */

/**
 * Event handler function type
 *
 * @typeParam T - Event data type
 * @param data - Event data
 * @public
 */
export type EventHandler<T = unknown> = (data: T) => void | Promise<void>

/**
 * Event subscription object
 *
 * @public
 */
export interface EventSubscription {
  /** Removes the subscription */
  unsubscribe(): void
  /** Whether the subscription is active */
  readonly isActive: boolean
}

/**
 * Event listener options
 *
 * @public
 */
export interface ListenerOptions {
  /**
   * If true, the listener is automatically removed after first invocation
   * @defaultValue false
   */
  once?: boolean

  /**
   * Priority for execution order (higher = earlier)
   * @defaultValue 0
   */
  priority?: number

  /**
   * Optional context to bind to the handler
   */
  context?: unknown
}

/**
 * Internal listener storage structure
 *
 * @internal
 */
interface ListenerEntry<T> {
  handler: EventHandler<T>
  options: ListenerOptions
  id: number
}

/**
 * Type-safe event emitter class
 *
 * @remarks
 * Provides a robust event system with support for:
 * - Typed events with type inference
 * - One-time listeners
 * - Priority-based execution
 * - Async event handlers
 * - Wildcard event listening
 *
 * A local image referenced from a TypeScript doc comment is copied into the
 * built site and its path rewritten, just like in `docs` pages and the README.
 * The diagram below lives in `img/event-flow.svg`, referenced from this source
 * file (`src/services/`) with a `../../img/` relative path:
 *
 * ![Event flow from emit() through EventEmitter to Listeners](../../img/event-flow.svg)
 *
 * @typeParam TEvents - Event map type defining event names and data types
 *
 * @example
 * Define typed events:
 * ```typescript
 * interface MyEvents {
 *   'user:login': { userId: string; timestamp: Date }
 *   'user:logout': { userId: string }
 *   'error': Error
 *   'ready': void
 * }
 *
 * const emitter = new EventEmitter<MyEvents>()
 * ```
 *
 * @example
 * Subscribe to events:
 * ```typescript
 * // Type-safe event subscription
 * emitter.on('user:login', (data) => {
 *   console.log(`User ${data.userId} logged in at ${data.timestamp}`)
 * })
 *
 * // One-time listener
 * emitter.once('ready', () => {
 *   console.log('Application is ready!')
 * })
 *
 * // Priority listener (executes first)
 * emitter.on('error', (error) => {
 *   console.error('Critical error:', error)
 * }, { priority: 100 })
 * ```
 *
 * @example
 * Emit events:
 * ```typescript
 * emitter.emit('user:login', {
 *   userId: 'user-123',
 *   timestamp: new Date()
 * })
 *
 * emitter.emit('ready')
 * ```
 *
 * @public
 */
export class EventEmitter<TEvents extends Record<string, unknown> = Record<string, unknown>> {
  /**
   * Map of event names to listener arrays
   * @internal
   */
  private listeners: Map<keyof TEvents | '*', ListenerEntry<unknown>[]> = new Map()

  /**
   * Counter for generating unique listener IDs
   * @internal
   */
  private nextListenerId = 0

  /**
   * Maximum number of listeners per event (0 = unlimited)
   */
  private maxListeners = 0

  /**
   * Sets the maximum number of listeners per event
   *
   * @param max - Maximum listeners (0 for unlimited)
   * @returns This emitter for chaining
   *
   * @example
   * ```typescript
   * emitter.setMaxListeners(10)
   * ```
   */
  public setMaxListeners(max: number): this {
    this.maxListeners = max
    return this
  }

  /**
   * Gets the maximum number of listeners per event
   *
   * @returns Maximum listeners count
   */
  public getMaxListeners(): number {
    return this.maxListeners
  }

  /**
   * Adds an event listener
   *
   * @typeParam K - Event name type
   * @param event - Event name to listen for
   * @param handler - Handler function
   * @param options - Listener options
   * @returns Subscription object for unsubscribing
   *
   * @example
   * ```typescript
   * const subscription = emitter.on('user:login', (data) => {
   *   console.log(data.userId)
   * })
   *
   * // Later: remove the listener
   * subscription.unsubscribe()
   * ```
   */
  public on<K extends keyof TEvents>(
    event: K,
    handler: EventHandler<TEvents[K]>,
    options: ListenerOptions = {}
  ): EventSubscription {
    return this.addListener(event, handler, options)
  }

  /**
   * Adds a one-time event listener
   *
   * @typeParam K - Event name type
   * @param event - Event name to listen for
   * @param handler - Handler function
   * @param options - Listener options (once is automatically set to true)
   * @returns Subscription object
   *
   * @example
   * ```typescript
   * emitter.once('ready', () => {
   *   console.log('This runs only once!')
   * })
   * ```
   */
  public once<K extends keyof TEvents>(
    event: K,
    handler: EventHandler<TEvents[K]>,
    options: ListenerOptions = {}
  ): EventSubscription {
    return this.addListener(event, handler, { ...options, once: true })
  }

  /**
   * Removes an event listener
   *
   * @typeParam K - Event name type
   * @param event - Event name
   * @param handler - Handler function to remove
   * @returns True if a listener was removed
   */
  public off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): boolean {
    const eventListeners = this.listeners.get(event)
    if (!eventListeners) return false

    const index = eventListeners.findIndex((l) => l.handler === handler)
    if (index !== -1) {
      eventListeners.splice(index, 1)
      return true
    }
    return false
  }

  /**
   * Emits an event to all listeners
   *
   * @typeParam K - Event name type
   * @param event - Event name to emit
   * @param data - Event data
   * @returns True if event had listeners
   *
   * @example
   * ```typescript
   * emitter.emit('user:login', {
   *   userId: 'user-123',
   *   timestamp: new Date()
   * })
   * ```
   */
  public emit<K extends keyof TEvents>(
    event: K,
    ...args: TEvents[K] extends void ? [] : [data: TEvents[K]]
  ): boolean {
    const data = args[0]
    const eventListeners = this.listeners.get(event) ?? []
    const wildcardListeners = this.listeners.get('*') ?? []

    const allListeners = [...eventListeners, ...wildcardListeners].sort(
      (a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)
    )

    if (allListeners.length === 0) return false

    const toRemove: ListenerEntry<unknown>[] = []

    for (const listener of allListeners) {
      const context = listener.options.context
      const handler = listener.handler

      if (context) {
        handler.call(context, data)
      } else {
        handler(data)
      }

      if (listener.options.once) {
        toRemove.push(listener)
      }
    }

    // Remove one-time listeners
    for (const listener of toRemove) {
      this.removeListenerById(event, listener.id)
    }

    return true
  }

  /**
   * Emits an event and waits for all async handlers
   *
   * @typeParam K - Event name type
   * @param event - Event name to emit
   * @param data - Event data
   * @returns Promise that resolves when all handlers complete
   *
   * @example
   * ```typescript
   * await emitter.emitAsync('data:save', { records: [...] })
   * console.log('All save handlers completed!')
   * ```
   */
  public async emitAsync<K extends keyof TEvents>(
    event: K,
    ...args: TEvents[K] extends void ? [] : [data: TEvents[K]]
  ): Promise<boolean> {
    const data = args[0]
    const eventListeners = this.listeners.get(event) ?? []
    const wildcardListeners = this.listeners.get('*') ?? []

    const allListeners = [...eventListeners, ...wildcardListeners].sort(
      (a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)
    )

    if (allListeners.length === 0) return false

    const toRemove: ListenerEntry<unknown>[] = []
    const promises: Promise<void>[] = []

    for (const listener of allListeners) {
      const context = listener.options.context
      const handler = listener.handler

      const result = context ? handler.call(context, data) : handler(data)

      if (result instanceof Promise) {
        promises.push(result)
      }

      if (listener.options.once) {
        toRemove.push(listener)
      }
    }

    await Promise.all(promises)

    // Remove one-time listeners
    for (const listener of toRemove) {
      this.removeListenerById(event, listener.id)
    }

    return true
  }

  /**
   * Gets the number of listeners for an event
   *
   * @param event - Event name
   * @returns Number of listeners
   */
  public listenerCount<K extends keyof TEvents>(event: K): number {
    return this.listeners.get(event)?.length ?? 0
  }

  /**
   * Gets all event names with listeners
   *
   * @returns Array of event names
   */
  public eventNames(): (keyof TEvents)[] {
    return Array.from(this.listeners.keys()) as (keyof TEvents)[]
  }

  /**
   * Removes all listeners for an event or all events
   *
   * @param event - Optional event name (if omitted, removes all)
   * @returns This emitter for chaining
   */
  public removeAllListeners<K extends keyof TEvents>(event?: K): this {
    if (event !== undefined) {
      this.listeners.delete(event)
    } else {
      this.listeners.clear()
    }
    return this
  }

  /**
   * Adds a wildcard listener that receives all events
   *
   * @param handler - Handler function
   * @param options - Listener options
   * @returns Subscription object
   *
   * @example
   * ```typescript
   * emitter.onAny((data) => {
   *   console.log('Event emitted:', data)
   * })
   * ```
   */
  public onAny(handler: EventHandler<unknown>, options: ListenerOptions = {}): EventSubscription {
    return this.addListener('*' as keyof TEvents, handler as EventHandler<TEvents[keyof TEvents]>, options)
  }

  /**
   * Waits for an event to be emitted
   *
   * @typeParam K - Event name type
   * @param event - Event to wait for
   * @param timeout - Optional timeout in milliseconds
   * @returns Promise resolving to the event data
   * @throws {@link Error} If timeout is reached
   *
   * @example
   * ```typescript
   * const loginData = await emitter.waitFor('user:login', 5000)
   * console.log('User logged in:', loginData.userId)
   * ```
   */
  public waitFor<K extends keyof TEvents>(event: K, timeout?: number): Promise<TEvents[K]> {
    return new Promise((resolve, reject) => {
      let timeoutId: ReturnType<typeof setTimeout> | undefined

      const subscription = this.once(event, ((data: TEvents[K]) => {
        if (timeoutId) clearTimeout(timeoutId)
        resolve(data)
      }) as EventHandler<TEvents[K]>)

      if (timeout !== undefined) {
        timeoutId = setTimeout(() => {
          subscription.unsubscribe()
          reject(new Error(`Timeout waiting for event: ${String(event)}`))
        }, timeout)
      }
    })
  }

  /**
   * Internal method to add a listener
   *
   * @internal
   */
  private addListener<K extends keyof TEvents>(
    event: K,
    handler: EventHandler<TEvents[K]>,
    options: ListenerOptions
  ): EventSubscription {
    let eventListeners = this.listeners.get(event)
    if (!eventListeners) {
      eventListeners = []
      this.listeners.set(event, eventListeners)
    }

    if (this.maxListeners > 0 && eventListeners.length >= this.maxListeners) {
      console.warn(`MaxListenersExceededWarning: Possible memory leak detected. ${eventListeners.length} listeners added for event "${String(event)}"`)
    }

    const id = this.nextListenerId++
    const entry: ListenerEntry<TEvents[K]> = {
      handler,
      options,
      id,
    }

    eventListeners.push(entry as ListenerEntry<unknown>)

    let isActive = true
    return {
      unsubscribe: () => {
        if (isActive) {
          this.removeListenerById(event, id)
          isActive = false
        }
      },
      get isActive() {
        return isActive
      },
    }
  }

  /**
   * Removes a listener by its internal ID
   *
   * @internal
   */
  private removeListenerById<K extends keyof TEvents>(event: K, id: number): void {
    const eventListeners = this.listeners.get(event)
    if (!eventListeners) return

    const index = eventListeners.findIndex((l) => l.id === id)
    if (index !== -1) {
      eventListeners.splice(index, 1)
    }
  }
}

/**
 * Creates a new EventEmitter instance
 *
 * @typeParam TEvents - Event map type
 * @returns New EventEmitter instance
 *
 * @example
 * ```typescript
 * interface AppEvents {
 *   'start': void
 *   'stop': { reason: string }
 * }
 *
 * const events = createEventEmitter<AppEvents>()
 * events.on('stop', ({ reason }) => console.log(reason))
 * ```
 *
 * @public
 */
export function createEventEmitter<
  TEvents extends Record<string, unknown> = Record<string, unknown>
>(): EventEmitter<TEvents> {
  return new EventEmitter<TEvents>()
}