@gigacodes/containerflip
v0.4.1
Published
Zero-dependency Node worker helper for the containerflip blue/green deploy supervisor: inherit fd 3, signal READY=1, and drain gracefully on SIGTERM. Works with any http server or framework (Express, Koa, Fastify, node:cluster).
Readme
containerflip (Node.js worker helper)
Make any Node HTTP server a well-behaved containerflip worker in one line. Zero dependencies, ESM, framework-agnostic.
containerflip is a blue/green deploy supervisor for a single host: a small process owns the listening TCP socket(s) for its lifetime and hands each worker duplicates as file descriptor 3 (and up, for multi-port workers), so worker versions can be swapped with zero dropped requests. This package implements the worker side of that contract, so your app cooperates with the supervisor:
- attach to the inherited, already-listening socket(s) (or fall
back to
PORTfor standalone dev); - signal readiness with
READY=1over the$NOTIFY_SOCKETunix socket, once — and only once it can actually serve requests; - drain gracefully on SIGTERM — stop accepting, bounce keep-alive
connections with
Connection: close, end long-lived streams reconnect-friendly, and exit 0 once idle.
Install
npm install @gigacodes/containerflipRequires Node ≥ 18.2 (for http.Server.closeIdleConnections).
For local development against a checkout, npm link it or
npm install /path/to/clients/node.
Quick start
import http from 'node:http';
import { serve } from '@gigacodes/containerflip';
const server = http.createServer((req, res) => {
res.end('hello');
});
await serve(server); // attach fd 3 → send READY=1 → drain on SIGTERMThat's it. Under the supervisor it serves on the inherited socket; run directly
(node app.mjs) it falls back to PORT so you can poke it locally.
Readiness: signal only when you can truly serve
serve() sends READY=1 as its last step, and READY=1 is what commits the
blue/green cutover — the supervisor retires the old worker the instant it
arrives. So do all of your initialization (open database pools, warm caches,
run a self-test) before calling serve():
await db.connect(); // open pools
await cache.warm(); // warm caches
// optionally: hit your own handler / check a critical dependency here
await serve(server); // attach fd 3, THEN send READY=1 — only now are we readyIf startup fails, exit non-zero before serve() — the supervisor then fails
the deploy closed and keeps the old version serving. Note the supervisor's
--ready-timeout (default 30s) must exceed your warm-up time, or the new worker
is killed and the deploy fails.
Framework recipes
The helper operates on the Node http.Server instance — which every framework
exposes. Build the server from your framework's request handler, then hand it to
serve().
Vanilla http
const server = http.createServer(handler);
await serve(server);Express
import express from 'express';
const app = express();
// ... app.get(...) etc.
const server = http.createServer(app); // NOT app.listen()
await serve(server);Koa
const server = http.createServer(app.callback());
await serve(server);TanStack Start (and other Nitro-based frameworks, e.g. Nuxt)
TanStack Start builds on Nitro, which by default emits a server that binds its
own port. Switch Nitro to the lower-level node-middleware preset so it
exports a Node listener instead, then mount that on a server you hand to
serve():
// app.config.ts — select the middleware preset (the config key is version-specific;
// newer Vite-based Start uses its nitro options, or build with NITRO_PRESET=node-middleware)
import { defineConfig } from '@tanstack/react-start/config';
export default defineConfig({ server: { preset: 'node-middleware' } });// server.mjs — your worker entry: `containerflip run --listen :8080 -- node server.mjs`
import http from 'node:http';
import { serve } from '@gigacodes/containerflip';
import { listener } from './.output/server/index.mjs'; // Nitro's node-middleware export
await serve(http.createServer(listener));The same shape works for anything that can hand you a Node request handler.
Frameworks that insist on calling .listen() themselves (e.g. Fastify)
Use the primitives instead of serve(): tell the framework to listen on the
inherited fd, then signal readiness and install the drain.
import { notifyReady, gracefulDrain, fdMode, listeners } from '@gigacodes/containerflip';
const server = fastify.server; // the underlying http.Server
gracefulDrain(server); // SIGTERM drain + Connection: close bounce
await fastify.listen(fdMode() ? { fd: 3 } : { port: Number(process.env.PORT ?? 0) });
await notifyReady(); // READY=1 — only after listeningFor a multi-port worker, resolve the fd by name instead of hardcoding 3 —
listeners().find((l) => l.name === 'listen').fd — and send notifyReady()
only once EVERY listener is attached (READY is whole-worker; see the
multi-port section below).
node:cluster (multi-core / per-worker crash isolation)
The cluster primary is the supervisor's direct child, so it owns the contract:
only it signals readiness — once all children are listening — and it fans the
drain out to them on SIGTERM. Strip NOTIFY_SOCKET from the children so a
child's notifyReady() no-ops and can't signal early.
import cluster from 'node:cluster';
import http from 'node:http';
import { serve, notifyReady, listeners } from '@gigacodes/containerflip';
const N = Number(process.env.CLUSTER_WORKERS ?? 4);
if (cluster.isPrimary) {
// 'listening' fires once per (child, socket): with M inherited sockets each
// child attaches M times, so aggregate readiness is N×M events. L is 1 for
// the common single-port case; capture it in the primary, where the
// activation env is intact.
const L = Math.max(1, listeners().length);
let listening = 0;
let draining = false;
const fork = () => cluster.fork({ NOTIFY_SOCKET: '' }); // children must not signal
for (let i = 0; i < N; i++) fork();
cluster.on('listening', () => {
if (++listening === N * L) notifyReady(); // aggregate readiness: all up
});
cluster.on('exit', () => {
if (!draining) fork(); // respawn steady-state crashes
else if (Object.keys(cluster.workers).length === 0) process.exit(0);
});
process.on('SIGTERM', () => {
draining = true;
for (const w of Object.values(cluster.workers)) w.process.kill('SIGTERM');
});
} else {
// A normal worker. listen({ fd }) inside a cluster child is resolved
// against the PRIMARY's fds — the sockets containerflip passed in — so the
// whole cluster serves on them. notify:false because only the primary
// signals. Multi-port children use the map form here, exactly as in the
// multi-port section below.
const server = http.createServer((req, res) => res.end('hello'));
await serve(server, { notify: false });
}gRPC — NOT supported via @grpc/grpc-js
@grpc/grpc-js's Server.bindAsync() creates and owns its own TCP listener
and exposes no API to adopt an existing server, handle, or inherited fd — so
there is nothing for serve()/attach() to attach to. A grpc-js worker
behind containerflip would bind its own port and silently miss the whole
socket-handover contract. Until grpc-js grows a listener-adoption API, run
gRPC workers on a runtime that can listen on an inherited fd — the
.NET/Kestrel helper has a full recipe (clients/dotnet/README.md, "gRPC")
and a runnable reference (demo/dotnet-master, task verify-master).
Note the limitation is grpc-js, not HTTP/2: a plain node:http2 server is a
net.Server subclass and works fine with listen({ fd: 3 }) and the
primitives above (gracefulDrain's Connection: close bounce is HTTP/1-only,
but Node's http2 server sends GOAWAY on close() — the HTTP/2 analogue).
Long-lived streams (SSE / WebSocket / subscriptions)
Streams never "complete" on their own, so without cooperation from your handlers
a deploy would hold the old worker for the full graceMs (default 28s) and then
force-close every stream abruptly. The helper exposes drainSignal() — an
AbortSignal that fires the instant the drain begins — so handlers can end
streams immediately and gracefully, with the protocol's reconnect-friendly
close. The client reconnects; the reconnect is a new connection and lands on the
new worker.
One rule applies to every stream type: register a drain listener per
stream, and remove it when the stream ends on its own. Streams end
constantly in steady state; a forgotten removeEventListener piles dead
listeners onto the one process-wide signal.
SSE — send a farewell event and end the response cleanly (EventSource
auto-reconnects on a cleanly ended stream, and the reconnect lands on the new
worker):
import { drainSignal } from '@gigacodes/containerflip';
function sse(res) {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
const timer = setInterval(() => res.write(`data: ${next()}\n\n`), 1000);
const onDrain = () => { clearInterval(timer); res.end('event: bye\ndata: {}\n\n'); };
drainSignal().addEventListener('abort', onDrain, { once: true });
res.on('close', () => { // client gone (also fires after our own end): tidy up
clearInterval(timer);
drainSignal().removeEventListener('abort', onDrain);
});
}A new SSE request that arrives while draining needs no special handling:
drainSignal() is already aborted, so once: true listeners registered after
the abort fire immediately — the stream ends cleanly right away and the
client's reconnect lands on the new worker.
WebSocket — two parts. First, refuse new handshakes while draining with
a retryable 503 (the helper cannot do this for you: Node fans 'upgrade'
out to every listener, so it can't short-circuit your handler). Second, on
drain, close each open socket with 1001 Going Away — the closing
handshake, not a TCP destroy; browsers, graphql-ws and SignalR all treat
1001 as "reconnect now". Complete example with the ws package:
import { WebSocketServer } from 'ws';
import { isDraining, drainSignal } from '@gigacodes/containerflip';
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (isDraining()) { // don't open a stream you'd immediately close
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nRetry-After: 0\r\n\r\n');
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
// ONE process-wide listener closing every open socket beats per-connection
// listeners here — wss.clients tracks the live set for us.
drainSignal().addEventListener('abort', () => {
for (const ws of wss.clients) ws.close(1001, 'going away');
}, { once: true });
wss.on('connection', (ws) => { /* your message/push logic */ });(A WebSocketServer({ server }) without noServer also works, but then the
ws package owns the 'upgrade' handler and you lose the 503 gate — prefer
noServer as above. The zero-dep hand-rolled equivalent lives in
demo/node/server.mjs, exercised across real
deploys by task verify-streams.)
GraphQL subscriptions ride on these transports, and the servers expose exactly the hook you need:
// graphql-ws: dispose() closes every client with 1001 Going Away.
import { useServer } from 'graphql-ws/use/ws'; // v6 path (v5: 'graphql-ws/lib/use/ws')
const disposer = useServer({ schema }, wss); // wss from the example above — keep the 503 gate
drainSignal().addEventListener('abort', () => disposer.dispose(), { once: true });
// graphql-sse: complete the subscription streams; the handler's responses
// then end cleanly and graphql-sse clients re-subscribe (single-connection
// mode responds like any SSE response — the pattern above applies).The standard clients (graphql-ws, graphql-sse, EventSource, SignalR)
all auto-retry on 1001 / a clean stream end / the 503 bounce — every retry is
a new connection and lands on the new worker, with no app-level error
handling.
If a stream ignores the signal, the helper still force-closes everything left
one second after the grace deadline (closeAllConnections) — abrupt
(clients see a reset / 1006), but deterministic and last-resort, per contract
§3. Deploys then take the full graceMs instead of milliseconds, which is
also your test signal: open a stream, deploy, and assert the close was
graceful and fast (see scripts/verify-streams.sh for the reference
assertions).
How it stays out of your way
The helper is deliberately non-invasive — it will not fight your framework, router, or middleware:
- It only touches the
Server(s) you pass it. It never creates a server, never wraps or replaces your request handler, and never binds a port behind your back. - One header, while draining only. The sole request-path hook is a
prependListener('request')that setsConnection: closeonly while draining. It never reads or writes the body and never ends the response. Theprependensures it runs before handlers that flush headers synchronously. - Additive signal handling. It adds a
process.on(signal)listener; it never removes yours. Opt out entirely withsignals: []and calldrain()yourself. - No monkey-patching. It calls the original
net.Server.prototype.close(to avoid Node ≥19 destroying idle keep-alive connections onhttp.Server.close()) but patches nothing. No global state beyond the draining flag and its AbortSignal, no env mutation, no dependencies.
API
serve(server, options?) → Promise<server>
One call: install the drain machinery, attach the accept loop (fd 3, or the
PORT fallback), then send READY=1 — in the order the contract requires
(attach first, ready second).
Options (all optional):
| Option | Default | Meaning |
|---|---|---|
| fd | 3 | Inherited socket file descriptor. |
| port | $PORT or ephemeral | Standalone fallback port (used only when LISTEN_FDS is unset). |
| graceMs | $DRAIN_GRACE_MS or 28000 | Drain grace period before idle connections are force-closed (kept ~2s under the supervisor's 30s drain deadline). |
| signals | ['SIGTERM'] | Signals that trigger the drain. [] installs only the Connection: close marker. |
| notify | true | Send READY=1. Set false when a parent process signals readiness (e.g. a cluster primary). |
| log | stderr writer | Lifecycle logger (msg) => void, or false to silence. |
Multi-port workers
When the supervisor passes more than one socket (repeatable
--listen name=addr; fds 3…3+LISTEN_FDS−1, named via LISTEN_FDNAMES —
worker-contract §1), hand serve() a plain object mapping listener names
to servers: serve({ name: server, … }, options?) → Promise<map>. The names
are the ones the supervisor was started with (--listen :8080 alone is named
listen; extra ports are whatever you named them, e.g.
--listen admin=127.0.0.1:9090) — reference those exact strings here:
import http from 'node:http';
import { serve } from '@gigacodes/containerflip';
// Two SEPARATE servers = two separate handlers: admin routes are physically
// unreachable on the public port. Any framework recipe above works per entry —
// e.g. `listen: http.createServer(expressApp)`.
const publicSrv = http.createServer(publicHandler);
const adminSrv = http.createServer((req, res) => {
if (req.url === '/healthz') return res.end(isDraining() ? 'draining' : 'ok');
if (req.url === '/metrics') return metrics(req, res);
res.statusCode = 404; res.end();
});
await warmUp(); // pools, caches — BEFORE serve(), as always
await serve({ listen: publicSrv, admin: adminSrv }, {
// standalone dev only (LISTEN_FDS unset): which ports to bind instead.
// Omitted names default to $PORT for "listen", an ephemeral port otherwise.
fallbackPorts: { listen: process.env.PORT ?? 8080, admin: 9090 },
});Semantics: every server is attached to its named socket (an unknown name
rejects — the worker exits pre-READY and the deploy fails closed), one
READY=1 is sent once all accept loops are up, and the drain is
coordinated: one signal handler drains every server and exits 0 only after
the last one is idle (per-server handlers would race the exit). graceMs,
signals, notify and log work as in the single-server form;
fallbackPorts replaces port. A single-port worker needs none of this —
the single-server form is unchanged.
Everything else in this README applies per port with no extra wiring:
drainSignal() and isDraining() are process-global (SIGTERM drains all
ports together, so a stream on ANY port ends off the same signal), and the
Connection: close bounce is installed on every server in the map. For
node:cluster with several sockets, see the readiness-gate note in the
cluster recipe above. Runnable reference:
example-multiport.mjs in this folder, and the
repo's demo/node/server.mjs, exercised across real deploys by
task verify-multiport.
Primitives
For advanced wiring (clusters, custom signals, frameworks that own .listen()):
attach(server, { name?, fd?, port? }) → Promise<server>— listen on an inherited socket (an explicitfd, a listenernameresolved vialisteners(), default fd 3), or the standalone fallback port. Rejects on listen error so you can exit non-zero (the supervisor then fails the deploy closed).listeners() → [{ name, fd }]— the inherited sockets in fd order ([]standalone). Latched on first call, so it keeps answering afterclearInheritedEnv(). Throws when several fds arrive with missing/short names (unmatchable — exiting fails the deploy closed).notifyReady() → Promise<void>— sendREADY=1\nover$NOTIFY_SOCKET(stream mode). No-ops when unset. Call after the accept loop is attached.gracefulDrain(server, { graceMs?, signals?, log? })— install theConnection: closeresponse marker and signal handler(s) that drain then exit 0. Idempotent per server.drain(server, { graceMs?, log? }) → Promise<void>— drain per the contract without exiting; resolves when idle or at the grace deadline. Useful to drive shutdown yourself. Idempotent per server.isDraining() → boolean— whether a drain is in progress (process-global).drainSignal() → AbortSignal— fires when the drain begins; the hook for ending SSE/WebSocket/subscription streams gracefully (see Long-lived streams).fdMode() → boolean— whether the supervisor passed fd 3 (LISTEN_FDS ≥ 1).
A note on
notifyReady: it uses anAF_UNIX/SOCK_STREAMconnection because Node core cannot open datagram unix sockets. Configure the supervisor with--notify-mode=streamfor Node workers. The payload is identical tosd_notify'sREADY=1.
License
MIT © 2026 Fabian Stelzer – Gigacodes GmbH. See LICENSE.
(The containerflip supervisor itself is GPL-3.0-or-later; the helper libraries are MIT on purpose, so importing one never imposes GPL terms on your app.)
