openvibers/openvibe.shared Commits

1.1.0: release manifest (/release.json, openvibe-shared/release) and release-watch.js — open tabs are prompted on a new release and reload only when safe (ADR-016)

OP OpenVibers committed 23 Sept 2026 · 6c855ea99a · 6 files, +250 −2


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f52f9cf..76c7bd8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,19 @@ 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.1.0 — 2026-09-23
++
++Active-client update safety (ADR-016, contract `registry.release-manifest@1`). Additive only.
++
++- `openvibe-shared/release` (server): `createRelease({ service })` → `handler` for
++ `GET /release.json` (deployed commit, commit time, package versions, `MIN_CLIENT_RELEASE`,
++ 24 h mixed-version window) and `metaTag()` for the page's own release.
++- `release-watch.js` (browser, served at `/shared/release-watch.js`): on focus, visibility,
++ reconnect and every 10 minutes, compares the page's release with `/release.json`. A newer
++ release shows one Reload toast; a required release or one older than the window reloads the tab
++ only when it is hidden or idle, no text field has focus and nothing is protected
++ (`form[data-dirty="true"]`, `[data-ov-protected]`, `window.OVProtected()`).
++
## 1.0.0 — 2026-09-22
First release from its own repository. Imported history-free from
ddiff --git a/files.js b/files.js
iindex 536c933..3bb9fb8 100644
---- a/files.js
++++ b/files.js
@@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
'ui.js', // toasts and page notices
'island.js', // activity island
'tooltip.js',
++ 'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'openvibe-sw.js', // Web Push service worker
]);
ddiff --git a/package.json b/package.json
iindex f04b1c5..dfdb20b 100644
---- a/package.json
++++ b/package.json
@@@ -1,12 +1,11 @@
{
"name": "openvibe-shared",
-- "version": "1.0.0",
++ "version": "1.1.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
".": "./index.js",
"./package.json": "./package.json",
-- "./files": "./files.js",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics.js",
"./app-icon": "./app-icon.js",
@@@ -14,6 +13,7 @@
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./chrome-ssr": "./chrome-ssr.js",
++ "./files": "./files.js",
"./footer": "./footer.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
@@@ -28,6 +28,8 @@
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
"./panels": "./panels.js",
++ "./release": "./release.js",
++ "./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
ddiff --git a/release-watch.js b/release-watch.js
nnew file mode 100644
iindex 0000000..81edbbb
---- /dev/null
++++ b/release-watch.js
@@@ -0,0 +1,80 @@
++/*
++ * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016).
++ *
++ * The page declares what it runs: <meta name="ov-release" content="<sha>" data-url="/release.json">
++ * (openvibe-shared/release metaTag()). On focus, when the tab becomes visible, when the network
++ * comes back and every 10 minutes, this reads /release.json:
++ * - same release: nothing;
++ * - newer release: one quiet "new version" toast with a Reload button, never an automatic reload;
++ * - the server requires it (min_client_release is the running release) or the new release is older
++ * than the mixed-version window: reload automatically, but ONLY when it is safe — the tab is
++ * hidden or idle for 2 minutes, no text field has focus, and nothing on the page is protected
++ * (a form marked data-dirty="true", an element with data-ov-protected, or window.OVProtected()
++ * returning true for uploads, calls, broadcasts and recordings).
++ */
++(function (root) {
++ if (typeof document === 'undefined' || root.OVRelease) return;
++ const meta = document.querySelector('meta[name="ov-release"]');
++ if (!meta || !meta.content) return;
++ const current = meta.content;
++ const url = meta.getAttribute('data-url') || '/release.json';
++ const IDLE_MS = 2 * 60 * 1000;
++ const MIN_GAP_MS = 60 * 1000;
++ let lastCheck = 0;
++ let lastInput = Date.now();
++ let prompted = false;
++ let mustReload = false;
++ let latest = null;
++
++ ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
++
++ function busy() {
++ const a = document.activeElement;
++ if (a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''))) return true;
++ if (document.querySelector('form[data-dirty="true"], [data-ov-protected]')) return true;
++ try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return true; } catch { return true; }
++ return !document.hidden && Date.now() - lastInput < IDLE_MS;
++ }
++
++ function prompt() {
++ if (prompted) return;
++ prompted = true;
++ const text = 'A new version of this page is available.';
++ const reload = () => root.location.reload();
++ if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
++ root.OpenVibeUI.toast(text, { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: reload } });
++ }
++ }
++
++ function maybeReload() {
++ if (mustReload && !busy()) root.location.reload();
++ }
++
++ async function check(force) {
++ const now = Date.now();
++ if (!force && now - lastCheck < MIN_GAP_MS) return latest;
++ lastCheck = now;
++ let m;
++ try {
++ const r = await root.fetch(url, { cache: 'no-store', credentials: 'omit' });
++ if (!r.ok) return latest;
++ m = await r.json();
++ } catch { return latest; }
++ if (!m || typeof m.release !== 'string') return latest;
++ latest = m;
++ if (m.release === current) return m;
++ const age = now - Date.parse(m.released_at);
++ const windowMs = (Number(m.mixed_version_window_hours) || 0) * 3600 * 1000;
++ mustReload = m.min_client_release === m.release || (windowMs > 0 && age > windowMs);
++ prompt();
++ maybeReload();
++ return m;
++ }
++
++ root.addEventListener('focus', () => check(false));
++ root.addEventListener('online', () => check(true));
++ document.addEventListener('visibilitychange', () => { if (document.hidden) maybeReload(); else check(false); });
++ const tick = root.setInterval(() => { check(false); maybeReload(); }, 30 * 1000);
++ const poll = root.setInterval(() => check(true), 10 * 60 * 1000);
++ root.OVRelease = { current, check: () => check(true), state: () => ({ current, latest, mustReload }), stop: () => { root.clearInterval(tick); root.clearInterval(poll); } };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/release.js b/release.js
nnew file mode 100644
iindex 0000000..40b6c74
---- /dev/null
++++ b/release.js
@@@ -0,0 +1,69 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/release — GET /release.json for a web surface (ADR-016, contract
++// registry.release-manifest@1). Open tabs poll it (release-watch.js) and learn when the page
++// they run is older than what the server now serves.
++//
++// const release = require('openvibe-shared/release').createRelease({ service: 'community' });
++// app.get('/release.json', release.handler);
++// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release
++//
++// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read once at boot.
++// MIN_CLIENT_RELEASE (env) set to the running release tells every older tab it must reload
++// (as soon as that is safe); otherwise older tabs are only prompted, and reload on their own once
++// the new release is older than the mixed-version window (24 h by default).
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const { execFileSync } = require('child_process');
++
++const SHA_RE = /^[0-9a-f]{7,40}$/;
++
++function git(root, args) {
++ try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return ''; }
++}
++
++function pkgVersion(name, root) {
++ try { return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`, { paths: [root] }), 'utf8')).version || null; } catch { return null; }
++}
++
++function createRelease({
++ service, root = process.cwd(), windowHours = 24,
++ minClientRelease = process.env.MIN_CLIENT_RELEASE || null,
++ packages = ['openvibe-shared', 'openvibe-sdk'],
++ env = process.env, now = () => new Date(),
++} = {}) {
++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(String(service || ''))) throw new TypeError('createRelease: service id required');
++ const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();
++ const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);
++ const release = SHA_RE.test(sha) ? sha : '0000000';
++ const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);
++ const bootedAt = now();
++ const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);
++ const versions = {};
++ for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }
++ const manifest = Object.freeze({
++ service,
++ release,
++ released_at: releasedAt.toISOString(),
++ booted_at: bootedAt.toISOString(),
++ contracts_version: pkgVersion('openvibe-contracts', root),
++ packages: Object.freeze(versions),
++ min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,
++ mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),
++ });
++ const body = JSON.stringify(manifest);
++
++ function handler(_req, res) {
++ res.setHeader('Content-Type', 'application/json; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache, max-age=0');
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ 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(release)}" data-released-at="${esc(manifest.released_at)}" data-url="${esc(url)}">`;
++
++ return { manifest: () => manifest, release, handler, metaTag };
++}
++
++module.exports = { createRelease };
ddiff --git a/test/release.test.js b/test/release.test.js
nnew file mode 100644
iindex 0000000..b9b8bb3
---- /dev/null
++++ b/test/release.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++// release.js (GET /release.json) and release-watch.js (prompt, and reload only when safe).
++const assert = require('assert'); const vm = require('vm'); const fs = require('fs'); const path = require('path'); const os = require('os');
++const { createRelease } = require('../release');
++
++(async () => {
++ // Server side: manifest from env (no git), metaTag, handler headers.
++ const r = createRelease({ service: 'community', root: os.tmpdir(), env: { RELEASE_COMMIT: 'ABCDEF1234567890', RELEASE_AT: '2026-09-23T01:00:00Z' }, minClientRelease: 'nope' });
++ const m = r.manifest();
++ assert.strictEqual(m.release, 'abcdef123456');
++ assert.strictEqual(m.released_at, '2026-09-23T01:00:00.000Z');
++ assert.strictEqual(m.min_client_release, null, 'a malformed MIN_CLIENT_RELEASE is ignored');
++ assert.strictEqual(m.mixed_version_window_hours, 24);
++ assert.match(r.metaTag(), /^<meta name="ov-release" content="abcdef123456" data-released-at="2026-09-23T01:00:00.000Z" data-url="\/release.json">$/);
++ const headers = {}; let body = '';
++ r.handler({}, { setHeader: (k, v) => { headers[k] = v; }, end: (b) => { body = b; } });
++ assert.strictEqual(headers['Cache-Control'], 'no-cache, max-age=0');
++ assert.deepStrictEqual(JSON.parse(body), JSON.parse(JSON.stringify(m)));
++ assert.throws(() => createRelease({ service: 'Bad Name' }));
++ const fromGit = createRelease({ service: 'shared', root: path.join(__dirname, '..'), env: {} });
++ assert.match(fromGit.release, /^[0-9a-f]{7,12}$/);
++
++ // Browser side, in a vm with a fake DOM and clock.
++ function page({ serverRelease, releasedAt, min = null, active = null, protectedFn = null, hidden = false }) {
++ const listeners = {}; const docListeners = {};
++ let reloads = 0; const toasts = [];
++ const ctx = {
++ console, Date, JSON, Promise,
++ setInterval: () => 1, clearInterval: () => {},
++ location: { reload: () => { reloads++; } },
++ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
++ OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },
++ fetch: async () => ({ ok: true, json: async () => ({ release: serverRelease, released_at: releasedAt, min_client_release: min, mixed_version_window_hours: 24 }) }),
++ document: {
++ hidden,
++ activeElement: active,
++ querySelector: (sel) => (sel.startsWith('meta') ? { content: 'aaaaaaa', getAttribute: () => '/release.json' } : null),
++ addEventListener: (t, f) => { docListeners[t] = f; },
++ },
++ };
++ if (protectedFn) ctx.OVProtected = protectedFn;
++ ctx.window = ctx; ctx.globalThis = ctx;
++ vm.createContext(ctx);
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
++ return { ctx, toasts, reloads: () => reloads, docListeners };
++ }
++ const fresh = new Date(Date.now() - 3600e3).toISOString();
++ const stale = new Date(Date.now() - 30 * 3600e3).toISOString();
++
++ let p = page({ serverRelease: 'aaaaaaa', releasedAt: fresh });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.toasts.length, 0, 'same release: nothing');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, hidden: true });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.toasts.length, 1, 'newer release: one prompt');
++ assert.strictEqual(p.toasts[0].o.ttl, 0, 'the prompt stays until dismissed');
++ assert.strictEqual(p.reloads(), 0, 'inside the window: never an automatic reload');
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.toasts.length, 1, 'prompted once');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.reloads(), 1, 'outside the window and hidden: reloads');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, min: 'bbbbbbb', hidden: true });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.reloads(), 1, 'server requires the new release: reloads when safe');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, active: { tagName: 'TEXTAREA' } });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.reloads(), 0, 'never while a text field has focus');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, protectedFn: () => true });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.reloads(), 0, 'never during a protected session (upload, call, broadcast)');
++
++ p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: false });
++ await p.ctx.OVRelease.check();
++ assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');
++
++ console.log('release: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });