Node.js backends — Express, Fastify, Hono, Koa
@atolljs/node puts the worker atoll on any Node HTTP framework, no adapter package needed: a WorkerPool of node:worker_threads workers shares one buffer with the API thread, heavy scans dispatch to the pool, and record reads hit shared memory directly for zero-dispatch responses. Nest apps get decorators and DI on top (Backend → NestJS); these examples show the plain-Node surface underneath.
Install
npm install @atolljs/core @atolljs/nodeThe pool + typed client
One module owns the pool: createNodePool adapts node:worker_threads.Worker to the DOM surface the pool expects, and workerClient<IncidentsWorker> makes every dispatch read like a method call. Shared-memory fields are readable on the API thread for free — seedProgress and record-by-id never touch a worker.
// src/incidents.ts — the atoll on the server: a WorkerPool of
// node:worker_threads workers bound to the shared-memory contract.
import { Worker } from 'node:worker_threads';
import { workerClient } from '@atolljs/core';
import { createNodePool } from '@atolljs/node';
import { incidentsMemory, type IncidentsWorker } from '@atolljs/incidents';
export const pool = createNodePool({
// The bundled worker entry — see "Bundling the worker" below.
worker: () => new Worker(new URL('../dist/incidents.worker.js', import.meta.url)),
sharedMemory: incidentsMemory,
poolSize: 'auto',
});
// Typed client — calls read like the worker's own method names.
export const incidents = workerClient<IncidentsWorker>(pool);
// Direct shared-memory reads cost nothing — no dispatch:
export const seedProgress = () => incidentsMemory.signals.seedProgress.read();
export const readIncident = (id: number) =>
incidentsMemory.lists.incidents.readAt(id, {} as Incident);// src/incidents.worker.ts — the shim binds self = parentPort BEFORE
// workerBootstrap (inside defineWorker) evaluates. Import order is the
// contract; the incidents module registers its task handlers on import.
import '@atolljs/node/shim';
import '@atolljs/incidents/worker/incidents.worker';The same pool drives any HTTP framework — see Framework adapters for Express/Fastify/Hono/Koa, and Clustering / Gateway routing to move request handling itself into workers.
Bundling the worker — required on plain Node
node:worker_threads spawns each worker as a fresh Node process. Loader hooks — tsx, --import tsx, tsconfig paths — do not propagate into worker threads, so an unbundled .ts worker entry can't resolve bare @atolljs/* specifiers. Bundle the entry once with esbuild (or your bundler of choice) and point the pool at the emitted file; the API code itself can keep running unbundled under tsx.
// package.json — the worker entry is bundled once; the app runs under tsx.
// esbuild resolves the tsconfig paths (@atolljs/* → repo sources) inline;
// real npm deps stay external.
{
"scripts": {
"bundle": "esbuild src/incidents.worker.ts --bundle --platform=node
--format=esm --packages=external
--outfile=dist/incidents.worker.js",
"dev": "npm run bundle && tsx watch src/main.ts",
"build": "tsc --noEmit && npm run bundle",
"start": "npm run bundle && tsx src/main.ts"
}
}This differs from the NestJS example, where webpack detects new Worker(new URL('./x.worker.ts', import.meta.url)) in the pool config and emits the worker chunk itself — esbuild does not rewrite worker URLs, so the worker: factory references dist/ directly (the documented workerFile/worker spec for plain-Node deployments).
Node API
| Export | Signature | What it does |
|---|---|---|
createNodePool | createNodePool({ worker | workerFile | createWorker, sharedMemory?, poolSize?, … }) | A WorkerPool backed by node:worker_threads — identical config to new WorkerPool except the worker is declared as a bundled file path or a factory. |
worker spec | worker: string | URL | (() => Worker | NodeWorker) | A factory may return node:worker_threads.Worker directly — it is auto-adapted, so new Worker(new URL('./x.worker.js', import.meta.url)) needs no wrapper. |
workerClient | workerClient<WorkerDef>(runner | () => runner) | Typed Proxy over the pool — calls read like the worker's own method names: client.computeMetrics(). From @atolljs/core. |
createNodeWorker | createNodeWorker(file | url | nodeWorker, options?) | Wrap a Node Worker (or worker file) in the DOM Worker surface — for connectWorker or a hand-built WorkerPool. |
NodeWorkerAdapter | class NodeWorkerAdapter | The EventEmitter → EventTarget adapter underneath createNodeWorker. |
@atolljs/node/shim | import '@atolljs/node/shim' | Worker-side entry shim — binds globalThis.self = parentPort before workerBootstrap wires INIT_MEMORY / EXECUTE_TASK. Must be the first import. |
@atolljs/node/redis | persistSharedMemory(memory, { client, name?, syncIntervalMs?, fields?, subscriber? }) · redisMemoryAdapter(client, opts?) | Shared-memory persistence/replication — mirrors field bytes to a Redis hash, restores at boot, optionally replicates across processes via pub/sub. Pass redisMemoryAdapter(redis) as the pool config's persistence option — see Persistence below; the buffer stays the synchronous source of truth. |
Notes
- No COOP/COEP needed — Node always allows
SharedArrayBuffer. poolSize: 'auto'sizes to cores; one pool per process is typical — every route shares it.- Args/results cross
postMessage(structured clone) — keep them small; the shared buffer carries the large state. - Terminate the pool on shutdown (
pool.terminate()in SIGINT/SIGTERM) so workers don't outlive the server. - Each example's
test/api.e2e.test.tsspawns the real server on an ephemeral port and exercises every route — the same flownpm testruns.