npm.io
0.2.2 • Published 5d ago

@gigacodes/containerflip

Licence
MIT
Version
0.2.2
Deps
0
Size
23 kB
Vulns
0
Weekly
0

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 for its lifetime and hands each worker a duplicate of it as file descriptor 3, 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:

  1. attach to the inherited, already-listening socket on fd 3 (or fall back to PORT for standalone dev);
  2. signal readiness with READY=1 over the $NOTIFY_SOCKET unix socket, once — and only once it can actually serve requests;
  3. drain gracefully on SIGTERM — stop accepting, bounce keep-alive connections with Connection: close, and exit 0 once idle.

Install

npm install @gigacodes/containerflip

Requires 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 SIGTERM

That'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.

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 fd 3, then signal readiness and install the drain.

import { notifyReady, gracefulDrain, fdMode } 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 listening

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 } from '@gigacodes/containerflip';

const N = Number(process.env.CLUSTER_WORKERS ?? 4);

if (cluster.isPrimary) {
  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) 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: 3 }) inside a cluster child is resolved
  // against the PRIMARY's fd 3 — the socket containerflip passed in — so the
  // whole cluster serves on it. notify:false because only the primary signals.
  const server = http.createServer((req, res) => res.end('hello'));
  await serve(server, { notify: false });
}

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 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 sets Connection: close only while draining. It never reads or writes the body and never ends the response. The prepend ensures 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 with signals: [] and call drain() yourself.
  • No monkey-patching. It calls the original net.Server.prototype.close (to avoid Node ≥19 destroying idle keep-alive connections on http.Server.close()) but patches nothing. No global state beyond a single draining flag, 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.
Primitives

For advanced wiring (clusters, custom signals, frameworks that own .listen()):

  • attach(server, { fd?, port? }) → Promise<server> — listen on fd 3, or the standalone fallback port. Rejects on listen error so you can exit non-zero (the supervisor then fails the deploy closed).
  • notifyReady() → Promise<void> — send READY=1\n over $NOTIFY_SOCKET (stream mode). No-ops when unset. Call after the accept loop is attached.
  • gracefulDrain(server, { graceMs?, signals?, log? }) — install the Connection: close response 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).
  • fdMode() → boolean — whether the supervisor passed fd 3 (LISTEN_FDS ≥ 1).

A note on notifyReady: it uses an AF_UNIX/SOCK_STREAM connection because Node core cannot open datagram unix sockets. Configure the supervisor with --notify-mode=stream for Node workers. The payload is identical to sd_notify's READY=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.)

Keywords