From 916ccddfd81e22b35b50666cc729196afed98d41 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:55:48 +0200 Subject: [PATCH] fix: add native lifecycle-aware health endpoint (#7852) * fix: add native health endpoint * fix: keep health endpoint dynamic --------- Co-authored-by: Ravi Tharuma --- src/app/healthz/route.ts | 31 +++++++++++ src/instrumentation-node.ts | 6 +++ src/lib/gracefulShutdown.ts | 3 ++ src/lib/serverLifecycle.ts | 23 ++++++++ tests/unit/healthz-route.test.ts | 92 ++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 src/app/healthz/route.ts create mode 100644 src/lib/serverLifecycle.ts create mode 100644 tests/unit/healthz-route.test.ts diff --git a/src/app/healthz/route.ts b/src/app/healthz/route.ts new file mode 100644 index 0000000000..4ee01c32d2 --- /dev/null +++ b/src/app/healthz/route.ts @@ -0,0 +1,31 @@ +import { getServerLifecyclePhase } from "@/lib/serverLifecycle"; + +export const dynamic = "force-dynamic"; + +const HEALTH_BODIES = { + ready: "ok\n", + starting: "starting\n", + stopping: "stopping\n", +} as const; + +function createHealthResponse(method: "GET" | "HEAD"): Response { + const phase = getServerLifecyclePhase(); + const body = HEALTH_BODIES[phase]; + + return new Response(method === "HEAD" ? null : body, { + status: phase === "ready" ? 200 : 503, + headers: { + "Cache-Control": "no-store", + "Content-Length": String(body.length), + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} + +export function GET(): Response { + return createHealthResponse("GET"); +} + +export function HEAD(): Response { + return createHealthResponse("HEAD"); +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 44827ad3bb..8500a9f786 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -6,6 +6,8 @@ * and emit spurious "not supported in Edge Runtime" warnings. */ +import { markServerReady, markServerStarting } from "@/lib/serverLifecycle"; + function getRandomBytes(byteLength: number): Uint8Array { const bytes = new Uint8Array(byteLength); globalThis.crypto.getRandomValues(bytes); @@ -214,6 +216,8 @@ export async function warmModelCatalogCache(): Promise { } export async function registerNodejs(): Promise { + markServerStarting(); + // Rename the process title so OmniRoute is identifiable in ps/htop instead // of the generic "next-server" standalone server name. process.title = renameProcessTitle(process.title); @@ -565,4 +569,6 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] Live dashboard WebSocket daemon failed to start (non-fatal):", msg); } } + + markServerReady(); } diff --git a/src/lib/gracefulShutdown.ts b/src/lib/gracefulShutdown.ts index e79cff12a3..f13a188ada 100644 --- a/src/lib/gracefulShutdown.ts +++ b/src/lib/gracefulShutdown.ts @@ -12,6 +12,8 @@ * @module lib/gracefulShutdown */ +import { markServerStopping } from "@/lib/serverLifecycle"; + /** Grace period before forced exit (default 30s, configurable) */ const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10); @@ -134,6 +136,7 @@ export function initGracefulShutdown(): void { const shutdown = async (signal: string) => { if (state.shuttingDown) return; state.shuttingDown = true; + markServerStopping(); console.log(`\n[Shutdown] Received ${signal}. Draining ${state.activeRequests} request(s)...`); diff --git a/src/lib/serverLifecycle.ts b/src/lib/serverLifecycle.ts new file mode 100644 index 0000000000..1f09509c8c --- /dev/null +++ b/src/lib/serverLifecycle.ts @@ -0,0 +1,23 @@ +export type ServerLifecyclePhase = "starting" | "ready" | "stopping"; + +declare global { + var __omnirouteServerLifecycle: ServerLifecyclePhase | undefined; +} + +export function getServerLifecyclePhase(): ServerLifecyclePhase { + return globalThis.__omnirouteServerLifecycle ?? "starting"; +} + +export function markServerStarting(): void { + globalThis.__omnirouteServerLifecycle = "starting"; +} + +export function markServerReady(): void { + if (getServerLifecyclePhase() !== "stopping") { + globalThis.__omnirouteServerLifecycle = "ready"; + } +} + +export function markServerStopping(): void { + globalThis.__omnirouteServerLifecycle = "stopping"; +} diff --git a/tests/unit/healthz-route.test.ts b/tests/unit/healthz-route.test.ts new file mode 100644 index 0000000000..bdbadc95eb --- /dev/null +++ b/tests/unit/healthz-route.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import { createRequire } from "node:module"; + +import { + getServerLifecyclePhase, + markServerReady, + markServerStopping, +} from "../../src/lib/serverLifecycle.ts"; + +const routeModule = await import("../../src/app/healthz/route.ts"); +const require = createRequire(import.meta.url); +const { getMiddlewareMatchers } = require("next/dist/build/analysis/get-page-static-info.js"); +const { + getMiddlewareRouteMatcher, +} = require("next/dist/shared/lib/router/utils/middleware-route-matcher.js"); + +test("/healthz follows the native server lifecycle without static caching", async () => { + assert.equal(routeModule.dynamic, "force-dynamic"); + assert.equal(getServerLifecyclePhase(), "starting"); + + const starting = await routeModule.GET(new Request("http://localhost/healthz")); + assert.equal(starting.status, 503); + assert.equal(starting.headers.get("Cache-Control"), "no-store"); + assert.equal(starting.headers.get("Content-Type"), "text/plain; charset=utf-8"); + assert.equal(starting.headers.get("Content-Length"), "9"); + assert.equal(await starting.text(), "starting\n"); + + markServerReady(); + const ready = await routeModule.GET(new Request("http://localhost/healthz")); + assert.equal(ready.status, 200); + assert.equal(ready.headers.get("Content-Length"), "3"); + assert.equal(await ready.text(), "ok\n"); + + const readyHead = await routeModule.HEAD( + new Request("http://localhost/healthz", { method: "HEAD" }) + ); + assert.equal(readyHead.status, 200); + assert.equal(readyHead.headers.get("Content-Length"), "3"); + assert.equal(await readyHead.text(), ""); + + markServerStopping(); + const stopping = await routeModule.GET(new Request("http://localhost/healthz")); + assert.equal(stopping.status, 503); + assert.equal(stopping.headers.get("Content-Length"), "9"); + assert.equal(await stopping.text(), "stopping\n"); + + const stoppingHead = await routeModule.HEAD( + new Request("http://localhost/healthz", { method: "HEAD" }) + ); + assert.equal(stoppingHead.status, 503); + assert.equal(stoppingHead.headers.get("Content-Length"), "9"); + assert.equal(await stoppingHead.text(), ""); + + markServerReady(); + assert.equal(getServerLifecyclePhase(), "stopping"); +}); + +test("native startup and shutdown hooks drive the health lifecycle", () => { + const startupSource = fs.readFileSync("src/instrumentation-node.ts", "utf8"); + const registerStart = startupSource.indexOf("export async function registerNodejs"); + const markStarting = startupSource.indexOf("markServerStarting();", registerStart); + const firstStartupAwait = startupSource.indexOf("await ", registerStart); + const markReady = startupSource.lastIndexOf("markServerReady();"); + + assert.ok(markStarting > registerStart && markStarting < firstStartupAwait); + assert.ok(markReady > firstStartupAwait); + assert.equal(startupSource.slice(markReady).trim(), "markServerReady();\n}"); + + const shutdownSource = fs.readFileSync("src/lib/gracefulShutdown.ts", "utf8"); + const drainingFlag = shutdownSource.indexOf("state.shuttingDown = true;"); + const markStopping = shutdownSource.indexOf("markServerStopping();", drainingFlag); + const drainAwait = shutdownSource.indexOf("await waitForDrain();", drainingFlag); + + assert.ok(markStopping > drainingFlag && markStopping < drainAwait); +}); + +test("/healthz bypasses the centralized auth proxy matcher", () => { + const proxySource = fs.readFileSync("src/proxy.ts", "utf8"); + const matcherBlock = proxySource.match(/matcher:\s*\[([\s\S]*?)\]/)?.[1]; + assert.ok(matcherBlock, "proxy matcher configuration must remain discoverable"); + + const configuredMatchers = Array.from( + matcherBlock.matchAll(/[\"']([^\"']+)[\"']/g), + (match) => match[1] + ); + const matcher = getMiddlewareRouteMatcher(getMiddlewareMatchers(configuredMatchers, "/")); + + assert.equal(matcher("/healthz", { headers: {} }), false); + assert.equal(matcher("/api/system/version", { headers: {} }), true); +});