diff --git a/source/event-emitter.ts b/source/event-emitter.ts new file mode 100644 index 0000000..e455a08 --- /dev/null +++ b/source/event-emitter.ts @@ -0,0 +1,201 @@ +export type EventMap = Record; + +type Listener = (detail: T) => void; + +/** + * A listener entry. + * @template T - The event type. + */ +interface ListenerEntry { + listener: Listener; + wrappedListener: Listener; + debounceTime?: number; + once?: boolean; +} + +export type OffCallback = () => void; + +/** + * A simple event emitter implementation. + * @template T - The event map type. + */ +export class EventEmitter { + /** + * The listeners map. + * @private + */ + 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, + ...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}), + }; + + // Add the listener entry to the listeners map. + this.listeners.get(type)?.add(listenerEntry as 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: T[K]) => { + 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, + ...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}), + }; + + // Add the listener entry to the listeners map. + this.listeners.get(type)?.add(listenerEntry as 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 entry. + const listenerEntry = Array.from(listeners).find((entry) => entry.listener === listener || entry.wrappedListener === listener); + + // If the listener entry is found, remove it from the listeners map. + if (listenerEntry) { + listeners.delete(listenerEntry); + } + } + + /** + * 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; + + // Emit the event to all listeners. + listeners.forEach((entry) => { + entry.wrappedListener(payload); + }); + + // 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. + * @param type - The event type. + * @param predicate - The predicate function. + * @param timeoutMs - The timeout in milliseconds. + * @returns The event payload. + */ + async waitFor(type: K, predicate: (payload: T[K]) => 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: T[K]): void => { + if (predicate(payload)) { + // Clean up + this.off(type, listener); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + + resolve(payload); + } + }; + + // 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: 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); + }; + } +} diff --git a/source/index.ts b/source/index.ts index dbbc78c..a89e794 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,3 +1,4 @@ +export * from './event-emitter.ts'; export * from './exponential-backoff.ts'; export * from './extended-json.ts'; export * from './script.ts'; diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts new file mode 100644 index 0000000..b0b0e81 --- /dev/null +++ b/test/event-emitter.test.ts @@ -0,0 +1,505 @@ +import { expect, test, vi } from 'vitest'; +import { EventEmitter } from '../source/event-emitter.ts'; + +/** Simple event map used across these tests. */ +type TestEvents = { + message: string; + count: number; +}; + +/** + * Tests that EventEmitter invokes listeners when an event is emitted. + */ +const testEventEmitterCallsListeners = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // Register the listener and emit an event. + emitter.on('message', listener); + emitter.emit('message', 'hello'); + + // Expect the listener to have been called with the emitted payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('hello'); +}; + +/** + * Tests that EventEmitter invokes all listeners registered for the same event. + */ +const testEventEmitterCallsMultipleListeners = (): void => { + const emitter = new EventEmitter(); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + // Register two listeners for the same event type. + emitter.on('count', firstListener); + emitter.on('count', secondListener); + emitter.emit('count', 42); + + // Expect both listeners to receive the same payload. + expect(firstListener).toHaveBeenCalledOnce(); + expect(firstListener).toHaveBeenCalledWith(42); + expect(secondListener).toHaveBeenCalledOnce(); + expect(secondListener).toHaveBeenCalledWith(42); +}; + +/** + * Tests that EventEmitter only invokes listeners registered for the emitted event type. + */ +const testEventEmitterCallsOnlyMatchingListeners = (): void => { + const emitter = new EventEmitter(); + const messageListener = vi.fn(); + const countListener = vi.fn(); + + // Register listeners on different event types. + emitter.on('message', messageListener); + emitter.on('count', countListener); + + // Emit only the message event. + emitter.emit('message', 'hello'); + + // Expect only the matching listener to have been called. + expect(messageListener).toHaveBeenCalledOnce(); + expect(countListener).not.toHaveBeenCalled(); +}; + +/** + * Tests that EventEmitter.emit returns false when no listeners are registered. + */ +const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => { + const emitter = new EventEmitter(); + + const hasListeners = emitter.emit('message', 'hello'); + + // Expect emit to report that nobody was listening. + expect(hasListeners).toBe(false); +}; + +/** + * Tests that EventEmitter.emit returns true when listeners are registered. + */ +const testEventEmitterEmitReturnsTrueWithListeners = (): void => { + const emitter = new EventEmitter(); + + emitter.on('message', vi.fn()); + + const hasListeners = emitter.emit('message', 'hello'); + + // Expect emit to report that at least one listener was invoked. + expect(hasListeners).toBe(true); +}; + +/** + * Tests that the off callback returned by on() removes the listener. + */ +const testEventEmitterOffCallbackRemovesListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // on() returns an off callback that removes the listener. + const off = emitter.on('message', listener); + emitter.emit('message', 'first'); + + // Unsubscribe before emitting again. + off(); + emitter.emit('message', 'second'); + + // Expect the listener to have only received the first event. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('first'); +}; + +/** + * Tests that off() removes a listener when given the same function reference. + */ +const testEventEmitterOffRemovesListenerByReference = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + emitter.off('message', listener); + emitter.emit('message', 'hello'); + + // Expect the listener to have been removed before the emit. + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that off() does nothing when given an unknown listener reference. + */ +const testEventEmitterOffIgnoresUnknownListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + + // Try to remove a different function reference. + emitter.off('message', vi.fn()); + emitter.emit('message', 'hello'); + + // Expect the original listener to still receive the event. + expect(listener).toHaveBeenCalledOnce(); +}; + +/** + * Tests that off() does nothing when called for an event type with no listeners. + */ +const testEventEmitterOffIgnoresUnregisteredEventType = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // Call off without ever registering this listener. + emitter.off('message', listener); + + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that once() listeners are invoked only one time. + */ +const testEventEmitterOnceListenerFiresOnce = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.once('message', listener); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + // Expect the listener to auto-unsubscribe after the first emit. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('first'); +}; + +/** + * Tests that once() can be added when regular listeners already exist for the event type. + */ +const testEventEmitterOnceWorksWithExistingListeners = (): void => { + const emitter = new EventEmitter(); + const existingListener = vi.fn(); + const onceListener = vi.fn(); + + // Register a regular listener first so the event type already exists in the map. + emitter.on('message', existingListener); + emitter.once('message', onceListener); + emitter.emit('message', 'hello'); + + expect(existingListener).toHaveBeenCalledOnce(); + expect(onceListener).toHaveBeenCalledOnce(); +}; + +/** + * Tests that the off callback returned by once() removes the listener before it fires. + */ +const testEventEmitterOnceOffCallbackRemovesListener = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const off = emitter.once('message', listener); + + // Unsubscribe before the event is ever emitted. + off(); + emitter.emit('message', 'hello'); + + expect(listener).not.toHaveBeenCalled(); +}; + +/** + * Tests that removeAllListeners() clears every registered listener. + */ +const testEventEmitterRemoveAllListeners = (): void => { + const emitter = new EventEmitter(); + const messageListener = vi.fn(); + const countListener = vi.fn(); + + emitter.on('message', messageListener); + emitter.on('count', countListener); + emitter.removeAllListeners(); + + // Emit on both event types after clearing all listeners. + emitter.emit('message', 'hello'); + emitter.emit('count', 1); + + expect(messageListener).not.toHaveBeenCalled(); + expect(countListener).not.toHaveBeenCalled(); +}; + +/** + * Tests that waitFor() resolves when a matching event is emitted. + */ +const testEventEmitterWaitForResolvesOnMatch = async (): Promise => { + const emitter = new EventEmitter(); + + // Wait until an event matches the predicate. + const waitPromise = emitter.waitFor('count', (payload) => payload === 42); + + // Emit a non-matching event first, then the matching one. + emitter.emit('count', 41); + emitter.emit('count', 42); + + await expect(waitPromise).resolves.toBe(42); +}; + +/** + * Tests that waitFor() ignores non-matching events while other listeners still receive them. + */ +const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const waitPromise = emitter.waitFor('message', (payload) => payload === 'done'); + + // A regular listener should still receive every emit while waitFor filters. + emitter.on('message', listener); + emitter.emit('message', 'pending'); + emitter.emit('message', 'done'); + + await expect(waitPromise).resolves.toBe('done'); + expect(listener).toHaveBeenCalledTimes(2); +}; + +/** + * Tests that waitFor() rejects when the timeout expires. + */ +const testEventEmitterWaitForRejectsOnTimeout = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + const waitPromise = emitter.waitFor('message', () => true, 100); + + // Attach the rejection handler before advancing timers so the rejection is handled. + const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"'); + + await vi.advanceTimersByTimeAsync(100); + + await assertion; + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that waitFor() clears its timeout when it resolves before expiry. + */ +const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + // Register waitFor with a timeout, then resolve it before the timer fires. + const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100); + + emitter.emit('message', 'done'); + + await expect(waitPromise).resolves.toBe('done'); + + // If clearTimeout was not called, advancing past the timeout would reject the promise. + await vi.advanceTimersByTimeAsync(100); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that waitFor() removes its listener after resolving. + */ +const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise => { + const emitter = new EventEmitter(); + + const waitPromise = emitter.waitFor('message', () => true); + + emitter.emit('message', 'first'); + await expect(waitPromise).resolves.toBe('first'); + + // Register a second waitFor so we can verify the first listener was cleaned up. + const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second'); + + // Emit a payload that only the second waitFor should accept. + emitter.emit('message', 'ignored'); + + // Track whether the second waitFor resolves too early. + let resolvedEarly = false; + /* eslint-disable-next-line */ + secondWaitPromise.then(() => { + resolvedEarly = true; + }); + + // Yield so any premature resolution would have a chance to run. + await Promise.resolve(); + expect(resolvedEarly).toBe(false); + + emitter.emit('message', 'second'); + await expect(secondWaitPromise).resolves.toBe('second'); +}; + +/** + * Tests that the first debounced emit does not call clearTimeout. + */ +const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const listener = vi.fn(); + + emitter.on('message', listener, 100); + emitter.emit('message', 'first'); + + // The first emit starts the debounce timer; there is nothing to clear yet. + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that debounced on() listeners receive only the last payload after the debounce window. + */ +const testEventEmitterDebouncedOnListener = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener, 100); + + // Emit several events in quick succession. + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + emitter.emit('message', 'third'); + + // Expect the listener to not have fired yet. + expect(listener).not.toHaveBeenCalled(); + + // Advance past the debounce window. + await vi.advanceTimersByTimeAsync(100); + + // Expect only the last payload to have been delivered. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('third'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that repeated debounced emits reset the debounce timer. + */ +const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('count', listener, 100); + emitter.emit('count', 1); + + // Advance halfway through the debounce window and emit again. + await vi.advanceTimersByTimeAsync(50); + emitter.emit('count', 2); + await vi.advanceTimersByTimeAsync(50); + + // The timer was reset, so the listener should not have fired yet. + expect(listener).not.toHaveBeenCalled(); + + // Advance the remaining time for the reset timer to expire. + await vi.advanceTimersByTimeAsync(50); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith(2); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that a debounce time of zero behaves like a normal listener. + */ +const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + // A debounce time of zero should behave like a normal listener. + emitter.on('message', listener, 0); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + expect(listener).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +/** + * Tests that debounced once() listeners fire once with the last payload. + */ +const testEventEmitterDebouncedOnceListener = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.once('message', listener, 100); + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + await vi.advanceTimersByTimeAsync(100); + + // Expect the debounced once listener to fire once with the last payload. + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith('second'); + + // Emit again after the debounce window; the once listener should stay removed. + emitter.emit('message', 'third'); + await vi.advanceTimersByTimeAsync(100); + + expect(listener).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + +const runTests = async (): Promise => { + test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners); + test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners); + test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners); + test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners); + test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners); + test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener); + test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference); + test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener); + test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType); + test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce); + test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners); + test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener); + test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners); + test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch); + test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents); + test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout); + test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve); + test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve); + test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout); + test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener); + test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits); + test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce); + test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener); +}; + +await runTests();