Skip to content
Buy me a coffee

enhanced-abort-controller

2.0 · real AbortSignal · zero dependencies

Cancellation
that cleans up after itself.

An AbortController with .NET CancellationTokenSource powers: a deadline you can move, linked controllers that don't leak, register() with a handle, whenAborted and TimeSpan. The signal stays a real AbortSignal.

report.ts
import {
EnhancedAbortController, TimeSpan, isAbortError, isTimeoutError,
} from '@ylcnfrht/enhanced-abort-controller';
const deadline = TimeSpan.fromSeconds(1.5);
using controller = new EnhancedAbortController().abortAfter(deadline);
controller.signal.register((reason) => console.log('cleanup', reason));
// abortAfter again replaces the deadline; abort() cancels now.
extend.onclick = () => controller.abortAfter(deadline);
cancel.onclick = () => controller.abort();
try {
const res = await fetch('/api/report', { signal: controller.signal });
console.log(res.status, 'OK'); // the request takes 2 s
} catch (error) {
if (!isAbortError(error)) throw error;
console.log(isTimeoutError(error) ? 'timed out' : 'cancelled');
}
live · real library, simulated fetch
0dependencies
AbortSignalthe real one
TimeoutErrorlike AbortSignal.timeout
Node ≥ 20Bun, Deno, browsers
ESM + CJStyped, tree-shakeable

What it adds

The platform cancels. This keeps it tidy.

Real signals

controller.signal is an AbortSignal. Not a wrapper.

EnhancedAbortController extends AbortController. Pass the signal to fetch, fs/promises, streams, timers/promises, AbortSignal.any and anything else that takes one.

Read more
const controller = new EnhancedAbortController();
controller instanceof AbortController; // true
controller.signal instanceof AbortSignal; // true
await fetch(url, { signal: controller.signal });
abortAfter

A deadline you can move.

Like .NET CancelAfter: calling it again replaces the schedule, Infinity cancels it. Aborts with a TimeoutError and never keeps Node alive.

Read more
controller.abortAfter(TimeSpan.fromSeconds(5));
controller.abortAfter(5000); // reset
controller.abortAfter(Infinity); // cancel
Linked

Per-step deadlines that follow the request.

A linked controller aborts with its parent’s reason and removes its listeners when it aborts or is disposed.

Read more
using step = EnhancedAbortController
.createLinkedController(request.signal)
.abortAfter(2000);
Callbacks

register() with a handle, whenAborted as a promise.

For any AbortSignal. The callback gets the reason; unregister() or using removes it.

Read more
using _ = register(signal, (reason) => socket.destroy());
const reason = await whenAborted(signal);
Lifetimes

dispose() releases. It does not abort.

Like .NET Dispose: the timer and the parent links go away, abort() still works. Supports using.

Read more
{
using controller = EnhancedAbortController.timeout(5000);
await work(controller.signal);
} // timer released here
Errors

Tell a timeout from a cancel.

Deadlines abort with a TimeoutError, abort() with an AbortError or your own reason. isAbortError() recognises fetch rejections, DOMExceptions and the library’s AbortError; isTimeoutError() picks out timeouts.

Read more
try {
await fetch(url, { signal: controller.signal });
} catch (error) {
if (!isAbortError(error)) throw error;
console.log(isTimeoutError(error) ? 'timed out' : 'cancelled');
}

The toolbox

Know CancellationTokenSource? You know the API.

Controller

  • abortAfter(delay)Resettable deadline
  • abort(reason?)Native, unchanged
  • dispose() · usingRelease, not abort
  • isAborted · reasonState
  • timeout(delay)Static shortcut

Link

  • createLinkedControllerCreateLinkedTokenSource
  • linkSignalsAlias
  • EnhancedAbortSignal.anyAbortSignal.any, optional-friendly
  • EnhancedAbortSignal.timeoutAccepts TimeSpan
  • EnhancedAbortSignal.noneCancellationToken.None

Observe

  • register(signal, cb)Disposable registration
  • whenAborted(signal)Promise of the reason
  • signal.register · .whenAbortedBuilt into the signal
  • canBeAbortedCanBeCanceled
  • isAbortError · isTimeoutErrorClassify errors

Time

  • TimeSpan.fromSeconds(30)Instead of 30000
  • add · subtract · compareToArithmetic
  • TimeSpan.infiniteCancels a deadline
  • > 24.8 daysChained timers
  • NaN / negativeRangeError, not instant abort

In code

Real cancellation, a few lines each.

app.get('/report', async (req, res) => {
// Aborts when the client goes away, or after 10 s.
using request = new EnhancedAbortController().abortAfter(TimeSpan.fromSeconds(10));
res.on('close', () => { if (!res.writableFinished) request.abort(new Error('client disconnected')); });
try {
res.json(await buildReport({ signal: request.signal }));
} catch (error) {
if (!isAbortError(error)) throw error;
if (!res.headersSent) res.status(isTimeoutError(error) ? 504 : 499).end();
}
});

Get started

Give one request a deadline. Grow from there.

Replace new AbortController() with new EnhancedAbortController(). Everything that took the signal still does.