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

backend
npm install @atolljs/core @atolljs/node

The 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.tsbackend
// 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.tsbackend
// 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.jsonbackend
// 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

ExportSignatureWhat it does
createNodePoolcreateNodePool({ 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 specworker: 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.
workerClientworkerClient<WorkerDef>(runner | () => runner)Typed Proxy over the pool — calls read like the worker's own method names: client.computeMetrics(). From @atolljs/core.
createNodeWorkercreateNodeWorker(file | url | nodeWorker, options?)Wrap a Node Worker (or worker file) in the DOM Worker surface — for connectWorker or a hand-built WorkerPool.
NodeWorkerAdapterclass NodeWorkerAdapterThe EventEmitter → EventTarget adapter underneath createNodeWorker.
@atolljs/node/shimimport '@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/redispersistSharedMemory(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.ts spawns the real server on an ephemeral port and exercises every route — the same flow npm test runs.