signal-controller
Advanced tools
+21
| MIT License | ||
| Copyright (c) 2025 Leonardo Raele <leonardoraele@gmail.com> | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+180
| # Signal Controller | ||
| A lightweight event emitter with separation of concerns between emitter and listener, | ||
| inspired by the `AbortController` interface. | ||
| > The term "signal", in the context of this library, is equivalent to "event". | ||
| > The lib is called `signal-controller` because `event-controller` was already taken. | ||
| > Both terms are used interchangeably here. | ||
| ## Features | ||
| - **Separation of Concerns.** Separate interfaces for emitting events and listening to events. | ||
| - **Supports AbortSignal.** Remove event listeners using an `AbortSignal`. | ||
| - **TypeScript Support.**: Full TypeScript definitions with type safety. | ||
| - **Promise Support.**: Get a promise that resolves when the next event of a type is emitted. | ||
| - **Immediate Mode.**: In this optional mode, the event emitter automatically replays the last event it had emitted to | ||
| new listenres when they start listening. | ||
| - **Stream Support.**: Convert events into streams: either pipe data from a `ReadableStream` into the | ||
| `SignalController`, converting chunks into events; or create a `ReadableStream` that is fed by events from an | ||
| `SignalEmitter`. | ||
| - **Lightweight.**: Just a single `.js` file. Zero dependencies. | ||
| - **Exceptions Handled.** Exceptions thrown by event listeners won't prevents other listeners from being called, and | ||
| emitting events will never throw exceptions. You can listen to error thrown by listeners if you want. | ||
| - **Modern.**: TypeScript, AbortSignal, Promises, Streams, Async Iterators. | ||
| ## Usage | ||
| ```typescript | ||
| import { SignalController } from 'signal-controller'; | ||
| // Define your event emitter interface (TypeScript only) | ||
| interface MySignals { | ||
| userLoggedIn: (user: { id: string; name: string }) => void; | ||
| dataUpdated: (data: any[]) => void; | ||
| error: (error: Error) => void; | ||
| } | ||
| // Create a controller | ||
| const controller = new SignalController<MySignals>(); | ||
| // Each controller instance has an `emitter` field that can be used to listen to events emitted by the controller. | ||
| // You expose only the `emitter` to clients interested in your events. | ||
| controller.emitter.on('userLoggedIn', (user) => { // `user` is of type `{ id: string; name: string }` | ||
| console.log(`Welcome, ${user.name}!`); | ||
| }); | ||
| controller.emitter.on('error', (error) => { // `error` is of type `Error` | ||
| console.error('An error occurred:', error.message); | ||
| }); | ||
| // Emit events | ||
| controller.emit('userLoggedIn', { id: '123', name: 'John Doe' }); // Arguments are type-checked | ||
| controller.emit('error', new Error('Something went wrong')); | ||
| ``` | ||
| ## Installation | ||
| ```bash | ||
| npm install signal-controller | ||
| ``` | ||
| ## API Reference | ||
| See [api.md](./api.md) file. | ||
| ## Advanced Usage | ||
| ### Remove Listenres | ||
| Using an `AbortController`: | ||
| ```typescript | ||
| const abortController = new AbortController(); | ||
| { | ||
| emitter.on('userLoggedIn', { signal: abortController.signal }, (user) => { | ||
| console.log(`User ${user.name} logged in`); | ||
| }); | ||
| emitter.on('error', { signal: abortController.signal }, (error) => { | ||
| console.error('Error:', error.message); | ||
| }); | ||
| } | ||
| // Remove all listeners at once | ||
| abortController.abort(); | ||
| ``` | ||
| Using `off()`: | ||
| ```typescript | ||
| emitter.on('userLoggedIn', function onUserLoggedIn(user) { | ||
| console.log(`User ${user.name} logged in`); | ||
| }); | ||
| emitter.on('error', function onError(error) { | ||
| console.error('Error:', error.message); | ||
| }); | ||
| emitter.off(onUserLoggedIn); | ||
| emitter.off(onError); | ||
| ``` | ||
| ### Streams | ||
| Convert signals to streams: | ||
| ```typescript | ||
| signalController.emitter.createReadableStream('dataUpdated').pipeTo(sinkStream); | ||
| signalController.emit('dataUpdated', someData); // This event will pushed a chunk into the sink stream | ||
| ``` | ||
| ```typescript | ||
| sourceStream.pipeTo(signalController.createWritableStream('dataUpdated')); | ||
| signalController.emitter.on('dataUpdated', (data) => { | ||
| // Chunks of data produced by `sourceStream` will trigger this event | ||
| }); | ||
| ``` | ||
| ### Async Iterators | ||
| ```typescript | ||
| // Transform events into an async iterator, transforms the data, then pipe into a sink stream. | ||
| emitter.iterate('dataUpdated') | ||
| .map(data => JSON.stringify(data)) | ||
| .toStream() | ||
| .pipeTo(sinkStream); | ||
| ``` | ||
| ### Error Handling | ||
| Signal listeners that throw errors will have their errors logged to the console, but won't stop other listeners from executing: | ||
| ```typescript | ||
| /// When a listener throws an exception... | ||
| controller.emitter.on('userLoggedIn', (user) => { | ||
| throw new Error('This will be logged but won\'t stop other listeners'); | ||
| }); | ||
| // This listener from the controller is called | ||
| controller.onError = (errors, signalName, args) => { | ||
| if (signalName === 'userLoggedIn') { | ||
| const [user] = args; | ||
| // `errors` is an array of the errors that had been thrown for each listener that threw | ||
| for (const error of errors) { | ||
| console.error( | ||
| 'A listener for the signal', signalName, 'threw this error:', error, | ||
| 'This happened when the signal was emitted with these arguments:', args, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| ### Immediate Mode | ||
| When `immediate: true` is set, new listeners will immediately receive the last emitted arguments: | ||
| ```typescript | ||
| const controller = new SignalController<MySignals>({ immediate: true }); | ||
| // Emit a signal with no listeners | ||
| // The emitter will hold onto the last emitted data for each signal type | ||
| controller.emit('userLoggedIn', { id: '123', name: 'John' }); | ||
| // Add a listener after the signal had been emitted | ||
| controller.emitter.on('userLoggedIn', (user) => { | ||
| console.log(`Welcome back, ${user.name}!`); // Runs immediately | ||
| }); | ||
| ``` | ||
| ## License | ||
| MIT | ||
| ## Contributing | ||
| Contributions are welcome! Please feel free to submit a Pull Request. |
+35
-10
@@ -13,14 +13,26 @@ declare const ONCE_SYMBOL: unique symbol; | ||
| export interface SignalListenerOptions { | ||
| /** If true, this listener will only be called once. */ | ||
| once?: boolean; | ||
| /** If provided, the listener will be removed when the abort signal is triggered. */ | ||
| signal?: AbortSignal; | ||
| } | ||
| export declare class SignalEmitter<T extends BaseEventEmitterAPI> { | ||
| export type { SignalEmitter }; | ||
| /** This class is used to listen to signals. */ | ||
| declare class SignalEmitter<T extends BaseEventEmitterAPI> { | ||
| #private; | ||
| [EMIT]<K extends keyof T>(signalName: K, ...args: Parameters<T[K]>): boolean; | ||
| constructor({ immediate }?: { | ||
| immediate?: boolean | undefined; | ||
| }); | ||
| [EMIT]<K extends keyof T>(signalName: K, ...args: Parameters<T[K]>): [boolean, unknown[]]; | ||
| /** Start listening to a signal. The provided callback is called whenever the specified signal is emitted. */ | ||
| on<K extends keyof T>(signalName: K, listener: T[K]): void; | ||
| on<K extends keyof T>(signalName: K, options: SignalListenerOptions, listener: T[K]): void; | ||
| readableStream<K extends keyof T>(signalName: K): ReadableStream<Parameters<T[K]>>; | ||
| next<K extends keyof T>(signalName: K, { signal }?: { | ||
| /** Creates a readable stream that produces data each time the specified signal is emitted. */ | ||
| createReadableStream<K extends keyof T>(signalName: K): ReadableStream<Parameters<T[K]>>; | ||
| /** 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<K extends keyof T>(signalName: K, { signal }?: { | ||
| signal?: AbortSignal | undefined; | ||
| }): Promise<Parameters<T[K]>>; | ||
| /** Removes a listener. */ | ||
| off(signalName: keyof T, listener: ListenerFunction): void; | ||
@@ -30,13 +42,26 @@ [CLEAR_SIGNAL](signalName: keyof T): void; | ||
| } | ||
| interface SignalControllerOptions { | ||
| /** If this option is true, the signal emitter will immediately call newly subscribed listeners with the last | ||
| * arguments emitted (if any). */ | ||
| immediate?: boolean; | ||
| /** Callback called when an signal listener throws. */ | ||
| onError?: (error: unknown, signalName: string, args: unknown[]) => void; | ||
| } | ||
| /** This class is used to emit signals. */ | ||
| export declare class SignalController<T extends BaseEventEmitterAPI> { | ||
| readonly signal: SignalEmitter<T>; | ||
| constructor(); | ||
| emit<K extends keyof T>(signalName: K, ...args: Parameters<T[K]>): void; | ||
| private readonly options; | ||
| constructor(options?: SignalControllerOptions); | ||
| readonly emitter: SignalEmitter<T>; | ||
| onError: SignalControllerOptions['onError']; | ||
| /** Emits a signal with some payload. Returns true if the signal was handled by at least one listener. */ | ||
| emit<K extends keyof T>(signalName: K, ...args: Parameters<T[K]>): boolean; | ||
| /** Removes all listeners for a specific signal. */ | ||
| off(signalName: keyof T): void; | ||
| /** Removes all listeners for all signals. */ | ||
| clear(): void; | ||
| /** Removes all event listeners, stops accepting new event listeners, and future calls to {@link emit} will only | ||
| /** 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(): void; | ||
| writableStream<K extends keyof T>(signalName: K): WritableStream<Parameters<T[K]>>; | ||
| /** Creates a writable stream that produces signals of the specified type for each chunk of data it receives. */ | ||
| createWritableStream<K extends keyof T>(signalName: K): WritableStream<Parameters<T[K]>>; | ||
| } | ||
| export {}; |
+81
-22
@@ -5,10 +5,23 @@ const ONCE_SYMBOL = Symbol('once'); | ||
| const CLEAR_ALL_SIGNALS = Symbol('clear'); | ||
| export class SignalEmitter { | ||
| /** 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; | ||
| return [false, []]; | ||
| } | ||
| for (const listener of listeners) { | ||
| const errors = listeners | ||
| .values() | ||
| .flatMap(listener => { | ||
| if (listener[ONCE_SYMBOL]) { | ||
@@ -21,6 +34,8 @@ this.off(signalName, listener); | ||
| catch (e) { | ||
| console.error(e); | ||
| return [e]; | ||
| } | ||
| } | ||
| return true; | ||
| return []; | ||
| }) | ||
| .toArray(); | ||
| return [true, errors]; | ||
| } | ||
@@ -30,2 +45,3 @@ on(signalName, ...args) { | ||
| const options = typeof args[0] === 'object' ? args[0] : undefined; | ||
| // Add listener to the list | ||
| { | ||
@@ -40,2 +56,3 @@ const listeners = this.#listeners.get(signalName); | ||
| } | ||
| // Handles `once` option | ||
| { | ||
@@ -51,2 +68,3 @@ if (options?.once) { | ||
| } | ||
| // Handles `signal` option | ||
| { | ||
@@ -57,4 +75,12 @@ options?.signal?.addEventListener('abort', () => { | ||
| } | ||
| // Handles `immediate` option | ||
| { | ||
| const lastArgs = this.#lastArgs?.get(signalName); | ||
| if (lastArgs) { | ||
| listener(...lastArgs); | ||
| } | ||
| } | ||
| } | ||
| readableStream(signalName) { | ||
| /** Creates a readable stream that produces data each time the specified signal is emitted. */ | ||
| createReadableStream(signalName) { | ||
| const aborter = new AbortController(); | ||
@@ -70,3 +96,5 @@ return new ReadableStream({ | ||
| } | ||
| next(signalName, { signal = undefined } = {}) { | ||
| /** 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) => { | ||
@@ -77,2 +105,3 @@ signal?.addEventListener('abort', reason => reject(reason)); | ||
| } | ||
| /** Removes a listener. */ | ||
| off(signalName, listener) { | ||
@@ -97,17 +126,35 @@ delete listener[ONCE_SYMBOL]; | ||
| } | ||
| /** This class is used to emit signals. */ | ||
| export class SignalController { | ||
| signal = new SignalEmitter(); | ||
| constructor( | ||
| // private readonly options: SignalControllerOptions = {}, | ||
| ) { } | ||
| 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) { | ||
| this.signal[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.signal[CLEAR_SIGNAL](signalName); | ||
| this.emitter[CLEAR_SIGNAL](signalName); | ||
| } | ||
| /** Removes all listeners for all signals. */ | ||
| clear() { | ||
| this.signal[CLEAR_ALL_SIGNALS](); | ||
| this.emitter[CLEAR_ALL_SIGNALS](); | ||
| } | ||
| /** Removes all event listeners, stops accepting new event listeners, and future calls to {@link emit} will only | ||
| /** 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. */ | ||
@@ -118,16 +165,28 @@ destroy() { | ||
| const dummy = {}; | ||
| Error.captureStackTrace(dummy, SignalController); | ||
| console.warn("Ignoring event emitted to a SignalController that has been destroyed.", dummy.stack); | ||
| 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.signal.on = () => { | ||
| this.emitter.on = () => { | ||
| const dummy = {}; | ||
| Error.captureStackTrace(dummy, SignalController); | ||
| 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); | ||
| }; | ||
| } | ||
| writableStream(signalName) { | ||
| /** 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 => this.emit(signalName, ...args), | ||
| write: args => void this.emit(signalName, ...args), | ||
| }); | ||
| } | ||
| } |
+2
-2
| { | ||
| "name": "signal-controller", | ||
| "version": "0.4.0", | ||
| "description": "A lightweight event emitter with separation of concerns between emitter and listener inspired by the AbortController interface. It also supports using AbortController to remove listeners.", | ||
| "version": "0.5.0", | ||
| "description": "A lightweight event emitter with separation of concerns between emitter and listener inspired by the AbortController interface.", | ||
| "author": "Leonardo Raele <leonardoraele@gmail.com>", | ||
@@ -6,0 +6,0 @@ "license": "MIT", |
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
17065
158.87%5
66.67%247
51.53%1
-50%181
Infinity%