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; }}Guidelines
Section titled “Guidelines”- 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.
Simple task
Section titled “Simple task”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",});Task with arguments
Section titled “Task with arguments”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,});Promise inputs and awaited outputs
Section titled “Promise inputs and awaited outputs”Knitting accepts promise task inputs and always returns awaited task
results from call.*().
What this means
Section titled “What this means”- 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
Promisevalues are awaited. - If the task function returns a promise, Knitting awaits it before resolving or rejecting the host call.
Why this exists
Section titled “Why this exists”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.
Type guarantees
Section titled “Type guarantees”- Task inputs accept native
Promisevalues (not arbitraryPromiseLike/thenables). - Task handlers receive awaited input types (
Awaited<A>). - Task handlers can return
RorPromise<R>. call.*()returnsPromise<Awaited<R>>, so callers receive the final resolved value type (notPromise<Promise<...>>).
type TaskInput = NoBlob<Args> | Promise<NoBlob<Args>>;type TaskHandler<A, R> = (args: Awaited<A>) => R | Promise<R>;type CallResult<R> = Promise<Awaited<R>>;Example
Section titled “Example”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>}Behavior summary
Section titled “Behavior summary”| Case | Outcome |
|---|---|
| Promise argument fulfilled | Task runs with the fulfilled value. |
| Promise argument rejected | Host call rejects immediately and task is not run. |
| Async task result | Host call resolves/rejects with the awaited task outcome. |
Options
Section titled “Options”You can modify the behaviour by adding options to a task.
Abort signal
Section titled “Abort signal”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(). abortSignalCapacityapplies only when at least one registered task declaresabortSignal.
Host-side rejection
Section titled “Host-side rejection”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 todefault).- 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();}Deno lockfile workflow (no --no-lock)
Section titled “Deno lockfile workflow (no --no-lock)”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" }}deno task lock:updatedeno task run:frozenIf 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
hrefpoints 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,});Single-task pool
Section titled “Single-task pool”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();}Timeout
Section titled “Timeout”Supported forms:
number(ms). Non-negative values reject withError("Task timeout").{ time: number, default: value }resolves with the provided value.{ time: number, maybe: true }resolves withundefined.{ 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,});