diff --git a/.changeset/ready-route-registration.md b/.changeset/ready-route-registration.md new file mode 100644 index 0000000000..f478de7b9f --- /dev/null +++ b/.changeset/ready-route-registration.md @@ -0,0 +1,14 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): mount `GET /ready` so the readiness probe is reachable over HTTP + +The dispatcher's `/ready` branch (seam #2) was only reachable when calling +`dispatch()` directly — no `server.get('${prefix}/ready')` registration existed, +so a real server returned the Hono not-found 404 before the handler ran (the same +class of bug as `/mcp` and `/keys`). `/ready` is now mounted alongside `/health`, +returning 200 while the kernel is `running` and 503 while it is booting or +draining — the contract the EE multi-node rolling-restart drain gate polls +(cloud ADR-0018). Adds a registration assertion plus an integration test that +hits the endpoint through a real HTTP server. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 77dfc3e686..fa88d885de 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -43,6 +43,7 @@ "@objectstack/driver-mongodb": "workspace:*" }, "devDependencies": { + "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/service-datasource": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts new file mode 100644 index 0000000000..7591d7f0a5 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** + * Integration regression for framework #2217 seam #2. + * + * The dispatcher's GET /ready branch had a passing UNIT test that called + * `dispatch()` directly — but in a real server the route was never mounted on + * the HTTP layer, so it 404'd with the Hono not-found body (`{"error":"Not + * found"}`) BEFORE reaching the handler. A dispatch()-only test cannot catch + * that; this one boots the actual HTTP stack (HonoServerPlugin + the dispatcher + * plugin), opens a real socket and uses `fetch`, exactly like a k8s / load + * balancer readiness probe (the EE rolling-restart drain gate — cloud ADR-0018). + */ +describe('GET /ready over a real HTTP server (integration)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + // port 0 → OS-assigned free port; resolved via getPort() after listening. + kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true })); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + + await kernel.bootstrap(); + + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort()}`; + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + it('returns 200 with state "running" once bootstrapped', async () => { + const res = await fetch(`${baseUrl}/api/v1/ready`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.data.status).toBe('ready'); + expect(body.data.state).toBe('running'); + }); + + it('mounts /health alongside /ready (both probes reachable)', async () => { + const res = await fetch(`${baseUrl}/api/v1/health`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.status).toBe('ok'); + }); + + it('proves the harness mirrors prod: an unmounted path 404s with the Hono not-found body', async () => { + // This is the exact response /ready produced BEFORE the fix. Asserting it + // here shows the test would have failed against the old code (the /ready + // assertion above would have returned this body), not passed vacuously. + const res = await fetch(`${baseUrl}/api/v1/this-route-does-not-exist`); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body).toEqual({ error: 'Not found' }); + }); + + it('returns 503 while the kernel is shutting down (drain signal)', async () => { + // The server socket must stay open to serve the probe, so we can't call + // shutdown() (it closes the socket). Instead simulate the draining state + // the dispatcher reads per-request via kernel.getState(). + const realGetState = kernel.getState.bind(kernel); + (kernel as any).getState = () => 'stopping'; + try { + const res = await fetch(`${baseUrl}/api/v1/ready`); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.error.code).toBe(503); + expect(body.error.details.state).toBe('stopping'); + } finally { + (kernel as any).getState = realGetState; + } + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts index 3ba90ecfd3..594531d10f 100644 --- a/packages/runtime/src/dispatcher-plugin.routes.test.ts +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -59,6 +59,20 @@ describe('createDispatcherPlugin — HTTP route registration', () => { expect(routes).toContain('POST /api/v1/keys'); }); + // Regression (framework #2217 seam #2): /ready shipped with a dispatch() + // branch but NO server.() registration, so it 404'd over HTTP before + // reaching the handler — the same class of bug as /mcp and /keys. /health and + // /ready are the k8s / load-balancer probes the EE rolling-restart drain gate + // polls (cloud ADR-0018); both must be mounted to be reachable. + it('mounts /health and /ready so the liveness/readiness probes reach dispatch()', async () => { + const { server, routes } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server)); + + expect(routes).toContain('GET /api/v1/health'); + expect(routes).toContain('GET /api/v1/ready'); + }); + it('also mounts a known existing route (sanity that start() ran)', async () => { const { server, routes } = makeFakeServer(); const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 670203a17c..5b6f575bf7 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -486,6 +486,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // ── Readiness ─────────────────────────────────────────────── + // Like /health, the dispatcher owns the /ready branch but it is + // only reachable over HTTP once mounted EXPLICITLY here (there is + // no catch-all). 200 while the kernel is `running`, 503 while it is + // booting or shutting down — the contract the EE multi-node + // rolling-restart drain gate polls (cloud ADR-0018) so a load + // balancer stops routing to a replica before it closes. + server.get(`${prefix}/ready`, async (_req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/ready', undefined, {}, { request: _req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + // ── Auth ──────────────────────────────────────────────────── // NOTE: /auth/* wildcard is mounted by AuthProxyPlugin (cloud) // or AuthPlugin (single-tenant) directly on the raw Hono app — diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25f03e47fc..6b7b854daf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1724,6 +1724,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@objectstack/plugin-hono-server': + specifier: workspace:* + version: link:../plugins/plugin-hono-server typescript: specifier: ^6.0.3 version: 6.0.3