@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
A reader's title search needs to stop the moment either of two things happens: the reader cancels, or the supplier's allotted time runs out — whichever comes first. Signal.create() gives you a compose() method that combines a caller AbortSignal and a timeout deadline with AbortSignal.any, aborting as soon as either source fires; supply neither and it hands back a never-aborting sentinel instead, so the calling code never has to branch on whether a signal exists. The example below runs through every combination — both sources, each alone, neither, a zero-millisecond deadline, and the SignalError thrown for a negative or NaN deadline — to pin down the exact behavior of each case.
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();
using composed = await signals.compose({ 'deadlineMs': 5000, 'signal': controller.signal });
const signal = composed.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();
using composed = await signals.compose({ 'signal': controller.signal });
const signal = composed.signal;
assert.ok(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> {
using composed = await signals.compose({ 'deadlineMs': 5000 });
const signal = composed.signal;
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 deadline nor signal supplied — returns a never-aborting AbortSignal. */
static async caseNeither(): Promise<void> {
using composed = await signals.compose({});
const signal = composed.signal;
assert.ok(!signal.aborted, 'the composed signal is never aborted');
console.log(`caseNeither: isAbortSignal=${signal instanceof AbortSignal}, aborted=${signal.aborted}`);
}
/** deadlineMs of 0 is valid (non-negative) — AbortSignal.timeout(0) aborts immediately. */
static async caseZeroDeadline(): Promise<void> {
using composed = await signals.compose({ 'deadlineMs': 0 });
const signal = composed.signal;
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
Not every operation has a caller signal or a deadline — sometimes the honest answer is "this never gets cancelled," and code further down the chain still needs a real AbortSignal to pass around. Signal.never() is built for that: every call hands back a fresh AbortSignal that simply never aborts, so there's no shared state to worry about. The example below confirms three successive calls return three distinct, never-aborted signals, then checks that compose({ deadlineMs }) still produces its own distinct signal for each deadline, following the same composition path proven in the previous example.
import { Signal } from '../src/index.js';
const signals = Signal.create();
class NeverTimeoutDemo {
/** Signal.never() returns a fresh signal on every call. */
static neverIsFreshEachCall(): void {
const a = Signal.never();
const b = Signal.never();
const c = Signal.never();
assert.notStrictEqual(a, b, 'first and second calls return distinct objects');
assert.notStrictEqual(b, c, 'second and third calls return distinct objects');
assert.ok(!a.aborted && !b.aborted && !c.aborted, 'each fresh signal is not aborted');
console.log(`neverIsFreshEachCall: 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> {
using composed = await signals.compose({ 'deadlineMs': 5000 });
const signal = composed.signal;
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> {
using first = await signals.compose({ 'deadlineMs': 1000 });
using second = await signals.compose({ 'deadlineMs': 2000 });
assert.notStrictEqual(first.signal, second.signal, 'different deadlines are distinct AbortSignal instances');
console.log(`deadlinesReturnDistinctInstances: a===b=${first.signal === second.signal}`);
}
}
NeverTimeoutDemo.neverIsFreshEachCall();
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.
What it is
@studnicky/signal is an AbortSignal composition primitive for caller cancellation, deadlines, and a never-aborting sentinel. It creates a cancellation boundary; it does not implement a request client, timeout policy, or task workflow.
What it is for
Northstar Books uses Signal when a catalogue lookup or inventory action must stop when either its caller cancels or its allowed time expires. The Node and browser entrypoints are runtime-specific alternatives, and the interfaces entrypoint supplies the compositional contract that Northstar can pass through its own ports.
Northstar Books examples
- AbortSignal composition — all four cases solves the “a reader cancels a title search while the supplier deadline is still active” problem. It demonstrates caller-and-deadline composition, each source alone, the never-aborting fallback, and invalid deadline handling, proving that every downstream lookup receives one valid cancellation signal.
Public entrypoints
| Import path | Use it when |
|---|---|
@studnicky/signal/node | Northstar composes cancellation for a Node-side catalogue or inventory operation. |
@studnicky/signal/browser | Northstar composes cancellation for a browser-side reader search or bookseller action. |
@studnicky/signal/interfaces | Northstar accepts or returns a composed signal through a typed port without choosing a runtime implementation. |
Exports
| Symbol | Purpose | Import path |
|---|---|---|
Signal | Composes caller and deadline abort signals. | @studnicky/signal/node |
SignalError | Represents invalid signal-composition configuration. | @studnicky/signal/node |
SignalTimeoutError | The AbortSignal.reason of a composed signal whose deadline elapsed. | @studnicky/signal/node |
RaceTimeout | Races a value against an abort-aware timeout. | @studnicky/signal/node |
Signal
| Member | Signature | Description |
|---|---|---|
create | static () => Signal | Creates a Signal instance |
compose | (options: { signal?, deadlineMs? }) => Promise<ComposedSignalInterface> | Returns a disposable handle whose signal merges caller signal and/or timeout; dispose it when the operation ends |
never | static () => AbortSignal | Returns a fresh signal that never aborts |