Skip to content

@studnicky/signal ​

Compose AbortSignals from callers and deadlines without repetitive AbortController boilerplate.

Install ​

bash
pnpm add @studnicky/signal

Requires @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.

ts
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.

ts
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 ​

Loading example…

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 pathUse it when
@studnicky/signal/nodeNorthstar composes cancellation for a Node-side catalogue or inventory operation.
@studnicky/signal/browserNorthstar composes cancellation for a browser-side reader search or bookseller action.
@studnicky/signal/interfacesNorthstar accepts or returns a composed signal through a typed port without choosing a runtime implementation.

Exports ​

SymbolPurposeImport path
SignalComposes caller and deadline abort signals.@studnicky/signal/node
SignalErrorRepresents invalid signal-composition configuration.@studnicky/signal/node
SignalTimeoutErrorThe AbortSignal.reason of a composed signal whose deadline elapsed.@studnicky/signal/node
RaceTimeoutRaces a value against an abort-aware timeout.@studnicky/signal/node

Signal ​

MemberSignatureDescription
createstatic () => SignalCreates 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
neverstatic () => AbortSignalReturns a fresh signal that never aborts

Source on GitHub