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

OP OpenVibers committed 26 Sept 2026 · 36ac2da4bf · 9 files, +98 −27


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a2b0850..e11754f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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.
ddiff --git a/README.md b/README.md
iindex b7715ed..ebd26ff 100644
---- a/README.md
++++ b/README.md
@@@ -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):
ddiff --git a/package-lock.json b/package-lock.json
iindex abd9f8d..361f910 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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": {
ddiff --git a/package.json b/package.json
iindex 313a1b6..7f31261 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/release-update.js b/release-update.js
iindex d25994c..b98106f 100644
---- a/release-update.js
++++ b/release-update.js
@@@ -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 };
ddiff --git a/release-watch.js b/release-watch.js
iindex 7f783ed..2596037 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -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; }
ddiff --git a/release.js b/release.js
iindex 1a589e8..e3ebb21 100644
---- a/release.js
++++ b/release.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 } = {}) {
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex d1a01b3..3bb68bd 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -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 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.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 ──
{
ddiff --git a/test/release.test.js b/test/release.test.js
iindex 2ac0f00..de94086 100644
---- a/test/release.test.js
++++ b/test/release.test.js
@@@ -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.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(v
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');
})().catch((e) => { console.error(e); process.exit(1); });