@zakkster/lite-task
v1.0.0
Published
Generator-driven task runner for @zakkster/lite-signal. Synchronous-looking function* flows that yield Promises, signal conditions, time-slices, and races, with deterministic AbortController-based cancellation (iter.return runs your finally). Take-latest/
Maintainers
Readme
@zakkster/lite-task
A generator-driven task runner for @zakkster/lite-signal. Write synchronous-looking function* flows that yield Promises, signal conditions, time-slices, or races; the runner drives them with deterministic, abortable cleanup. Cancelling a run aborts its AbortSignal and calls the generator's .return(), so your finally always runs.
import { task } from "@zakkster/lite-task";
const search = task(function* (signal, q) { // signal is the run's AbortSignal
const res = yield fetch(`/api/search?q=${q}`, { signal });
return yield res.json();
}, { mode: "switch" }); // "switch" = take-latest
search("re"); // typing fast...
search("rea"); // ...each call aborts the previous fetch
const run = search("react");
run.done.then(console.log);
search.running(); // reactive signal -> booleanWhy a generator runner (and an honest GC note)
async/await allocates a Promise and schedules microtasks at every await; in a hot loop that is visible GC pressure. A generator compiles to a single state machine, and -- unlike a Promise -- it is trivially cancellable: stop calling .next(), or call .return() to run its finally.
To be precise about the claim: lite-task is zero-GC for the synchronous orchestration and time-slicing. yield tick() resumes via a scheduler callback and yield waitFor(sig) resumes via lite-signal's when -- neither allocates a Promise per step. Real I/O (fetch) still returns one platform Promise per operation; you yield it and the runner awaits it. That is unavoidable and is not the hot path. So this is zero-GC control flow, not "zero-GC async."
Install
State is exposed as lite-signal signals, so @zakkster/lite-signal is a peer dependency:
npm install @zakkster/lite-task @zakkster/lite-signalESM only. Zero runtime dependencies of its own. Runs in browsers and Node (the time-slice scheduler uses MessageChannel, falling back to setTimeout; frame() uses requestAnimationFrame, falling back to a macrotask off-DOM).
flowchart TD
CALL["task(gen)(...args)"] --> RUN["AbortController + iter = gen(signal, ...args)"]
RUN --> NEXT["iter.next(value)"]
NEXT --> Y{what was yielded?}
Y -->|Promise| AWAIT["await -> resume with value, or throw on reject"]
Y -->|waitFor pred| WHENC["when pred -> resume once truthy"]
Y -->|tick / frame / sleep| SCHED["scheduler -> resume next macrotask / frame / timer"]
Y -->|race / all| COMB["combine -> resume with winner / results"]
Y -->|return| SETTLE["settle: status = done, resolve done"]
AWAIT --> NEXT
WHENC --> NEXT
SCHED --> NEXT
COMB --> NEXT
RUN -. cancel .-> ABORT["abort + iter.return -> finally runs; status = cancelled"]What you can yield
A task generator receives the run's AbortSignal first, then your call arguments, and may yield:
- a
Promise(e.g.fetch(url, { signal })) -- awaited; resumes with the value, or the rejection is thrown back in (catch it normally). waitFor(predicate)-- resumes oncepredicate(a reactive read) first returns truthy, with that value. Built on lite-signal'swhen, so it is allocation-free and the subscription is disposed if the run is cancelled.sleep(ms)-- resumes after a delay.tick()-- yields control to the event loop (a macrotask) and resumes on the next turn. Use it to time-slice heavy loops so the frame can paint. (A microtask would not unblock rendering, which is why this is a macrotask.)frame()-- resumes on the next animation frame.race([...])/all([...])-- first-settles / all-settle over a mix of the above; losing branches are cancelled.
import { task, tick } from "@zakkster/lite-task";
// time-slice a heavy build so the UI stays responsive
const buildIndex = task(function* (signal, items) {
for (let i = 0; i < items.length; i++) {
indexOne(items[i]);
if (i % 500 === 0) yield tick(); // let the browser paint
}
});Cancellation
run.cancel(reason?) aborts the run's AbortSignal and calls the generator's .return(), so a try { ... } finally { ... } cleanup block runs deterministically -- you do not need if (cancelled) return checks scattered through your logic. Pass the run's signal to fetch and the network request is dropped at the same moment. run.done rejects with TaskError (code cancelled) on abort, so a try/catch can tell cancel from error from success -- or just read the status signal.
Modes
task(gen, { mode }) controls how overlapping invocations behave:
switch(default) -- a new call aborts the previous run. Take-latest; ideal for typeahead and dependent fetches.drop-- ignore a new call while one is running; returns the in-flight handle. Take-leading; good for submit buttons.concurrent-- every call runs.queue-- calls run one after another, in order.
Reactive state
The task object carries lite-signal signals reflecting the latest run, so a UI can bind them directly: task.status ('idle' | 'running' | 'done' | 'error' | 'cancelled'), task.running (boolean), task.error, task.result. task.cancelAll(reason?) cancels everything in flight (and any queued runs).
Combining cancellation sources
combineSignals(...signals) merges several AbortSignals into one (using native AbortSignal.any when available, a listener-merge fallback otherwise) and returns { signal, dispose }. timeout(ms) is an AbortSignal that aborts after a delay. Together they give cancel-or-timeout:
import { task, combineSignals, timeout } from "@zakkster/lite-task";
const load = task(function* (cancel, url) {
const { signal, dispose } = combineSignals([cancel, timeout(5000)]); // cancel OR 5s
try {
const res = yield fetch(url, { signal });
return yield res.json();
} finally {
dispose(); // detach the merge listeners
}
});Not a reactive resource
lite-task is an imperative runner: you call it to start work. If you want the reactive "subscribe to a signal, auto-fetch, expose data/loading/error" pattern, use @zakkster/lite-resource (resource(source, fetcher)), which is built for it. The two are deliberately separate.
Scope and limitations
v1 covers the runner, the yieldables, the four modes, reactive status, and the combineSignals/timeout utilities. Out of scope: saga-style channels and fork/join trees, retry/backoff, and debounce/throttle wrappers (composable from sleep/switch). For the reactive auto-fetch resource pattern, use lite-resource.
License
MIT (c) Zahary Shinikchiev
