Skip to content

Defining tasks

task({ f, timeout?, abortSignal? }) wraps a function so workers can discover and run it. Define tasks at module scope and export them.

Use importTask({ href, name?, timeout?, abortSignal? }) when the function should be imported and evaluated inside the worker at runtime.

type Task = {
f: (args: Awaited<A>, toolkit?: AbortSignalToolkit) => B | Promise<B>,
abortSignal?: true | { hasAborted: true },
createPool?: (options?: CreatePool) => SingleTaskPool,
href?: string,
timeout?: number | {
time: number;
maybe?: true;
default?: unknown;
error?: unknown;
}
}
  • Define tasks at module scope (no conditional exports).
  • Export tasks from the module where they are defined.
  • Use a single argument; use a tuple or object for multiple values.
  • Better to keep each task in its own file.

Tasks are a fixed point where the worker can import them with all the context of the file.

import { task } from "@vixeny/knitting";
export const hello = task({
f: async () => "hello",
});

Return type can be inferred by use but the arguments must be typed.

export const add = task({
f: async ([a, b]: [number, number]) => a + b,
});

Or with explicit generic parameters:

export const add = task<[number, number], number>({
f: async ([a, b]) => a + b,
});

Knitting accepts promise task inputs and always returns awaited task results from call.*().

  • You can call pool.call.someTask(Promise.resolve(value)).
  • If the input promise resolves, the resolved value is dispatched to the worker.
  • If the input promise rejects, the host call rejects immediately and the task is not executed on the worker.
  • Thenables are not awaited; only native Promise values are awaited.
  • If the task function returns a promise, Knitting awaits it before resolving or rejecting the host call.

Promises are runtime state, not transferable payload data. Knitting resolves promise inputs on the host first, then serializes plain values through IPC. This keeps transport deterministic while preserving ergonomic async APIs.

  • Task inputs accept native Promise values (not arbitrary PromiseLike/thenables).
  • Task handlers receive awaited input types (Awaited<A>).
  • Task handlers can return R or Promise<R>.
  • call.*() returns Promise<Awaited<R>>, so callers receive the final resolved value type (not Promise<Promise<...>>).
type TaskInput = NoBlob<Args> | Promise<NoBlob<Args>>;
type TaskHandler<A, R> = (args: Awaited<A>) => R | Promise<R>;
type CallResult<R> = Promise<Awaited<R>>;
import { createPool, isMain, task } from "@vixeny/knitting";
export const addOne = task<Promise<number> | number, number>({
f: (n) => {
const next = n + 1; // n is already awaited here
return Promise.resolve(next); // return can be a Promise
},
});
const pool = createPool({ threads: 1 })({ addOne });
if (isMain) {
// Promise input is accepted and resolved before dispatch.
const result = await pool.call.addOne(Promise.resolve(41));
console.log(result);
// result: number (42), not Promise<number>
}
CaseOutcome
Promise argument fulfilledTask runs with the fulfilled value.
Promise argument rejectedHost call rejects immediately and task is not run.
Async task resultHost call resolves/rejects with the awaited task outcome.

You can modify the behaviour by adding options to a task.

Tasks can opt in to cooperative abort signaling. When enabled, the host can reject the returned promise and the worker can check whether the caller has abandoned the task.

There are two forms:

abortSignal: true makes the task abort-aware. On shutdown(), all in-flight abort-aware calls reject with "Thread closed". This is the minimal form when you just need clean cancellation on pool teardown.

import { task } from "@vixeny/knitting";
export const longJob = task({
abortSignal: true,
f: async (data: string) => {
// long running work...
return data.length;
},
});

abortSignal: { hasAborted: true } additionally injects an abort toolkit as the second argument to the task function. The toolkit has a hasAborted() method you can poll during long-running work to bail out early.

import { task } from "@vixeny/knitting";
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;
},
});

What this means in practice:

  • Host allocates/recycles per-call signal IDs for abort-aware tasks.
  • Workers can read signal state before execution and during execution through hasAborted().
  • abortSignalCapacity applies only when at least one registered task declares abortSignal.

