import type { DeeplyReadonly } from './types.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; debounceTime?: number; once?: boolean; } /** * 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): OffCallback { // Create a wrapped listener so that the debounce can be applied. const wrappedListener = debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(listener, debounceMilliseconds) : listener; // 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, }; // Set the debounce time if specified. if (debounceMilliseconds && debounceMilliseconds > 0) { listenerEntry.debounceTime = debounceMilliseconds; } // 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): OffCallback { const wrappedListener: Listener = (detail: DeeplyReadonly) => { this.off(type, listener); listener(detail); }; // Create a debounced listener. const debouncedListener = debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(wrappedListener, debounceMilliseconds) : wrappedListener; // 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, once: true, }; // Set the debounce time if specified. if (debounceMilliseconds && debounceMilliseconds > 0) { listenerEntry.debounceTime = debounceMilliseconds; } // 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.wrappedListener = (): void => {}; // Remove the listener entry from the listeners set. listeners.delete(entry); }); // If no listener was provided, remove the listeners set from the listeners map. if (!listener) { 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 { this.#listeners.clear(); } /** * 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 listener function. const listener = (payload: Readonly): void => { try { // If the event payload does not match the predicate condition, return. if (!predicate(payload)) { return; } // Clean up this.off(type, listener); if (timeoutId !== undefined) { clearTimeout(timeoutId); } resolve(payload); } catch (error) { reject(error); } }; // Set up timeout if specified if (timeoutMs !== undefined) { timeoutId = setTimeout(() => { this.off(type, listener); reject(new Error(`Timeout waiting for event "${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); }; } }