Skip to content

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.

  • 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.

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. import statements are hoisted, so they execute before any if (isMain) guard — isMain gates 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(...) (or importTask) is invisible to the loader, so calling it just hangs — no handler is ever registered. Always export your tasks and importTask wrappers.

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.

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.

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,
});

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.

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 Promise is 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());

task() accepts two options in addition to f:

OptionPurpose
timeoutBound how long a call may run.
abortSignalMake a task cancellable and abort-aware.

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:

FormOutcome
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 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:

MethodWhat 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;
main.ts
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.

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
}

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.

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.