🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

signal-controller

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

signal-controller - npm Package Compare versions

Comparing version
0.4.0
to
0.5.0
+21
LICENSE.md
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.
# 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 {};

@@ -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),
});
}
}
{
"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",