npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. attach to the inherited, already-listening socket(s) (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, end long-lived streams reconnect-friendly, 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.

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 ready

If 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 listening

For 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 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 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 explicit fd, a listener name resolved via listeners(), 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 after clearInheritedEnv(). Throws when several fds arrive with missing/short names (unmatchable — exiting 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).
  • 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 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.)