async_event_emitter.js

'use strict'

const _isRegExp = require('lodash/isRegExp')
const _isUndefined = require('lodash/isUndefined')
const PI = require('p-iteration')

const CATCH_ALL_EVENT_NAME = '_*'

/**
 * Event emitter class that provides an async `emit` function, useful for when
 * one needs to `await` the event and all of its listeners.
 */
class AsyncEventEmitter {
  constructor () {
    this.listeners = []
  }

  /**
   * Removes all listeners, only those for the specified event name, or those
   * matching/not matching a regular expression
   *
   * @param {string|RegExp} matcher - regular expression or string to match with
   * @param {boolean} negativeMatch - if true, events not matching are deleted
   */
  removeAllListeners (matcher, negativeMatch) {
    if (_isUndefined(matcher) && !negativeMatch) {
      this.listeners = []
      return
    }

    let l
    let matched

    for (let i = this.listeners.length - 1; i >= 0; i -= 1) {
      l = this.listeners[i]
      matched = false

      if (
        (_isRegExp(matcher) && matcher.test(l.matcher.toString())) ||
        (l.matcher === matcher)
      ) {
        if (!negativeMatch) {
          this.listeners.splice(i, 1)
        }

        matched = true
      }

      if (!matched && negativeMatch) {
        this.listeners.splice(i, 1)
      }
    }
  }

  /**
   * Remove an event handler by event name
   *
   * @param {Function} cb - callback
   */
  off (cb) {
    for (let i = this.listeners.length - 1; i >= 0; i -= 1) {
      if (this.listeners[i].cb === cb) {
        this.listeners.splice(i, 1)
      }
    }
  }

  /**
   * Bind an event handler that should only fire once
   *
   * @param {string|RegExp} matcher - regular expression or string to match with
   * @param {Function} cb - callback
   */
  once (matcher, cb) {
    this.listeners.push({
      type: 'once',
      matcher,
      cb
    })
  }

  /**
   * Bind an event handler
   *
   * @param {string|RegExp} matcher - regular expression or string to match with
   * @param {Function} cb - callback
   */
  on (matcher, cb) {
    this.listeners.push({
      type: 'on',
      matcher,
      cb
    })
  }

  /**
   * Bind an event handler for all event types
   *
   * @param {Function} cb - callback
   * @returns {Promise} p - resolves when all listeners complete
   */
  onAll (cb) {
    return this.on(CATCH_ALL_EVENT_NAME, cb) // special event handler
  }

  /**
   * Bind an event handler for all event types that only fires once
   *
   * @param {Function} cb - callback
   * @returns {Promise} p - resolves when all listeners complete
   */
  onAllOnce (cb) {
    return this.once(CATCH_ALL_EVENT_NAME, cb) // special event handler
  }

  /**
   * Emit an event; can be await'ed, and will resolve after all handlers have
   * been called
   *
   * @param {string} eventName - event name to emit
   * @param  {...any} args - arguments to pass to listeners
   * @returns {Promise} p - resolves when all listeners complete
   */
  async emit (eventName, ...args) {
    const indexesToRemove = []

    await PI.forEachSeries(this.listeners, async (l, i) => {
      if (
        (_isRegExp(l.matcher) && l.matcher.test(eventName)) ||
        (l.matcher === eventName)
      ) {
        await l.cb(...args)

        if (l.type === 'once') {
          indexesToRemove.push(i)
        }
      }
    })

    indexesToRemove.forEach(i => this.listeners.splice(i, 1))

    if (eventName !== CATCH_ALL_EVENT_NAME) {
      await this.emit(CATCH_ALL_EVENT_NAME, ...args)
    }
  }
}

module.exports = AsyncEventEmitter