openvibers/openvibe.games Commits
GET /api/ready: 200 only while world.db answers and the simulation ticks
Changes
@@@ -82,8 +82,10 @@ pnpm buildSTATIC_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,@@@ -1 +1 @@--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAyBL,aAAa,EAkBd,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,uBAAuB,CAAA;AAExE,OAAO,EAIL,KAAK,aAAa,EAGnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAUL,KAAK,MAAM,EAEZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,KAAK,EAAe,iBAAiB,EAAoB,MAAM,2BAA2B,CAAA;AACjG,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAa/C,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1C;AAKD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,6DAA6D;IAC7D,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB;AAED,qBAAa,UAAU;IA2BnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,YAAY;IA/B/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoB;IAClD,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA0D;IAC9F,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkB;IAC9C,+EAA+E;IAC/E,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,aAAa,CAAI;IACzB,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAkB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,gBAAwB;IACxC,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,mEAAmE;IACnE,OAAO,CAAC,MAAM,CAAiC;gBAG5B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,YAAY,GAAE,gBAAqB;IA8FtD,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiBrB,SAAS,CAAC,IAAI,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,IAAI;IA6dzD,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgE;IAE5F,6DAA6D;IAC7D,OAAO,CAAC,OAAO;IAgCf,OAAO,CAAC,cAAc;IAMtB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAqEpB,iDAAiD;IACjD,OAAO,CAAC,SAAS;IA+DjB,OAAO,CAAC,QAAQ;IAgChB,OAAO,CAAC,cAAc;IAYtB,uEAAuE;IACvE,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,QAAQ;IAIhB;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IA6JtB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;YA4B1B,WAAW;IAuLzB,OAAO,CAAC,aAAa;IAoFrB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAUnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAyBnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAiDnB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IA6FlB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAyCpB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IAmCxB,mEAAmE;IACnE,OAAO,CAAC,eAAe;IAyBvB,qEAAqE;IACrE,OAAO,CAAC,cAAc;IAmEtB;+DAC2D;IAC3D,OAAO,CAAC,eAAe;IAmBvB,wEAAwE;IACxE,OAAO,CAAC,WAAW;IAiCnB;;;OAGG;IACH,OAAO,CAAC,aAAa;IAyBrB,yEAAyE;IACzE,OAAO,CAAC,gBAAgB;IAgDxB,OAAO,CAAC,QAAQ;IAehB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IA8FpB,mEAAmE;IACnE,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,SAAS;IAajB,IAAI,IAAI,IAAI;IA4KZ,OAAO,CAAC,mBAAmB;IA+E3B,OAAO,CAAC,SAAS;IAyCjB;;;OAGG;IACH,KAAK,CAAC,MAAM,GAAE,YAAY,GAAG,UAAyB,GAAG,IAAI;IAiC7D,6BAA6B;IAC7B,QAAQ,IAAI,IAAI;IAMhB,6FAA6F;IAC7F,OAAO,CAAC,UAAU;IAclB;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAqBvB;IAED,OAAO,CAAC,WAAW;IAyBnB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,cAAc;IAkBtB,0EAA0E;IAC1E,OAAO,CAAC,wBAAwB;IAUhC,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAO1B,sEAAsE;IACtE,kBAAkB,IAAI,IAAI;IAK1B,yFAAyF;IACzF,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,WAAW,CAAC,MAAM,CAAC;CAG9D"}\\ No newline at end of file++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAyBL,aAAa,EAkBd,MAAM,oBAAoB,CAAA;AAE3B,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,uBAAuB,CAAA;AAExE,OAAO,EAIL,KAAK,aAAa,EAGnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAUL,KAAK,MAAM,EAEZ,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,KAAK,EAAe,iBAAiB,EAAoB,MAAM,2BAA2B,CAAA;AACjG,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAa/C,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1C;AAKD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,6DAA6D;IAC7D,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB;AAED,qBAAa,UAAU;IA2BnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,YAAY;IA/B/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAoB;IAClD,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA0D;IAC9F,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAkB;IAC9C,+EAA+E;IAC/E,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,aAAa,CAAI;IACzB,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAkB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,gBAAwB;IACxC,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,mEAAmE;IACnE,OAAO,CAAC,MAAM,CAAiC;gBAG5B,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,YAAY,GAAE,gBAAqB;IA8FtD,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAiBrB,SAAS,CAAC,IAAI,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,IAAI;IA6dzD,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgE;IAE5F,6DAA6D;IAC7D,OAAO,CAAC,OAAO;IAgCf,OAAO,CAAC,cAAc;IAMtB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAqEpB,iDAAiD;IACjD,OAAO,CAAC,SAAS;IA+DjB,OAAO,CAAC,QAAQ;IAgChB,OAAO,CAAC,cAAc;IAYtB,uEAAuE;IACvE,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,QAAQ;IAIhB;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IA6JtB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;YA4B1B,WAAW;IAuLzB,OAAO,CAAC,aAAa;IAoFrB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAUnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;IAyBnB,2EAA2E;IAC3E,OAAO,CAAC,WAAW;IAiDnB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IA6FlB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAyCpB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IAmCxB,mEAAmE;IACnE,OAAO,CAAC,eAAe;IAyBvB,qEAAqE;IACrE,OAAO,CAAC,cAAc;IAmEtB;+DAC2D;IAC3D,OAAO,CAAC,eAAe;IAmBvB,wEAAwE;IACxE,OAAO,CAAC,WAAW;IAiCnB;;;OAGG;IACH,OAAO,CAAC,aAAa;IAyBrB,yEAAyE;IACzE,OAAO,CAAC,gBAAgB;IAgDxB,OAAO,CAAC,QAAQ;IAehB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IA8FpB,mEAAmE;IACnE,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,SAAS;IAajB,IAAI,IAAI,IAAI;IA6KZ,OAAO,CAAC,mBAAmB;IA+E3B,OAAO,CAAC,SAAS;IAyCjB;;;OAGG;IACH,KAAK,CAAC,MAAM,GAAE,YAAY,GAAG,UAAyB,GAAG,IAAI;IAiC7D,6BAA6B;IAC7B,QAAQ,IAAI,IAAI;IAMhB,6FAA6F;IAC7F,OAAO,CAAC,UAAU;IAclB;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO,CAqBvB;IAED,OAAO,CAAC,WAAW;IAyBnB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,cAAc;IAkBtB,0EAA0E;IAC1E,OAAO,CAAC,wBAAwB;IAUhC,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAO1B,sEAAsE;IACtE,kBAAkB,IAAI,IAAI;IAK1B,yFAAyF;IACzF,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,WAAW,CAAC,MAAM,CAAC;CAG9D"}\\ No newline at end of file@@@ -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;@@@ -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 filennew file mode 100644@@@ -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 filennew file mode 100644@@@ -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 filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=readiness.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -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@@@ -2323,6 +2323,7 @@ export class GameServer {}this.metrics.tick = this.tick++ this.metrics.lastTickAt = Date.now()this.metrics.entities = this.world.entities.sizethis.metrics.constraints = this.world.constraintCountthis.metrics.constraintIslands = this.world.islands.islandCount()@@@ -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 trueif ((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' })@@@ -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 = 0tickDurationMs = 0physicsMs = 0sessions = 0nnew file mode 100644@@@ -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)++ })++ })++})nnew file mode 100644@@@ -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 }++}@@@ -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;
@@ -82,8 +82,10 @@ pnpm build
@@ -82,8 +82,10 @@ pnpm build
STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openvibe/server start
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).
`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
Deployed at https://openvibe.games (nginx TLS termination → server on :8000,
Deployed at https://openvibe.games (nginx TLS termination → server on :8000,
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
@@ -6,6 +6,8 @@ import type { Logger } from '@openvibe/shared';
@@ -6,6 +6,8 @@ import type { Logger } from '@openvibe/shared';
*/
*/
export declare class ServerMetrics {
export declare class ServerMetrics {
tick: number;
tick: number;
+ /** When the last simulation tick completed (epoch ms; 0 = none yet). /api/ready reads it. */
+ lastTickAt: number;
tickDurationMs: number;
tickDurationMs: number;
physicsMs: number;
physicsMs: number;
sessions: number;
sessions: number;
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=readiness.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
@@ -2323,6 +2323,7 @@ export class GameServer {
@@ -2323,6 +2323,7 @@ export class GameServer {
}
}
this.metrics.tick = this.tick
this.metrics.tick = this.tick
+ this.metrics.lastTickAt = Date.now()
this.metrics.entities = this.world.entities.size
this.metrics.entities = this.world.entities.size
this.metrics.constraints = this.world.constraintCount
this.metrics.constraints = this.world.constraintCount
this.metrics.constraintIslands = this.world.islands.islandCount()
this.metrics.constraintIslands = this.world.islands.islandCount()
@@ -19,6 +19,7 @@ import { createHttpServer } from './net/httpServer.js'
@@ -19,6 +19,7 @@ import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { attachWebSocket } from './net/wsTransport.js'
import { ServerMetrics } from './observability/metrics.js'
import { ServerMetrics } from './observability/metrics.js'
+import { createReadiness } from './observability/readiness.js'
import { ModRegistry } from './mods/registry.js'
import { ModRegistry } from './mods/registry.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
@@ -152,6 +153,13 @@ async function main(): Promise<void> {
@@ -152,6 +153,13 @@ async function main(): Promise<void> {
mods: modRuntime,
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(
const http = createHttpServer(
config.staticDir,
config.staticDir,
metrics,
metrics,
@@ -199,6 +207,7 @@ async function main(): Promise<void> {
@@ -199,6 +207,7 @@ async function main(): Promise<void> {
config.oauth,
config.oauth,
{
{
handle: (req, res) => {
handle: (req, res) => {
+ if (readiness.handle(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
// Operational status of the platform adapters; never secrets.
// Operational status of the platform adapters; never secrets.
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
@@ -7,6 +7,8 @@ import type { Logger } from '@openvibe/shared'
@@ -7,6 +7,8 @@ import type { Logger } from '@openvibe/shared'
*/
*/
export class ServerMetrics {
export class ServerMetrics {
tick = 0
tick = 0
+ /** When the last simulation tick completed (epoch ms; 0 = none yet). /api/ready reads it. */
+ lastTickAt = 0
tickDurationMs = 0
tickDurationMs = 0
physicsMs = 0
physicsMs = 0
sessions = 0
sessions = 0
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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)
+ })
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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 }
+}
@@ -28,6 +28,12 @@ server {
@@ -28,6 +28,12 @@ server {
client_max_body_size 64m; # map saves + editor asset uploads (48 MB cap in-app)
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 / {
location / {
proxy_pass http://127.0.0.1:8000;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -55,6 +61,10 @@ server {
@@ -55,6 +61,10 @@ server {
client_max_body_size 64m;
client_max_body_size 64m;
+ location = /api/ready {
+ return 404;
+ }
+
location / {
location / {
proxy_pass http://127.0.0.1:8000;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_http_version 1.1;