Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/ready-route-registration.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
88 changes: 88 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ready.integration.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<any>('http.server');
baseUrl = `http://127.0.0.1:${httpServer.getPort()}`;
}, 30_000);

afterAll(async () => {
if (kernel) {
await Promise.race([
kernel.shutdown(),
new Promise<void>((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;
}
});
});
14 changes: 14 additions & 0 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<verb>() 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 });
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 —
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.