Skip to content

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.

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

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

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.

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

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:

  • input promise fulfilled → the worker runs with the resolved value;
  • input promise rejected → the host call rejects and the worker never runs;
  • only 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());

Beyond f, a task accepts a few options:

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 shape of timeout decides what happens when the budget runs out:

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.

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

By default task() captures its own module URL and workers import from there. Passing href forces a different module path instead.