@zakkster/lite-await
v1.3.0
Published
Zero-GC bridge between @zakkster/lite-signal and Promise/async-await. Wait for signal values, race multi-source predicates, AbortSignal-first cancellation, bidirectional promise<->signal conversion. The missing async-coordination primitive for the lite-si
Downloads
739
Maintainers
Readme
@zakkster/lite-await
Zero-GC bridge between
@zakkster/lite-signaland theasync/awaitworld. Wait for signal values. Race multi-source predicates. AbortSignal-first cancellation. Bidirectional promise<->signal conversion. The missing async-coordination primitive for the lite-signal ecosystem.
The async-coordination primitive the ecosystem was missing
lite-signal is the reactive core: synchronous, pull-based, zero-GC. Every consumer hits the same question on day one -- "how do I await a signal becoming X?" -- and the naive subscribe-then-resolve answer leaks in five places (no cancellation, no timeout, initial-state races, no rejection path, pool exhaustion). lite-await is the piece that closes that gap: it turns a reactive read into a Promise whose every settlement path -- resolve, reject, timeout, abort -- structurally tears the underlying effect down and returns its node to the lite-signal pool. It also ships the four Promise combinators (allOf / anyOf / raceOf / allSettledOf) over signals and the newest platform Promise statics (withResolvers, tryFn, delay) so consumers on the Node >=18 floor have them everywhere.
npm install @zakkster/lite-await @zakkster/lite-signal@zakkster/lite-signal is a peer dependency (not bundled, install it alongside). ESM only. Node >=18 (for AggregateError, AbortController, DOMException). Zero runtime dependencies.
import { whenSignal, raceOf, fromPromise } from "@zakkster/lite-await";
import { signal, effect } from "@zakkster/lite-signal";
const auth = signal("anonymous");
// Wait for a signal to satisfy a predicate -- with deadline and cancellation.
const ctrl = new AbortController();
const token = await whenSignal(
auth,
(s) => s === "authenticated",
{ timeout: 5000, signal: ctrl.signal }
);
// Race success against failure (the EBS / request-response pattern).
const response = signal(null);
const error = signal(null);
const result = await raceOf([
[response, (r) => r !== null],
[error, (e) => e !== null]
], { timeout: 10000 });
if (result.index === 1) throw new Error(result.value);
// Project a fetch back into a signal-shaped resource for the UI loop.
const userQuery = fromPromise(fetch("/api/me").then((r) => r.json()));
effect(() => {
const s = userQuery();
if (s.status === "resolved") render(s.data);
});Seventeen exports, one settlement discipline, zero allocation on the per-change path. The bridge is bidirectional: signals flow to Promises via whenSignal and the combinators; Promises flow back to signals via fromPromise. Two consumer-pull helpers -- withRetry and mapLimit -- close the loop for transient-failure retry and concurrency-limited fan-out.
Table of contents
- Why this exists
- What you get
- Deep-dive: whenSignal
- API reference
- Composability: one end-to-end pipeline
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
Bridging a reactive signal to await has two problems that no small helper solves at once:
Cleanup is structural, not best-effort. The naive
new Promise((res) => sig.subscribe(...))leaks its subscription on every path that is not the happy one -- caller abort, deadline, a component unmount. Under load, every leaked subscription holds an observer slot and you exhaust the lite-signal pool.lite-awaitruns the samefullCleanupon resolve, reject, timeout, and abort: there is no settlement path where the effect, the timer, or the abort listener is skipped. A 4096-cycle leak probe proves the pool returns to baseline.// DON'T do this. It looks fine. It leaks on abort, on timeout, on unmount. function waitFor(sig, pred) { return new Promise((resolve) => { const stop = sig.subscribe((v) => { if (pred(v)) { stop(); resolve(v); } }); }); }The door must fail closed. An unverified option is a bug the caller has to hear about, not a silent default. A typo'd
{ timeot: 5000 }used to mean "no deadline" and waited forever; atimeout: Infinityon a combinator used to destroy the whole bundle after 1 ms with the wrong error class; a scalaropts(delay(0, 5)) used to be silently swallowed. Every one of those is now a loud, named error at registration time, before any timer, listener, effect node, or signal node is acquired.nullis never treated as zero.
Existing options: hand-rolled subscribe loops (leak, no timeout, no abort identity), or a general async library (heavyweight, not signal-aware, no structural cleanup). lite-await is the API for this specific job.
What you get
whenSignal(source, predicate, opts?)-- the foundational primitive. Resolve when a reactive read first satisfies a predicate; settlement always cleans the effect. Everything else is built on it or shares its door.- Four combinators over signals --
allOf(every spec),anyOf(first to resolve,AggregateErroronly if all fail),raceOf(first to settle, success or failure cascades),allSettledOf(every spec settles, never rejects on a spec failure). Each owns an internalAbortControllerthat cancels siblings on settlement. - Three platform-parity Promise primitives --
withResolvers()(ES2024),tryFn(fn, ...args)(ES2025Promise.try), anddelay(ms, opts?)(abortable sleep), implemented directly so they behave identically on every supported Node, including the>=18floor where the natives are absent. - Two consumer-pull helpers --
withRetry(fn, opts)(exponential backoff over a per-attempt-cancellable factory; abort is never a retry) andmapLimit(items, fn, limit, opts?)(concurrency-limited fan-out, results in input order, first rejection cancels the rest). - Two Promise wrappers --
withTimeoutandwithAbortfor arbitrary, non-signal-aware promises. - Two shorthands + one bridge + one specialization --
whenTruthy,whenEquals,fromPromise(Promise ->Signal<AsyncState<T>>), andwhenStatechart(duck-typed FSM). TimeoutError--{ name: "TimeoutError", timeout: number }; abort errors are platform-shaped.VERSION-- the package version string, in lockstep withpackage.jsonandllms.txt(three-place version law).
Full types ship in Await.d.ts. Every export is documented.
Deep-dive: whenSignal
whenSignal(source, predicate, opts?) creates a lite-signal effect that reads source() and tests predicate(value). The moment the predicate returns truthy, the promise resolves with the satisfying value and the effect is disposed.
- Synchronous-first-match. If the predicate is already true on the first effect run,
whenSignalstill resolves on the next microtask (never a synchronous throw-or-return), so ordering is predictable. The effect is created and torn down within a single tick. - A throwing predicate or
source()rejects the promise. On the synchronous first read or on a later change-driven fire, a throw routes todoReject: the promise rejects with the thrown value and the effect tears down via the same late-binding stop. Without this, a throw on a change-driven fire would unwind at the signal writer's.set()call site and leave the promise pending with a leaked effect node. - Structural cleanup.
doResolveanddoRejectboth run onefullCleanup: stop the effect,clearTimeoutthe deadline,removeEventListenerthe abort listener. Every settlement path runs it; none skips a step. - Zero per-fire allocation. The predicate-check closure (
checkPredicate) is allocated once at registration and reused on every effect fire -- the hoisted-untrack-body discipline borrowed fromWatchEx.js. An effect fire that does not settle allocates nothing. - Pre-aborted short-circuit. If
opts.signalis already aborted,whenSignalreturns a rejected Promise synchronously and never builds the effect machinery just to tear it down.
The combinators are whenSignal calls sharing one internal AbortController; whenTruthy / whenEquals are whenSignal with a fixed predicate. Learn this one primitive and you know the settlement model of the whole package.
API reference
Every awaiter accepts the same options shape:
interface AwaitOptions {
timeout?: number; // ms; rejects with TimeoutError
signal?: AbortSignal; // first-class cancellation
}delay is the one exception: its opts accepts only signal (the ms argument is the timeout).
Core primitives
whenSignal(source, predicate, opts?): Promise<T>
allOf(specs, opts?): Promise<T[]>
anyOf(specs, opts?): Promise<{ index: number, value: T }>
raceOf(specs, opts?): Promise<{ index: number, value: T }>
allSettledOf(specs, opts?): Promise<Array<{ status: "fulfilled", value } | { status: "rejected", reason }>>whenSignal-- resolve whensource()first satisfiespredicate. See the deep-dive above.allOf--specsisArray<[source, predicate]>. Resolves with values in input order when every spec satisfies. Any failure (rejection, timeout, abort) aborts the remaining in-flight specs.allOf([])resolves[](after opts validation).anyOf-- first spec to resolve wins; losers aborted. Rejects withAggregateErroronly if every spec rejects independently. A bundle abort surfaces the platformAbortErrordirectly, not wrapped.anyOf([])rejectsAggregateError("anyOf: empty specs").raceOf-- first spec to settle wins -- success or failure cascades.raceOf([], { timeout })rejectsTimeoutErrorat the deadline;raceOf([], { signal })rejects the abort reason (pre- or mid-flight);raceOf([])with neither stays forever-pending (Promise.race([])parity).allSettledOf-- waits for every spec to settle and resolves with a result array in spec order, key-for-key the platformPromise.allSettledshape. A spec rejecting never rejects the bundle; the bundle rejects only on a bundle-level timeout (TimeoutError) or abort (AbortErrordirectly). Empty specs resolve[]after opts validation.
const [user, room] = await allOf([
[jwt, (j) => j !== null],
[state, (s) => s === "joined"]
], { timeout: 10000 });
const winner = await anyOf([[primary, ok], [fallback, ok], [cache, ok]]);
console.log("source", winner.index, "won with", winner.value);
const results = await allSettledOf([[primary, ok], [fallback, ok]]);
for (const r of results) r.status === "fulfilled" ? use(r.value) : logFailure(r.reason);Platform-parity primitives
Direct, feature-detection-free implementations of the newest Promise statics, so they behave identically on every supported Node -- including the >=18 floor, where withResolvers (native >=22) and try (native >=24) are absent.
withResolvers(): { promise: Promise<T>, resolve, reject }
tryFn(fn, ...args): Promise<T>
delay(ms, opts?): Promise<void> // opts accepts ONLY { signal }withResolvers-- the ES2024Promise.withResolvers(). One object literal + one Promise per call; no options, no pooling (Promises are one-shot).tryFn-- the ES2025Promise.try(). Runsfninside atryand adopts its outcome: a sync return resolves, a sync throw rejects (the caller always gets a Promise), a returned thenable is adopted, args are forwarded. A non-functionfnrejectsTypeError. The zero-arg call path allocates no rest array.delay-- a one-shot timer as a cancellable Promise.opts.signalaborts the wait (rejecting the reason) and clears the timer. The uniform timeout law applies toms:undefined/Infinitynever settles (no timer armed -- still abortable if a signal is passed),NaN/ negative / non-number rejectRangeError. A pre-aborted signal rejects immediately. Nounrefoption (portability);delaylives on the Promise boundary, notlite-clock.
const { promise, resolve, reject } = withResolvers();
socket.once("message", resolve); socket.once("error", reject);
const value = await tryFn(() => JSON.parse(input)); // parse error -> rejection
await delay(5000, { signal: ctrl.signal }); // abortable sleepConsumer helpers
withRetry(fn, opts): Promise<T>
// fn: (signal) => Promise<T>
// opts: { attempts, baseMs?, factor?, jitter?, signal?, timeout?, retryable? }
mapLimit(items, fn, limit, opts?): Promise<T[]>
// fn: (item, index, signal) => Promise<T>; opts: { signal?, timeout? }withRetry-- retry a factory with exponential backoff. Each attempt gets a fresh per-attemptAbortControllerlinked to the bundlesignal(a new controller each attempt, so aborting attemptknever bleeds intok+1) and runs throughtryFn, so a sync-throwing factory becomes a failed attempt on one uniform error channel. Backoff between attempts isbaseMs * factor**(k-1), jittered by+/- jitterfraction (jitter: 0.2=+/-20%), slept viadelay(ms, { signal }).attemptsis required (integer>= 1);baseMs/factor/jitterdefault to0/2/0. Exhaustion rejects the last attempt's error. Abort is never a retry: a bundle abort (fromsignalor thetimeoutbudget) short-circuits and rejects the abort reason /TimeoutErrordirectly --retryableis never consulted for anAbortError.timeoutis the total budget across all attempts and backoffs, not per-attempt (the factory owns per-attempt cancellation via its signal).retryable(error, attempt)defaults to retrying every non-abort error; a throwing predicate rejects the bundle.mapLimit-- mapitemsthrough a factory with a concurrency cap oflimit, resolving with the results in input order regardless of settlement order. Launches up tolimitat once; as each settles, the next pending launches until exhausted. Each item runs throughtryFnoff a shared internalAbortController(linked to the bundlesignal), with both handlers attached at creation, so an aborted pending / in-flight item never escapes as anunhandledRejection. The first rejection aborts the shared controller (cancelling in-flight items), stops launching pending items, and rejects the bundle with that error -- or the abort reason directly on a bundle abort, orTimeoutErroron the bundle deadline. Emptyitemsresolve[]after validation.limitmust be an integer>= 1(0/ negative /1.5/NaN/"8"/truerejectRangeError).
// Retry a flaky fetch, backing off, giving up on 4xx, honoring a shared abort.
const data = await withRetry(
(signal) => fetch(url, { signal }).then((r) => {
if (r.status >= 400 && r.status < 500) throw new HttpError(r.status);
return r.json();
}),
{ attempts: 5, baseMs: 100, factor: 2, jitter: 0.2, signal: ctrl.signal,
retryable: (e) => !(e instanceof HttpError) }
);
// Fan out N id lookups, at most 8 in flight, results in input order.
const users = await mapLimit(
userIds,
(id, index, signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
8,
{ signal: ctrl.signal, timeout: 30000 }
);Promise wrappers
withTimeout(promise, ms): Promise<T>
withAbort(promise, signal): Promise<T>Wrap an arbitrary Promise with a deadline / an AbortSignal. The inner promise is not cancelled -- arbitrary promises are not AbortSignal-aware; the result is merely detached. For cancellable signal work, pass timeout / signal directly to the primitive that creates the work. withTimeout keeps its documented identity-on-Infinity (and undefined) passthrough (returns the same promise object). A non-thenable rejects TypeError at the door with zero timers / listeners acquired.
Convenience + bridge + statechart
whenTruthy(source, opts?): Promise<T> // whenSignal(source, Boolean, opts)
whenEquals(source, target, opts?): Promise<T> // whenSignal(source, v => Object.is(v, target), opts)
fromPromise(promise, initialData?): Signal<AsyncState<T>>
whenStatechart(machine, stateName, opts?): Promise<void>fromPromise-- drive one signal from a Promise's lifecycle. The signal holds{ status: "pending" | "resolved" | "rejected", data, error }and updates exactly once on settlement. A non-thenable throws synchronously with zero signal nodes acquired. Dispose via lite-signal'sdispose(sig)when done.whenStatechart-- resolve when a duck-typed FSM ({ state: { peek() }, onTransition(fn) }) entersstateName. HooksonTransitiondirectly (one observer slot) instead of trackingstate(one effect node). Resolves on the next microtask if already in target.
Scopes
createAwaitScope(ctrl?): AwaitScope
// scope.whenSignal / allOf / anyOf / raceOf / allSettledOf / withRetry
// / mapLimit / delay / whenStatechart / whenTruthy / whenEquals(...)
// scope.signal scope.aborted scope.abort(reason?)createAwaitScope-- bind the signal-aware awaiters to ONEAbortControllerso a consumer stops hand-rolling a per-call controller + cleanup for every wait.ctrlomitted owns a fresh controller (scope.abort(reason)aborts it);ctrlpassed borrows it (the caller owns abort;scope.abort()throws). A per-call{ timeout, signal }merges over the scope binding: a per-call signal links WITHscope.signal(both can abort) and whichever fires first surfaces its OWN reason unflattened --scope.abort(new RoomClosedError())arrives=== reasonat every in-flight wait, never a genericAbortError. The bound surface is built ONCE; the straight-through path reuses one opts object, so a scopedwhenSignalretains the same bytes/op as a bare one.withResolvers/tryFn/fromPromisetake no signal and are not on the scope. Full reasoning indecisions/0007-await-scope.md.
Errors
TimeoutError extends Error -- { name: "TimeoutError", timeout: number }. The numeric timeout field is the ms value that elapsed.
try { await whenSignal(s, p, { timeout: 100 }); }
catch (e) { if (e.name === "TimeoutError") console.log("deadline was", e.timeout, "ms"); }Abort errors are platform-shaped: the signal's reason if set (DOM spec), otherwise DOMException("Aborted", "AbortError"), otherwise an Error named AbortError.
Contract constants and laws
| Constant / law | Value | Meaning |
| --- | --- | --- |
| VERSION | "1.2.0" | Package version string; lockstep with package.json and llms.txt. |
| TimeoutError.name | "TimeoutError" | Discriminant for a deadline rejection. |
| TimeoutError.timeout | number | The ms deadline that elapsed. |
| Timeout law | undefined / Infinity -> no deadline (no timer armed); any other value must be finite >= 0, else RangeError. null is RangeError, never zero. | Uniform across whenSignal, allOf, anyOf, raceOf, allSettledOf, whenStatechart, and delay's ms. withTimeout keeps identity-on-Infinity. |
| Abort identity | The platform AbortError (signal.reason if set) directly, not wrapped. | Same shape pre-abort and mid-abort, across every primitive. |
| Unknown-key rule | Any own-enumerable opts key other than timeout / signal (or signal only, for delay) is a TypeError with a did-you-mean hint. | A typo like { timeot: 5000 } is a loud error, never a silent "no deadline". |
| Non-object-opts rule | A non-object opts (number / boolean / bigint / symbol / array / string / function) is a TypeError, same fail-closed door as an unknown key. | A scalar has no own keys, so a pre-1.1.1 scan silently accepted it as "no opts". No correct program passes a scalar opts. |
All validation runs at registration time (the cold path), before any timer, listener, effect node, or signal node is acquired.
Composability: one end-to-end pipeline
A single flow that touches five primitives -- withResolvers to bridge a callback, whenSignal to await a reactive gate, allSettledOf to gather independent resources, delay for abortable backoff, and fromPromise to project the result into the UI loop:
import {
withResolvers, whenSignal, allSettledOf, delay, fromPromise
} from "@zakkster/lite-await";
import { signal, effect, dispose } from "@zakkster/lite-signal";
async function boot(socket, ctrl) {
// 1. Bridge a callback API into a Promise without nesting an executor.
const { promise: handshake, resolve, reject } = withResolvers();
socket.once("ready", resolve);
socket.once("error", reject);
await handshake;
// 2. Await a reactive gate with a deadline + shared cancellation.
const authState = signal("anonymous");
socket.on("auth", (s) => authState.set(s));
await whenSignal(authState, (s) => s === "authenticated",
{ timeout: 5000, signal: ctrl.signal });
// 3. Gather several independent resources; a single failure does not sink the boot.
const profile = signal(null), prefs = signal(null), flags = signal(null);
socket.emit("load", ["profile", "prefs", "flags"]);
const settled = await allSettledOf([
[profile, (v) => v !== null],
[prefs, (v) => v !== null],
[flags, (v) => v !== null]
], { timeout: 10000, signal: ctrl.signal });
// 4. Back off before retrying anything that came back rejected -- abortably.
if (settled.some((r) => r.status === "rejected")) {
await delay(250, { signal: ctrl.signal });
socket.emit("retry-missing");
}
// 5. Project a follow-up fetch into a signal the UI can render reactively.
const dash = fromPromise(fetch("/api/dashboard").then((r) => r.json()), { widgets: [] });
const stop = effect(() => {
const s = dash();
if (s.status === "pending") renderSpinner(s.data); // initialData fallback
else if (s.status === "resolved") renderDashboard(s.data);
else renderError(s.error);
});
return () => { stop(); dispose(dash); }; // caller owns teardown
}Every stage shares one AbortController: ctrl.abort() rejects the in-flight whenSignal, allSettledOf, and delay with the same AbortError, cleaning up every effect, timer, and listener structurally. No stage leaks if the boot is cancelled mid-flight.
The two consumer helpers compose directly -- a batch fetch with per-request retry is mapLimit over withRetry, both sharing one bundle signal:
import { withRetry, mapLimit } from "@zakkster/lite-await";
async function batchFetch(ids, ctrl) {
return mapLimit(
ids,
(id, index, itemSignal) => withRetry(
// withRetry hands each attempt its own signal; forward the item's
// signal (aborted when the bundle aborts) so a per-item cancel and a
// per-attempt cancel both cut the inner fetch.
(attemptSignal) => fetch(`/api/item/${id}`, { signal: attemptSignal })
.then((r) => {
if (r.status >= 500) throw new Error("transient " + r.status);
return r.json();
}),
{ attempts: 3, baseMs: 200, factor: 2, jitter: 0.25, signal: itemSignal }
),
6, // max 6 concurrent, results in id order
{ signal: ctrl.signal, timeout: 30000 }
);
}ctrl.abort() cancels every in-flight item AND its in-flight retry attempt with the same AbortError; a 5xx on one item retries with backoff without stalling the other five lanes; the first non-retryable failure (or the 30s bundle deadline) cancels the whole batch and rejects directly.
Zero-GC design notes
A Promise is an allocation; so are its two settlement closures and (for fromPromise) the state literal. That bounded, monomorphic, dies-young settlement cost is the contract -- not "no allocation ever", but "no retained allocation and no allocation on the per-change path". An effect fire that does not settle allocates nothing (the hoisted checkPredicate); the pool returns to baseline after every settlement.
| Operation | Steady-state allocation | Retained after settle |
| --- | --- | --- |
| whenSignal resolve/reject | one Promise + settlement closures (die young) | 0 |
| whenSignal per effect fire (no settle) | 0 (hoisted checkPredicate) | 0 |
| allOf / anyOf / raceOf / allSettledOf | one Promise + one AbortController + one result array | 0 |
| withResolvers | one object literal + one Promise | 0 |
| tryFn (zero-arg path) | one Promise (no rest array) | 0 |
| delay | one Promise + one timer (cleared on settle/abort) | 0 |
| fromPromise | one signal node + one state literal per transition | 0 (dispose returns the node) |
| withRetry | one Promise + one AbortController + one tryFn args array per attempt (die young) | 0 |
| mapLimit | one Promise + one shared AbortController + one result array + per-item tryFn | 0 |
The torture harness (@zakkster/lite-leak + @zakkster/lite-gc-profiler, under --expose-gc) commits these as gates. The T6 window numbers from the reference run (node 26.3.1, darwin arm64, lite-await 1.2.0, 2026-08-30):
gc window whenSignal-resolve: major=0 minor=87 maxMs=0.23 bytesPerOp=0.66
gc window whenSignal-abort: major=0 minor=104 maxMs=0.23 bytesPerOp=8.99
gc window allOf-4spec: major=0 minor=139 maxMs=0.19 bytesPerOp=3.69
gc window fromPromise: major=0 minor=15 maxMs=0.45 bytesPerOp=0.70
gc window withRetry: major=0 minor=40 maxMs=0.11 bytesPerOp=3.58
gc window mapLimit-4item: major=0 minor=38 maxMs=0.12 bytesPerOp=0.00
withResolvers=1.79 tryFn=1.59 delay=0.57 allSettledOf-4spec=5.86 B/op
GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 maxMs=0.45 | alloc<=FLOOR 16 B/opmaxMajor: 0 over 200k-op loops, bytes/op <= FLOOR 16, maxPauseMs <= 4, and a 4096-cycle soak with tracker.size() returning to 0. The whenSignal-abort window carries an evidence-backed maxMajor: 1 floor (see decisions/0001-torture-budgets.md); no budget is ever widened to pass.
Design decisions worth knowing
- The uniform timeout law (Law A).
undefined/Infinitymeans "no deadline" everywhere; anything else must be finite and non-negative, elseRangeError;nullis not zero. This relaxedwhenSignal/whenStatechart(Infinitywas aRangeError, now a legal no-op) and tightened the combinators (garbage was a clamped 1 msTimeoutError+ a process warning, nowRangeErrorat the door). Full reasoning indecisions/0002-timeout-law.md. - Fail closed at the door; the scalar-opts seam. A non-null
optsthat cannot be a plain options object is aTypeErrorbefore any resource is acquired -- including a scalar (delay(0, 5),whenSignal(s, p, 5)) that aObject.keys-only scan used to swallow silently. See the "Non-object opts reject" section ofdecisions/0002-timeout-law.md. - Abort identity is direct, not wrapped. A bundle abort surfaces the platform
AbortError(the signal'sreason) directly -- the same shape pre-abort and mid-abort, and the same acrosswhenSignal/allOf/anyOf/raceOf/allSettledOf.anyOfcarries its own direct user-signal listener so cancellation is never misreported asAggregateError. Seedecisions/0003-abort-identity.md. - Own the parity primitives, do not feature-detect.
withResolvers/tryFn/allSettledOf/delayare implemented directly, no native delegation, so behavior is identical on every supported Node; the torture T8 tier proves parity against the native where it exists. No resolver/deferred pooling: Promises are one-shot. Seedecisions/0004-parity-surface.md. delaylives here, not inlite-clock. A timer surfaced as a one-shot cancellable Promise is the Promise boundary's job;lite-clockowns recurring frame/tick scheduling.- The consumer helpers on spec.
withRetryrejects the last error on exhaustion (not anAggregateError), treats every abort as terminal (never retryable), scopestimeoutas the total budget, and jitters withMath.randomon the cold backoff path;mapLimit's factory is(item, index, signal), major-locked. Full reasoning indecisions/0005-consumer-helpers.md.
Testing
305 node:test cases across the numbered suites (4 heap-budget assertions skip without --expose-gc; the lite-statechart integration test also skips when @zakkster/lite-statechart is not installed -- so 5 skip with neither present, 1 with --expose-gc), plus a 10-tier torture gate that proves both leak-freedom and the allocation budget.
npm test # the numbered node:test suites (01..22)
npm run test:gc # the same suites under --expose-gc (heap-budget assertions run)
npm run torture # @zakkster/lite-leak + lite-gc-profiler: leak 0/0, maxMajor 0, bytes/op <= FLOOR
npm run bench # ops/s + retained-bytes/op table with a runtime provenance line
npm run verify # test + test:gc + torture + bench, the publish gateThe suites cover: whenSignal (predicate, sync match, timeout, abort, reason propagation, cleanup), each combinator (in-order / out-of-order / empty / timeout / abort / validation), the fail-closed door surface (13-doors: the full degenerate opts x specs matrix, unknown keys, non-object opts, acquire-before-validate), the abort-identity matrix (14-abort-identity), the parity primitives (15..17), delay (18: timing, abort-clears-timer, delay(0), never-settling Infinity, RangeError door, non-object-opts door), the consumer helpers (21 withRetry: exhaustion / abort-is-not-retry / total-budget timeout / retryable predicate; 22 mapLimit: concurrency cap / input order / first-rejection cancellation), the cleanup leak probe (09: 4K / 2K / 2K / 1K cycles, pool back to baseline), predicate-throw routing (11), and the three-place version sync (12). The torture runner prints exactly ok on success; node --expose-gc test/torture.mjs is the ecosystem DONE-WHEN gate, and AWAIT_TORTURE_BREAK=1 proves every tier can fail.
What this is not
- Not a multi-shot stream or AsyncIterable bridge. That is
@zakkster/lite-stream(toAsyncIterable,fromAsyncIterable,pipeToSignal, bounded queues, drop-oldest overflow,for awaitconsumers).lite-awaitowns the Promise boundary (one-shot:whenSignal,fromPromise);lite-streamowns the AsyncIterable boundary (multi-shot). Feature requests for richer async-iterable bridging land there, not here. - Not a scheduler. No frame/tick scheduling, priorities, or recurring timers. That is
@zakkster/lite-clock;attachRAFcomplementslite-awaitfor frame-by-frame work.delayis a one-shot Promise, not a scheduler. - Not a cache or query manager. No caching, deduplication, or stale-while-revalidate. That is
@zakkster/lite-query;fromPromiseis its core projection primitive, not a substitute for it. - Not a fetch/network layer. No fetch wrappers or parse-and-validate.
withRetrygives you the retry policy (backoff, abort identity, a retryable predicate) but does not know about HTTP -- you supply the factory and decide what is retryable. Compose it withmapLimit/withTimeout/withAbortyourself. - Not for per-frame render loops. It is for one-shot async coordination at lifecycle boundaries (load, ready, dispose). For per-frame work use signals +
effect().
Ecosystem
Part of the @zakkster zero-GC stack. lite-await is the async-coordination layer:
lite-signal-- the reactive core (peer dep).lite-stream-- the AsyncIterable-boundary sibling (multi-shot);lite-awaitowns the Promise boundary (one-shot).lite-statechart-- the FSM that pairs withwhenStatechart.lite-clock-- deterministic time pool; frame/tick scheduling.lite-query-- HTTP query manager;fromPromiseis its core projection primitive.lite-await-- this package.
All packages share the same conventions: ASCII source, node:test, zero runtime deps, zero-GC hot paths, MIT license, single-file ESM.
License
MIT (c) 2026 Zahary Shinikchiev [email protected]
