Add Event Emitter
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import type { DeeplyReadonly } from './types.ts';
|
||||
import type { deepFreeze } from './misc.ts';
|
||||
|
||||
import { WaitForTimeoutError } from './errors.ts';
|
||||
|
||||
export type EventMap = Record<string, unknown>;
|
||||
|
||||
type Listener<T> = (detail: T) => void;
|
||||
|
||||
/**
|
||||
* Internally permits listeners for individual event payloads to be stored
|
||||
* in a collection typed with the union of all event payloads.
|
||||
*/
|
||||
type StoredListener<T> = {
|
||||
bivarianceHack(detail: T): void;
|
||||
}['bivarianceHack'];
|
||||
|
||||
/**
|
||||
* A listener entry.
|
||||
* @template T - The event payload type.
|
||||
*/
|
||||
interface ListenerEntry<T> {
|
||||
listener: StoredListener<T>;
|
||||
wrappedListener: StoredListener<T>;
|
||||
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<T extends EventMap> {
|
||||
/**
|
||||
* The listeners map.
|
||||
* @private
|
||||
*/
|
||||
#listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = 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<K extends keyof T>(type: K, listener: Listener<T[K]>, 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<T[K]> = {
|
||||
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<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
|
||||
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
|
||||
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<T[K]> = {
|
||||
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<K extends keyof T>(type: K, listener?: Listener<T[K]>): 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.
|
||||
*
|
||||
* @remarks The caller is responsible for ensuring the payload suits the intended mutability requirements.
|
||||
* By default, the payload will be mutable, so listeners may mutate the payload, effecting both
|
||||
* the original object and the other listeners.
|
||||
* To prevent this, the caller can use the {@link Object.freeze} or {@link deepFreeze} function to freeze the payload.
|
||||
* This will need to be defined in the EventMap using the built-in {@link Readonly} type or the provided {@link DeeplyReadonly} type.
|
||||
*
|
||||
* @param type - The event type.
|
||||
* @param payload - The event payload.
|
||||
* @returns True if there are listeners for the event, false otherwise.
|
||||
*/
|
||||
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
|
||||
// Get the listeners for the event type.
|
||||
const listeners = this.#listeners.get(type);
|
||||
if (!listeners) return false;
|
||||
|
||||
// Emit the event to all listeners.
|
||||
listeners.forEach((entry) => {
|
||||
try {
|
||||
entry.wrappedListener(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 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<K extends keyof T>(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise<T[K]> {
|
||||
// Create a promise to wait for the event to be emitted.
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Create a cleanup function to remove the listener and clear the timeout if it is still pending.
|
||||
const cleanup = (listener: Listener<T[K]>): 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: T[K]): 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.
|
||||
*
|
||||
* @remarks If {@link off} is called on a listener while it is debounced, the timeout is not cleared with clearTimeout.
|
||||
* Instead, the function is no-oped.
|
||||
*
|
||||
* @param func - The function to debounce.
|
||||
* @param wait - The wait time in milliseconds.
|
||||
* @returns The debounced function.
|
||||
*/
|
||||
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
|
||||
// Create a timeout variable.
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (detail: T[K]) => {
|
||||
// If a debounce timer is already pending, clear it before scheduling the next one.
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(detail);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a function cancellable.
|
||||
* @param func - The function to make cancelable.
|
||||
* @returns The cancellable function with a cancel method.
|
||||
*/
|
||||
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
|
||||
let cancelled = false;
|
||||
|
||||
return {
|
||||
cancel: (): boolean => (cancelled = true),
|
||||
listener: (detail: T[K]): void => {
|
||||
if (cancelled) return;
|
||||
func(detail);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user