Move the heat
Export the expensive function once. Call
pool.call.name().
The shape of the API
Start with plain exports, add task metadata only when you need
timeouts or abort signals, then let using close the pool
when the scope exits.
npm install knittingdeno add --npm knittingimport { createPool, isMain } from "knitting";
export const square = (value: number) => value * value;export const greet = (name: string) => "hello " + name;
if (isMain) { using pool = createPool({ threads: 2 })({ square, greet });
const [four, message] = await Promise.all([ pool.call.square(2), pool.call.greet("knitting"), ]);
console.log({ four, message });}import { createPool, isMain, task } from "knitting";
export const maybeSlow = task({ timeout: { time: 200, default: "timed out" }, f: async (name: string) => { await new Promise((resolve) => setTimeout(resolve, 1_000)); return "hello " + name; },});
if (isMain) { using pool = createPool({})({ maybeSlow }); console.log(await pool.call.maybeSlow("knitting"));}import { createPool, isMain, task } from "knitting";
export const countUntilStopped = task({ abortSignal: { hasAborted: true }, f: (limit: number, signal) => { let count = 0; while (!signal.hasAborted() && limit > ++count) {} return count; },});
if (isMain) { using pool = createPool({})({ countUntilStopped }); const pending = pool.call.countUntilStopped(1_000_000_000); setTimeout(() => pending.reject(), 0 /*100*/); console.log(await pending);}import { createPool, isMain } from "knitting";
export const isDeno = () => typeof Deno;
if (isMain) { using pool = createPool({ worker: { runtime: "process", processRuntime: "deno", }, })({ isDeno });
console.log("From" , typeof Deno) console.log("To" , await pool.call.isDeno());}import { createPool, isMain, importTask } from "knitting";
// The task lives in another module, so only the worker ever runs it.export const add = importTask<[number, number], number>({ href: "./worker-tasks.ts", name: "add",});
if (isMain) { using pool = createPool({ worker: { runtime: "process", processRuntime: "bun", // Wrap the worker in bubblewrap. Its handle arrives on stdin, // so the prefix must leave fd 0 alone. processCommandPrefix: [ "bwrap", "--unshare-all", // new namespaces, no network "--ro-bind", "/", "/", // read-only filesystem "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", // writable scratch "--die-with-parent", ], }, })({ add });
console.log(await pool.call.add([1, 2])); // 3}// worker-tasks.ts — imported into the sandboxed worker, never the host.export const add = ([a, b]: [number, number]) => a + b;
// The sandbox blocks network and makes the filesystem read-only, but a// read-only bind is still readable: it is isolation, not a full sandbox.import { createPool, isMain, task } from "knitting";import { getDefaultProcessSharedBufferPrimitives, ProcessSharedBuffer,} from "knitting/shared-memory";
// Read shared memory inside a worker — no payload copy.export const readFirst = task<ProcessSharedBuffer, number>({ f: (buf) => Atomics.load(buf.view(Int32Array), 0),});
if (isMain) { using pool = createPool({ threads: 1 })({ readFirst });
const shared = ProcessSharedBuffer.create( 64, getDefaultProcessSharedBufferPrimitives(), ); Atomics.store(shared.view(Int32Array), 0, 42);
console.log(await pool.call.readFirst(shared)); // 42 shared.descriptor.mapping?.close?.();}Benchmarks
Measured against Tokio: same order of magnitude on scheduling, and ~17% faster on a 1 MiB round copy at batch scale (31.3 ms vs 37.7 ms / 100). Source: bench.
The missing middle
Knitting is the missing middle: keep the function in your app, move where it runs.
Export the expensive function once. Call
pool.call.name().
Runtime, permissions, bootstrap, process wrapper, timeouts, scheduling.
Creating poolsRequest and response live in shared memory, so task calls do not have to become little socket.
See the architectureKnitting is not a replacement for distributed systems — it is for the moment before one, when your hot function just needs a better place to run. The full argument, and the roadmap, live in Why Knitting.
What’s in the box
The nice part is calling workers like functions. The useful part is what comes with that boundary: payloads, deadlines, isolation, scheduling, and cleanup in one place.
Primitives, typed arrays, Buffer, Envelope,
and zero-copy ProcessSharedBuffer. Promise inputs resolve
on the host before dispatch.
Give a task a deadline, or cancel it cooperatively from either side of the boundary.
Defining tasksFast runtime threads by default, or separate processes — even
inside a bwrap sandbox or a container — when
isolation matters.
Errors return as real Errors with stack and cause chain;
debug: true streams per-worker diagnostics — zero
cost when off.
Balance lanes with pluggable strategies, or add the host itself as an inline lane for tiny compute.
InlinerProcessSharedBuffer moves bytes between processes with no
copy — the lower-level channel under process workers.
Four safety layers
Four layers stack from cheap guardrails to an OS sandbox. Trusted code stays on fast threads; each pool pays only for the boundary it needs.
Termination APIs — process.exit,
Deno.exit, and friends — are neutralized before any
task code loads. Workers can’t take the host down.
worker.bootstrap runs privileged setup before task
imports: strip env vars, freeze globals, add your own guards.
Strict by default and enforced by the runtime itself — not a library check task code could bypass.
PermissionsThe OS-enforced boundary: process workers behind bwrap,
Docker, or systemd-run, with importTask
keeping the code off the host entirely.
Where it helps
postMessage for task calls, not for pub/sub or streaming — reach for MessagePort there.abortSignal cancels cooperatively, worker.hardTimeoutMs hard-stops runaway CPU.bwrap or container sandbox, with importTask keeping untrusted code off the host.Knitting ships machine-readable docs for AI agents: /llms.txt for the essentials, and /llms-full.txt for every documentation page inlined into one file. If you are an AI agent — or using one to build with Knitting — read those before generating code.
Key model detail: each worker re-imports the module that defines your tasks,
so that module’s top-level imports run in every worker (isMain
guards code, not imports). Keep tasks in a lean module separate from your
server or framework code, export them, and use
importTask
for full isolation.
Read next