Task Runner with DependenciesLoading saved progress…

Task Runner with Dependencies

A task runner executes a graph of named, interdependent jobs in the right order: each task runs only after the tasks it depends on have finished, and tasks that do not depend on each other run at the same time. It is the engine inside build tools like Gulp, Just, and npm scripts — you declare what depends on what, and the runner figures out the order and the parallelism. You will build a small one, exposed as a factory taskRunnerDepGraph().

Signature

const runner = taskRunnerDepGraph();

runner.task(name, deps, fn); // register a task
runner.task(name, fn);       // shorthand when the task has no dependencies
//   name: string           unique task id
//   deps: string[]         names of tasks that must finish first (default [])
//   fn:   (results) => any  receives { [depName]: result }; may be sync or async

runner.run(target); // Promise — resolves with target's result once its subgraph has run

Examples

const runner = taskRunnerDepGraph();

runner.task('clean', () => 'cleaned');
runner.task('compile', ['clean'], () => 'compiled');
runner.task('lint', ['clean'], () => 'linted');
runner.task('bundle', ['compile', 'lint'], (results) => [results.compile, results.lint]);

await runner.run('bundle'); // ['compiled', 'linted']
// clean runs first; compile and lint then run in parallel; bundle runs last.
// A dependency's result is passed to the task that needs it.
runner.task('config', () => ({ port: 3000 }));
runner.task('url', ['config'], (results) => `http://localhost:${results.config.port}`);
await runner.run('url'); // 'http://localhost:3000'

// A cycle is rejected, not run forever.
runner.task('a', ['b'], () => 1);
runner.task('b', ['a'], () => 2);
await runner.run('a'); // rejects: Cycle detected: a -> b -> a

Notes

  • Dependencies first, in parallel where possible — a task's fn runs only after every task in its deps has resolved. Tasks that do not depend on one another run concurrently, not one after the other.
  • Results flow to dependentsfn receives one object mapping each dependency name to that dependency's resolved result, for example { compile: '...', lint: '...' }. A leaf task with no dependencies receives {}.
  • Each task runs once per run — if two tasks both depend on a third, that third runs a single time and both dependents share its result (a diamond runs the shared task once).
  • Errors reject, they do not crash — running an unregistered name rejects with a clear error; a dependency cycle such as a -> b -> a rejects naming the cycle instead of overflowing the stack; and if a task's fn rejects, run rejects and the tasks that depend on it never start.
  • fn may be sync or async — return a value or a promise; the runner awaits either before handing the result to dependents.
  • Do not worry about — cancellation, a concurrency limit, task priorities, or re-running a finished graph. task and run are the whole surface.

FAQ

How does the runner run independent tasks in parallel?
It resolves a task's dependencies with Promise.all, which starts every dependency in the same tick. Two branches with no dependency between them are kicked off together, so their work overlaps in time instead of running one after the other.
Why does a shared dependency run only once?
The first time a task is resolved, the runner stores its in-flight promise in a per-run map keyed by name. A second dependent that asks for the same task gets that same promise back instead of starting the work again, so a diamond dependency runs exactly once per run.
How is a dependency cycle detected instead of hanging?
The runner threads the current chain of task names down each resolution. Before resolving a task it checks whether the name already appears on that chain; if it does, the graph has looped back on itself, so it rejects with a cycle error instead of recursing until the stack overflows.
Loading editor…