UNPKG

signal-controller

Version:

A lightweight event emitter with separation of concerns between emitter and listener inspired by the AbortController interface.

191 lines (190 loc) 6.75 kB
const ONCE_SYMBOL = Symbol('once'); const EMIT = Symbol('emit'); const CLEAR_SIGNAL = Symbol('off'); const CLEAR_ALL_SIGNALS = Symbol('clear'); const COUNT_LISTENERS = Symbol('countListeners'); /** This class is used to listen to signals. */ class SignalEmitter { #listeners = new Map(); #lastArgs; constructor({ immediate = false } = {}) { if (immediate) { this.#lastArgs = new Map(); } } [EMIT](signalName, ...args) { // Save arguments if necessary { this.#lastArgs?.set(signalName, args); } const listeners = this.#listeners.get(signalName); if (!listeners) { return [false, []]; } const errors = listeners .values() .flatMap(listener => { if (listener[ONCE_SYMBOL]) { this.off(signalName, listener); } try { listener(...args); } catch (e) { return [e]; } return []; }) .toArray(); return [true, errors]; } on(signalName, ...args) { const listener = typeof args[0] === 'function' ? args[0] : args[1]; const options = typeof args[0] === 'object' ? args[0] : undefined; // Add listener to the list { const listeners = this.#listeners.get(signalName); if (listeners) { listeners.add(listener); } else { this.#listeners.set(signalName, new Set([listener])); } } // Handles `once` option { if (options?.once) { Object.defineProperty(listener, ONCE_SYMBOL, { enumerable: false, configurable: true, writable: false, value: true, }); } } // Handles `signal` option { options?.signal?.addEventListener('abort', () => { this.off(signalName, listener); }, { once: true }); } // Handles `immediate` option { const lastArgs = this.#lastArgs?.get(signalName); if (lastArgs) { listener(...lastArgs); } } } /** Creates a readable stream that produces data each time the specified signal is emitted. */ createReadableStream(signalName) { const aborter = new AbortController(); return new ReadableStream({ start: (controller) => { this.on(signalName, { signal: aborter.signal }, ((...args) => { controller.enqueue(args); })); }, cancel: reason => aborter.abort(reason), }); } /** Creates a Proimse that is resolved the next time the specified signal is emitted. The promise is rejected only * if an abort signal is provided and it is aborted before the waited signal is emitted. */ once(signalName, { signal = undefined } = {}) { return new Promise((resolve, reject) => { signal?.addEventListener('abort', reason => reject(reason)); this.on(signalName, { once: true }, ((values) => resolve(values))); }); } /** Removes a listener. */ off(signalName, listener) { delete listener[ONCE_SYMBOL]; const listeners = this.#listeners.get(signalName); if (!listeners) { return; } listeners.delete(listener); if (listeners.size === 0) { this.#listeners.delete(signalName); } } [CLEAR_SIGNAL](signalName) { this.#listeners.get(signalName) ?.forEach(listener => this.off(signalName, listener)); } [CLEAR_ALL_SIGNALS]() { Object.keys(this.#listeners).forEach(key => this[CLEAR_SIGNAL](key)); } [COUNT_LISTENERS](signalName) { return this.#listeners.get(signalName)?.size ?? 0; } } /** This class is used to emit signals. */ export class SignalController { options; constructor(options = {}) { this.options = options; this.emitter = new SignalEmitter({ immediate: options.immediate }); this.onError = options.onError || console.error; } emitter; onError; /** Emits a signal with some payload. Returns true if the signal was handled by at least one listener. */ emit(signalName, ...args) { const [called, errors] = this.emitter[EMIT](signalName, ...args); for (const error of errors) { try { this.options.onError?.(error, signalName, args); } catch (e) { console.error(error, signalName, args); console.error(e); } } return called; } /** Removes all listeners for a specific signal. */ off(signalName) { this.emitter[CLEAR_SIGNAL](signalName); } /** Removes all listeners for all signals. */ clear() { this.emitter[CLEAR_ALL_SIGNALS](); } /** Removes all signal listeners, stops accepting new signal listeners, and future calls to {@link emit} will only * produce a warning message, but otherwise be ignored. */ destroy() { this.clear(); this.emit = () => { const dummy = {}; if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(dummy, SignalController); } else { dummy.stack = new Error().stack || ''; } console.warn("Ignoring signal emitted to a SignalController that has been destroyed.", dummy.stack); return false; }; this.emitter.on = () => { const dummy = {}; if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(dummy, SignalController); } else { dummy.stack = new Error().stack || ''; } console.warn("Ignoring subscription of new listener to a SignalEmitter whose controller has been destroyed.", dummy.stack); }; } /** Checks whether there are listeners attached for the specified signal. */ testSignal(signalName) { return this.emitter[COUNT_LISTENERS](signalName) > 0; } /** Creates a writable stream that produces signals of the specified type for each chunk of data it receives. */ createWritableStream(signalName) { return new WritableStream({ write: args => void this.emit(signalName, ...args), }); } }