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)
Changes
@@@ -4,6 +4,19 @@ 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.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-22First release from its own repository. Imported history-free from@@@ -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]);@@@ -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",nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 };nnew file mode 100644@@@ -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); });
@@ -4,6 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 1.0.0 — 2026-09-22
First release from its own repository. Imported history-free from
First release from its own repository. Imported history-free from
@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
'ui.js', // toasts and page notices
'ui.js', // toasts and page notices
'island.js', // activity island
'island.js', // activity island
'tooltip.js',
'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
'openvibe-sw.js', // Web Push service worker
]);
]);
@@ -1,12 +1,11 @@
@@ -1,12 +1,11 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
".": "./index.js",
".": "./index.js",
"./package.json": "./package.json",
"./package.json": "./package.json",
- "./files": "./files.js",
"./account-switcher": "./account-switcher.js",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics.js",
"./analytics": "./analytics.js",
"./app-icon": "./app-icon.js",
"./app-icon": "./app-icon.js",
@@ -14,6 +13,7 @@
@@ -14,6 +13,7 @@
"./brand": "./brand.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
+ "./files": "./files.js",
"./footer": "./footer.js",
"./footer": "./footer.js",
"./history": "./history.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
@@ -28,6 +28,8 @@
@@ -28,6 +28,8 @@
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
"./panels": "./panels.js",
"./panels": "./panels.js",
+ "./release": "./release.js",
+ "./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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); });