Merge branch 'event-emitter' into sse-and-backoff

This commit is contained in:
2026-08-07 05:47:18 +00:00
4 changed files with 131 additions and 58 deletions
+61 -25
View File
@@ -1,18 +1,29 @@
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: T) => void;
type Listener<T> = (detail: Readonly<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: Readonly<T>): void;
}['bivarianceHack'];
/**
* A listener entry.
* @template T - The event type.
* @template T - The event payload type.
*/
interface ListenerEntry<T> {
listener: Listener<T>;
wrappedListener: Listener<T>;
listener: StoredListener<T>;
wrappedListener: StoredListener<T>;
debounceTime?: number;
once?: boolean;
}
/**
* Callback returned by {@link on} and {@link once} for removing a listener.
*/
export type OffCallback = () => void;
/**
@@ -24,7 +35,7 @@ export class EventEmitter<T extends EventMap> {
* The listeners map.
* @private
*/
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
#listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
/**
* Add a listener for an event.
@@ -38,19 +49,23 @@ export class EventEmitter<T extends EventMap> {
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());
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}),
};
// 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 as ListenerEntry<T[keyof T]>);
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
@@ -64,7 +79,7 @@ export class EventEmitter<T extends EventMap> {
* @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): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
const wrappedListener: Listener<T[K]> = (detail: Readonly<T[K]>) => {
this.off(type, listener);
listener(detail);
};
@@ -74,8 +89,8 @@ export class EventEmitter<T extends EventMap> {
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());
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
@@ -83,11 +98,15 @@ export class EventEmitter<T extends EventMap> {
listener,
wrappedListener: debouncedListener,
once: true,
...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}),
};
// 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 as ListenerEntry<T[keyof T]>);
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
@@ -98,11 +117,18 @@ export class EventEmitter<T extends EventMap> {
* @param type - The event type.
* @param listener - The listener function.
*/
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
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);
const listeners = this.#listeners.get(type);
if (!listeners) return;
// If no listener is provided, remove all listeners for the event type.
if (!listener) {
this.#listeners.delete(type);
return;
}
// Find the listener entry.
const listenerEntry = Array.from(listeners).find((entry) => entry.listener === listener || entry.wrappedListener === listener);
@@ -120,12 +146,15 @@ export class EventEmitter<T extends EventMap> {
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
// Get the listeners for the event type.
const listeners = this.listeners.get(type);
const listeners = this.#listeners.get(type);
if (!listeners) return false;
// Freeze the payload to make it readonly.
const readonlyPayload = Object.freeze(payload);
// Emit the event to all listeners.
listeners.forEach((entry) => {
entry.wrappedListener(payload);
entry.wrappedListener(readonlyPayload);
});
// Return true if there are listeners for the event, false otherwise.
@@ -136,24 +165,29 @@ export class EventEmitter<T extends EventMap> {
* Remove all listeners.
*/
removeAllListeners(): void {
this.listeners.clear();
this.#listeners.clear();
}
/**
* Wait for an event to be emitted.
* Wait for an event to be emitted that matches the provided predicate function's criteria.
* @param type - The event type.
* @param predicate - The predicate function.
* @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]> {
async waitFor<K extends keyof T>(type: K, predicate: (payload: Readonly<T[K]>) => boolean, timeoutMs?: number): Promise<Readonly<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 listener function.
const listener = (payload: T[K]): void => {
if (predicate(payload)) {
const listener = (payload: Readonly<T[K]>): 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) {
@@ -161,6 +195,8 @@ export class EventEmitter<T extends EventMap> {
}
resolve(payload);
} catch (error) {
reject(error);
}
};
@@ -187,7 +223,7 @@ export class EventEmitter<T extends EventMap> {
// Create a timeout variable.
let timeout: ReturnType<typeof setTimeout>;
return (detail: T[K]) => {
return (detail: Readonly<T[K]>) => {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);