import { LensList } from './lens-list' export type DefaultEventMap = { [eventType: string]: TypedEvent } /** * Reserved event map containing special event types like '*' for catch-all listeners. */ export type ReservedEventMap = { '*': TypedEvent } type IsReservedEvent = Type extends keyof ReservedEventMap ? true : false export interface TypedEvent< DataType = void, ReturnType = void, EventType extends string = string, > extends Omit, 'type'> { type: EventType } const kDefaultPrevented = Symbol('kDefaultPrevented') const kPropagationStopped = Symbol('kPropagationStopped') const kImmediatePropagationStopped = Symbol('kImmediatePropagationStopped') export class TypedEvent< DataType = void, ReturnType = void, EventType extends string = string, > extends MessageEvent implements TypedEvent { /** * @note Keep a placeholder property with the return type * because the type must be set somewhere in order to be * correctly associated and inferred from the event. */ #returnType: ReturnType; [kDefaultPrevented]: boolean; [kPropagationStopped]?: Emitter; [kImmediatePropagationStopped]?: boolean constructor( ...args: [DataType] extends [void] ? [type: EventType] : [type: EventType, init: { data: DataType }] ) { super(args[0], args[1]) this[kDefaultPrevented] = false } get defaultPrevented(): boolean { return this[kDefaultPrevented] } public preventDefault(): void { super.preventDefault() this[kDefaultPrevented] = true } public stopImmediatePropagation(): void { /** * @note Despite `.stopPropagation()` and `.stopImmediatePropagation()` being defined * in Node.js, they do nothing. It is safe to re-define them. */ super.stopImmediatePropagation() this[kImmediatePropagationStopped] = true } } /** * Brands a TypedEvent or its subclass while preserving its (narrower) type. */ type Brand< Event extends TypedEvent, EventType extends string, Loose extends boolean = false, > = Loose extends true ? Event extends TypedEvent ? /** * @note Omit the `ReturnType` so emit methods can accept type events * where infering the return type is impossible. */ TypedEvent & { type: EventType } : never : Event & { type: EventType } type InferEventMap> = Target extends Emitter ? WithReservedEvents : never /** * Extracts only user-defined events, excluding reserved event types. */ type UserEventMap = Omit< EventMap, keyof ReservedEventMap > /** * Decorates the given `EventMap` with the reserved emitter events (e.g. `*`). */ export type WithReservedEvents = EventMap & ReservedEventMap /** * Creates a union of all events in the EventMap with their literal type strings. */ type AllEvents = { [K in keyof EventMap & string]: Brand }[keyof EventMap & string] export type TypedListenerOptions = { once?: boolean signal?: AbortSignal } export interface HookListenerOptions extends TypedListenerOptions { persist?: boolean } export type EmitterHookMap = { newListener: ( type: keyof WithReservedEvents & string, listener: { [K in keyof WithReservedEvents & string]: Emitter.Listener< Emitter, K, WithReservedEvents > }[keyof WithReservedEvents & string], options: HookListenerOptions | undefined, ) => void removeListener: ( type: keyof WithReservedEvents & string, listener: { [K in keyof WithReservedEvents & string]: Emitter.Listener< Emitter, K, WithReservedEvents > }[keyof WithReservedEvents & string], options: HookListenerOptions | undefined, ) => void beforeEmit: (event: EventMap[keyof EventMap & string]) => boolean | void } export namespace Emitter { /** * Returns a union of all event types, both public and reserved, for the given emitter. * * @example * const emitter = new Emitter<{ greeting: TypedEvent, handshake: TypedEvent }>() * type AllEventTypes = Emitter.AllEventTypes * // "*" | "greeting" | "handshake" */ export type AllEventTypes< Target extends Emitter, EventMap extends DefaultEventMap = InferEventMap, > = string extends keyof EventMap ? never : keyof EventMap & string /** * Returns a union of all public event types for the given emitter. * * @example * const emitter = new Emitter<{ greeting: TypedEvent, handshake: TypedEvent }>() * type EventTypes = Emitter.EventTypes * // "greeting" | "handshake" */ export type PublicEventTypes< Target extends Emitter, EventMap extends DefaultEventMap = InferEventMap, UserEvents extends UserEventMap = UserEventMap, > = string extends keyof UserEvents ? never : keyof UserEvents & string /** * Returns a union of all public event type for the given emitter. * * @example * const emitter = new Emitter<{ greeting: GreetingEvent, handshake: HandshakeEvent }>() * type Events = Emitter.Events * // GreetingEvent | HandshakeEvent */ export type Events< Target extends Emitter, EventMap extends DefaultEventMap = InferEventMap, > = string extends keyof UserEventMap ? never : UserEventMap[keyof UserEventMap] /** * Returns an appropriate `Event` type for the given event type. * * @example * const emitter = new Emitter<{ greeting: TypedEvent }>() * type GreetingEvent = Emitter.Event * // TypedEvent */ export type Event< Target extends Emitter, EventType extends keyof EventMap & string, EventMap extends DefaultEventMap = InferEventMap, > = IsReservedEvent extends true ? AllEvents> : Brand /** * Returns an appropriate event data type for the given event type. * * @example * const emitter = new Emitter<{ greeting: TypedEvent<'hello'> }>() * type GreetingData = Emitter.EventData * // "hello" */ export type EventData< Target extends Emitter, EventType extends keyof EventMap & string, EventMap extends DefaultEventMap = InferEventMap, > = EventMap[EventType] extends TypedEvent ? DataType : never /** * Returns the listener type for the given event type. * * @example * const emitter = new Emitter<{ getTotalPrice: TypedEvent }>() * type Listener = Emitter.ListenerType * // (event: TypedEvent) => number */ export type Listener< Target extends Emitter, EventType extends keyof EventMap & string, EventMap extends DefaultEventMap = InferEventMap, > = IsReservedEvent extends true ? (event: Emitter.Event) => void : ( event: Emitter.Event, ) => Emitter.ListenerReturnType extends [ void, ] ? void : Emitter.ListenerReturnType /** * Returns the return type of the listener for the given event type. * * @example * const emitter = new Emitter<{ getTotalPrice: TypedEvent }>() * type ListenerReturnType = Emitter.ListenerReturnType * // number */ export type ListenerReturnType< Target extends Emitter, EventType extends keyof EventMap & string, EventMap extends DefaultEventMap = InferEventMap, > = IsReservedEvent extends true ? void : EventMap[EventType] extends TypedEvent ? ReturnType : never } export namespace EventMap { /** * Returns a union of all public event types from the given event map. * * @example * type MyEventMap = { greeting: TypedEvent, handshake: TypedEvent } * type EventTypes = EventMap.EventTypes * // "greeting" | "handshake" */ export type EventTypes = Emitter.PublicEventTypes> /** * Returns a union of all public event type from the given event map. * * @example * type MyEventMap = { greeting: GreetingEvent, handshake: HandshakeEvent } * type Events = EventMap.Events * // GreetingEvent | HandshakeEvent */ export type Events = Emitter.Events> /** * Returns an appropriate `Event` type for the given event type. * * @example * type MyEventMap = { greeting: TypedEvent } * type GreetingEvent = EventMap.Event * // TypedEvent */ export type Event< Map extends DefaultEventMap, Type extends keyof WithReservedEvents & string, > = Emitter.Event, Type, WithReservedEvents> /** * Returns an appropriate event data type for the given event type. * * @example * type MyEventMap = { greeting: TypedEvent<'hello'> } * type GreetingData = EventMap.EventData * // "hello" */ export type EventData< Map extends DefaultEventMap, Type extends keyof WithReservedEvents & string, > = Emitter.EventData, Type, WithReservedEvents> /** * Returns the listener type for the given event type. * * @example * type MyEventMap = { getTotalPrice: TypedEvent }> * type Listener = EventMap.Listener * // (event: TypedEvent) => number */ export type Listener< Map extends DefaultEventMap, Type extends keyof WithReservedEvents & string, > = Emitter.Listener, Type, WithReservedEvents> /** * Returns the return type of the listener for the given event type. * * @example * type MyEventMap = { getTotalPrice: TypedEvent } * type ListenerReturnType = EventMap.ListenerReturnType * // number */ export type ListenerReturnType< Map extends DefaultEventMap, Type extends keyof WithReservedEvents & string, > = Emitter.ListenerReturnType, Type, WithReservedEvents> } export class Emitter { #listeners: LensList< Record< string, Emitter.Listener< typeof this, keyof WithReservedEvents & string, WithReservedEvents > > > #listenerOptions: WeakMap #listenerAbortCleanups: WeakMap void> #typelessListeners: WeakSet #hookListeners: LensList> #hookListenerOptions: WeakMap #hookListenerAbortCleanups: WeakMap void> public readonly hooks: { on>( type: HookType, callback: EmitterHookMap[HookType], options?: HookListenerOptions, ): void removeListener>( type: HookType, callback: EmitterHookMap[HookType], options?: HookListenerOptions, ): void } constructor() { this.#listeners = new LensList() this.#listenerOptions = new WeakMap() this.#listenerAbortCleanups = new WeakMap() this.#typelessListeners = new WeakSet() this.#hookListeners = new LensList() this.#hookListenerOptions = new WeakMap() this.#hookListenerAbortCleanups = new WeakMap() this.hooks = { on: (hook, callback, options) => { /** * @note An already-aborted signal would never fire its 'abort' event, * leaving the hook registered indefinitely. Skip registration entirely. */ if (options?.signal?.aborted) { return } if (options?.once) { const original = callback as (...args: Array) => void const wrapper = ((...args: Array) => { this.#deleteHookListener(hook, wrapper) return original(...args) }) as typeof callback callback = wrapper } this.#hookListeners.append(hook, callback) if (options) { this.#hookListenerOptions.set(callback, options) } if (options?.signal) { const { signal } = options const onAbort = () => { this.#deleteHookListener(hook, callback) } signal.addEventListener('abort', onAbort, { once: true }) this.#hookListenerAbortCleanups.set(callback, () => { signal.removeEventListener('abort', onAbort) }) } }, removeListener: (hook, callback) => { this.#deleteHookListener(hook, callback) }, } } #deleteHookListener>( hook: HookType, callback: EmitterHookMap[HookType], ): void { this.#hookListeners.delete(hook, callback) const cleanup = this.#hookListenerAbortCleanups.get(callback) if (cleanup) { cleanup() this.#hookListenerAbortCleanups.delete(callback) } } #deleteListener< EventType extends keyof WithReservedEvents & string, >( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, ): boolean { const removed = this.#listeners.delete(type, listener) const cleanup = this.#listenerAbortCleanups.get(listener) if (cleanup) { cleanup() this.#listenerAbortCleanups.delete(listener) } return removed } /** * Adds a listener for the given event type. */ public on & string>( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, options?: TypedListenerOptions, ): typeof this { this.#addListener(type, listener, options) return this } /** * Adds a one-time listener for the given event type. */ public once & string>( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, options?: Omit, ): typeof this { return this.on(type, listener, { ...(options || {}), once: true, }) } /** * Prepends a listener for the given event type. */ public earlyOn & string>( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, options?: TypedListenerOptions, ): typeof this { this.#addListener(type, listener, options, 'prepend') return this } /** * Prepends a one-time listener for the given event type. */ public earlyOnce< EventType extends keyof WithReservedEvents & string, >( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, options?: Omit, ): typeof this { return this.earlyOn(type, listener, { ...(options || {}), once: true, }) } /** * Emits the given typed event. * * @returns {boolean} Returns `true` if the event had any listeners, `false` otherwise. */ public emit( event: Brand, ): boolean { if (this.#listeners.size === 0) { return false } /** * @note Calculate matching listeners before calling them * since one-time listeners will self-destruct. */ const hasListeners = this.listenerCount(event.type) > 0 const proxiedEvent = this.#proxyEvent(event) for (const listener of this.#matchListeners(event.type)) { if ( proxiedEvent.event[kPropagationStopped] != null && proxiedEvent.event[kPropagationStopped] !== this ) { proxiedEvent.revoke() return false } if (proxiedEvent.event[kImmediatePropagationStopped]) { break } this.#callListener(proxiedEvent.event, listener) } proxiedEvent.revoke() return hasListeners } /** * Emits the given typed event and returns a promise that resolves * when all the listeners for that event have settled. * * @returns {Promise>} A promise that resolves * with the return values of all listeners. */ public async emitAsPromise( event: Brand, ): Promise< Array> > { if (this.#listeners.size === 0) { return [] } const pendingListeners: Array< Promise> > = [] const proxiedEvent = this.#proxyEvent(event) for (const listener of this.#matchListeners(event.type)) { if ( proxiedEvent.event[kPropagationStopped] != null && proxiedEvent.event[kPropagationStopped] !== this ) { proxiedEvent.revoke() return [] } if (proxiedEvent.event[kImmediatePropagationStopped]) { break } const listenerPromise = Promise.resolve( this.#callListener(proxiedEvent.event, listener), ) const returnValue = await listenerPromise if (!this.#isTypelessListener(listener)) { pendingListeners.push(returnValue) } } proxiedEvent.revoke() return Promise.allSettled(pendingListeners).then((results) => { return results.map((result) => result.status === 'fulfilled' ? result.value : result.reason, ) }) } /** * Emits the given event and returns a generator that yields * the result of each listener in the order of their registration. * This way, you stop exhausting the listeners once you get the expected value. */ public *emitAsGenerator( event: Brand, ): Generator> { if (this.#listeners.size === 0) { return } const proxiedEvent = this.#proxyEvent(event) for (const listener of this.#matchListeners(event.type)) { if ( proxiedEvent.event[kPropagationStopped] != null && proxiedEvent.event[kPropagationStopped] !== this ) { proxiedEvent.revoke() return } if (proxiedEvent.event[kImmediatePropagationStopped]) { break } const returnValue = this.#callListener(proxiedEvent.event, listener) if (!this.#isTypelessListener(listener)) { yield returnValue } } proxiedEvent.revoke() } /** * Removes a listener for the given event type. */ public removeListener< EventType extends keyof WithReservedEvents & string, >( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, ): void { const options = this.#listenerOptions.get(listener) if (!this.#deleteListener(type, listener)) { return } /** * @note Snapshot the hook list before iterating so a hook that removes * another `removeListener` hook can't shift the live array mid-iteration. */ for (const hook of this.#hookListeners.get('removeListener').slice()) { hook( type, listener as Parameters['removeListener']>[1], options, ) } } /** * Removes all listeners for the given event type. * If no event type is provided, removes all existing listeners. */ public removeAllListeners< EventType extends keyof WithReservedEvents & string, >(type?: EventType): void { if (type == null) { for (const [listenerType, listeners] of this.#listeners.entries()) { while (listeners.length > 0) { this.removeListener( listenerType as keyof WithReservedEvents & string, listeners[0], ) } } for (const [hookType, hookListener] of [...this.#hookListeners]) { if (!this.#hookListenerOptions.get(hookListener)?.persist) { this.#deleteHookListener( hookType as keyof EmitterHookMap, hookListener as EmitterHookMap[keyof EmitterHookMap], ) } } return } const listeners = this.listeners(type) while (listeners.length > 0) { this.removeListener(type, listeners[0]) } } /** * Returns the list of listeners for the given event type. * If no even type is provided, returns all listeners. */ public listeners< EventType extends keyof WithReservedEvents & string, >( type?: EventType, ): Array< Emitter.Listener> > { if (type == null) { return this.#listeners.getAll() } return this.#listeners.get(type) } /** * Returns the number of listeners for the given event type. * If no even type is provided, returns the total number of listeners. */ public listenerCount< EventType extends keyof WithReservedEvents & string, >(type?: EventType): number { if (type == null) { return this.#listeners.size } return this.listeners(type).length } #addListener & string>( type: EventType, listener: Emitter.Listener< typeof this, EventType, WithReservedEvents >, options: TypedListenerOptions | undefined, insertMode: 'append' | 'prepend' = 'append', ): void { /** * @note An already-aborted signal would never fire its 'abort' event, * leaving the listener registered indefinitely. Skip registration entirely. */ if (options?.signal?.aborted) { return } for (const hook of this.#hookListeners.get('newListener').slice()) { hook( type, listener as Parameters['newListener']>[1], options, ) } if (type === '*') { this.#typelessListeners.add(listener) } if (insertMode === 'prepend') { this.#listeners.prepend(type, listener) } else { this.#listeners.append(type, listener) } if (options) { this.#listenerOptions.set(listener, options) if (options.signal) { const { signal } = options const onAbort = () => { this.removeListener(type, listener) } signal.addEventListener('abort', onAbort, { once: true }) this.#listenerAbortCleanups.set(listener, () => { signal.removeEventListener('abort', onAbort) }) } } } #proxyEvent( event: Event, ): { event: Event; revoke: () => void } { const { stopPropagation } = event event.stopPropagation = () => { event[kPropagationStopped] = this stopPropagation.call(event) } return { event, revoke() { event.stopPropagation = stopPropagation }, } } #callListener(event: Event, listener: (event: any) => any) { for (const hook of this.#hookListeners.get('beforeEmit').slice()) { if (hook(event as EventMap[keyof EventMap & string]) === false) { return } } const returnValue = listener.call(this, event) const options = this.#listenerOptions.get(listener) if (options?.once) { const type = this.#isTypelessListener(listener) ? '*' : event.type if (this.#deleteListener(type, listener)) { for (const hook of this.#hookListeners.get('removeListener').slice()) { hook(type, listener, options) } } } return returnValue } /** * Return a list of all event listeners relevant for the given event type. * This includes the explicit event listeners and also typeless event listeners. * * @note Snapshot the matching listeners before yielding. Listeners can add or * remove other listeners during emission (e.g. `earlyOn` unshifts `#list`), * which would otherwise shift the live iterator and re-yield prior entries. */ *#matchListeners(type: EventType) { const snapshot: Array< Emitter.Listener< typeof this, keyof WithReservedEvents & string, WithReservedEvents > > = [] for (const [key, listener] of this.#listeners) { if (key === '*' || key === type) { snapshot.push(listener) } } yield* snapshot } #isTypelessListener(listener: any): boolean { return this.#typelessListeners.has(listener) } }