@cancjs/toolbox
v1.0.0
Published
Cancelable promise utilities: delay, timeout, defer, waitFor, retry, minDelay.
Downloads
692
Readme
Introduction
Two kinds of helpers live here. The first kind is the usual promise utility set, delay,
timeout, retry, waitFor and friends, built so that canceling the result also stops what the
helper started: the timer is cleared, the pending attempt is canceled, the polling stops.
The second kind is more important in practice. cancelify and promisify turn an existing API
into a cancelable one, once, at the boundary. After that the application code stops passing
signals around, because canceling a promise reaches the underlying request on its own.
Features
- timing, control and rate limiting helpers that clean up after themselves on cancellation
- adapters that make signal-aware and callback-style APIs return cancelable promises
AbortSignalinterop in both directions, including timeout composition- deliberate ways to end a cancelable flow instead of blanket error swallowing
- every helper accepts
CancelablePromiseoptions
Getting Started
Installation
npm install @cancjs/toolbox @cancjs/promise@cancjs/promise is a peer dependency. This package is ecosystem tier: a minor release can carry
a breaking change, so pin it with a tilde, ~1.4 (pin the minor, not ~1.x, which npm expands to the
same range as ^1), rather than the default caret. See
Versioning for the full policy.
Usage
import { delay, timeout, retry } from '@cancjs/toolbox';
const undoWindow = delay(5000);
undoWindow.then(sendEmail);
// The user pressed undo. The timer is cleared, the email is never sent.
undoWindow.cancel();const quotes = timeout(fetchQuotes(), 3000);
// On timeout the request itself is canceled, not just abandoned.Wrapping an API that takes a signal is a one-liner, and callers never see the signal again:
import { cancelify } from '@cancjs/toolbox';
const searchFlights = cancelify(({ getSignal }, query) =>
flightApi.search(query, getSignal()),
);
const search = searchFlights('LIS');
search.cancel(); // the underlying request is abortedHow It Works
Helpers build their result through the resolved promise implementation, which is
CancelablePromise unless something else is registered (see
pluggable implementation).
That is what makes the cleanup possible: delay registers a cancel handler that clears its timer,
retry cancels the attempt in flight and drops the backoff wait, waitFor stops polling,
timeout cancels the promise it was watching once the deadline wins.
cancelify works from the other end. It hands the wrapped function a lazy signal thunk. The
controller is created on the first getSignal() call and aborted when the returned promise is
canceled, so a function that never asks for a signal allocates nothing.
Description
Adapters
Adapting an API is the same discipline as promisifying one. Do it once, at the boundary, and keep the application code free of the mechanism:
const orderApi = {
list: cancelify(({ getSignal }, filter) =>
rawOrderApi.list(filter, { signal: getSignal() })
),
get: cancelify(({ getSignal }, id) =>
rawOrderApi.get(id, { signal: getSignal() })
),
};getSignal() can be placed anywhere the underlying call wants it, not only in a trailing options
object.
For callback-style APIs use promisify, which covers error-first and value-first callbacks,
multiple callback values, and the nodejs.util.promisify.custom hook. promisifyAll applies it
across an object, with include and exclude patterns and a choice of cloning, merging or
overwriting.
What not to do: build a new CancelablePromise around a controller and a call, per call site.
That is the promise constructor antipattern in cancelable clothing. Wrap once, compose after.
Signal interop
toAbortSignal(promise) derives a signal that aborts when the promise is canceled or otherwise
rejects, for handing cancelable work to an API that only speaks AbortSignal.
withSignal(signal, promiseOrFn) is the inverse convenience: it races work against an incoming
signal, and passes the value through unraced when the signal is undefined, so optional
cancellation does not need a branch at every call site.
To combine an external signal with a deadline, pass both to timeout: timeout(promise, 5000, {
signal }) races the deadline and the signal together and cancels the underlying promise whichever
wins.
createAbortSignal() mints a plain controller and returns its signal with a bound abort. For a
signal that aborts with a CancelError rather than a bare DOMException, use
createCancelSignal from
@cancjs/promise.
Ending a flow
Filtering error helpers swallow specific expected errors when a flow ends:
await suppressAbort(uploadInProgress);Four pair helpers are exported:
catchAbort(promiseOrError)/suppressAbort(promiseOrError): matches an abort (AbortErroror aCancelErrorcaused by an abort). An ordinary cancellation is rethrown.catchTimeout(promiseOrError)/suppressTimeout(promiseOrError): matches a timeout (TimeoutErroror aCancelErrorcaused by a timeout). An ordinary cancellation is rethrown.
suppressAbort does not swallow an ordinary cancellation. To swallow an ordinary cancellation as well as an abort, use suppressCancel(promise, { abort: true }) from @cancjs/promise.
createCatchError(...matchers) and createSuppressError(...matchers) compile a matcher function for a custom set of expected errors:
const suppressExpected = createSuppressError(AbortError, TimeoutError, 'RetryError');
await suppressExpected(searchProducts(query));Retry and polling
retry takes a function of the attempt number, so the attempt itself can vary, and backs off
exponentially between attempts (retries, minTimeout, factor, maxTimeout, onRetry).
Canceling stops both the wait and the attempt in flight.
waitFor polls a condition (interval, timeout). An async condition is awaited before the next
poll is scheduled, so slow checks never overlap.
Lazy promises
LazyPromise defers its executor until the first subscription, caches the result so every later
consumer shares one execution, and can be canceled before it ever runs:
import { LazyPromise } from '@cancjs/toolbox';
const session = LazyPromise.try(connect);
session.cancel(); // before the first subscription, connect() never runs
await session; // starts here; a second await gets the same session, not a second connectIt mirrors the full CancelablePromise static surface (try, resolve, reject,
withResolvers, all, race, any, allSettled), and combinators stay cold: an aggregate does
not subscribe to its inputs until the aggregate itself is subscribed. createLazyPromise(x,
options?) is the front door for input whose shape varies, function, lazy promise, plain promise
or value, and passes a lazy input through unchanged so its laziness survives.
lazy.execute() starts the work without subscribing to it, which matters for prefetch-then-await:
void lazy.then() builds a derived promise with no handlers, so a later rejection on it is
reported unhandled even when the lazy itself is awaited elsewhere. execute() has no such node.
Laziness stops at the first subscription and does not carry through a chain: delay(1000, { lazy:
true }).then(f) starts at the .then, because then is what a subscription is. A cold multi-step
chain is a cancAsync body that has not been called yet, not a chain of lazy promises.
Lazy async iterator helpers
Pipeable operators for cancelable async iterables are planned as the @cancjs/toolbox/async-iter
entry point 🚧. Until it lands, consume and produce async iterables with canc.forAwait and
cancGen.async from
@cancjs/coroutine.
API
Every helper takes
CancelablePromise options
as its last argument.
Options
| Option | Description |
| -------- | --------------------------------------------------------------------------------------------- |
| bubble | Cancel travels back up to the parent once every child is canceled and the value is unconsumed |
| shield | Stops cancel from propagating down into this promise |
| signal | An AbortSignal that cancels the promise when it aborts |
| lazy | Defers the work until the first subscription. Not accepted everywhere, see below |
bubble, shield and signal come from CancelablePromise and behave identically here.
lazy is a toolbox addition. It defers starting the work (the timer, the retry attempt, the poll,
the callback invocation) until the first then, catch, finally or await. Accepted by delay,
timeout, retry, waitFor and promisify. The helpers that must start immediately, minDelay,
defer, debounce, throttle and cancelify, reject it at compile time rather than accepting it
and ignoring it.
Laziness is not contagious. delay(1000, { lazy: true }).then(f) starts the timer at the .then
call, because a subscription is what wakes it. It does not defer anything further down the chain.
Timing
| Export | Description |
| ------------------------------- | ------------------------------------------------------------- |
| delay(ms, options?) | Resolves after ms, cancel clears the timer |
| delay(input, ms, options?) | Resolves with input's value after ms |
| minDelay(input, ms, options?) | Settles no earlier than ms, for flicker-free loading states |
| timeout(ms, options?) | Rejects with TimeoutError after ms |
| timeout(input, ms?, options?) | Settles with input and cancels it, unless ms passes first |
| waitFor(condition, options?) | Resolves once condition is truthy, polling at interval |
ms is a number of milliseconds or a [min, max] tuple, rolled once per call for a jittered
duration. It is always the last positional argument before options: one positional argument is
the duration, two is (input, duration). input is a value, a promise, or a function; delay
calls a function input after the timer, minDelay and timeout call it immediately.
delay and minDelay differ only on rejections. delay holds an early rejection until ms
elapses, alongside everything else. minDelay reports it the moment it happens, because it is a
floor on success, not a timer. Pick the one that matches what a failure should do.
Control
| Export | Description |
| ------------------------ | --------------------------------------------------------------------- |
| retry(input, options?) | Retries with exponential backoff, input receives the attempt number |
| defer(options?) | { promise, resolve, reject, cancel } where promise is cancelable |
Rate limiting
| Export | Description |
| ---------------------------- | ------------------------------------------------------------------------------------- |
| debounce(fn, ms, options?) | Debounces function calls, returning a wrapper with cancel(), flush(), isPending |
| throttle(fn, ms, options?) | Throttles function calls, returning a wrapper with cancel(), flush(), isPending |
Adapters
| Export | Description |
| -------------------------------- | ---------------------------------------------------------------------- |
| cancelify(fn, options?) | Wraps a promise-returning fn, giving it a signal that aborts on cancel |
| promisify(fn, options?) | Wraps a callback-style fn into one returning a cancelable promise |
| promisifyAll(source, options?) | Applies promisify across an object's methods |
Signal interop
| Export | Description |
| --------------------------------- | ---------------------------------------------------------------- |
| toAbortSignal(promise) | Signal that aborts when the promise cancels or rejects |
| withSignal(signal, promiseOrFn) | Races work against a signal, passes through when there is none |
| createAbortSignal() | Plain AbortController convenience, returns { signal, abort } |
Filtering errors
| Export | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------- |
| catchAbort(promiseOrError) | Returns an AbortError or abort-caused CancelError, rethrows everything else |
| suppressAbort(promiseOrError) | Swallows an AbortError or abort-caused CancelError, rethrows everything else |
| catchTimeout(promiseOrError) | Returns a TimeoutError or timeout-caused CancelError, rethrows everything else |
| suppressTimeout(promiseOrError) | Swallows a TimeoutError or timeout-caused CancelError, rethrows everything else |
| createCatchError(...matchers) | Compiles a matcher returning specified expected errors |
| createSuppressError(...matchers) | Compiles a matcher swallowing specified expected errors |
Lazy promises
| Export | Description |
| --------------------------------------------- | ------------------------------------------------------------------------- |
| new LazyPromise(executor, options?) | Executor form, deferred to the first subscription |
| LazyPromise.try(fn, ...args) | Deferred call of fn, CancelablePromise.try semantics |
| createLazyPromise(x, options?), lazy(run) | Front door: function, lazy promise, plain promise or value |
| LazyPromise.all/race/any/allSettled(...) | Cold combinators, semantics from CancelablePromise |
| LazyPromise.withResolvers(options?) | { promise, resolve, reject, cancel }, adoption deferred to subscription |
| lazy.execute() | Starts the work now, without subscribing to it |
| lazy.started | Whether the executor has been triggered |
| isLazyPromise(value) | Brand check |
Errors
AbortError, isAbortError(error), TimeoutError, isTimeoutError(error).
Compatibility
Node.js 18 and later, current browsers, TypeScript 4.2 and later. AbortController and
AbortSignal are required by the signal interop helpers. Everything else follows
@cancjs/promise.
For the same helpers on plain Promise, without cancellation, see
@cancjs/toolbox-native.
Documentation
@cancjs/promisefor the cancellation model and options- Coroutines for using these helpers inside a cancelable flow
- Examples:
demo-toolboxfor the helpers under cancellation,demo-signal-interopfor the bridges
Contributing
You are welcome to participate through issues and pull requests!
