openvibers/openvibe.live Commits

openvibe-shared v1.5.1 (Track R release manifest), openvibe-contracts v0.33.0, openvibe-sdk v0.5.0

OP OpenVibers committed 23 Sept 2026 · 7ae1b24cba · 6 files, +52 −18

- /release.json is mounted with release.mount(app, { registry }): POST /release-metrics takes open
  tabs' update reports (release-watch beacons, text/plain or JSON, behind the global JSON parser)
  into /metrics as release_client_updates_total{outcome,reason}. With contracts >= 0.31 the
  manifest carries the release-manifest 1.1.0 fields; no components are declared, so every release
  still prompts a reload. test/observability.test.js validates the served manifest against the
  contracts and checks the beacons land in /metrics; test/drill-mode.test.js validates the drill's
  manifest and adds POST /release-metrics to the writes a drill refuses with 403.
- Shared 1.5.0 serves release-update.js at /shared/ (from files.js, loaded on demand by
  release-watch.js); 1.5.1 adds touch and keyboard feedback to the shared navbar/footer. Home page
  budgets pass (eager JS 254.3/260 KB brotli); browser smoke passes.
- Contracts 0.32.0 -> 0.33.0 is additive (Tools platform API, ADR-027); SDK 0.4.0 -> 0.5.0 adds the
  Media objects client, which Live does not use yet. The contracts check passes.
- docs/deploy.md names the new metric and route.

