Work
A small reactive task graph for typed async computations
@hello-terrain/work
Overview
@hello-terrain/work is a small library for building reactive computation graphs.
You define inputs (param), computations (task), and run them in an engine (graph).
It's designed to run in a hot loop (such as your render update loop), with minimal overhead. That's because it will bake the graph topology on the first call, and memoize (cache) unchanged dependencies.
Dependencies are marked dirty when you change them by calling myParam.set(newValue) on the param handler.
The graph won't be executed until you explicitly call .run(), but it will automatically sort out dirty dependencies and mark downstream tasks for re-computation.
You can set tasks to a "lane" (a simple string tag) and optionally enable per-lane concurrency limits
by passing laneConcurrency to graph.run(). This is useful when you want to cap access to constrained
resources (e.g. GPU work, rate-limited APIs, small DB pools).
Key features:
- Typed dependency graphs (params + tasks)
- Memoized recomputation when inputs change
- Optional lane-based concurrency via
laneConcurrency - Cancellation (AbortSignal)
- Observability via a lightweight event stream
Key Concepts
| Concept | Description |
|---|---|
| Param | A reactive value that can be read, updated, and subscribed to |
| Task | A computation node that depends on params or other tasks |
| Graph | An execution engine that runs tasks with dependency resolution and caching |
Installation
# npm
npm install @hello-terrain/work
# pnpm
pnpm add @hello-terrain/work
# yarn
yarn add @hello-terrain/workQuick start
import { graph, param, task } from "@hello-terrain/work";
const value = param(2);
const calcSquare = task((get, work) => {
const currentValue = get(value);
return work(() => currentValue * currentValue);
});
const calcGraph = graph();
calcGraph.add(calcSquare);
await calcGraph.run();
calcGraph.get(calcSquare); // 4
value.set(4);
await calcGraph.run();
calcGraph.get(calcSquare); // 16Features
Automatic Dependency Tracking
Dependencies are discovered automatically when tasks call get() (before work()). No manual wiring required.
const total = task((get, work) => {
const aVal = get(a); // this could be a param
const bVal = get(b); // ...or a task!
const cVal = get(c);
return work(() => aVal + bVal + cVal);
});Memoization
Tasks cache their results by default. They only recompute when their dependencies change.
const expensive = task((get, work) => {
const input = get(someParam);
return work(() => heavyComputation(input));
}).cache("memo"); // Default behavior
const alwaysFresh = task((get, work) => {
const input = get(someParam);
return work(() => input);
}).cache("none"); // Always recomputeCancellation Support
All tasks receive an abort signal for cooperative cancellation.
const fetchTask = task(async (get, work, ctx) => {
return work(() => {
const response = await fetch(url, { signal: ctx.signal });
response.json()
});
});
// Cancel a running graph
const controller = new AbortController();
const promise = g.run({ signal: controller.signal });
controller.abort();Event System
Subscribe to graph events for monitoring, logging, or debugging.
// Subscribe to a group of events
const unsubTasks = g.on("task:*", (event) => console.log(event.type, event.taskId));
// Subscribe to a single event type
const unsubCacheHits = g.on("task:cacheHit", (event) => console.log("cache hit", event.taskId));
unsubTasks();
unsubCacheHits();