Defining tasks
A task is a function your workers run. An exported function already counts as
one; wrap it in task({ f }) when you want options like timeouts or abort
signals.
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 separate files 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 things follow
from that:
- 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}greet has to be a real exported binding. Workers look tasks up by name in the
module they re-import, so an inline { greet: (name) => ... } handed straight to
createPool leaves them nothing to find. Wrapping it in task() does not change
that — task() adds options, not discoverability.
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 specify 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:
- Fulfilled input → the worker runs with the resolved value.
- Rejected input → the host call rejects and the worker never runs.
- Only a 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”task() accepts two options in addition to f:
| 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 form of timeout determines what happens when the time limit is reached:
| 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”Abort signals opt a task into cooperative cancellation. abortSignal: true is
all it takes: the task becomes abort-aware, so its in-flight calls reject with
"Thread closed" when shutdown() runs instead of hanging, and it receives an
abort toolkit as a second argument.
export const cpuWork = task({ abortSignal: true, f: (items: number[], signal) => { let sum = 0; for (const item of items) { if (signal.hasAborted()) throw new Error("Task aborted"); sum += item; } return sum; },});The toolkit differs from a DOM AbortSignal: it has no .aborted property or
addEventListener method, and it cannot be passed to fetch. It provides two methods:
| Method | What it gives you |
|---|---|
signal.hasAborted() | true once the call has been cancelled. Poll it in a loop and bail out. |
signal.now() | A monotonic millisecond clock, for measuring elapsed time inside the task. |
Monotonic means a clock adjustment can’t make a duration come out negative, which
is what makes now() safe for giving a loop its own budget:
export const budgeted = task({ abortSignal: true, f: (items: number[], signal) => { const started = signal.now(); let sum = 0; for (const item of items) { if (signal.hasAborted() || signal.now() - started > 50) break; sum += item; } return sum; },});abortSignal: { hasAborted: true } is accepted as well, and does exactly the
same thing. Use true for brevity.
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 is what makes it the
right tool for process workers and sandboxing: keep
the code you want isolated in its own file, and the worker’s permissions are what
it runs under.
// 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 short script with one task, chain .createPool() onto its definition:
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() records the URL of the module it was called in, and workers
import from there. Passing href points them at a different module.