/** * @packageDocumentation * * Race an event against an AbortSignal, taking care to remove any event * listeners that were added. * * @example Getting started * * ```TypeScript * import { raceEvent } from 'race-event' * * const controller = new AbortController() * const emitter = new EventTarget() * * setTimeout(() => { * controller.abort() * }, 500) * * setTimeout(() => { * // too late * emitter.dispatchEvent(new CustomEvent('event')) * }, 1000) * * // throws an AbortError * const resolve = await raceEvent(emitter, 'event', controller.signal) * ``` * * @example Aborting the promise with an error event * * ```TypeScript * import { raceEvent } from 'race-event' * * const emitter = new EventTarget() * * setTimeout(() => { * emitter.dispatchEvent(new CustomEvent('failure', { * detail: new Error('Oh no!') * })) * }, 1000) * * // throws 'Oh no!' error * const resolve = await raceEvent(emitter, 'success', AbortSignal.timeout(5000), { * errorEvent: 'failure' * }) * ``` * * @example Customising the thrown AbortError * * The error message and `.code` property of the thrown `AbortError` can be * specified by passing options: * * ```TypeScript * import { raceEvent } from 'race-event' * * const controller = new AbortController() * const emitter = new EventTarget() * * setTimeout(() => { * controller.abort() * }, 500) * * // throws a Error: Oh no! * const resolve = await raceEvent(emitter, 'event', controller.signal, { * errorMessage: 'Oh no!', * errorCode: 'ERR_OH_NO' * }) * ``` * * @example Only resolving on specific events * * Where multiple events with the same type are emitted, a `filter` function can * be passed to only resolve on one of them: * * ```TypeScript * import { raceEvent } from 'race-event' * * const controller = new AbortController() * const emitter = new EventTarget() * * // throws a Error: Oh no! * const resolve = await raceEvent(emitter, 'event', controller.signal, { * filter: (evt: Event) => { * return evt.detail.foo === 'bar' * } * }) * ``` * * @example Terminating early by throwing from the filter * * You can cause listening for the event to cease and all event listeners to be * removed by throwing from the filter: * * ```TypeScript * import { raceEvent } from 'race-event' * * const controller = new AbortController() * const emitter = new EventTarget() * * // throws Error: Cannot continue * const resolve = await raceEvent(emitter, 'event', controller.signal, { * filter: (evt) => { * if (...reasons) { * throw new Error('Cannot continue') * } * * return true * } * }) * ``` */ import { AbortError } from 'abort-error' import type { EventEmitter } from 'node:events' export interface RaceEventOptions { /** * The message for the error thrown if the signal aborts */ errorMessage?: string /** * The code for the error thrown if the signal aborts */ errorCode?: string /** * The name of an event emitted on the emitter that should cause the returned * promise to reject. The rejection reason will be the `.detail` field of the * event. * * @default "error" */ errorEvent?: string /** * If the 'errorEvent' option has been passed, and the emitted event has no * `.detail` field, reject the promise with this error instead. */ error?: Error /** * When multiple events with the same name may be emitted, pass a filter * function here to allow ignoring ones that should not cause the returned * promise to resolve. */ filter?(evt: T): boolean } /** * Race a promise against an abort signal */ export async function raceEvent (emitter: EventTarget | EventEmitter, eventName: string, signal?: AbortSignal, opts?: RaceEventOptions): Promise { // create the error here so we have more context in the stack trace const error = new AbortError(opts?.errorMessage) if (opts?.errorCode != null) { // @ts-expect-error not a field of AbortError error.code = opts.errorCode } const errorEvent = opts?.errorEvent ?? 'error' if (signal?.aborted === true) { return Promise.reject(error) } return new Promise((resolve, reject) => { function removeListeners (): void { removeListener(signal, 'abort', abortListener) removeListener(emitter, eventName, eventListener) removeListener(emitter, errorEvent, errorEventListener) } const eventListener = (evt: any): void => { try { if (opts?.filter?.(evt) === false) { return } } catch (err: any) { removeListeners() reject(err) return } removeListeners() resolve(evt) } const errorEventListener = (evt: any): void => { removeListeners() if (evt instanceof Error) { reject(evt) return } reject(evt.detail ?? opts?.error ?? new Error(`The "${opts?.errorEvent}" event was emitted but the event had no '.detail' field. Pass an 'error' option to race-event to change this message.`)) } const abortListener = (): void => { removeListeners() reject(error) } addListener(signal, 'abort', abortListener) addListener(emitter, eventName, eventListener) addListener(emitter, errorEvent, errorEventListener) }) } function addListener (emitter: EventEmitter | EventTarget | undefined, event: string, listener: any): void { if (emitter == null) { return } if (isEventTarget(emitter)) { emitter.addEventListener(event, listener) } else { emitter.addListener(event, listener) } } function removeListener (emitter: EventEmitter | EventTarget | undefined, event: string, listener: any): void { if (emitter == null) { return } if (isEventTarget(emitter)) { emitter.removeEventListener(event, listener) } else { emitter.removeListener(event, listener) } } function isEventTarget (emitter: any): emitter is EventTarget { return typeof emitter.addEventListener === 'function' && typeof emitter.removeEventListener === 'function' }