asyncraft
v0.2.0
Published
Zero-dependency async control-flow utilities: retry, timeout, circuit breaker, concurrency limit, async map, single-flight memoize, debounce, deferred, and AbortSignal helpers — fully typed, AbortSignal-aware.
Maintainers
Readme
asyncraft
Zero-dependency async control-flow utilities: retry, timeout, circuit breaker, concurrency limit, async map, single-flight memoize, debounce, deferred, and AbortSignal helpers — fully typed,
AbortSignal-aware.
Instead of installing p-retry + p-timeout + p-limit + p-map + p-memoize + a circuit-breaker lib separately, get the resilience and concurrency primitives every project eventually needs in one tiny, tree-shakeable package.
- Zero dependencies — nothing else lands in your
node_modules. - TypeScript-first — strict types, no
any, full inference. AbortSignaleverywhere — every wait is cancellable.- Composable — stack
circuitBreaker→retry→withTimeoutinsideasyncMap. - AI-friendly — full TSDoc in IntelliSense + a machine-readable
llms.txtso coding agents pick the right primitive. - ESM + CJS — works in modern and legacy setups, Node ≥ 18.
Which primitive do I need?
| Problem | Use |
| -------------------------------------------------------- | ---------------- |
| Fails intermittently — retry with backoff | retry |
| Might hang — enforce a time budget | withTimeout |
| Dependency is down — fail fast, stop hammering it | circuitBreaker |
| Too much parallelism — cap it | createLimit |
| Run over a list with bounded parallelism, keep order | asyncMap |
| Same call fired repeatedly/concurrently — dedupe + cache | memoize |
| Collapse rapid triggers into one trailing call | debounceAsync |
| A promise you resolve from elsewhere | deferred |
| Merge several cancellation sources | anySignal |
| Just wait, cancellably | sleep |
Install
npm install asyncraftUsage
retry(fn, options?)
Call a function until it succeeds, with exponential backoff and jitter.
import { retry } from 'asyncraft';
const user = await retry(() => fetchUser(id), {
retries: 5, // extra attempts after the first (default 3)
minDelay: 100, // ms before first retry (default 100)
maxDelay: 10_000, // cap for any single delay (default 10s)
factor: 2, // exponential growth (default 2)
jitter: true, // randomize delays to avoid thundering herds (default true)
shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
onRetry: (err, attempt, delay) => console.warn(`attempt ${attempt} failed, waiting ${delay}ms`),
});When every attempt fails, a RetryError is thrown with the last error as .cause and the attempt count as .attempts. Pass a signal to abort retrying (including mid-delay).
withTimeout(promiseOrFn, ms, options?)
Reject with TimeoutError if something takes too long. Pass a function to receive an AbortSignal that fires on timeout, so the underlying work is actually cancelled:
import { withTimeout, TimeoutError } from 'asyncraft';
// simple: race a promise against the clock
const data = await withTimeout(loadData(), 5000);
// better: cancel the underlying work too
const res = await withTimeout((signal) => fetch(url, { signal }), 5000);createLimit(concurrency)
A minimal p-limit: run at most N promises at once.
import { createLimit } from 'asyncraft';
const limit = createLimit(4);
const pages = await Promise.all(urls.map((url) => limit(() => fetch(url))));
limit.activeCount; // running now
limit.pendingCount; // waiting in queue
limit.clearQueue(); // drop everything not yet startedasyncMap(items, mapper, options?)
Map over an iterable with bounded concurrency. Results always come back in input order.
import { asyncMap } from 'asyncraft';
const pages = await asyncMap(urls, (url) => fetch(url), { concurrency: 4 });
// don't fail fast — collect errors per item, like Promise.allSettled
const results = await asyncMap(urls, (url) => fetch(url), { concurrency: 4, settled: true });
for (const r of results) {
if (r.status === 'fulfilled') use(r.value);
else report(r.reason);
}circuitBreaker(fn, options?)
Fail fast while a dependency is down. After failureThreshold consecutive failures the circuit opens and calls reject immediately with CircuitOpenError (without calling fn); after resetTimeout a single trial call is allowed, and success closes it again.
import { circuitBreaker, CircuitOpenError } from 'asyncraft';
const call = circuitBreaker((id: string) => api.fetchUser(id), {
failureThreshold: 3,
resetTimeout: 10_000,
shouldTrip: (err) => !(err instanceof HttpError && err.status < 500), // ignore 4xx
});
try {
const user = await call('123');
} catch (err) {
if (err instanceof CircuitOpenError) return cachedUser; // fast fallback
throw err;
}
call.state; // 'closed' | 'open' | 'half-open'
call.reset(); // force back to closedmemoize(fn, options?)
De-duplicate and cache async calls. Concurrent identical calls share one in-flight promise (single-flight); resolved values are cached until ttl. Turns a thundering herd of identical requests into one call.
import { memoize } from 'asyncraft';
const getUser = memoize((id: string) => api.fetchUser(id), { ttl: 60_000 });
// 10 concurrent getUser('42') → one fetch, one shared result
const [a, b] = await Promise.all([getUser('42'), getUser('42')]);
getUser.delete('42'); // evict one key
getUser.clear(); // evict everythingOptions: ttl (default Infinity), key (default JSON.stringify(args)), cacheRejections (default false), maxSize (LRU cap, default Infinity).
debounceAsync(fn, { wait })
Trailing-edge debounce: rapid calls collapse into a single invocation wait ms after the last call, using the latest args. Every caller in the window receives that invocation's result.
import { debounceAsync } from 'asyncraft';
const save = debounceAsync((doc: Doc) => api.save(doc), { wait: 500 });
editor.on('change', (doc) => save(doc)); // one save 500ms after typing stops
save.cancel(); // reject anything pending
save.pending; // is a call scheduled?deferred()
A promise you resolve or reject from the outside — no hand-rolled executor.
import { deferred } from 'asyncraft';
const ready = deferred<void>();
server.once('listening', () => ready.resolve());
server.once('error', (err) => ready.reject(err));
await ready.promise;sleep(ms, options?)
A cancellable delay.
import { sleep } from 'asyncraft';
await sleep(1000);
await sleep(60_000, { signal: controller.signal }); // rejects on abortanySignal(...signals) & timeoutSignal(ms)
Compose cancellation. timeoutSignal makes a signal that self-aborts after ms; anySignal merges several signals into one that aborts when the first does (a drop-in for AbortSignal.any on Node < 20).
import { anySignal, timeoutSignal } from 'asyncraft';
// abort when the caller cancels OR a 5s deadline passes
const signal = anySignal(req.signal, timeoutSignal(5000));
await fetch(url, { signal });Composing
The pieces are designed to combine:
import { asyncMap, retry, withTimeout } from 'asyncraft';
const results = await asyncMap(
urls,
(url) =>
retry(() => withTimeout((signal) => fetch(url, { signal }), 5000), {
retries: 3,
}),
{ concurrency: 8, settled: true },
);API summary
| Export | Purpose |
| --------------------------- | ------------------------------------------------ |
| retry(fn, opts?) | Exponential-backoff retry with jitter |
| withTimeout(p, ms, o?) | Time-bound a promise, cancel underlying work |
| circuitBreaker(fn, opts?) | Fail fast while a dependency is down |
| createLimit(n) | Concurrency limiter (p-limit style) |
| asyncMap(items, fn, o?) | Ordered async map with bounded concurrency |
| memoize(fn, opts?) | Single-flight de-dup + TTL cache for async calls |
| debounceAsync(fn, {wait}) | Trailing-edge async debounce |
| deferred() | Externally-resolvable promise |
| sleep(ms, opts?) | Cancellable delay |
| anySignal(...signals) | Merge AbortSignals into one |
| timeoutSignal(ms) | An AbortSignal that self-aborts after ms |
| TimeoutError | Thrown by withTimeout |
| RetryError | Thrown by retry when attempts are exhausted |
| CircuitOpenError | Thrown by an open circuitBreaker |
For AI coding agents
asyncraft is built to be consumed correctly by LLM-based tools:
- A machine-readable
llms.txtships in the published package — a concise map of every export with signatures, a "which primitive for which problem" guide, conventions, and copy-paste recipes, so an agent can pick the right primitive without reading source. - Every public symbol carries full TSDoc (
@param/@throws/@remarks/@example), so the same guidance appears in editor IntelliSense and in any tool that reads.d.ts. - Strong, inference-friendly types (verified by type-level tests) mean generated call sites either type-check or fail loudly.
Stability guarantees
These behaviors are pinned by a dedicated test suite (unit + stress + property-based + type-level; see TESTING.md) — a change that breaks any of them fails CI and cannot be published:
- No timer or listener leaks. Every
setTimeoutis cleared and everyAbortSignallistener removed once an operation settles or is aborted. A long-lived signal can be reused across thousands of calls, and a cancelled delay never keeps the process alive. - Abort reasons are always throwable. If
signal.reasonis anErrorit is rethrown as-is; strings and other primitives are wrapped in anErrornamedAbortErrorwith a readable message. - Deterministic retry accounting.
retrycalls your function exactlyretries + 1times in the worst case. Without jitter, delays followmin(minDelay · factor^(n-1), maxDelay)and never decrease; with jitter each delay stays within[50%, 100%]of that value. - Order preservation.
asyncMapresults always match input order, for any input and any concurrency (verified with property-based tests). - Failure isolation. A rejecting task frees its
createLimitslot like any other, andasyncMap'ssettledmode reports every item — one failure never stalls the queue or hides another result. - Typed error surface. Timeouts throw
TimeoutError, exhausted retries throwRetryError(with.causeand.attempts) — bothinstanceof-able.
All public APIs carry full TSDoc (@param, @throws, @remarks,
@example), so the same documentation appears in your editor's IntelliSense.
Development
npm install
npm run tdd # TDD loop: vitest watch mode + live coverage
npm test # unit + property + type tests
npm run test:coverage # same, plus the coverage gate CI enforces
npm run lint # eslint (type-checked)
npm run typecheck # tsc --noEmit
npm run build # tsup → dist/ (ESM + CJS + d.ts)See TESTING.md for the TDD workflow and the test-harness layers (unit / stability / property-based / type tests).
License
MIT