The promise returned by call.*() for abort-aware tasks exposes a .reject() method. Calling it rejects the promise from the host side. The worker task may still complete in the background, but the caller receives an immediate rejection.

import { createPool, isMain, task } from "@vixeny/knitting";
export const slow = task({
abortSignal: true,
f: async () => {
await new Promise((r) => setTimeout(r, 10_000));
return "done";
},
});
const pool = createPool({ threads: 1 })({ slow });
if (isMain) {
const promise = pool.call.slow();
// Cancel from the host after 100ms
setTimeout(() => promise.reject?.("cancelled by host"), 100);
try {
await promise;
} catch (e) {
console.log(e); // "cancelled by host"
}
await pool.shutdown();
}

importTask({ href, name?, timeout?, abortSignal? })

Section titled “importTask({ href, name?, timeout?, abortSignal? })”

Defines a task whose function is imported dynamically inside the worker.

  • href: module URL/path to import.
  • name: named export to call (defaults to default).
  • Import/evaluation happens in worker context, so worker permission policy applies to that import path.
import { createPool, importTask, isMain } from "@vixeny/knitting";
const REMOTE_TASKS_URL = "https://knittingdocs.netlify.app/example-task.mjs";
export const addFromWeb = importTask<[number, number], number>({
href: REMOTE_TASKS_URL,
name: "add",
});
export const wordStatsFromWeb = importTask<
{ text: string },
{ words: number; chars: number }
>({
href: REMOTE_TASKS_URL,
name: "wordStats",
});
const pool = createPool({ threads: 2 })({
addFromWeb,
wordStatsFromWeb,
});
if (isMain) {
const [sum, stats] = await Promise.all([
pool.call.addFromWeb([8, 5]),
pool.call.wordStatsFromWeb({ text: "hello from remote tasks" }),
]);
console.log("sum from web:", sum);
console.log("word stats from web:", stats);
await pool.shutdown();
}

When href points to a remote URL, keep deno.lock updated and run with frozen lock checks.

deno.json example:

{
"imports": {
"@vixeny/knitting": "jsr:@vixeny/knitting"
},
"tasks": {
"lock:update": "deno cache --config deno.json --lock=deno.lock --frozen=false --reload uwu.ts",
"run:frozen": "deno run -A --config deno.json --lock=deno.lock --frozen=true uwu.ts"
}
}
Terminal window
deno task lock:update
deno task run:frozen

If the remote module content changes, rerun lock:update and commit the new deno.lock.

href override behavior (unsafe / experimental)

Section titled “href override behavior (unsafe / experimental)”

By default, task() captures the caller module URL and workers use that for task discovery.

Passing href overrides that module URL and forces workers to import from your custom path/URL.

Rules for href:

  • Prefer not using href; default caller resolution is the supported path.
  • If you use it, pass an absolute module URL (file://... or full URL).
  • Avoid remote URLs (http(s)://...) in production; runtime support and security expectations vary across Node, Deno, and Bun.
  • Ensure the target module exports top-level task(...) values discoverable by workers.
  • Ensure href points to a stable module identity (do not use ad-hoc dynamic URL variations for the same task module).
  • Treat this as compatibility-risky and pin versions if you depend on it.
import { task } from "@vixeny/knitting";
const stableModuleHref = new URL("./tasks.ts", import.meta.url).href;
export const parseJob = task({
href: stableModuleHref,
f: (payload: string) => payload.length,
});
import { isMain, task } from "@vixeny/knitting";
export const world = task({
f: async () => "world",
}).createPool({
threads: 2,
});
if (isMain) {
const results = await Promise.all([world.call()]);
console.log("Results:", results);
world.shutdown();
}

Supported forms:

  • number (ms). Non-negative values reject with Error("Task timeout").
  • { time: number, default: value } resolves with the provided value.
  • { time: number, maybe: true } resolves with undefined.
  • { time: number, error: value } rejects with the provided error.

If time is negative or missing, timeouts are ignored.

export const maybeSlow = task<string, string>({
timeout: { time: 50, default: "timeout" },
f: async (value) => value,
});