Health checks and graceful shutdown
Start required resources before listening. On shutdown, stop accepting requests, let active work finish, then close the database. Brevity returns a normal Node HTTP server, so you can use Node's lifecycle methods.
Initialize required resources before listening. This checked example provides separate liveness/readiness routes, rejects readiness while stopping, stops accepting connections, drains existing HTTP work, then closes resources once.
import { createApp } from "@portablesheep/brevity";
type ServiceOptions = {
port?: number;
host?: string;
work: () => Promise<string>;
closeResources: () => Promise<void>;
drainTimeoutMs?: number;
};
export function startService(options: ServiceOptions) {
let ready = true;
let shutdown: Promise<void> | undefined;
const app = createApp();
app.router.get("/health/live", (_req, res) => res.json({ live: true }));
app.router.get("/health/ready", (_req, res) => res.json({ ready }, ready ? 200 : 503));
app.router.get("/work", async (_req, res) => res.send(await options.work()));
const server = app.listen(options.port ?? 3000, options.host ?? "127.0.0.1");
server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.keepAliveTimeout = 5_000;
server.on("request", (_req, res) => {
res.once("finish", () => {
if (!ready) setImmediate(() => server.closeIdleConnections());
});
});
function stop(): Promise<void> {
if (shutdown) return shutdown;
ready = false;
shutdown = (async () => {
let deadlineExceeded = false;
const deadline = setTimeout(() => {
deadlineExceeded = true;
console.error("[service] drain deadline exceeded; closing HTTP connections");
server.closeAllConnections();
}, options.drainTimeoutMs ?? 5_000);
deadline.unref();
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeIdleConnections();
});
await options.closeResources();
if (deadlineExceeded) throw new Error("Service drain deadline exceeded");
} finally {
clearTimeout(deadline);
}
})();
return shutdown;
}
return { app, server, stop };
}Signal handling and the process entry point
import { setTimeout } from "node:timers/promises";
import { startService } from "./operations.js";
const service = startService({
port: 0,
drainTimeoutMs: Number(process.env.DRAIN_TIMEOUT_MS ?? 5_000),
async work() {
console.log("Work started");
await setTimeout(Number(process.env.WORK_DELAY_MS ?? 100));
return "Work finished";
},
async closeResources() {
console.log("Resources closed");
},
});
service.server.on("listening", () => {
const address = service.server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP listener");
console.log(`Listening: http://127.0.0.1:${address.port}`);
});
service.server.on("error", (error) => {
console.error(error);
process.exitCode = 1;
});
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => {
void service.stop().catch((error) => {
console.error(error);
process.exitCode = 1;
});
});
}npm test checks active-request draining, repeated cleanup, SIGTERM, and deadline failure locally. Check your host's termination grace period too.
Before deploying
Set the platform's termination grace period longer than your drain deadline. Give database/HTTP calls their own timeouts: Node's requestTimeout limits receiving a request, not executing a handler.
Forced socket closure doesn't cancel async work. Cancel or account for outstanding work before closing resources. A supervisor should enforce a final process-exit deadline; upgraded WebSockets need their own cleanup.
Readiness should reflect the dependencies you need to serve traffic. This sample assumes startup succeeded; it isn't a database health probe.
For HTTPS, proxy trust, assets, durable sessions, backups, and rollout checks, see deployment and the launch checklist.