@studnicky/signal
Compose AbortSignals from callers and deadlines without repetitive AbortController boilerplate.
Install
pnpm add @studnicky/signalRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
Compose a signal from a caller signal and/or a deadline
Create a Signal instance with Signal.create(). Its async compose method combines a caller AbortSignal and a timeout deadline using AbortSignal.any. The composed signal aborts as soon as either source fires. If neither is provided, it returns the never-aborting sentinel so consuming code always receives a valid AbortSignal.
import { Signal, SignalError } from '../src/index.js';
const signals = Signal.create();
class ComposeDemo {
/** Case 1: both caller signal + deadlineMs — returns AbortSignal.any composite. */
static async caseCallerAndDeadline(): Promise<void> {
const controller = new AbortController();
const signal = await signals.compose({ 'deadlineMs': 5000, 'signal': controller.signal });
assert.ok(signal instanceof AbortSignal, 'composite is an AbortSignal');
assert.ok(!signal.aborted, 'composite is not yet aborted');
console.log(`caseCallerAndDeadline: aborted=${signal.aborted}`);
}
/** Case 2: caller signal only — returns that signal unchanged. */
static async caseCallerOnly(): Promise<void> {
const controller = new AbortController();
const signal = await signals.compose({ 'signal': controller.signal });
assert.strictEqual(signal, controller.signal, 'returns the caller signal directly');
assert.ok(!signal.aborted, 'signal is not aborted');
console.log(`caseCallerOnly: same reference=${signal === controller.signal}`);
}
/** Case 3: deadlineMs only — returns a timeout signal. */
static async caseDeadlineOnly(): Promise<void> {
const signal = await signals.compose({ 'deadlineMs': 5000 });
assert.ok(signal instanceof AbortSignal, 'returns an AbortSignal');
assert.ok(!signal.aborted, 'not yet aborted at 5 s');
console.log(`caseDeadlineOnly: aborted=${signal.aborted}`);
}
/** Case 4: neither — returns the never-aborting sentinel. */
static async caseNeither(): Promise<void> {
const composed = await signals.compose({});
const sentinel = Signal.never();
assert.strictEqual(composed, sentinel, 'compose({}) returns the same object as Signal.never()');
assert.ok(!composed.aborted, 'sentinel is never aborted');
console.log(`caseNeither: compose({}) === Signal.never() → ${composed === sentinel}`);
}
/** deadlineMs of 0 is valid (non-negative) — AbortSignal.timeout(0) aborts immediately. */
static async caseZeroDeadline(): Promise<void> {
const signal = await signals.compose({ 'deadlineMs': 0 });
assert.ok(signal instanceof AbortSignal, 'zero deadline produces an AbortSignal');
console.log(`caseZeroDeadline: is AbortSignal=${signal instanceof AbortSignal}`);
}
/** Negative deadlineMs throws SignalError. */
static async caseInvalidNegative(): Promise<void> {
let caught: unknown;
try {
await signals.compose({ 'deadlineMs': -1 });
} catch (error) {
caught = error;
}
assert.ok(caught instanceof SignalError, 'throws SignalError for negative deadlineMs');
console.log(`caseInvalidNegative: threw SignalError=${caught instanceof SignalError}`);
}
/** NaN deadlineMs throws SignalError. */
static async caseInvalidNaN(): Promise<void> {
let caught: unknown;
try {
await signals.compose({ 'deadlineMs': NaN });
} catch (error) {
caught = error;
}
assert.ok(caught instanceof SignalError, 'throws SignalError for NaN deadlineMs');
console.log(`caseInvalidNaN: threw SignalError=${caught instanceof SignalError}`);
}
}
await ComposeDemo.caseCallerAndDeadline();
await ComposeDemo.caseCallerOnly();
await ComposeDemo.caseDeadlineOnly();
await ComposeDemo.caseNeither();
await ComposeDemo.caseZeroDeadline();
await ComposeDemo.caseInvalidNegative();
await ComposeDemo.caseInvalidNaN();Never-aborting sentinel and deadline signal
The sentinel is a singleton: Signal.never() returns the same AbortSignal instance on every call. Signal.create().compose({ deadlineMs }) creates a deadline signal through the same observed composition path used for every other option combination.
import { Signal } from '../src/index.js';
const signals = Signal.create();
class NeverTimeoutDemo {
/** Signal.never() returns the same singleton on every call. */
static neverIsSingleton(): void {
const a = Signal.never();
const b = Signal.never();
const c = Signal.never();
assert.strictEqual(a, b, 'first and second calls return the same object');
assert.strictEqual(b, c, 'second and third calls return the same object');
console.log(`neverIsSingleton: a===b=${a === b}, b===c=${b === c}`);
}
/** Signal.never() is never aborted. */
static neverIsNotAborted(): void {
const signal = Signal.never();
assert.ok(!signal.aborted, 'sentinel is not aborted');
console.log(`neverIsNotAborted: aborted=${signal.aborted}`);
}
/** compose() returns an AbortSignal that is not yet aborted for a generous deadline. */
static async deadlineNotYetAborted(): Promise<void> {
const signal = await signals.compose({ 'deadlineMs': 5000 });
assert.ok(signal instanceof AbortSignal, 'deadline composition returns an AbortSignal');
assert.ok(!signal.aborted, 'signal with 5 s deadline is not yet aborted');
console.log(`deadlineNotYetAborted: aborted=${signal.aborted}`);
}
/** compose() with distinct deadlines returns distinct signal instances. */
static async deadlinesReturnDistinctInstances(): Promise<void> {
const a = await signals.compose({ 'deadlineMs': 1000 });
const b = await signals.compose({ 'deadlineMs': 2000 });
assert.notStrictEqual(a, b, 'different deadlines are distinct AbortSignal instances');
console.log(`deadlinesReturnDistinctInstances: a===b=${a === b}`);
}
}
NeverTimeoutDemo.neverIsSingleton();
NeverTimeoutDemo.neverIsNotAborted();
await NeverTimeoutDemo.deadlineNotYetAborted();
await NeverTimeoutDemo.deadlinesReturnDistinctInstances();Try it
The output confirms each composition case: caller+deadline composite, caller-only passthrough, deadline-only timeout, the never-aborting sentinel, and SignalError thrown for invalid deadline values.
Exports
| Symbol | Purpose | Import path |
|---|---|---|
Signal | Composes caller and deadline abort signals. | @studnicky/signal |
SignalError | Represents invalid signal-composition configuration. | @studnicky/signal |
RaceTimeout | Races a value against an abort-aware timeout. | @studnicky/signal |
Signal
| Member | Signature | Description |
|---|---|---|
create | static () => Signal | Creates a Signal instance |
compose | (options: { signal?, deadlineMs? }) => Promise<AbortSignal> | Merges caller signal and/or timeout; returns never-signal when neither is provided |
never | static () => AbortSignal | Returns a singleton signal that never aborts |