import type { DeeplyReadonly } from './types.ts'; import { WaitForTimeoutError } from './errors.ts'; import { deepFreeze } from './misc.ts'; export type EventMap = Record; type Listener = (detail: DeeplyReadonly) => void; /** * Internally permits listeners for individual event payloads to be stored * in a collection typed with the union of all event payloads. */ type StoredListener = { bivarianceHack(detail: DeeplyReadonly): void; }['bivarianceHack']; /** * A listener entry. * @template T - The event payload type. */ interface ListenerEntry { listener: StoredListener; wrappedListener: StoredListener; cancel: () => void; } /** * Callback returned by {@link on} and {@link once} for removing a listener. */ export type OffCallback = () => void; /** * A simple event emitter implementation. * @template T - The event map type. */ export class EventEmitter { /** * The listeners map. * @private */ #listeners: Map>> = new Map(); /** * Add a listener for an event. * @param type - The event type. * @param listener - The listener function. * @param debounceMilliseconds - The debounce time in milliseconds. * @returns An off callback that can be called to stop listening for events. */ on(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { const { cancel, listener: cancellableListener } = this.cancellable(listener); // Create a wrapped listener so that the debounce can be applied. const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; // If the listeners map does not have the event type, create a new set. if (!this.#listeners.has(type)) { this.#listeners.set(type, new Set()); } // Create a listener entry. const listenerEntry: ListenerEntry = { listener, wrappedListener, cancel, }; // Add the listener entry to the listeners map. this.#listeners.get(type)?.add(listenerEntry); // Return an "off" callback that can be called to stop listening for events. return () => this.off(type, listener); } /** * Add a one-time listener for an event. * @param type - The event type. * @param listener - The listener function. * @param debounceMilliseconds - The debounce time in milliseconds. * @returns An off callback that can be called to stop listening for events. */ once(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { const wrappedListener: Listener = (detail: DeeplyReadonly) => { this.off(type, listener); listener(detail); }; // Create a cancellable listener. const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener); // Create a debounced listener. const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; // If the listeners map does not have the event type, create a new set. if (!this.#listeners.has(type)) { this.#listeners.set(type, new Set()); } // Create a listener entry. const listenerEntry: ListenerEntry = { listener, wrappedListener: debouncedListener, cancel, }; // Add the listener entry to the listeners map. this.#listeners.get(type)?.add(listenerEntry); // Return an "off" callback that can be called to stop listening for events. return () => this.off(type, listener); } /** * Remove a listener for an event. * @param type - The event type. * @param listener - The listener function. */ off(type: K, listener?: Listener): void { // Get the listeners for the event type. const listeners = this.#listeners.get(type); if (!listeners) return; // Find the listener entries (If a listener was provided, only 1 entry will be returned. Otherwise, all entries will be returned). const listenerEntries = Array.from(listeners).filter((entry) => !listener || entry.listener === listener || entry.wrappedListener === listener); // Remove the listener entries from the listeners set. listenerEntries.forEach((entry) => { // Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed. entry.cancel(); // Remove the listener entry from the listeners set. listeners.delete(entry); }); // If no listener was provided and no listeners are left for the event type, remove the listeners set from the listeners map. if (!listener || this.#listeners.get(type)?.size === 0) { this.#listeners.delete(type); } } /** * Emit an event. * @param type - The event type. * @param payload - The event payload. * @returns True if there are listeners for the event, false otherwise. */ emit(type: K, payload: T[K]): boolean { // Get the listeners for the event type. const listeners = this.#listeners.get(type); if (!listeners) return false; // Clone the payload to avoid freezing the original object. const payloadClone = structuredClone(payload); // Freeze the cloned payload to make it readonly. const readonlyPayload = deepFreeze(payloadClone); // Emit the event to all listeners. listeners.forEach((entry) => { entry.wrappedListener(readonlyPayload); }); // Return true if there are listeners for the event, false otherwise. return listeners.size > 0; } /** * Remove all listeners. */ removeAllListeners(): void { for (const [ type, listeners ] of this.#listeners.entries()) { listeners.forEach((entry) => { this.off(type, entry.listener); }); } } /** * Wait for an event to be emitted that matches the provided predicate function's criteria. * @param type - The event type. * @param predicate - Predicate function to filter for whether the event payload matches the criteria. * @param timeoutMs - The timeout in milliseconds. * @returns The event payload. */ async waitFor(type: K, predicate: (payload: DeeplyReadonly) => boolean, timeoutMs?: number): Promise> { // Create a promise to wait for the event to be emitted. return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; // Create a cleanup function to remove the listener and clear the timeout if it is still pending. const cleanup = (listener: Listener): void => { // Remove the listener from the listeners map. this.off(type, listener); // Clear the timeout if it is still pending. if (timeoutId !== undefined) { clearTimeout(timeoutId); } }; // Create a listener function. const listener = (payload: DeeplyReadonly): void => { try { // If the event payload does not match the predicate condition, return. if (!predicate(payload)) { return; } cleanup(listener); resolve(payload); } catch (error) { cleanup(listener); reject(error); } }; // Set up timeout if specified if (timeoutMs !== undefined) { timeoutId = setTimeout(() => { this.off(type, listener); reject(new WaitForTimeoutError(String(type))); }, timeoutMs); } // Add the listener to the listeners map. this.on(type, listener); }); } /** * Debounce a function. * @param func - The function to debounce. * @param wait - The wait time in milliseconds. * @returns The debounced function. */ private debounce(func: Listener, wait: number): Listener { // Create a timeout variable. let timeout: ReturnType; return (detail: DeeplyReadonly) => { // If a debounce timer is already pending, clear it before scheduling the next one. if (timeout !== undefined) { clearTimeout(timeout); } timeout = setTimeout(() => { func(detail); }, wait); }; } private cancellable(func: Listener): { cancel: () => void; listener: Listener } { let cancelled = false; return { cancel: (): boolean => (cancelled = true), listener: (detail: DeeplyReadonly): void => { if (cancelled) return; func(detail); }, }; } }