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
JavaScript
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),
});
}
}