import { Hono } from "hono"; /** * The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short * enough for a Compose health check or an external monitor to treat a stall as "not ready" rather * than as a hung probe, and well inside the shared pool's own 10s connection and operation * timeouts, which bound the underlying query independently. */ export const READINESS_DATABASE_TIMEOUT_MS = 2_000; export const HEALTH_LIVE_PATH = "/api/v1/health/live"; export const HEALTH_READY_PATH = "/api/v1/health/ready"; export interface ApiHealthOptions { /** * One trivial database round trip. Resolves when the database answered; rejects on any failure. * The rejection is never read — not its message, code, or cause — so nothing a driver says can * reach a response. The route bounds the wait and never overlaps two calls; this function only * has to perform the query. */ readonly checkDatabase: () => Promise; /** Whether module startup has completed. `false` again from the moment shutdown begins, so a * draining instance stops reporting ready while it finishes in-flight requests. */ readonly isModuleStartupComplete: () => boolean; } const LIVE_BODY = Object.freeze({ status: "live" }); const READY_BODY = Object.freeze({ status: "ready" }); const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" }); /** * Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than * `timeoutMs`. * * Every probe arriving while a check is unsettled shares that check's outcome, including after its * deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes * answer immediately. A new check starts only once the previous call has actually settled. A timed- * out query still occupies a pool connection until the database or the pool's own timeouts end it, * so starting a fresh query on every probe would let a stalled database drain the pool one health * check at a time. */ function createBoundedDatabaseCheck( checkDatabase: () => Promise, timeoutMs: number, ): () => Promise { let inFlight: Promise | undefined; return () => { if (inFlight !== undefined) { return inFlight; } let timer: ReturnType | undefined; // The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like // any other rather than an exception escaping the route. const settled = new Promise((resolve) => resolve(checkDatabase())).then( () => true, () => false, ); const outcome = Promise.race([ settled, new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }), ]); inFlight = outcome; void settled.then(() => { clearTimeout(timer); inFlight = undefined; }); return outcome; }; } /** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD, * and these routes accept GET alone. */ function methodNotAllowed(): Response { return new Response(null, { headers: { Allow: "GET", "Cache-Control": "no-store" }, status: 405, }); } /** * Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external * monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies * that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed * operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that * installed modules own entirely, so no module route can shadow them. */ export function createHealthRoutes(options: ApiHealthOptions): Hono { const checkDatabase = createBoundedDatabaseCheck( options.checkDatabase, READINESS_DATABASE_TIMEOUT_MS, ); const routes = new Hono(); routes.all(HEALTH_LIVE_PATH, (c) => { if (c.req.method !== "GET") { return methodNotAllowed(); } c.header("Cache-Control", "no-store"); return c.json(LIVE_BODY, 200); }); routes.all(HEALTH_READY_PATH, async (c) => { if (c.req.method !== "GET") { return methodNotAllowed(); } c.header("Cache-Control", "no-store"); if (!options.isModuleStartupComplete()) { return c.json(NOT_READY_BODY, 503); } const databaseReady = await checkDatabase(); // Checked again afterwards: shutdown may have begun while the query was in flight. return databaseReady && options.isModuleStartupComplete() ? c.json(READY_BODY, 200) : c.json(NOT_READY_BODY, 503); }); return routes; }