NestJS — housed APIs

A route subtree that lives only inside workers: a dedicated message-only pool boots a real Nest app per worker — decorators, DI, and guards intact — and the main app proxies a URL prefix into it. The whole controller stack executes off the API thread.

The message-only pool

The housed pool carries no sharedMemory of its own — a second pool would allocate a second buffer. withSharedBuffer threads the incidents pool's existing buffer into every spawned worker (respawns included), so housed controllers read the same contract state the API thread owns.

housed/housed-atoll.module.tsbackend
// src/housed/housed-atoll.module.ts — the 'housed' pool is
// MESSAGE-ONLY: a sharedMemory here would allocate a SECOND buffer, so
// withSharedBuffer hands each spawned worker the incidents pool's
// buffer instead. Two pools' workers, one shared buffer.
import { Module } from '@nestjs/common';
import { Worker } from 'node:worker_threads';
import { AtollModule, getAtollPool } from '@atolljs/nestjs';
import { withSharedBuffer } from '@atolljs/node';

@Module({
  imports: [
    AtollModule.registerPool({
      name: 'housed',
      worker: withSharedBuffer(
        () => new Worker(new URL('./housed.worker.ts', import.meta.url)),
        () => getAtollPool('incidents')?.sharedBuffer, // lazy — respawns included
      ),
      poolSize: 2,
    }),
  ],
})
export class HousedAtollModule {}

The worker — a whole Nest app

No runAtollWorker here: this is an HTTP worker, not a task worker. It binds the shared buffer, boots HousedApiModule with NestFactory, and hands its HTTP server to serveHttp — which listens on an internal 127.0.0.1 port (announced to the parent for the gateway path) and accepts transferred sockets (for clustering).

housed/housed.worker.tsbackend
// src/housed/housed.worker.ts — an HTTP worker, NOT a task worker
// (no runAtollWorker/bootstrap): bind the shared buffer, then boot a
// whole Nest app that exists ONLY inside workers.
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { bindSharedBuffer } from '@atolljs/node';
import { serveHttp } from '@atolljs/node/http';
import { HousedApiModule } from './housed-api.module';

void (async () => {
  await bindSharedBuffer(); // the incidents pool's buffer — same memory
  const app = await NestFactory.create(HousedApiModule, { logger: ['warn', 'error'] });
  await app.init();
  serveHttp(app.getHttpServer(), { listen: 0 }); // internal port, announced to parent
})();

Main thread — three entry points to the same routes

main.tsbackend
// src/main.ts — /api/housed/* proxies into workers; the rest of
// the app keeps its own controllers on the API thread.
import { proxyToWorker, proxyUpgradeToWorker, createHttpCluster,
         workerHttpPorts } from '@atolljs/node/http';

const pool = getAtollPool('housed');
const tracker = workerHttpPorts(pool);
let cursor = 0;
app.use(
  '/api/housed',
  proxyToWorker({
    pool,
    tracker,
    to: '/api/housed', // express stripped the mount — restore it for worker routes
    worker: (workers) => workers[cursor++ % workers.length], // round-robin
  }),
);

// WebSocket upgrades bypass middleware — attach the upgrade sibling to the
// server itself (full URL, nothing stripped, so no 'to' needed).
app.getHttpServer().on('upgrade',
  proxyUpgradeToWorker({ pool, tracker, worker: (w) => w[cursor++ % w.length] }));

// Optional second listener: cluster the housed pool (Node ≥ 26). The main
// thread hands each accepted socket to a housed worker unparsed — zero
// parsing/serialization on the API thread. Per-CONNECTION routing can't
// split a port by path, so it lives on its own port. Below Node 26 the
// call logs a notice and returns null — no capability check needed.
createHttpCluster({ pool, port: port + 1 });

proxyToWorker keeps /api/housed/* on the app's own port — one origin for clients, one port to expose — at the cost of a parse + proxy hop on the API thread. createHttpCluster skips even that: its dedicated listener hands each connection to a worker unparsed, but per-connection routing can't share the app port by URL path, so it lives on PORT + 1. See Clustering and WebSockets for the other two entry points.

What executes where

Trace GET :3100/api/housed/incidents/whoami through the proxy path — the stages split across two threads:

  1. API thread — accepts the connection, Express parses the request, app.use('/api/housed', ...) matches, and proxyToWorker picks a housed worker (round-robin cursor) and rewrites the mount-stripped path back to /api/housed/....
  2. API thread → worker — the request is re-serialized onto that worker's internal 127.0.0.1 port (the one serveHttp({ listen: 0 }) announced via workerHttpPorts). Method, path, headers, and body all cross — this hop is the proxy's cost.
  3. Housed worker thread — the request lands on the worker's own HTTP server, so a complete Nest pipeline executes off-thread: guards, interceptors, DI resolution, the controller body — which reads the incidents buffer directly (the same SharedArrayBuffer bindSharedBuffer() attached, so the row read is a memory read, not a message).
  4. Worker → API thread → client — the response streams back over the internal port and the gateway pipes it to the client untouched — status, headers, the stamped threadId in the body.

The clustered path on :3101 removes steps 2 and 4's application-level hop: the API thread hands the whole TCP socket to a worker unparsed, and that worker parses HTTP, runs the same Nest pipeline, and answers the client directly. The API thread only ever saw an opaque connection. Same controllers either way — the difference is purely how bytes reach the worker:

StageApp routes (/api/*)Proxied (/api/housed/*)Clustered (:3101)
Accept + parseAPI threadAPI threadAPI thread accepts, worker parses
Nest pipeline (guards, DI, controller)API threadhoused workerhoused worker
Incidents data accessmemory read / task dispatchdirect memory read in workerdirect memory read in worker
Response pathdirect to clientworker → internal port → API thread → clientworker → client

DI inside housed controllers

Housed controllers inject normally — IncidentsAnalytics's @AtollService methods find an empty pool registry in-worker and run their real bodies, so per-worker state (telemetry, caches) stays genuinely per-worker. The standalone pieces — serveHttp, workerHttpPorts, proxyToWorker, withSharedBuffer, bindSharedBuffer — come from @atolljs/node and work without Nest too.

Live example

examples/nestjs serves the housed API both ways. Start it via npm run serve:all, then: