openvibers/openvibe.shared Commits

1.1.1: release-watch learns its baseline from /release.json when the page has no ov-release meta

OP OpenVibers committed 23 Sept 2026 · 7c9a373964 · 4 files, +63 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 76c7bd8..96ed00b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,12 @@ 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.1 — 2026-09-23
++
++- `release-watch.js` works without the `<meta name="ov-release">` tag: the release `/release.json`
++ reports when the script loads becomes the page's baseline, and a site that serves no
++ `/release.json` is asked once and left alone. Adopting it is a route plus one script tag.
++
## 1.1.0 — 2026-09-23
Active-client update safety (ADR-016, contract `registry.release-manifest@1`). Additive only.
ddiff --git a/package.json b/package.json
iindex dfdb20b..827f990 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.1.0",
++ "version": "1.1.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/release-watch.js b/release-watch.js
iindex 81edbbb..355d8dd 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -1,8 +1,8 @@
/*
* 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
++ * The page's release comes from <meta name="ov-release" content="<sha>" data-url="/release.json">
++ * (openvibe-shared/release metaTag()) or, without it, from /release.json when the script loads. 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;
@@@ -14,10 +14,11 @@
*/
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
++ // The page's release: from <meta name="ov-release"> when the server rendered one, otherwise the
++ // release /release.json reports when this script first runs (a site without the route stops).
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';
++ let current = meta && meta.content ? meta.content : null;
++ const url = (meta && meta.getAttribute('data-url')) || '/release.json';
const IDLE_MS = 2 * 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
let lastCheck = 0;
@@@ -62,6 +63,7 @@
} catch { return latest; }
if (!m || typeof m.release !== 'string') return latest;
latest = m;
++ if (!current) { current = m.release; root.OVRelease.current = current; return 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;
@@@ -71,10 +73,19 @@
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); } };
++ let stopped = false;
++ root.addEventListener('focus', () => { if (!stopped) check(false); });
++ root.addEventListener('online', () => { if (!stopped) check(true); });
++ document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) maybeReload(); else check(false); });
++ const tick = root.setInterval(() => { if (!stopped) { check(false); maybeReload(); } }, 30 * 1000);
++ const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
++ function stop() { stopped = true; root.clearInterval(tick); root.clearInterval(poll); }
++ root.OVRelease = { current, check: () => check(true), state: () => ({ current, latest, mustReload }), stop };
++ // No server-rendered release: learn it now; a site that serves no /release.json is left alone.
++ if (!current) {
++ root.fetch(url, { cache: 'no-store', credentials: 'omit' })
++ .then((r) => (r.ok ? r.json() : null))
++ .then((m) => { if (m && typeof m.release === 'string') { current = m.release; root.OVRelease.current = current; latest = m; lastCheck = Date.now(); } else stop(); })
++ .catch(stop);
++ }
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/release.test.js b/test/release.test.js
iindex b9b8bb3..2e6945c 100644
---- a/test/release.test.js
++++ b/test/release.test.js
@@@ -79,5 +79,39 @@ const { createRelease } = require('../release');
await p.ctx.OVRelease.check();
assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');
++ // Without a meta tag the first /release.json read is the baseline; a later release prompts.
++ {
++ let served = 'ccccccc';
++ const listeners = {}; const toasts = [];
++ const ctx = {
++ console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {},
++ location: { reload: () => {} }, addEventListener: (t, f) => { listeners[t] = f; },
++ OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },
++ fetch: async () => ({ ok: true, json: async () => ({ release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 }) }),
++ document: { hidden: true, activeElement: null, querySelector: () => null, addEventListener: () => {} },
++ };
++ ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
++ await new Promise((r) => setImmediate(r));
++ assert.strictEqual(ctx.OVRelease.current, 'ccccccc', 'baseline learned from /release.json');
++ await ctx.OVRelease.check();
++ assert.strictEqual(toasts.length, 0);
++ served = 'ddddddd';
++ await ctx.OVRelease.check();
++ assert.strictEqual(toasts.length, 1, 'a later release prompts');
++ }
++ {
++ let calls = 0;
++ const ctx = {
++ console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {}, location: { reload: () => {} }, addEventListener: () => {},
++ fetch: async () => { calls++; return { ok: false, json: async () => null }; },
++ document: { hidden: true, activeElement: null, querySelector: () => null, addEventListener: () => {} },
++ };
++ ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
++ await new Promise((r) => setImmediate(r));
++ assert.strictEqual(calls, 1, 'a site without /release.json is asked once, then left alone');
++ }
++
console.log('release: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });