openvibers/openvibe.shared Commits
v1.20.0: release manifest 1.2.0 (roadmap WS-P task 7, openvibe-contracts 0.60.0): createRelease serves client_generation, min_client_generation (env MIN_CLIENT_GENERATION) and shell { version, components }; the meta tag carries data-generation; a tab below the minimum generation reloads when safe (required) and a changed shell never goes in place (it prompts). Contracts devDependency 0.60.0
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.20.0 — 2026-09-26++++**Release manifest 1.2.0** (roadmap WS-P task 7; openvibe-contracts 0.60.0, ADR-016 amendment 2).++- `createRelease({ clientGeneration, minClientGeneration, shell })` serves `client_generation`, `min_client_generation` (also env `MIN_CLIENT_GENERATION`) and `shell: { version, components }`, where the version is the first 12 hex of SHA-256 over the named components' ids and versions.++- The meta tag carries `data-generation`.++- A tab below the minimum generation reloads when safe (`required`). A changed shell is never applied in place; it prompts.++- release-watch stays under its 5 KB brotli budget (4.94 KB) with a tighter header comment.++++The Contracts devDependency moves to 0.60.0.++## 1.19.0 — 2026-09-26**The update matrix, complete** (roadmap WS-P task 8). `release-update.js` sends `ov:content-dispose` (bubbling, `{ component, release }`) on a region just before it is replaced, so a stateless widget inside can drop its listeners and timers; `ov:content-updated` follows as before, to mount it again. Tests now cover every row of the matrix: stylesheet swap and `ov:styles-updated`, revision-aware content with scroll kept, widget disposal and remount, deferral of busy regions, the prompt, and the security minimum. The README "Releases" section lists the rows.@@@ -192,6 +192,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele`data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.Mark anything that must not be replaced with `data-ov-protected`.++- **Client generations and the shell (manifest 1.2.0, openvibe-contracts ≥ 0.60.0).** Pass++ `createRelease({ clientGeneration, minClientGeneration, shell })`. `clientGeneration` (an integer, or a++ function returning one) goes up only when an older client can no longer work against this server; the++ meta tag then carries `data-generation`. `minClientGeneration` (or env `MIN_CLIENT_GENERATION`) makes++ every tab below it reload at the next safe moment (`reloaded: required`), without waiting for the++ window. `shell` names the components that form the page shell (`['shell', 'nav']`); the manifest serves++ `{ version, components }`, and a tab whose shell version differs is prompted, never updated in place.- **The update matrix (WS-P task 8)**, what an open tab does with each kind of change, each row tested(test/release-update.test.js, test/release.test.js):@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.19.0",++ "version": "1.20.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.19.0",++ "version": "1.20.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -15,7 +15,7 @@"better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0"}},"node_modules/accepts": {@@@ -1321,9 +1321,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.52.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",-- "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",++ "version": "0.60.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0",++ "integrity": "sha512-+xbwXzsaBXlZmIrKk0Yjej/jKajshXpZ2GtiBuQ3+3kPODBOD2Wbla6b2CMRZXi5hGRgHR4C+IFzK/g56xNOJQ==","dev": true,"license": "AGPL-3.0-only","dependencies": {@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.19.0",++ "version": "1.20.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -84,6 +84,6 @@"better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0"}}@@@ -46,6 +46,10 @@if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };const c = compatible(page, server);if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };++ // Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.++ const pg = page.client_generation; const mg = server.min_client_generation;++ if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };++ const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);const a = page.components; const b = server.components; let changed = null;if (a && b) {let ok = true; changed = [];@@@ -56,7 +60,7 @@changed.push({ id, kind });if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;}-- if (ok) return { action: 'in-place', changed };++ if (ok && !shellMoved) return { action: 'in-place', changed };}const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };@@@ -1,17 +1,16 @@/** openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).-- * Reads /release.json on focus, visibility, reconnect and every 10 minutes. A new release with components-- * or contract ranges is planned by release-update.js (loaded then, from this directory): style, content and-- * server changes are applied in place; anything else prompts. It reloads by itself only when it must-- * (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle-- * 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing-- * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46), with a-- * 5-minute beat (random tab id, release) for sessions by generation.-- * Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream-- * (topic host.release.published, public). An event naming this page's service with a release it neither-- * runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.++ * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract++ * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server++ * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or++ * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no++ * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live++ * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).++ * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream++ * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at++ * most every 30 s. Polling stays the fallback.* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta-- * tag may carry data-service and data-events. See README "Releases".++ * tag may carry data-service, data-events and data-generation. See README "Releases".*/(function (root) {if (typeof document === 'undefined' || root.OVRelease) return;@@@ -21,6 +20,7 @@const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;const meta = document.querySelector('meta[name="ov-release"]');let current = (meta && meta.content) || null;++ const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined;const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';const IDLE_MS = 2 * 60 * 1000;const MIN_GAP_MS = 60 * 1000;@@@ -109,9 +109,9 @@}));}/** Without release-update.js: the release id, min_client_release and the window only. */-- function legacy(m) {++ function legacy(m, pg) {const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;-- if (m.min_client_release === m.release) return { action: 'reload', reason: 'required' };++ if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' };return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };}function commitRegions(next) {@@@ -128,8 +128,8 @@async function decide(m) {let x = null;if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }-- const page = base || { release: current };-- let p = x ? x.plan(page, m, Date.now()) : legacy(m);++ const page = base || { release: current, client_generation: gen };++ let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation);if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());if (p.action === 'none') return;if (p.action !== 'in-place') {@@@ -231,7 +231,7 @@if (!p || ev.event_type !== TOPIC) return;if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }if (p.service !== rt.service || typeof p.release !== 'string') return;-- // Service ids repeat across origins (Sites' placeholders): a named origin must be this page's.++ // Ids repeat across origins (Sites' placeholders): a named origin must be ours.if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }rt.events++;if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }@@@ -239,7 +239,7 @@queued = p.release;queue();}-- /** One check after a 0-20 s jitter; whatever arrives meanwhile, or in the 30 s after it, collapses into one more. */++ /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */function queue() {if (t.run) return;if (t.cool) { again = true; return; }@@@ -323,6 +323,20 @@ function collector(registry, { perMinute = 30, keyOf = null, currentRelease = nu};}++/**++ * The shell (manifest 1.2.0): the named components that exist, and one version over their ids and++ * versions (first 12 hex of SHA-256, in id order). Null when none is named or none exists.++ */++function shellOf(ids, components, warn) {++ if (!Array.isArray(ids) || !ids.length) return null;++ const have = ids.filter((id) => components && components[id]).sort();++ const missing = ids.filter((id) => !(components && components[id]));++ if (missing.length) warn(`shell names components that do not exist: ${missing.join(', ')}`);++ if (!have.length) return null;++ const version = crypto.createHash('sha256').update(have.map((id) => `${id}@${components[id].version}`).join('\n')).digest('hex').slice(0, 12);++ return { version, components: have };++}++// ── createRelease ──────────────────────────────────────────────function createRelease({@@@ -331,6 +345,7 @@ function createRelease({packages = ['openvibe-shared', 'openvibe-sdk'],components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,++ clientGeneration = null, minClientGeneration = process.env.MIN_CLIENT_GENERATION ?? null, shell = null,metricsPath = null, schema, recheckMs = 0,env = process.env, now = () => new Date(), logger = console,} = {}) {@@@ -381,6 +396,10 @@ function createRelease({schema_generation: generation,schema_compatible_from: compatibleFrom,contract_ranges: ranges,++ // Manifest 1.2.0 (WS-P task 7): client generations and the shell.++ client_generation: intOrNull(clientGeneration, 'clientGeneration'),++ min_client_generation: intOrNull(typeof minClientGeneration === 'string' ? (minClientGeneration.trim() === '' ? null : Number(minClientGeneration)) : minClientGeneration, 'minClientGeneration'),++ shell: shellOf(shell, built.components, warn),metrics_url: metricsUrl,});const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));@@@ -408,7 +427,7 @@ function createRelease({res.end(body);}const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));-- const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(state.full.release)}" data-released-at="${esc(state.full.released_at)}" data-url="${esc(url)}"${metricsUrl ? ` data-metrics="${esc(metricsUrl)}"` : ''}>`;++ const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(state.full.release)}" data-released-at="${esc(state.full.released_at)}" data-url="${esc(url)}"${metricsUrl ? ` data-metrics="${esc(metricsUrl)}"` : ''}${state.full.client_generation != null ? ` data-generation="${state.full.client_generation}"` : ''}>`;/** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {@@@ -68,6 +68,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.++// 1.20.0: client generations (+0.05 KB) fit by a tighter header comment: 4.94 KB.{const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));@@@ -85,6 +86,12 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objecassert.strictEqual(plan(M1, { ...M2, components: comps({ jobs: { kind: 'server', version: 'j1' } }) }).action, 'in-place', 'a new server component needs nothing from the tab');assert.deepStrictEqual(plan(M1, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') }).reason, 'contract');assert.strictEqual(plan(M1, M1).action, 'none');++ // Manifest 1.2.0: generations and the shell.++ assert.deepStrictEqual(plan({ ...M1, client_generation: 1 }, { ...M2, min_client_generation: 2 }), { action: 'reload', reason: 'required', changed: [] }, 'below the minimum generation: required');++ assert.strictEqual(plan({ ...M1, client_generation: 2 }, { ...M2, min_client_generation: 2 }).action, 'in-place', 'at the minimum: as before');++ const sh = (v) => ({ version: v.repeat(12), components: ['shell'] });++ assert.strictEqual(plan({ ...M1, shell: sh('a') }, { ...M2, shell: sh('b') }).action, 'prompt', 'a changed shell is never in place');++ assert.strictEqual(plan({ ...M1, shell: sh('a') }, { ...M2, shell: sh('a') }).action, 'in-place', 'the same shell: as before');// ── Style and content in place ──{@@@ -78,6 +78,21 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.strictEqual(valid11(full), true, 'the full manifest validates against registry.release-manifest 1.1.0');assert.deepStrictEqual(rel.manifest(), full, 'a 1.1.0 schema serves every field');assert.deepStrictEqual(Object.keys(full.components).sort(), ['docs', 'server', 'shell', 'styles']);++ // Manifest 1.2.0 (WS-P task 7): client generations and the shell.++ {++ const r2 = createRelease({ ...spec, clientGeneration: 3, minClientGeneration: '2', shell: ['styles', 'shell', 'nope'] });++ const f2 = r2.full();++ assert.deepStrictEqual([f2.client_generation, f2.min_client_generation], [3, 2], 'generations (the minimum from an env string)');++ assert.deepStrictEqual(f2.shell.components, ['shell', 'styles'], 'the shell: named components that exist, in id order');++ assert.strictEqual(f2.shell.version, sha12(`shell@${f2.components.shell.version}\nstyles@${f2.components.styles.version}`));++ assert.strictEqual(valid11(f2), true, 'validates against registry.release-manifest 1.2.0');++ assert.match(r2.metaTag(), /data-generation="3"/, 'the page states its generation');++ const w = typeof r2.warnings === 'function' ? r2.warnings() : r2.warnings;++ assert.ok(w.some((x) => /nope/.test(x)), 'a missing shell component is warned about');++ assert.deepStrictEqual([full.client_generation, full.min_client_generation, full.shell], [null, null, null], 'all three are null by default');++ assert.ok(!/data-generation/.test(rel.metaTag()));++ assert.strictEqual(createRelease({ ...spec, minClientGeneration: 'x' }).full().min_client_generation, null, 'a bad minimum is dropped (and warned)');++ }assert.strictEqual(full.components.styles.kind, 'style');assert.match(full.components.styles.version, /^[0-9a-f]{12}$/);assert.strictEqual(full.components.server.version, '1234567abcde');@@@ -326,5 +341,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');}++ // A tab below the server's minimum client generation reloads when safe (manifest 1.2.0); at it, only the prompt.++ for (const [gen, reloads] of [[1, 1], [2, 0]]) {++ const manifest = { service: 'test', release: 'bbbbbbb', released_at: fresh, min_client_release: null, mixed_version_window_hours: 24, min_client_generation: 2 };++ const page = await openPage({ url: 'https://site.test/', html: HTML().replace('data-url="/release.json"', `data-url="/release.json" data-generation="${gen}"`), hidden: true, config: { metricsUrl: '/release-metrics' }, serve: () => ({ json: manifest }) });++ await page.settle();++ assert.strictEqual(page.reloads, reloads, `generation ${gen} against a minimum of 2`);++ assert.deepStrictEqual(page.metrics(), gen < 2 ? { prompted: { required: 1 }, reloaded: { required: 1 } } : { prompted: { optional: 1 } });++ }++console.log('release: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.20.0 — 2026-09-26
+
+**Release manifest 1.2.0** (roadmap WS-P task 7; openvibe-contracts 0.60.0, ADR-016 amendment 2).
+- `createRelease({ clientGeneration, minClientGeneration, shell })` serves `client_generation`, `min_client_generation` (also env `MIN_CLIENT_GENERATION`) and `shell: { version, components }`, where the version is the first 12 hex of SHA-256 over the named components' ids and versions.
+- The meta tag carries `data-generation`.
+- A tab below the minimum generation reloads when safe (`required`). A changed shell is never applied in place; it prompts.
+- release-watch stays under its 5 KB brotli budget (4.94 KB) with a tighter header comment.
+
+The Contracts devDependency moves to 0.60.0.
+
## 1.19.0 — 2026-09-26
## 1.19.0 — 2026-09-26
**The update matrix, complete** (roadmap WS-P task 8). `release-update.js` sends `ov:content-dispose` (bubbling, `{ component, release }`) on a region just before it is replaced, so a stateless widget inside can drop its listeners and timers; `ov:content-updated` follows as before, to mount it again. Tests now cover every row of the matrix: stylesheet swap and `ov:styles-updated`, revision-aware content with scroll kept, widget disposal and remount, deferral of busy regions, the prompt, and the security minimum. The README "Releases" section lists the rows.
**The update matrix, complete** (roadmap WS-P task 8). `release-update.js` sends `ov:content-dispose` (bubbling, `{ component, release }`) on a region just before it is replaced, so a stateless widget inside can drop its listeners and timers; `ov:content-updated` follows as before, to mount it again. Tests now cover every row of the matrix: stylesheet swap and `ov:styles-updated`, revision-aware content with scroll kept, widget disposal and remount, deferral of busy regions, the prompt, and the security minimum. The README "Releases" section lists the rows.
@@ -192,6 +192,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -192,6 +192,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
`data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.
`data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.
A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.
A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.
Mark anything that must not be replaced with `data-ov-protected`.
Mark anything that must not be replaced with `data-ov-protected`.
+- **Client generations and the shell (manifest 1.2.0, openvibe-contracts ≥ 0.60.0).** Pass
+ `createRelease({ clientGeneration, minClientGeneration, shell })`. `clientGeneration` (an integer, or a
+ function returning one) goes up only when an older client can no longer work against this server; the
+ meta tag then carries `data-generation`. `minClientGeneration` (or env `MIN_CLIENT_GENERATION`) makes
+ every tab below it reload at the next safe moment (`reloaded: required`), without waiting for the
+ window. `shell` names the components that form the page shell (`['shell', 'nav']`); the manifest serves
+ `{ version, components }`, and a tab whose shell version differs is prompted, never updated in place.
- **The update matrix (WS-P task 8)**, what an open tab does with each kind of change, each row tested
- **The update matrix (WS-P task 8)**, what an open tab does with each kind of change, each row tested
(test/release-update.test.js, test/release.test.js):
(test/release-update.test.js, test/release.test.js):
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.19.0",
+ "version": "1.20.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.19.0",
+ "version": "1.20.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0"
}
}
},
},
"node_modules/accepts": {
"node_modules/accepts": {
@@ -1321,9 +1321,9 @@
@@ -1321,9 +1321,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.52.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
- "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
+ "version": "0.60.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0",
+ "integrity": "sha512-+xbwXzsaBXlZmIrKk0Yjej/jKajshXpZ2GtiBuQ3+3kPODBOD2Wbla6b2CMRZXi5hGRgHR4C+IFzK/g56xNOJQ==",
"dev": true,
"dev": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.19.0",
+ "version": "1.20.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -84,6 +84,6 @@
@@ -84,6 +84,6 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.60.0"
}
}
}
}
@@ -46,6 +46,10 @@
@@ -46,6 +46,10 @@
if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
const c = compatible(page, server);
const c = compatible(page, server);
if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
+ // Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.
+ const pg = page.client_generation; const mg = server.min_client_generation;
+ if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };
+ const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);
const a = page.components; const b = server.components; let changed = null;
const a = page.components; const b = server.components; let changed = null;
if (a && b) {
if (a && b) {
let ok = true; changed = [];
let ok = true; changed = [];
@@ -56,7 +60,7 @@
@@ -56,7 +60,7 @@
changed.push({ id, kind });
changed.push({ id, kind });
if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
}
}
- if (ok) return { action: 'in-place', changed };
+ if (ok && !shellMoved) return { action: 'in-place', changed };
}
}
const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
@@ -1,17 +1,16 @@
@@ -1,17 +1,16 @@
/*
/*
* openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).
* openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).
- * Reads /release.json on focus, visibility, reconnect and every 10 minutes. A new release with components
- * or contract ranges is planned by release-update.js (loaded then, from this directory): style, content and
- * server changes are applied in place; anything else prompts. It reloads by itself only when it must
- * (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
- * 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing
- * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46), with a
- * 5-minute beat (random tab id, release) for sessions by generation.
- * Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream
- * (topic host.release.published, public). An event naming this page's service with a release it neither
- * runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.
+ * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract
+ * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server
+ * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or
+ * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
+ * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
+ * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
+ * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream
+ * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at
+ * most every 30 s. Polling stays the fallback.
* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
- * tag may carry data-service and data-events. See README "Releases".
+ * tag may carry data-service, data-events and data-generation. See README "Releases".
*/
*/
(function (root) {
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
if (typeof document === 'undefined' || root.OVRelease) return;
@@ -21,6 +20,7 @@
@@ -21,6 +20,7 @@
const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;
const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;
const meta = document.querySelector('meta[name="ov-release"]');
const meta = document.querySelector('meta[name="ov-release"]');
let current = (meta && meta.content) || null;
let current = (meta && meta.content) || null;
+ const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined;
const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';
const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';
const IDLE_MS = 2 * 60 * 1000;
const IDLE_MS = 2 * 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
@@ -109,9 +109,9 @@
@@ -109,9 +109,9 @@
}));
}));
}
}
/** Without release-update.js: the release id, min_client_release and the window only. */
/** Without release-update.js: the release id, min_client_release and the window only. */
- function legacy(m) {
+ function legacy(m, pg) {
const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;
const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;
- if (m.min_client_release === m.release) return { action: 'reload', reason: 'required' };
+ if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' };
return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };
return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };
}
}
function commitRegions(next) {
function commitRegions(next) {
@@ -128,8 +128,8 @@
@@ -128,8 +128,8 @@
async function decide(m) {
async function decide(m) {
let x = null;
let x = null;
if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
- const page = base || { release: current };
- let p = x ? x.plan(page, m, Date.now()) : legacy(m);
+ const page = base || { release: current, client_generation: gen };
+ let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation);
if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
if (p.action === 'none') return;
if (p.action === 'none') return;
if (p.action !== 'in-place') {
if (p.action !== 'in-place') {
@@ -231,7 +231,7 @@
@@ -231,7 +231,7 @@
if (!p || ev.event_type !== TOPIC) return;
if (!p || ev.event_type !== TOPIC) return;
if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
if (p.service !== rt.service || typeof p.release !== 'string') return;
if (p.service !== rt.service || typeof p.release !== 'string') return;
- // Service ids repeat across origins (Sites' placeholders): a named origin must be this page's.
+ // Ids repeat across origins (Sites' placeholders): a named origin must be ours.
if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
rt.events++;
rt.events++;
if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
@@ -239,7 +239,7 @@
@@ -239,7 +239,7 @@
queued = p.release;
queued = p.release;
queue();
queue();
}
}
- /** One check after a 0-20 s jitter; whatever arrives meanwhile, or in the 30 s after it, collapses into one more. */
+ /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */
function queue() {
function queue() {
if (t.run) return;
if (t.run) return;
if (t.cool) { again = true; return; }
if (t.cool) { again = true; return; }
@@ -323,6 +323,20 @@ function collector(registry, { perMinute = 30, keyOf = null, currentRelease = nu
@@ -323,6 +323,20 @@ function collector(registry, { perMinute = 30, keyOf = null, currentRelease = nu
};
};
}
}
+/**
+ * The shell (manifest 1.2.0): the named components that exist, and one version over their ids and
+ * versions (first 12 hex of SHA-256, in id order). Null when none is named or none exists.
+ */
+function shellOf(ids, components, warn) {
+ if (!Array.isArray(ids) || !ids.length) return null;
+ const have = ids.filter((id) => components && components[id]).sort();
+ const missing = ids.filter((id) => !(components && components[id]));
+ if (missing.length) warn(`shell names components that do not exist: ${missing.join(', ')}`);
+ if (!have.length) return null;
+ const version = crypto.createHash('sha256').update(have.map((id) => `${id}@${components[id].version}`).join('\n')).digest('hex').slice(0, 12);
+ return { version, components: have };
+}
+
// ── createRelease ──────────────────────────────────────────────
// ── createRelease ──────────────────────────────────────────────
function createRelease({
function createRelease({
@@ -331,6 +345,7 @@ function createRelease({
@@ -331,6 +345,7 @@ function createRelease({
packages = ['openvibe-shared', 'openvibe-sdk'],
packages = ['openvibe-shared', 'openvibe-sdk'],
components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,
components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,
contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,
contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,
+ clientGeneration = null, minClientGeneration = process.env.MIN_CLIENT_GENERATION ?? null, shell = null,
metricsPath = null, schema, recheckMs = 0,
metricsPath = null, schema, recheckMs = 0,
env = process.env, now = () => new Date(), logger = console,
env = process.env, now = () => new Date(), logger = console,
} = {}) {
} = {}) {
@@ -381,6 +396,10 @@ function createRelease({
@@ -381,6 +396,10 @@ function createRelease({
schema_generation: generation,
schema_generation: generation,
schema_compatible_from: compatibleFrom,
schema_compatible_from: compatibleFrom,
contract_ranges: ranges,
contract_ranges: ranges,
+ // Manifest 1.2.0 (WS-P task 7): client generations and the shell.
+ client_generation: intOrNull(clientGeneration, 'clientGeneration'),
+ min_client_generation: intOrNull(typeof minClientGeneration === 'string' ? (minClientGeneration.trim() === '' ? null : Number(minClientGeneration)) : minClientGeneration, 'minClientGeneration'),
+ shell: shellOf(shell, built.components, warn),
metrics_url: metricsUrl,
metrics_url: metricsUrl,
});
});
const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));
const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));
@@ -408,7 +427,7 @@ function createRelease({
@@ -408,7 +427,7 @@ function createRelease({
res.end(body);
res.end(body);
}
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
- const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(state.full.release)}" data-released-at="${esc(state.full.released_at)}" data-url="${esc(url)}"${metricsUrl ? ` data-metrics="${esc(metricsUrl)}"` : ''}>`;
+ const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(state.full.release)}" data-released-at="${esc(state.full.released_at)}" data-url="${esc(url)}"${metricsUrl ? ` data-metrics="${esc(metricsUrl)}"` : ''}${state.full.client_generation != null ? ` data-generation="${state.full.client_generation}"` : ''}>`;
/** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */
/** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */
function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
@@ -68,6 +68,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -68,6 +68,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.
// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.
+// 1.20.0: client generations (+0.05 KB) fit by a tighter header comment: 4.94 KB.
{
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
@@ -85,6 +86,12 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -85,6 +86,12 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
assert.strictEqual(plan(M1, { ...M2, components: comps({ jobs: { kind: 'server', version: 'j1' } }) }).action, 'in-place', 'a new server component needs nothing from the tab');
assert.strictEqual(plan(M1, { ...M2, components: comps({ jobs: { kind: 'server', version: 'j1' } }) }).action, 'in-place', 'a new server component needs nothing from the tab');
assert.deepStrictEqual(plan(M1, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') }).reason, 'contract');
assert.deepStrictEqual(plan(M1, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') }).reason, 'contract');
assert.strictEqual(plan(M1, M1).action, 'none');
assert.strictEqual(plan(M1, M1).action, 'none');
+ // Manifest 1.2.0: generations and the shell.
+ assert.deepStrictEqual(plan({ ...M1, client_generation: 1 }, { ...M2, min_client_generation: 2 }), { action: 'reload', reason: 'required', changed: [] }, 'below the minimum generation: required');
+ assert.strictEqual(plan({ ...M1, client_generation: 2 }, { ...M2, min_client_generation: 2 }).action, 'in-place', 'at the minimum: as before');
+ const sh = (v) => ({ version: v.repeat(12), components: ['shell'] });
+ assert.strictEqual(plan({ ...M1, shell: sh('a') }, { ...M2, shell: sh('b') }).action, 'prompt', 'a changed shell is never in place');
+ assert.strictEqual(plan({ ...M1, shell: sh('a') }, { ...M2, shell: sh('a') }).action, 'in-place', 'the same shell: as before');
// ── Style and content in place ──
// ── Style and content in place ──
{
{
@@ -78,6 +78,21 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -78,6 +78,21 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(valid11(full), true, 'the full manifest validates against registry.release-manifest 1.1.0');
assert.strictEqual(valid11(full), true, 'the full manifest validates against registry.release-manifest 1.1.0');
assert.deepStrictEqual(rel.manifest(), full, 'a 1.1.0 schema serves every field');
assert.deepStrictEqual(rel.manifest(), full, 'a 1.1.0 schema serves every field');
assert.deepStrictEqual(Object.keys(full.components).sort(), ['docs', 'server', 'shell', 'styles']);
assert.deepStrictEqual(Object.keys(full.components).sort(), ['docs', 'server', 'shell', 'styles']);
+ // Manifest 1.2.0 (WS-P task 7): client generations and the shell.
+ {
+ const r2 = createRelease({ ...spec, clientGeneration: 3, minClientGeneration: '2', shell: ['styles', 'shell', 'nope'] });
+ const f2 = r2.full();
+ assert.deepStrictEqual([f2.client_generation, f2.min_client_generation], [3, 2], 'generations (the minimum from an env string)');
+ assert.deepStrictEqual(f2.shell.components, ['shell', 'styles'], 'the shell: named components that exist, in id order');
+ assert.strictEqual(f2.shell.version, sha12(`shell@${f2.components.shell.version}\nstyles@${f2.components.styles.version}`));
+ assert.strictEqual(valid11(f2), true, 'validates against registry.release-manifest 1.2.0');
+ assert.match(r2.metaTag(), /data-generation="3"/, 'the page states its generation');
+ const w = typeof r2.warnings === 'function' ? r2.warnings() : r2.warnings;
+ assert.ok(w.some((x) => /nope/.test(x)), 'a missing shell component is warned about');
+ assert.deepStrictEqual([full.client_generation, full.min_client_generation, full.shell], [null, null, null], 'all three are null by default');
+ assert.ok(!/data-generation/.test(rel.metaTag()));
+ assert.strictEqual(createRelease({ ...spec, minClientGeneration: 'x' }).full().min_client_generation, null, 'a bad minimum is dropped (and warned)');
+ }
assert.strictEqual(full.components.styles.kind, 'style');
assert.strictEqual(full.components.styles.kind, 'style');
assert.match(full.components.styles.version, /^[0-9a-f]{12}$/);
assert.match(full.components.styles.version, /^[0-9a-f]{12}$/);
assert.strictEqual(full.components.server.version, '1234567abcde');
assert.strictEqual(full.components.server.version, '1234567abcde');
@@ -326,5 +341,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -326,5 +341,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');
assert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');
}
}
+ // A tab below the server's minimum client generation reloads when safe (manifest 1.2.0); at it, only the prompt.
+ for (const [gen, reloads] of [[1, 1], [2, 0]]) {
+ const manifest = { service: 'test', release: 'bbbbbbb', released_at: fresh, min_client_release: null, mixed_version_window_hours: 24, min_client_generation: 2 };
+ const page = await openPage({ url: 'https://site.test/', html: HTML().replace('data-url="/release.json"', `data-url="/release.json" data-generation="${gen}"`), hidden: true, config: { metricsUrl: '/release-metrics' }, serve: () => ({ json: manifest }) });
+ await page.settle();
+ assert.strictEqual(page.reloads, reloads, `generation ${gen} against a minimum of 2`);
+ assert.deepStrictEqual(page.metrics(), gen < 2 ? { prompted: { required: 1 }, reloaded: { required: 1 } } : { prompted: { optional: 1 } });
+ }
+
console.log('release: all checks passed');
console.log('release: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });