openvibers/openvibe.shared Commits
1.1.1: release-watch learns its baseline from /release.json when the page has no ov-release meta
Changes
@@@ -4,6 +4,12 @@ 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.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-23Active-client update safety (ADR-016, contract `registry.release-manifest@1`). Additive only.@@@ -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": {@@@ -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);@@@ -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); });
@@ -4,6 +4,12 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 1.1.0 — 2026-09-23
Active-client update safety (ADR-016, contract `registry.release-manifest@1`). Additive only.
Active-client update safety (ADR-016, contract `registry.release-manifest@1`). Additive only.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
/*
/*
* openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016).
* 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:
* comes back and every 10 minutes, this reads /release.json:
* - same release: nothing;
* - same release: nothing;
* - newer release: one quiet "new version" toast with a Reload button, never an automatic reload;
* - newer release: one quiet "new version" toast with a Reload button, never an automatic reload;
@@ -14,10 +14,11 @@
@@ -14,10 +14,11 @@
*/
*/
(function (root) {
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
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"]');
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 IDLE_MS = 2 * 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
let lastCheck = 0;
let lastCheck = 0;
@@ -62,6 +63,7 @@
@@ -62,6 +63,7 @@
} catch { return latest; }
} catch { return latest; }
if (!m || typeof m.release !== 'string') return latest;
if (!m || typeof m.release !== 'string') return latest;
latest = m;
latest = m;
+ if (!current) { current = m.release; root.OVRelease.current = current; return m; }
if (m.release === current) return m;
if (m.release === current) return m;
const age = now - Date.parse(m.released_at);
const age = now - Date.parse(m.released_at);
const windowMs = (Number(m.mixed_version_window_hours) || 0) * 3600 * 1000;
const windowMs = (Number(m.mixed_version_window_hours) || 0) * 3600 * 1000;
@@ -71,10 +73,19 @@
@@ -71,10 +73,19 @@
return m;
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);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -79,5 +79,39 @@ const { createRelease } = require('../release');
@@ -79,5 +79,39 @@ const { createRelease } = require('../release');
await p.ctx.OVRelease.check();
await p.ctx.OVRelease.check();
assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');
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');
console.log('release: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });