openvibers/openrestream Commits

OpenRe's /api/ready is in the openvibe-shared/ready shape (roadmap WS-Q task 7, Shared v1.24.0). It answered { status, checks: { db: true, key: true }, … }, an ad-hoc body Network's registry could only read by HTTP status, so the status page showed OpenRe up (basis ready-legacy) on a 200 alone; since Network ac71bb4 it shows degraded for that reason. Now createReadiness with required db and network_key (as before: 503 without them) and optional workers (a ready rtmp-ingest and restream worker), coordinator (a valid lease) and events_relay, so a worker generation rolling over degrades the API, which keeps serving reads, rather than failing it. A restore drill's relay is skipped ("restore drill: never publishes"), never ok. The body keeps mode, store, workers, coordinator and events for scripts/cutover-preflight.js, which already requires status ready. openvibe-shared is pinned at v1.24.0 (from v1.22.0); README and STATUS.json name it. Tests: api (shape, required flags, no workers → degraded, preflight fields kept) and drill-mode (relay skipped). npm test (Node 22) 18/18.

OP OpenVibers committed 28 Sept 2026 · 0afc0690bc · 7 files, +68 −32


Changes

ddiff --git a/README.md b/README.md
iindex 3741408..f8f1614 100644
---- a/README.md
++++ b/README.md
@@@ -32,7 +32,7 @@ The ingest/restream control plane and runtime extracted from OpenVibe.Live. Live
- OpenVibe.Network (JWKS for service tokens and user JWTs; OAuth client `openre`; client-credentials tokens for Events/Media)
- OpenVibe.Events (event relay; optional: rows wait in the outbox)
- OpenVibe.Media (recording requests; optional: sessions work without it)
--- OpenVibe.Contracts v0.49.0 (ids, problem+json, capability checks), OpenVibe.SDK v0.12.0 (outbox, token client, per-actor limits), OpenVibe.Shared v1.22.0 (chrome), pinned by release tarball
++- OpenVibe.Contracts v0.49.0 (ids, problem+json, capability checks), OpenVibe.SDK v0.12.0 (outbox, token client, per-actor limits), OpenVibe.Shared v1.24.0 (chrome, readiness), pinned by release tarball
- OpenVibe.Live (`live.lineage.resolve`: which channel a Live-linked stream belongs to)
- node-media-server 2.7.4 (the RTMP session code Live runs) and the system ffmpeg
ddiff --git a/STATUS.json b/STATUS.json
iindex c60e58e..9e09ac1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4500,
"rtmpPort": 1936,
"domain": "openre.stream",
-- "contracts": "openvibe-contracts v0.49.0 (openvibe-sdk v0.12.0, openvibe-shared v1.22.0)",
++ "contracts": "openvibe-contracts v0.49.0 (openvibe-sdk v0.12.0, openvibe-shared v1.24.0)",
"protocols": {
"rtmp": "ingest ported (openre-rtmp-ingest)",
"restream": "RTMP source to RTMP/RTMPS/SRT destinations ported (openre-restream-worker)",
ddiff --git a/package-lock.json b/package-lock.json
iindex 063a026..daa4467 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0"
},
"engines": {
"node": ">=22.12.0"
@@@ -1140,9 +1140,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.22.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
-- "integrity": "sha512-FtqbR8h6YphOnJNqklJED3HyqvTwZJl58gyiZ3QgzmZtZcA16O2NMGq67O4p7grLojwaIG5DhXDMgmZcpScxJQ==",
++ "version": "1.24.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0",
++ "integrity": "sha512-VPzafrc07Pw0oqmXioOlDRtlX5xu82jNLBYkVYxXvhjkILMpWsyjExItIRt5Y6l2st0ozHm7bDQFTJpKEShMEA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 354da26..11c17d0 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"node-media-server": "2.7.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.24.0"
},
"engines": {
"node": ">=22.12.0"
ddiff --git a/server/app.js b/server/app.js
iindex 34abd53..4078d2a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,6 +10,8 @@ const { createUiRouter } = require('./ui/routes');
const pkg = require('../package.json');
const SESSION_FLV_RE = /^(ses_[0-9A-HJKMNP-TV-Z]{26})\.flv$/;
++// The worker kinds a switched slot needs (scripts/cutover-preflight.js checks the same two).
++const WORKER_KINDS = Object.freeze(['rtmp-ingest', 'restream']);
function createApp({ rt, auth, keys, log = console, fetchImpl }) {
const { config, store, events, db } = rt;
@@@ -56,30 +58,56 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
});
release.mount(app);
-- // Ready = the database answers and the Network key is loaded. Worker and coordinator state is
-- // reported, not required: the API serves reads while a worker generation rolls over.
-- app.get('/api/ready', (_req, res) => {
-- let dbOk = false;
-- try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }
-- const checks = { db: dbOk, key: keys.loaded() };
-- const ready = Object.values(checks).every(Boolean);
-- let workers = [];
-- let coordinator = null;
-- try {
-- workers = store.workers.alive().map(w => ({ kind: w.kind, generation: w.generation, state: w.state, heartbeat_age_ms: Date.now() - w.heartbeat_at }));
-- const l = db.prepare("SELECT holder, expires_at FROM leases WHERE name = 'coordinator'").get();
-- coordinator = l ? { holder: l.holder, lease_valid: l.expires_at > Date.now() } : null;
-- } catch { /* reported as empty */ }
-- res.status(ready ? 200 : 503).json({
-- status: ready ? 'ready' : 'not_ready',
-- ...(config.drill ? { mode: 'drill' } : {}),
-- checks,
-- store: { engine: 'sqlite', path_configured: Boolean(config.dbPath) },
-- workers,
-- coordinator,
-- events: dbOk ? events.status() : null,
-- });
++ // Readiness in the openvibe-shared/ready shape (roadmap WS-Q task 7: the registry reads nothing else as green).
++ // Required: the database answers and the Network key is loaded. Optional: a ready worker of each kind, a valid
++ // coordinator lease and the event relay, so a worker generation rolling over degrades the API, which keeps
++ // serving reads, rather than failing it. The worker list, the lease and the outbox stay in the body for
++ // scripts/cutover-preflight.js; a restore drill never publishes, so its relay check is skipped, never ok.
++ const { createReadiness, skip } = require('openvibe-shared/ready');
++ const workerView = () => store.workers.alive().map(w => ({ kind: w.kind, generation: w.generation, state: w.state, heartbeat_age_ms: Date.now() - w.heartbeat_at }));
++ const leaseView = () => {
++ const l = db.prepare("SELECT holder, expires_at FROM leases WHERE name = 'coordinator'").get();
++ return l ? { holder: l.holder, lease_valid: l.expires_at > Date.now() } : null;
++ };
++ const readiness = createReadiness({
++ service: 'openre',
++ release: release.release,
++ checks: [
++ { name: 'db', required: true, description: 'SQLite answers a query', check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
++ { name: 'network_key', required: true, description: 'OpenVibe.Network RS256 key (sign-in and service tokens)', check: () => keys.loaded() || 'Network public key not loaded yet' },
++ {
++ name: 'workers', required: false, description: 'a ready rtmp-ingest and restream worker',
++ check: () => {
++ const alive = workerView();
++ const missing = WORKER_KINDS.filter(k => !alive.some(w => w.kind === k && w.state === 'ready'));
++ return missing.length ? `no ready ${missing.join(' or ')} worker` : { ok: true, detail: { ready: alive.filter(w => w.state === 'ready').map(w => `${w.kind}#${w.generation}`) } };
++ },
++ },
++ { name: 'coordinator', required: false, description: 'the coordinator holds a valid lease', check: () => { const l = leaseView(); return (l && l.lease_valid) || 'no valid coordinator lease'; } },
++ {
++ name: 'events_relay', required: false, description: 'durable events to OpenVibe.Events',
++ check: () => {
++ const st = events.status();
++ if (!st.configured) return config.drill ? skip('restore drill: never publishes') : 'relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset): openre.* events wait in the outbox';
++ return { ok: true, detail: { pending: st.pending, rejected: st.rejected } };
++ },
++ },
++ ],
++ details: (body) => {
++ let workers = [];
++ let coordinator = null;
++ try { workers = workerView(); coordinator = leaseView(); } catch { /* reported as empty */ }
++ const dbOk = body.checks.db && body.checks.db.status === 'ok';
++ return {
++ ...(config.drill ? { mode: 'drill' } : {}),
++ store: { engine: 'sqlite', path_configured: Boolean(config.dbPath) },
++ workers,
++ coordinator,
++ events: dbOk ? events.status() : null,
++ };
++ },
});
++ app.get('/api/ready', readiness.handler);
const apiJson = express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] });
const v1 = createV1Router({ rt, auth });
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 4a9d9c8..6d7b5bc 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -15,8 +15,15 @@ t('boot', async () => {
api = await bootApi();
const ready = await request(api.base, 'GET', '/api/ready');
assert.strictEqual(ready.status, 200);
-- assert.strictEqual(ready.body.checks.db, true);
-- assert.strictEqual(ready.body.checks.key, true);
++ // The openvibe-shared/ready shape (WS-Q task 7): required db and Network key; no workers in this test, so the
++ // optional workers check degrades the API rather than failing it.
++ assert.strictEqual(ready.body.service, 'openre');
++ assert.strictEqual(ready.body.checks.db.status, 'ok');
++ assert.strictEqual(ready.body.checks.network_key.status, 'ok');
++ assert.deepStrictEqual([ready.body.checks.db.required, ready.body.checks.network_key.required, ready.body.checks.workers.required], [true, true, false]);
++ assert.ok(ready.body.degraded.includes('workers'));
++ assert.strictEqual(ready.body.status, 'degraded');
++ assert.ok(Array.isArray(ready.body.workers) && 'coordinator' in ready.body && 'store' in ready.body, 'the preflight fields stay');
});
t('anonymous and ungranted callers are refused with problem+json', async () => {
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 817386d..c5c5fbc 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -82,6 +82,7 @@ t('openre-api in a drill: reads work and match a normal instance; writes, /play/
assert.strictEqual(ready.status, 200);
assert.strictEqual(ready.body.mode, 'drill');
assert.strictEqual(ready.body.events.configured, false);
++ assert.deepStrictEqual([ready.body.checks.events_relay.status, ready.body.checks.events_relay.reason], ['skipped', 'restore drill: never publishes'], 'a drill\'s relay is skipped, never ok');
const h = await request(drill.base, 'GET', '/api/health');
assert.strictEqual(h.text, health.text, '/api/health is byte-identical (ovhost compares it)');
const l = await request(drill.base, 'GET', '/api/v1/streams', { token: reader });