openvibers/openvibe.games Commits

GET /api/ready: 200 only while world.db answers and the simulation ticks

OP OpenVibers committed 23 Sept 2026 · f11e21c80c · 14 files, +368 −4

/healthz returns { ok, tick } without touching world.db, and there was no
readiness endpoint. /api/ready answers in the openvibe-shared/ready shape
(status ready/degraded/not_ready, checks, failed, degraded) with two
required checks, 503 naming the failed one otherwise:
- db: SELECT value FROM meta WHERE key = 'schema_version' on world.db.
- tick: the last completed tick (ServerMetrics.lastTickAt, set in
  GameServer.step) is at most 5 s old; the loop runs at 30/s and resyncs
  after a second behind, so 5 s is a stall, not jitter.

/healthz is unchanged. nginx proxies every path (all of /api/ included) to
:8000, so both vhosts now answer /api/ready with 404 and it stays a loopback
endpoint. Vitest: ready, before the first tick, a stalled and resumed tick,
a closed and an empty world.db, and the HTTP status codes.

Changes

ddiff --git a/README.md b/README.md
iindex c3a61af..c7fae25 100644
---- a/README.md
++++ b/README.md
@@@ -82,8 +82,10 @@ pnpm build
STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openvibe/server start
```
--The server serves the built client, `/healthz`, `/metrics`, and the game
--WebSocket on one port. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
++The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
++WebSocket on one port. `/api/ready` is 200 only while `world.db` answers a query and the simulation
++has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
++`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
Deployed at https://openvibe.games (nginx TLS termination → server on :8000,
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 3b3bd30..2cf2688 100644
---- a/apps/server/dist-types/game/gameServer.d.ts.map
++++ b/apps/server/dist-types/game/gameServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/metrics.d.ts b/apps/server/dist-types/observability/metrics.d.ts
iindex 53af92f..081b0e0 100644
---- a/apps/server/dist-types/observability/metrics.d.ts
++++ b/apps/server/dist-types/observability/metrics.d.ts
@@@ -6,6 +6,8 @@ import type { Logger } from '@openvibe/shared';
*/
export declare class ServerMetrics {
tick: number;
++ /** When the last simulation tick completed (epoch ms; 0 = none yet). /api/ready reads it. */
++ lastTickAt: number;
tickDurationMs: number;
physicsMs: number;
sessions: number;
ddiff --git a/apps/server/dist-types/observability/metrics.d.ts.map b/apps/server/dist-types/observability/metrics.d.ts.map
iindex 46c3708..7525747 100644
---- a/apps/server/dist-types/observability/metrics.d.ts.map
++++ b/apps/server/dist-types/observability/metrics.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/observability/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAE9C;;;;GAIG;AACH,qBAAa,aAAa;IACxB,IAAI,SAAI;IACR,cAAc,SAAI;IAClB,SAAS,SAAI;IACb,QAAQ,SAAI;IACZ,QAAQ,SAAI;IACZ,WAAW,SAAI;IACf,aAAa,SAAI;IACjB,QAAQ,SAAI;IACZ,WAAW,SAAI;IACf,aAAa,SAAI;IACjB,YAAY,SAAI;IAChB,gEAAgE;IAChE,WAAW,SAAI;IACf,iBAAiB,SAAI;IACrB,6EAA6E;IAC7E,aAAa,SAAI;IACjB,eAAe,SAAI;IACnB,qCAAqC;IACrC,QAAQ,SAAI;IACZ,YAAY,SAAI;IAChB;;;;OAIG;IACH,UAAU,SAAI;IACd,WAAW,SAAI;IACf,QAAQ,SAAI;IACZ,4EAA4E;IAC5E,WAAW,SAAI;IAEf,OAAO,CAAC,QAAQ,CAAO;IAEvB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKpD,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IA2BlC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAG9B"}
\\ No newline at end of file
++{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/observability/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAE9C;;;;GAIG;AACH,qBAAa,aAAa;IACxB,IAAI,SAAI;IACR,6FAA6F;IAC7F,UAAU,SAAI;IACd,cAAc,SAAI;IAClB,SAAS,SAAI;IACb,QAAQ,SAAI;IACZ,QAAQ,SAAI;IACZ,WAAW,SAAI;IACf,aAAa,SAAI;IACjB,QAAQ,SAAI;IACZ,WAAW,SAAI;IACf,aAAa,SAAI;IACjB,YAAY,SAAI;IAChB,gEAAgE;IAChE,WAAW,SAAI;IACf,iBAAiB,SAAI;IACrB,6EAA6E;IAC7E,aAAa,SAAI;IACjB,eAAe,SAAI;IACnB,qCAAqC;IACrC,QAAQ,SAAI;IACZ,YAAY,SAAI;IAChB;;;;OAIG;IACH,UAAU,SAAI;IACd,WAAW,SAAI;IACf,QAAQ,SAAI;IACZ,4EAA4E;IAC5E,WAAW,SAAI;IAEf,OAAO,CAAC,QAAQ,CAAO;IAEvB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAKpD,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IA2BlC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAG9B"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/readiness.d.ts b/apps/server/dist-types/observability/readiness.d.ts
nnew file mode 100644
iindex 0000000..36c3668
---- /dev/null
++++ b/apps/server/dist-types/observability/readiness.d.ts
@@@ -0,0 +1,53 @@
++import type { IncomingMessage, ServerResponse } from 'node:http';
++/**
++ * GET /api/ready: whether this process can serve the game, in the shape the
++ * other OpenVibe services answer (openvibe-shared/ready): `status`
++ * ready/degraded/not_ready, a `checks` object with one entry per named check,
++ * and the names of the `failed` (required) and `degraded` (optional) ones.
++ * 200 when every required check passes, 503 otherwise; each failed check
++ * carries its reason in `error`.
++ *
++ * Required: `db` (world.db answers a trivial query) and `tick` (the fixed-tick
++ * simulation has advanced recently). /healthz stays liveness only.
++ */
++/**
++ * No completed tick for this long means the simulation is stalled. The loop
++ * runs at `tickRate` (30/s, one tick every ~33 ms) and resyncs itself after
++ * falling more than a second behind, so a healthy server never goes a second
++ * without a tick; 5 s leaves room for a GC pause, a persistence flush or a
++ * live map rebuild, and still flags a frozen world within one deploy poll.
++ */
++export declare const TICK_STALL_MS = 5000;
++export interface ReadinessCheck {
++ status: 'ok' | 'fail';
++ required: boolean;
++ latency_ms: number;
++ checked_at: string;
++ error?: string;
++ detail?: Record<string, unknown>;
++}
++export interface ReadinessBody {
++ ready: boolean;
++ status: 'ready' | 'degraded' | 'not_ready';
++ service: 'games';
++ checked_at: string;
++ failed: string[];
++ degraded: string[];
++ checks: Record<string, ReadinessCheck>;
++}
++export interface ReadinessDeps {
++ /** Runs a trivial query against world.db; throws or returns false when it fails. */
++ pingDb: () => boolean;
++ /** The tick counter and when the last tick completed (epoch ms, 0 = never). */
++ metrics: {
++ readonly tick: number;
++ readonly lastTickAt: number;
++ };
++ tickStallMs?: number;
++ now?: () => number;
++}
++export declare function createReadiness(deps: ReadinessDeps): {
++ check: () => ReadinessBody;
++ handle: (req: IncomingMessage, res: ServerResponse) => boolean;
++};
++//# sourceMappingURL=readiness.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/readiness.d.ts.map b/apps/server/dist-types/observability/readiness.d.ts.map
nnew file mode 100644
iindex 0000000..c7715b2
---- /dev/null
++++ b/apps/server/dist-types/observability/readiness.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"readiness.d.ts","sourceRoot":"","sources":["../../src/observability/readiness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE;;;;;;;;;;GAUG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,OAAQ,CAAA;AAElC,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,IAAI,GAAG,MAAM,CAAA;IACrB,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,OAAO,GAAG,UAAU,GAAG,WAAW,CAAA;IAC1C,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,aAAa;IAC5B,oFAAoF;IACpF,MAAM,EAAE,MAAM,OAAO,CAAA;IACrB,+EAA+E;IAC/E,OAAO,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AA4BD,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG;IACpD,KAAK,EAAE,MAAM,aAAa,CAAA;IAC1B,MAAM,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;CAC/D,CA4DA"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/readiness.test.d.ts b/apps/server/dist-types/observability/readiness.test.d.ts
nnew file mode 100644
iindex 0000000..69a10c5
---- /dev/null
++++ b/apps/server/dist-types/observability/readiness.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=readiness.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/observability/readiness.test.d.ts.map b/apps/server/dist-types/observability/readiness.test.d.ts.map
nnew file mode 100644
iindex 0000000..d4319e4
---- /dev/null
++++ b/apps/server/dist-types/observability/readiness.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"readiness.test.d.ts","sourceRoot":"","sources":["../../src/observability/readiness.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex edc6a35..bb38cce 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -2323,6 +2323,7 @@ export class GameServer {
}
this.metrics.tick = this.tick
++ this.metrics.lastTickAt = Date.now()
this.metrics.entities = this.world.entities.size
this.metrics.constraints = this.world.constraintCount
this.metrics.constraintIslands = this.world.islands.islandCount()
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 2a7c185..16fb2bc 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -19,6 +19,7 @@ import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { ServerMetrics } from './observability/metrics.js'
++import { createReadiness } from './observability/readiness.js'
import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
@@@ -152,6 +153,13 @@ async function main(): Promise<void> {
mods: modRuntime,
})
++ // GET /api/ready: world.db answers and the simulation ticks (/healthz stays liveness only).
++ const schemaVersion = store.db.prepare('SELECT value FROM meta WHERE key = ?')
++ const readiness = createReadiness({
++ pingDb: () => schemaVersion.get('schema_version') !== undefined,
++ metrics,
++ })
++
const http = createHttpServer(
config.staticDir,
metrics,
@@@ -199,6 +207,7 @@ async function main(): Promise<void> {
config.oauth,
{
handle: (req, res) => {
++ if (readiness.handle(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
// Operational status of the platform adapters; never secrets.
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
ddiff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts
iindex 2af2ce1..6ba3fa3 100644
---- a/apps/server/src/observability/metrics.ts
++++ b/apps/server/src/observability/metrics.ts
@@@ -7,6 +7,8 @@ import type { Logger } from '@openvibe/shared'
*/
export class ServerMetrics {
tick = 0
++ /** When the last simulation tick completed (epoch ms; 0 = none yet). /api/ready reads it. */
++ lastTickAt = 0
tickDurationMs = 0
physicsMs = 0
sessions = 0
ddiff --git a/apps/server/src/observability/readiness.test.ts b/apps/server/src/observability/readiness.test.ts
nnew file mode 100644
iindex 0000000..b154113
---- /dev/null
++++ b/apps/server/src/observability/readiness.test.ts
@@@ -0,0 +1,140 @@
++import { mkdtempSync, rmSync } from 'node:fs'
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
++import { afterEach, beforeEach, describe, expect, it } from 'vitest'
++import { TICK_STALL_MS, createReadiness } from './readiness.js'
++
++const T0 = 1_750_000_000_000
++
++let dir: string
++let store: SqlitePersistenceStore
++
++beforeEach(() => {
++ dir = mkdtempSync(join(tmpdir(), 'ovg-ready-'))
++ store = openSqliteStore(join(dir, 'world.db'))
++})
++
++afterEach(() => {
++ try {
++ store.close()
++ } catch {
++ // already closed by the test
++ }
++ rmSync(dir, { recursive: true, force: true })
++})
++
++/** The same wiring main.ts uses, over a real world.db and a controllable clock. */
++function setup(metrics: { tick: number; lastTickAt: number }, clock = { now: T0 }) {
++ const stmt = store.db.prepare('SELECT value FROM meta WHERE key = ?')
++ return createReadiness({
++ pingDb: () => stmt.get('schema_version') !== undefined,
++ metrics,
++ now: () => clock.now,
++ })
++}
++
++describe('/api/ready', () => {
++ it('is ready when world.db answers and the tick is recent', () => {
++ const body = setup({ tick: 120, lastTickAt: T0 - 40 }).check()
++ expect(body).toMatchObject({
++ ready: true,
++ status: 'ready',
++ service: 'games',
++ failed: [],
++ degraded: [],
++ })
++ expect(body.checks.db).toMatchObject({ status: 'ok', required: true })
++ expect(body.checks.tick).toMatchObject({
++ status: 'ok',
++ required: true,
++ detail: { tick: 120, age_ms: 40, threshold_ms: TICK_STALL_MS },
++ })
++ })
++
++ it('is not ready before the first tick', () => {
++ const body = setup({ tick: 0, lastTickAt: 0 }).check()
++ expect(body.status).toBe('not_ready')
++ expect(body.failed).toEqual(['tick'])
++ expect(body.checks.tick?.error).toBe('simulation has not ticked yet')
++ })
++
++ it('is not ready once the tick stops advancing', () => {
++ const metrics = { tick: 300, lastTickAt: T0 }
++ const clock = { now: T0 + TICK_STALL_MS }
++ const ready = setup(metrics, clock)
++ expect(ready.check().ready).toBe(true)
++ clock.now = T0 + TICK_STALL_MS + 1
++ const body = ready.check()
++ expect(body.ready).toBe(false)
++ expect(body.failed).toEqual(['tick'])
++ expect(body.checks.tick?.error).toMatch(/no simulation tick for \d+ ms/)
++ // The tick moves again: ready again.
++ metrics.tick = 301
++ metrics.lastTickAt = clock.now
++ expect(ready.check().ready).toBe(true)
++ })
++
++ it('is not ready when world.db stops answering', () => {
++ const ready = setup({ tick: 5, lastTickAt: T0 })
++ store.close()
++ const body = ready.check()
++ expect(body.status).toBe('not_ready')
++ expect(body.failed).toEqual(['db'])
++ expect(body.checks.db?.status).toBe('fail')
++ expect(body.checks.db?.error).toMatch(/not open/)
++ })
++
++ it('is not ready when the query returns nothing', () => {
++ store.db.prepare('DELETE FROM meta').run()
++ const body = setup({ tick: 5, lastTickAt: T0 }).check()
++ expect(body.failed).toEqual(['db'])
++ expect(body.checks.db?.error).toBe('world.db query returned nothing')
++ })
++
++ describe('over HTTP', () => {
++ let server: Server
++ let base: string
++ const metrics = { tick: 10, lastTickAt: T0 }
++ const clock = { now: T0 }
++
++ beforeEach(async () => {
++ const ready = setup(metrics, clock)
++ server = createServer((req, res) => {
++ if (ready.handle(req, res)) return
++ res.writeHead(404)
++ res.end()
++ })
++ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
++ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
++ })
++
++ afterEach(async () => {
++ await new Promise<void>((r) => server.close(() => r()))
++ })
++
++ it('answers 200 with the JSON body, uncached', async () => {
++ clock.now = T0
++ const res = await fetch(`${base}/api/ready`)
++ expect(res.status).toBe(200)
++ expect(res.headers.get('cache-control')).toBe('no-store')
++ expect(await res.json()).toMatchObject({ ready: true, status: 'ready' })
++ })
++
++ it('answers 503 with the reason when not ready', async () => {
++ clock.now = T0 + TICK_STALL_MS + 1000
++ const res = await fetch(`${base}/api/ready?probe=1`)
++ expect(res.status).toBe(503)
++ const body = (await res.json()) as { failed: string[]; checks: { tick: { error: string } } }
++ expect(body.failed).toEqual(['tick'])
++ expect(body.checks.tick.error).toMatch(/no simulation tick/)
++ })
++
++ it('leaves other paths alone and refuses writes', async () => {
++ expect((await fetch(`${base}/api/readyz`)).status).toBe(404)
++ expect((await fetch(`${base}/api/ready`, { method: 'POST' })).status).toBe(405)
++ })
++ })
++})
ddiff --git a/apps/server/src/observability/readiness.ts b/apps/server/src/observability/readiness.ts
nnew file mode 100644
iindex 0000000..f7a04f6
---- /dev/null
++++ b/apps/server/src/observability/readiness.ts
@@@ -0,0 +1,141 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++
++/**
++ * GET /api/ready: whether this process can serve the game, in the shape the
++ * other OpenVibe services answer (openvibe-shared/ready): `status`
++ * ready/degraded/not_ready, a `checks` object with one entry per named check,
++ * and the names of the `failed` (required) and `degraded` (optional) ones.
++ * 200 when every required check passes, 503 otherwise; each failed check
++ * carries its reason in `error`.
++ *
++ * Required: `db` (world.db answers a trivial query) and `tick` (the fixed-tick
++ * simulation has advanced recently). /healthz stays liveness only.
++ */
++
++/**
++ * No completed tick for this long means the simulation is stalled. The loop
++ * runs at `tickRate` (30/s, one tick every ~33 ms) and resyncs itself after
++ * falling more than a second behind, so a healthy server never goes a second
++ * without a tick; 5 s leaves room for a GC pause, a persistence flush or a
++ * live map rebuild, and still flags a frozen world within one deploy poll.
++ */
++export const TICK_STALL_MS = 5_000
++
++export interface ReadinessCheck {
++ status: 'ok' | 'fail'
++ required: boolean
++ latency_ms: number
++ checked_at: string
++ error?: string
++ detail?: Record<string, unknown>
++}
++
++export interface ReadinessBody {
++ ready: boolean
++ status: 'ready' | 'degraded' | 'not_ready'
++ service: 'games'
++ checked_at: string
++ failed: string[]
++ degraded: string[]
++ checks: Record<string, ReadinessCheck>
++}
++
++export interface ReadinessDeps {
++ /** Runs a trivial query against world.db; throws or returns false when it fails. */
++ pingDb: () => boolean
++ /** The tick counter and when the last tick completed (epoch ms, 0 = never). */
++ metrics: { readonly tick: number; readonly lastTickAt: number }
++ tickStallMs?: number
++ now?: () => number
++}
++
++type Outcome = { ok: boolean; error?: string; detail?: Record<string, unknown> }
++
++/** One line, short: a reason safe to show to whoever can reach the endpoint. */
++function reason(err: unknown): string {
++ const text = err instanceof Error ? err.message : String(err)
++ return (text.split('\n')[0] ?? '').slice(0, 200) || 'failed'
++}
++
++function run(fn: () => Outcome, required: boolean, now: () => number): ReadinessCheck {
++ const t0 = performance.now()
++ let out: Outcome
++ try {
++ out = fn()
++ } catch (err) {
++ out = { ok: false, error: reason(err) }
++ }
++ return {
++ status: out.ok ? 'ok' : 'fail',
++ required,
++ ...(out.error !== undefined ? { error: out.error } : {}),
++ ...(out.detail !== undefined ? { detail: out.detail } : {}),
++ latency_ms: Math.round((performance.now() - t0) * 10) / 10,
++ checked_at: new Date(now()).toISOString(),
++ }
++}
++
++export function createReadiness(deps: ReadinessDeps): {
++ check: () => ReadinessBody
++ handle: (req: IncomingMessage, res: ServerResponse) => boolean
++} {
++ const now = deps.now ?? Date.now
++ const stallMs = deps.tickStallMs ?? TICK_STALL_MS
++
++ function check(): ReadinessBody {
++ const checks: Record<string, ReadinessCheck> = {
++ db: run(
++ () =>
++ deps.pingDb() ? { ok: true } : { ok: false, error: 'world.db query returned nothing' },
++ true,
++ now,
++ ),
++ tick: run(
++ () => {
++ const { tick, lastTickAt } = deps.metrics
++ const at = now()
++ const ageMs = lastTickAt > 0 ? at - lastTickAt : null
++ const detail = { tick, age_ms: ageMs, threshold_ms: stallMs }
++ if (ageMs === null) return { ok: false, error: 'simulation has not ticked yet', detail }
++ if (ageMs > stallMs) {
++ return { ok: false, error: `no simulation tick for ${Math.round(ageMs)} ms`, detail }
++ }
++ return { ok: true, detail }
++ },
++ true,
++ now,
++ ),
++ }
++ const names = Object.keys(checks)
++ const failed = names.filter((n) => checks[n]?.required && checks[n]?.status !== 'ok')
++ const degraded = names.filter((n) => !checks[n]?.required && checks[n]?.status !== 'ok')
++ const ready = failed.length === 0
++ return {
++ ready,
++ status: !ready ? 'not_ready' : degraded.length ? 'degraded' : 'ready',
++ service: 'games',
++ checked_at: new Date(now()).toISOString(),
++ failed,
++ degraded,
++ checks,
++ }
++ }
++
++ function handle(req: IncomingMessage, res: ServerResponse): boolean {
++ if ((req.url ?? '/').split('?')[0] !== '/api/ready') return false
++ if (req.method !== 'GET' && req.method !== 'HEAD') {
++ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET, HEAD' })
++ res.end('{"error":"method_not_allowed"}')
++ return true
++ }
++ const body = check()
++ res.writeHead(body.ready ? 200 : 503, {
++ 'content-type': 'application/json; charset=utf-8',
++ 'cache-control': 'no-store',
++ })
++ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(body))
++ return true
++ }
++
++ return { check, handle }
++}
ddiff --git a/deploy/nginx-openvibe-games.conf b/deploy/nginx-openvibe-games.conf
iindex 0d55bfe..a10f276 100644
---- a/deploy/nginx-openvibe-games.conf
++++ b/deploy/nginx-openvibe-games.conf
@@@ -28,6 +28,12 @@ server {
client_max_body_size 64m; # map saves + editor asset uploads (48 MB cap in-app)
++ # Readiness is for callers on the host (curl 127.0.0.1:8000/api/ready),
++ # not the internet: everything else under /api/ is proxied as-is below.
++ location = /api/ready {
++ return 404;
++ }
++
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
@@@ -55,6 +61,10 @@ server {
client_max_body_size 64m;
++ location = /api/ready {
++ return 404;
++ }
++
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;