Node.js — gateway routing

Path-level ownership on any Node version: the main thread parses HTTP once and proxies matched URL prefixes to worker-owned internal listeners. Some routes in worker A, some in worker B, the rest served right here.

Worker side

serveHttp(app, { listen: 0 }) binds an internal 127.0.0.1 port and announces it to the parent — the gateway owns the only public port.

offload.worker.tsbackend
// worker entry — listen on an internal port and announce it
import { serveHttp } from '@atolljs/node/http';

serveHttp(createApp(), { listen: 0 }); // → parent gets {type:'HTTP_PORT',port}

Main thread

main.tsbackend
// :3204 — gateway: split one listener by route prefix (any Node)
import { routeHttpGateway } from '@atolljs/node/http';

routeHttpGateway({
  pool,
  port: 3204,
  routes: [
    { prefix: '/api/a/', to: '/api/', worker: 0 },  // always worker A
    { prefix: '/api/b/', to: '/api/', worker: 1 },  // always worker B
  ],
  handler: mainHandler, // everything else stays on the API thread
});
  • worker: is a slot index into the live pool.workers snapshot (or a selector fn) — a respawned worker re-announces its port and takes over its routes automatically.
  • to: rewrites the prefix — /api/a/incidents/query reaches the worker as /api/incidents/query; the prefix only names the owner.
  • A route whose worker hasn't announced yet gets a 503.
  • WebSocket upgrades match the same prefix table and tunnel end-to-end — see WebSockets.

Embedding in a host framework

When the main thread's HTTP stack belongs to Express/Nest/etc., mount just the proxy piece instead of running the standalone gateway: workerHttpPorts tracks announcements (respawns re-announce on the next refresh(); HTTP_PORT_QUERY covers announcements that raced the attach) and proxyToWorker resolves the target worker per request.

main.tsbackend
// Embedding into a host framework — mountable pieces instead of
// the standalone gateway:
import { workerHttpPorts, proxyToWorker } from '@atolljs/node/http';

const tracker = workerHttpPorts(pool);          // HTTP_PORT handshake tracker
app.use('/api/housed', proxyToWorker({
  pool, tracker, to: '/api/housed',             // restore the stripped mount
  worker: (w) => w[i++ % w.length],             // or a slot index to pin
}));

The NestJS version of this pattern — a whole Nest application housed inside workers — is Backend → NestJS → Housed APIs.

Two pools, one shared buffer

Worker-housed routes get their own pool and worker entry — but a pool's sharedMemory config allocates a new buffer. To let a message-only pool read another pool's contract memory, hand each spawned worker the existing buffer:

pools + worker entrybackend
// Two pools, one buffer — a message-only pool reading another
// pool's contract memory (the housed-API pattern):
import { withSharedBuffer } from '@atolljs/node';

const housed = createNodePool({
  // NO sharedMemory here — a second pool would allocate a second buffer.
  worker: withSharedBuffer(
    () => new Worker(new URL('../dist/housed.worker.js', import.meta.url)),
    () => pool.sharedBuffer,   // resolved per spawn — respawns included
  ),
  poolSize: 2,
});

// housed.worker.ts — receive + bind before serving:
import { bindSharedBuffer } from '@atolljs/node';
await bindSharedBuffer(); // same SharedArrayBuffer as the incidents pool

Live example

examples/http-offload runs the gateway on port 3204. Start it via npm run serve:all, then hit:

For zero main-thread parsing on Node ≥ 26, see Clustering.