Node.js — WebSockets in workers
A WebSocket handshake is just an HTTP Upgrade request — which means both offload topologies carry it, differently. Clustered listeners need nothing at all; the gateway and embedded proxy tunnel the handshake, then splice the sockets.
Clustered listeners — zero code (mind stickiness)
A socket transferred by createHttpCluster arrives in the worker unparsed — handshake included. The worker's http.Server emits 'upgrade'normally, so any ws implementation attached to it (or a noServer + manual handleUpgrade) works unchanged:
// worker entry — the clustered listener needs no WS-specific code:
// the upgrade handshake rides inside the transferred socket, so attaching
// a ws server to the serveHttp server handles upgrades in-worker.
import { WebSocketServer } from 'ws';
import { serveHttp } from '@atolljs/node/http';
const server = serveHttp(createApp(), { listen: 0 });
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => { /* runs inside this worker */ });One caveat: a transferred socket pins for life, but separate connections round-robin. Multi-connection session flows — socket.io's polling→upgrade sequence, an HTTP call whose state must be visible to a later WS connection — need route: stickyByAddress() on the cluster (client-address hashing; the acceptor can't read cookies). The gateway topology sidesteps this entirely: a /socket.io/ prefix pinned to one worker covers polling and upgrades.
Gateway — tunneled upgrades
Upgrade requests never reach 'request' listeners — the gateway matches them against the same prefix table, replays the handshake verbatim to the owning worker's internal port (rawHeaders preserved, host rewritten), writes back the consumed head bytes, then splices the two sockets. After the handshake the main thread is out of the data path entirely.
// gateway — upgrade handshakes match the SAME prefix table as
// requests: the handshake replays to the owning worker's listener, then
// the two sockets splice. Frames never touch main-thread code again.
routeHttpGateway({
pool,
port: 3204,
routes: [
{ prefix: '/api/a/', to: '/api/', worker: 0 },
{ prefix: '/ws/', to: '/ws/', worker: 1 }, // upgrades to /ws/* → worker B
],
handler: mainHandler,
onUpgrade: myOwnWsHandler, // optional: unmatched upgrades stay on main
});- Unmatched upgrades go to the
onUpgradefallback — e.g. a ws server living on the main thread — or the socket is destroyed (Node's default). - The tunnel is byte-level: ws, socket.io, or any upgrade protocol works the same.
Embedded — proxyUpgradeToWorker
Middleware (app.use) can't see handshakes, so the upgrade sibling attaches to the server's 'upgrade' event directly:
// host framework — upgrades bypass middleware, so the proxy gets its
// own mount point on the server's 'upgrade' event:
import { proxyUpgradeToWorker } from '@atolljs/node/http';
server.on('upgrade', proxyUpgradeToWorker({
pool,
tracker,
worker: (w) => w[i++ % w.length],
// no mount stripping here — 'to' is a rewrite prefix, usually omitted
}));Unlike proxyToWorker, the URL here is the original — nothing stripped it — so to acts as a rewrite prefix and is usually omitted. The NestJS mounting point is Backend → NestJS → WebSockets.