Changes

ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex 6a91b584..79a22e91 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -53,6 +53,9 @@ restart. A rollback resets the checkout but cannot restore previous `node_module
- `GET /metrics` (Prometheus text: requests by route template, latency, in-flight, process,
`release_info`, live streams, WebSocket connections per server, outbox) answers only
`curl http://127.0.0.1:3000/metrics` on the host; through nginx it is a 404.
++ `release_client_updates_total{outcome,reason}` counts what open tabs did with a new release
++ (applied, reloaded, deferred, failed); they report it to `POST /release-metrics`, which
++ `/release.json` names in `metrics_url` (openvibe-shared `release.mount`).
- `--rollback` selects the previous release with **its own** `node_modules`.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
ddiff --git a/package-lock.json b/package-lock.json
iindex 7c39043d..02b9e9f5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,9 +25,9 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
@@@ -2814,9 +2814,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.32.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
-- "integrity": "sha512-ntN58SIG03YDxbhsiIzOM5GRH19jRMHZUYWI3xU2VwovExagF5DdwApLVfezsu84UTJ+V2Jfn+LWDc+Oz8ab+g==",
++ "version": "0.33.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -2830,18 +2830,18 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
++ "version": "0.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "integrity": "sha512-QVJkR5iazQX0ZwIrDp2A88ZeYYBSXEmSzhpgURPUSnzezIM3jz0HBhVKowyhkvg2aBO61XNf77eb4zJGkMjZiw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/openvibe-shared": {
-- "version": "1.4.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
-- "integrity": "sha512-H7jAsUklS1p57d9y6ozME++HmsX18iPYZ/U3P6bLoL1E2oSl6DbSPzLsVUkA7RYoYmAtcI3AwJ4hTNlAr66Vdw==",
++ "version": "1.5.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
++ "integrity": "sha512-1XWRFwN8br3iiy2Z8fmnXQMgR76VlfMpHjP1xr5uKLCJmtvtNSWO1BgA1kQmVtyBY7g+1n6YFIjahaD4qWbD/Q==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 12ca9b3a..487170d8 100644
---- a/package.json
++++ b/package.json
@@@ -43,9 +43,9 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.32.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
ddiff --git a/server/index.js b/server/index.js
iindex 70678cd2..d7aba339 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -772,8 +772,9 @@ app.get('/api/health', (req, res) => {
*/
let _bootComplete = false;
// The shared navbar loads /shared/release-watch.js, which polls this and prompts open tabs after a
--// deploy; it never reloads a tab that is watching, broadcasting or typing.
--app.get('/release.json', release.handler);
++// deploy; it never reloads a tab that is watching, broadcasting or typing. GET /release.json, and
++// POST /release-metrics: open tabs' update reports into /metrics (release_client_updates_total).
++release.mount(app, { registry: metricsRegistry });
// Required: boot finished and the database answers. Optional (a failure is "degraded", still 200):
// the WebRTC SFU, OpenVibe.Media and the Network signing key — Live serves channels, chat and RTMP
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex f89da2a2..2c778957 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -221,6 +221,7 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
const rel = await request('GET', '/release.json');
assert.strictEqual(rel.status, 200);
assert.strictEqual(rel.json.release, head);
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json).errors, []);
const streams = await request('GET', '/api/streams');
assert.strictEqual(streams.status, 200);
assert.deepStrictEqual(streams.json.streams.map((s) => [s.title, s.user_id, s.username]), [['Restored stream', 1, 'drillstar']]);
@@@ -269,7 +270,7 @@ const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
['POST', '/api/streams', { title: 'x' }], ['PUT', '/api/streams/1', { title: 'y' }], ['PATCH', '/api/streams/1', { title: 'y' }],
['DELETE', '/api/streams/1', null], ['POST', '/api/chat/send', { message: 'hi' }], ['POST', '/api/auth/logout', null],
['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/internal/media-webhook', {}], ['POST', '/whip/1', null],
-- ['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}],
++ ['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}], ['POST', '/release-metrics', { counts: { applied: { style: 1 } } }],
];
for (const [m, p, body] of writes) {
const r = await request(m, p, { body });
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex a052903f..0945d361 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -6,6 +6,8 @@
* - release_info and the domain gauges (live streams, WebSocket connections, outbox)
* - /api/ready: boot + db required (503 when either fails); SFU, Media and the Network key optional
* (200 "degraded", naming what is missing)
++ * - /release.json validates as registry.release-manifest@1 and names /release-metrics, whose beacons
++ * (text/plain or JSON, behind the global JSON parser) land in release_client_updates_total
* A temp SQLite database stands in for Live's; no Media, Network or mediasoup is needed.
*
* Run: node test/observability.test.js
@@@ -41,6 +43,10 @@ const tmpDb = path.join(os.tmpdir(), `openvibe-observability-test-${process.pid}
const app = express();
const { registry } = observability.mountMetrics(app, { release });
++ // As server/index.js: the global JSON parser, then GET /release.json + POST /release-metrics.
++ app.use(express.json({ limit: '1mb' }));
++ const liveRelease = require('openvibe-shared/release').createRelease({ service: 'live', root: path.join(__dirname, '..') });
++ liveRelease.mount(app, { registry });
const readiness = observability.createLiveReadiness({
release, bootComplete: () => booted, dbQuery, sfuReady: () => sfu, mediaUrl, networkKey: () => key,
});
@@@ -154,6 +160,27 @@ const tmpDb = path.join(os.tmpdir(), `openvibe-observability-test-${process.pid}
outbox = { enabled: false };
assert.ok(!(await get('/metrics')).body.includes('live_events_outbox{'), 'a disabled outbox reports nothing, not zero');
++ // ── release manifest (ADR-016, Track R) ──
++ // registry.release-manifest 1.1.0 under openvibe-contracts >= 0.31: it names where open tabs report.
++ const rel = await get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ const manifest = JSON.parse(rel.body);
++ assert.strictEqual(manifest.service, 'live');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', manifest).errors, []);
++ assert.strictEqual(manifest.metrics_url, '/release-metrics');
++ // release-watch beacons its counts as text/plain (sendBeacon); express.json() must not swallow them.
++ const beacon = (payload, type = 'text/plain;charset=UTF-8') => new Promise((resolve, reject) => {
++ const req = http.request(base + '/release-metrics', { method: 'POST', headers: { 'Content-Type': type } }, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
++ req.on('error', reject); req.end(payload);
++ });
++ assert.strictEqual(await beacon(JSON.stringify({ counts: { applied: { style: 2 }, reloaded: { user: 1 } } })), 204);
++ assert.strictEqual(await beacon(JSON.stringify({ counts: { deferred: { typing: 1 } } }), 'application/json'), 204);
++ assert.strictEqual(await beacon('not json'), 400);
++ const mt = (await get('/metrics')).body;
++ assert.ok(mt.includes('release_client_updates_total{outcome="applied",reason="style"} 2\n'), mt);
++ assert.ok(mt.includes('release_client_updates_total{outcome="reloaded",reason="user"} 1\n'));
++ assert.ok(mt.includes('release_client_updates_total{outcome="deferred",reason="typing"} 1\n'));
++
// Through nginx (X-Forwarded-For on a loopback connection) /metrics does not exist.
assert.strictEqual((await get('/metrics', { 'X-Forwarded-For': '203.0.113.7' })).status, 404);
assert.strictEqual((await get('/metrics', { 'X-Real-IP': '203.0.113.7' })).status, 404);
@@@ -163,6 +190,8 @@ const tmpDb = path.join(os.tmpdir(), `openvibe-observability-test-${process.pid}
const mountAt = src.indexOf('observability.mountMetrics(app');
assert.ok(mountAt > 0 && mountAt < src.indexOf('app.use(helmet('), 'metrics middleware is mounted before the rest of the middleware');
assert.ok(/app\.get\('\/api\/ready', readiness\.handler\)/.test(src));
++ assert.ok(/release\.mount\(app, \{ registry: metricsRegistry \}\)/.test(src), '/release.json and /release-metrics are mounted with the metrics registry');
++ assert.ok(src.indexOf('release.mount(app') > src.indexOf('app.use(express.json('), 'after the JSON parser, as tested above');
server.close(); media.close(); sqlite.close();
for (const f of [tmpDb, `${tmpDb}-wal`, `${tmpDb}-shm`]) { try { fs.unlinkSync(f); } catch { /* */ } }