Defining tasks
A task is a function your workers run. The simplest task is just an exported
function; wrap it with task({ f }) when you want options like timeouts or
abort signals. Either way, define tasks at module scope and export them so
workers can find them by name.
The rules
Section titled “The rules”- Define tasks at module scope — no conditional or dynamic exports.
- Export them from the module where they are defined.
- One argument in, one value out. Use a tuple or object for multiple values.
- Keep tasks in their own file(s) so workers load only what they need.
Module loading
Section titled “Module loading”Each worker re-imports the module that defines your tasks — the file that
calls task() / importTask() and hands them to createPool. Two consequences
are worth designing around:
- Top-level
imports run in every worker.importstatements are hoisted, so they execute before anyif (isMain)guard —isMaingates your executable code, not your imports. If you define tasks in the same file as a web framework, every worker loads that framework too. Keep tasks in their own lean module and import it from your server, so workers only load what they run. - Tasks must be exported. The worker discovers tasks by scanning the
module’s exports. An unexported
const myTask = task(...)(orimportTask) is invisible to the loader, so calling it just hangs — no handler is ever registered. Alwaysexportyour tasks andimportTaskwrappers.
For full isolation — keeping a task’s own code off the host entirely — use
importTask: only the worker
imports the target module, and that target must be a plain exported function,
not a task() wrapper.
A plain function
Section titled “A plain function”When a task needs no options, a bare exported function is enough:
import { createPool, isMain } from "knitting";
export const greet = (name: string) => `hello ${name}`;
if (isMain) { using pool = createPool({ threads: 1 })({ greet }); console.log(await pool.call.greet("knitting")); // hello knitting}It must be exported from the module that creates the pool — workers re-import
that module to find it. Anonymous inline functions can’t be imported, so reach
for task() (below) when you need more than a plain function.
Wrapping with task()
Section titled “Wrapping with task()”Use task({ f }) when you want options — a timeout, an abort signal, or
explicit types:
import { task } from "knitting";
export const add = task({ f: ([a, b]: [number, number]) => a + b,});Return types are inferred, but argument types are not — annotate the parameter, or pin both with generics:
export const add = task<[number, number], number>({ f: ([a, b]) => a + b,});Arguments and return values
Section titled “Arguments and return values”Each task receives one argument and returns one value. For multiple inputs, pass a tuple or an object:
type ResizeInput = { width: number; height: number };
export const pixels = task<ResizeInput, number>({ f: ({ width, height }) => width * height,});See Payloads for everything that can cross the boundary.
Promise inputs are awaited on the host
Section titled “Promise inputs are awaited on the host”call.*() also accepts a Promise as input. Knitting awaits it on the host
before dispatch, so only plain values ever reach the worker:
- input promise fulfilled → the worker runs with the resolved value;
- input promise rejected → the host call rejects and the worker never runs;
- only native
Promiseis awaited (thenables are not).
That’s why chaining works — call.hello() returns a promise, and Knitting
resolves it before world runs:
const lines = await pool.call.world(pool.call.hello());Options
Section titled “Options”Beyond f, a task accepts a few options:
| Option | Purpose |
|---|---|
timeout | Bound how long a call may run. |
abortSignal | Make a task cancellable and abort-aware. |
Timeouts
Section titled “Timeouts”Use a timeout when a call should not wait forever:
export const maybeSlow = task<string, string>({ timeout: { time: 100, default: "timed out" }, f: async (value) => value,});The shape of timeout decides what happens when the budget runs out:
| Form | Outcome |
|---|---|
number (ms) | Rejects with Error("Task timeout"). |
{ time, default } | Resolves with default. |
{ time, maybe: true } | Resolves with undefined. |
{ time, error } | Rejects with error. |
A missing or negative time disables the timeout.
Abort signals
Section titled “Abort signals”Opt a task into cooperative cancellation. The minimal form, abortSignal: true,
makes the task abort-aware — on shutdown(), in-flight abort-aware calls reject
with "Thread closed":
export const longJob = task({ abortSignal: true, f: async (data: string) => { // long running work... return data.length; },});abortSignal: { hasAborted: true } also injects a toolkit as the second
argument, so the worker can poll hasAborted() and bail out early:
export const cpuWork = task({ abortSignal: { hasAborted: true }, f: (items: number[], toolkit) => { let sum = 0; for (const item of items) { if (toolkit.hasAborted()) throw new Error("Task aborted"); sum += item; } return sum; },});The promise returned by an abort-aware call also exposes .reject(), so the
host can cancel without touching the worker:
import { createPool, isMain, task } from "knitting";
export const slow = task({ abortSignal: true, f: async () => { await new Promise((r) => setTimeout(r, 10_000)); return "done"; },});
if (isMain) { using pool = createPool({ threads: 1 })({ slow });
const promise = pool.call.slow(); setTimeout(() => promise.reject?.("cancelled by host"), 100);
try { await promise; } catch (e) { console.log(e); // "cancelled by host" }}Importing worker-side code with importTask
Section titled “Importing worker-side code with importTask”importTask({ href, name?, timeout?, abortSignal? }) points at a function in
another module. The host gets a typed task wrapper but never imports or
evaluates that module itself — only the worker does. That’s the tool to reach
for with process workers and sandboxing: keep the
code you want isolated in a separate file and let the worker’s permissions apply
to it.
// worker-tasks.ts — only the worker imports this.export const add = ([a, b]: [number, number]) => a + b;import { createPool, importTask, isMain } from "knitting";
export const add = importTask<[number, number], number>({ href: "./worker-tasks.ts", name: "add",});
if (isMain) { using pool = createPool({ threads: 2 })({ add }); console.log(await pool.call.add([2, 3])); // 5}href can be a relative path (resolved from the calling module), an absolute
path, or a URL. name is the export to call and defaults to "default". Worker
permission policy applies to the import, so a strict pool can still load task
modules but limit what they read, write, or reach. See
Permissions.
Importing from a URL
Section titled “Importing from a URL”href can be remote, which is handy for shared task bundles:
import { createPool, importTask, isMain } from "knitting";
const REMOTE = "https://knittingdocs.netlify.app/example-task.mjs";
export const addFromWeb = importTask<[number, number], number>({ href: REMOTE, name: "add",});
if (isMain) { using pool = createPool({ threads: 2 })({ addFromWeb }); console.log(await pool.call.addFromWeb([8, 5])); // 13}A task can make its own pool
Section titled “A task can make its own pool”For a quick script with a single task, chain .createPool() and skip the
separate call:
import { isMain, task } from "knitting";
export const double = task({ f: (n: number) => n * 2,}).createPool({ threads: 2 });
if (isMain) { try { console.log(await double.call(21)); // 42 } finally { await double.shutdown(); }}The single-task pool is created where it’s defined (module scope), so close it
with shutdown() rather than using.
Advanced: overriding href on task()
Section titled “Advanced: overriding href on task()”By default task() captures its own module URL and workers import from there.
Passing href forces a different module path instead.