openvibers/openvibe.shared Commits

v1.17.0: release-watch hears about releases over the Events realtime plane (WS-P task 9, ADR-016 amendment 1)

OP OpenVibers committed 26 Sept 2026 · 17a32e4adc · 8 files, +413 −19

OpenVibe.Host publishes host.deploy.activated (public, subject release; contract host.deploy.activated@1, openvibe-contracts 0.58.0) when a release goes live (ovhost deploy/rollback/announce). release-watch.js now listens for it, so an open tab checks /release.json within seconds instead of at its next poll:
- one EventSource per tab, without credentials, on https://events.openvibe.network/realtime/stream?topics=host.deploy.activated, opened once the page's release and service are known (OVReleaseConfig.service, the meta tag's data-service, else /release.json's service);
- generations: an event counts only for the page's service and a release the tab neither runs, nor knows from /release.json, nor has queued (a hex prefix of the other is the same release); a repeated event id or a seq at or below the last one seen is ignored;
- coalescing: a new release runs the usual check(true) after a random 0-20 s delay (tabs do not all fetch at once); whatever arrives while that check is pending collapses into it; after a check, 30 s of quiet, then one more check for whatever arrived meanwhile. The update plan, the prompt and the safe-reload rules are unchanged. An `event: gap` also runs a coalesced check;
- account switches: the events are public, so openvibe-auth-changed changes nothing, and a second load of the script still returns early: never a second EventSource;
- a tab hidden 5 minutes closes its stream (the poll and the visibility check cover it) and reopens on return with last_event_id; an error closes the stream at once (no browser retry every 3 s) and reopens after 30 s doubling to 15 min, jittered; after 6 failures in a row only polling is left until the browser is back online;
- polling is unchanged throughout; without EventSource, or with Events unreachable, nothing else changes. The default URL applies on https pages; OVReleaseConfig.eventsUrl or data-events sets another (false/"off" turns it off). OVRelease.state().realtime reports { state, service, url, events, ignored, checks, failures, lastSeq }.

release-watch.js is 4.6 KB brotli (was 3.2); its budget moves from 3.5 to 5 KB for this. release-compat openPage() takes `globals` (a fake EventSource, a Math with a fixed random) and adds timeouts(), fireTimeouts(filter), dispatch(type) and timeoutsSet. test/release-watch-realtime.test.js drives it with a fake EventSource (each rule was checked to fail when its line is removed). README "Releases" and CHANGELOG updated. Not tagged.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1c10513..959f26f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,27 @@ 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.17.0 — 2026-09-26
++
++**Release notifications in `release-watch.js`** (roadmap WS-P task 9, ADR-016 amendment 1). When a release goes live, OpenVibe.Host publishes `host.deploy.activated` to OpenVibe.Events (contract `host.deploy.activated@1`, openvibe-contracts 0.58.0; `ovhost deploy` and `ovhost announce`). The event is public, has subject `release` and carries `service`, `release`, `commit` and `origin`. Tabs now hear about a release within seconds instead of at their next poll:
++- **One stream per tab.** release-watch opens one EventSource, without credentials, on `https://events.openvibe.network/realtime/stream?topics=host.deploy.activated`, once the page's release and service are known.
++- **Which events count.** Only those for the page's service: `OVReleaseConfig.service`, the meta tag's `data-service`, else `/release.json`'s `service`.
++- **Generations.** The tab ignores the release it runs, the one it already knows from `/release.json` and one already queued (a hex prefix of the other counts as the same release). It also ignores a repeated event id and a seq at or below the last one seen.
++- **Coalescing.** A new release runs the usual `check(true)` after a random 0–20 s delay, so tabs do not all fetch at once. Everything arriving while that check is pending collapses into it, and after a check there are 30 s of quiet, after which whatever arrived meanwhile gets one more check. The update plan, the prompt and the safe-reload rules are unchanged.
++- **Gaps.** An `event: gap` (events missed while away) also runs a coalesced check.
++- **Account switches.** The events are public, so `openvibe-auth-changed` changes nothing, and a second load of the script still returns early, so there is never a second EventSource.
++- **Hidden tabs.** A tab hidden for 5 minutes closes its stream: the poll and the check on becoming visible cover it, and it reopens on return with `last_event_id`.
++- **Failures.** An error closes the stream at once, rather than letting the browser retry every 3 s, and it reopens after 30 s, doubling to 15 minutes, with jitter. After 6 failures in a row only polling is left until the browser comes back online.
++- **Polling.** Unchanged throughout (focus, visibility, `online`, every 10 minutes). Without EventSource, or when Events cannot be reached, nothing else changes.
++- **Configuration.** The default URL applies on https pages. `OVReleaseConfig.eventsUrl` or the meta tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Events answers `https://*.openvibe.*` origins and those in its `REALTIME_CORS_ORIGINS`; elsewhere the stream fails and backs off.
++- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
++
++`release-watch.js` is 4.6 KB brotli (was 3.2 KB). Its budget in `test/release-update.test.js` moves from 3.5 KB to 5 KB for this.
++
++`release-compat` `openPage()` also takes `globals` (extra window properties, such as a fake `EventSource` or a `Math` with a fixed `random`). It adds `timeouts()` (the pending delays), `fireTimeouts(filter)`, `dispatch(type)` and `timeoutsSet`.
++
++Tests are in `test/release-watch-realtime.test.js`. Additive.
++
## 1.16.1 — 2026-09-26
Fix: the **OV mark** (`ov-mark.js`) no longer animates forever. Its SVG animations restyle and re-lay-out on the main thread every frame, which kept every idle page with the navbar 12–29% busy (the browser check counted 8–19 endless animations per page). A mark now moves for the intro (the first 8 s of a visit in a tab, from the first time a mark is on screen; the next page of a multi-page site finishes that intro rather than starting another), while it or its link is hovered or focused (plus 1.2 s), and while it shows activity (`data-state` busy/ok from `island.js`). Otherwise its CSS animations are paused where they are and its SMIL clock is stopped, so it keeps its look. It never moves while the page is hidden, while it is off-screen (IntersectionObserver), with `data-static`, under `prefers-reduced-motion` or under the network's own reduced-motion setting (`html[data-ov-motion=reduced]`); the V's light sweep, a SMIL animation, used to keep running in all of those. Measured in headless Chrome after five page loads: 12 running animations and 16.5% CPU before, none and 0% after. `test/ov-mark.test.js`. Sites pick it up with their `openvibe-shared` pin (the file is `/shared/ov-mark.js`); pages that load it from openvibe.network get it when Network's pin moves.
ddiff --git a/README.md b/README.md
iindex 7108d1e..98bacb6 100644
---- a/README.md
++++ b/README.md
@@@ -192,6 +192,26 @@ 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`.
++- **Release notifications (1.17.0).** When OpenVibe.Host announces a release, it publishes
++ `host.deploy.activated` (public, `ovhost deploy` / `ovhost announce`). release-watch opens one
++ EventSource per tab, without credentials, on
++ `https://events.openvibe.network/realtime/stream?topics=host.deploy.activated`.
++ - **Which events count.** Only events whose `payload.service` is the page's service: `OVReleaseConfig.service`,
++ the meta tag's `data-service`, else the `service` of `/release.json`. The tab ignores the release it runs
++ or already knows (a hex prefix counts as the same release) and repeats of an event, and runs its usual
++ check once, after a random 0–20 s delay.
++ - **Bursts.** At most one check per 30 s; anything that arrives in between collapses into one more.
++ - **Account changes.** The events are public, so an account switch changes nothing and never opens a
++ second stream.
++ - **Hidden tabs.** A tab hidden for 5 minutes closes its stream, and reopens it on return with
++ `last_event_id`.
++ - **Failures.** Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,
++ until the browser goes back online.
++ - **Where to connect.** The default applies on https pages only. `OVReleaseConfig.eventsUrl` or the meta
++ tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,
++ every 10 minutes) is unchanged.
++ - **Origins.** Events answers `https://*.openvibe.*` origins, plus those in its `REALTIME_CORS_ORIGINS`.
++ - **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,
`reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`
(set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:
@@@ -211,7 +231,9 @@ await compat.assertMixedVersion({ releases: [
It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call
compatible fails against the real server, and when an incompatible pair would not make the tab
reload. `compat.openPage({ html, serve })` runs release-watch in a linkedom page (install
--`linkedom` as a devDependency) for in-place tests of your own markup.
++`linkedom` as a devDependency) for in-place tests of your own markup. `globals` adds window properties,
++such as a fake `EventSource`, and `timeouts()`, `fireTimeouts(filter)` and `dispatch(type)` drive the
++page's timers and events.
### Analytics (server, ADR-021)
@@@ -290,7 +312,7 @@ node scripts/build-theme-loader.js
Tests are plain Node with stubbed browser globals. Some tests guard the release:
- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`). release-watch.js and
-- release-update.js: 3.5 KB each (`test/release-update.test.js`).
++ release-update.js: 5 KB and 3.5 KB (`test/release-update.test.js`).
- **Releases (ADR-016)**: the manifest validates against the installed openvibe-contracts (and
the 1.1.0 schema), in-place updates are transactional and never replace a region in use, the
reload rules hold, and the mixed-version matrix runs against real servers
ddiff --git a/package-lock.json b/package-lock.json
iindex 97ccd01..22559c2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.16.1",
++ "version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.16.1",
++ "version": "1.17.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex a87fab7..4acf03f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.16.1",
++ "version": "1.17.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/release-compat.js b/release-compat.js
iindex 326abfb..19139c8 100644
---- a/release-compat.js
++++ b/release-compat.js
@@@ -167,16 +167,18 @@ function toResponse(out) {
* A page at `url` rendered from `html`, running release-watch.js. `serve(url, init)` answers every fetch
* the scripts make (return { json } | { body } | { status } | a Response). Options: config
* (OVReleaseConfig), updateScript: 'lazy' (default: loaded when release-watch asks) | 'fail', hidden,
-- * protectedFn (window.OVProtected).
-- * Returns controls to drive it: settle(), settleStyles(ok), focus(el), setHidden(bool), tick(), and what
-- * it did: reloads, beacons, toasts, requests.
++ * protectedFn (window.OVProtected), globals (extra window properties, e.g. a fake EventSource or a Math
++ * with a fixed random()).
++ * Returns controls to drive it: settle(), settleStyles(ok), focus(el), setHidden(bool), tick(),
++ * timeouts() (the pending setTimeout delays), fireTimeouts(filter?), dispatch(type), and what it did:
++ * reloads, beacons, toasts, requests, timeoutsSet.
*/
--async function openPage({ url = 'https://site.test/', html, serve, config = null, updateScript = 'lazy', protectedFn = null, hidden: startHidden = false } = {}) {
++async function openPage({ url = 'https://site.test/', html, serve, config = null, updateScript = 'lazy', protectedFn = null, hidden: startHidden = false, globals = null } = {}) {
let linkedom;
try { linkedom = require('linkedom'); } catch { throw new Error('openPage needs linkedom (npm i -D linkedom)'); }
const { window: lw, document } = linkedom.parseHTML(html || '<!doctype html><html><head></head><body></body></html>');
const origin = new URL(url).origin;
-- const out = { reloads: 0, beacons: [], toasts: [], requests: [], intervals: [] };
++ const out = { reloads: 0, beacons: [], toasts: [], requests: [], intervals: [], timeoutsSet: 0 };
const timers = new Map(); let tid = 0;
let focused = null; let hidden = !!startHidden;
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => focused || document.body });
@@@ -191,7 +193,7 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) },
setInterval: (f) => { out.intervals.push(f); return out.intervals.length; },
clearInterval: () => {},
-- setTimeout: (f) => { timers.set(++tid, f); return tid; },
++ setTimeout: (f, ms) => { out.timeoutsSet++; timers.set(++tid, { f, ms: Number(ms) || 0 }); return tid; },
clearTimeout: (id) => { timers.delete(id); },
addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
removeEventListener: () => {},
@@@ -205,6 +207,7 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
};
if (config) ctx.OVReleaseConfig = config;
if (protectedFn) ctx.OVProtected = protectedFn;
++ if (globals) Object.assign(ctx, globals);
ctx.window = ctx; ctx.globalThis = ctx;
vm.createContext(ctx);
// A script element release-watch appends is "loaded": release-update.js runs, then onload fires.
@@@ -227,6 +230,8 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
const page = {
window: ctx, document, ...out,
get reloads() { return out.reloads; },
++ /** How many timeouts the page has set so far. */
++ get timeoutsSet() { return out.timeoutsSet; },
get release() { return ctx.OVRelease; },
/** The tab's outcome counts so far (a plain copy). */
metrics() { return JSON.parse(JSON.stringify(ctx.OVRelease ? ctx.OVRelease.state().metrics : {})); },
@@@ -241,8 +246,17 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
}
return n;
},
-- /** Runs the timeouts still pending (release-update's 15 s stylesheet timeout). */
-- fireTimeouts() { const t = [...timers.values()]; timers.clear(); t.forEach((f) => f()); return t.length; },
++ /** Runs the timeouts still pending (release-update's 15 s stylesheet timeout), or those whose delay `filter(ms)` accepts. */
++ fireTimeouts(filter = null) {
++ const due = [...timers].filter(([, x]) => !filter || filter(x.ms));
++ for (const [id] of due) timers.delete(id);
++ due.forEach(([, x]) => x.f());
++ return due.length;
++ },
++ /** The delays (ms) of the timeouts still pending, in the order they were set. */
++ timeouts() { return [...timers.values()].map((x) => x.ms); },
++ /** Dispatches a plain event of `type` on the window (e.g. openvibe-auth-changed, online). */
++ dispatch(type) { ctx.dispatchEvent(new ctx.Event(type)); },
focus(el) { focused = el; },
blur() { focused = null; },
setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },
ddiff --git a/release-watch.js b/release-watch.js
iindex 4805d60..93f745b 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -6,7 +6,11 @@
* (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).
-- * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false }. See README "Releases".
++ * Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream
++ * (topic host.deploy.activated, 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.
++ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
++ * tag may carry data-service and data-events. See README "Releases".
*/
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
@@@ -158,26 +162,105 @@
if (!current) current = m.release;
if (!base && m.release === current) base = m;
consider(m);
++ live();
if (busyWith) await busyWith;
return m;
}
++ // ── Release notifications: OpenVibe.Host publishes host.deploy.activated (public) to OpenVibe.Events ──
++ // One EventSource per tab, without credentials: the events are public, so an account switch changes
++ // nothing (and a second load of this script returns early). Closed after 5 min hidden (the poll and the
++ // visibility check cover a hidden tab), reopened on return with last_event_id; errors back off 30 s to
++ // 15 min, and after 6 failures in a row only polling is left.
++ const TOPIC = 'host.deploy.activated';
++ const HEX = /^[0-9a-f]{7,40}$/;
++ const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
++ let es = null; let queued = null; let again = false; let seen = [];
++ const t = {}; // timers: run (the jittered check), cool (30 s after it), retry, hide
++ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
++ const same = (a, b) => !!a && !!b && (a === b || (HEX.test(a) && HEX.test(b) && (a.startsWith(b) || b.startsWith(a))));
++ function eventsUrl() {
++ const v = 'eventsUrl' in cfg ? cfg.eventsUrl : meta && meta.hasAttribute('data-events') ? meta.getAttribute('data-events') : undefined;
++ if (v !== undefined) return v && v !== 'off' ? String(v) : null;
++ try { return new URL(root.location.href).protocol === 'https:' ? 'https://events.openvibe.network/realtime/stream' : null; } catch { return null; }
++ }
++ function live() {
++ if (stopped || es || rt.state !== 'off' || typeof root.EventSource !== 'function') return;
++ const svc = cfg.service || (meta && meta.getAttribute('data-service')) || (base && base.service) || (latest && latest.service);
++ const at = eventsUrl();
++ if (!at || !/^[a-z][a-z0-9-]{1,39}$/.test(String(svc || ''))) return;
++ rt.service = svc; rt.url = at;
++ open();
++ if (document.hidden) later('hide', hide, 5 * 60 * 1000);
++ }
++ function open() {
++ let u;
++ try { u = new URL(rt.url, root.location.href); u.searchParams.set('topics', TOPIC); if (rt.lastSeq != null) u.searchParams.set('last_event_id', rt.lastSeq); } catch { rt.state = 'failed'; return; }
++ rt.state = 'connecting';
++ let s;
++ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
++ s.onopen = () => { if (es === s) { rt.state = 'open'; rt.failures = 0; } };
++ s.onmessage = (e) => { if (es === s) heard(e); };
++ s.onerror = () => { if (es === s) fail(); };
++ s.addEventListener('gap', () => { if (es === s) queue(); }); // events were missed: check anyway
++ }
++ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
++ function fail() {
++ close();
++ if (++rt.failures >= 6) { rt.state = 'failed'; return; }
++ rt.state = 'backoff';
++ later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
++ }
++ function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
++ function heard(e) {
++ let m; try { m = JSON.parse(e.data); } catch { return; }
++ const ev = m && m.event; const p = ev && ev.payload;
++ 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;
++ 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; }
++ seen = seen.concat(ev.event_id).slice(-20);
++ 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. */
++ function queue() {
++ if (t.run) return;
++ if (t.cool) { again = true; return; }
++ later('run', () => {
++ const q = queued;
++ rt.checks++;
++ later('cool', () => { if (again) { again = false; queue(); } }, 30 * 1000);
++ if (!stopped) check(true).then(() => { if (queued === q) queued = null; });
++ }, Math.floor(Math.random() * 20 * 1000));
++ }
++
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(false); });
-- root.addEventListener('online', () => { if (!stopped) check(true); });
++ root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });
root.addEventListener('pagehide', flush);
-- document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) { flush(); maybeReload(); } else check(false); });
++ document.addEventListener('visibilitychange', () => {
++ if (stopped) return;
++ if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
++ root.clearTimeout(t.hide); t.hide = null;
++ if (rt.state === 'hidden') open();
++ check(false);
++ });
const tick = root.setInterval(() => {
if (stopped) return;
if (pending && commitRegions(pending)) adopt(pending);
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); }
++ function stop() {
++ stopped = true; root.clearInterval(tick); root.clearInterval(poll);
++ close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); rt.state = 'off';
++ }
root.OVRelease = {
get current() { return current; },
check: () => check(true),
-- state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals }),
++ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }),
flush,
stop,
};
@@@ -191,6 +274,7 @@
if (!current) current = m.release;
if (m.release === current) base = m;
consider(m);
++ live();
})
.catch(() => { if (!current) stop(); });
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex 489490e..3a94dec 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -65,10 +65,12 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// Size: release-watch.js loads on every page after first paint; release-update.js only when a release
// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
++// 2026-09-26 (1.17.0): release-watch 4.6 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.
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
-- assert.ok(size('release-watch.js') <= 3.5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
++ assert.ok(size('release-watch.js') <= 5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 5 KB`);
assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
}
ddiff --git a/test/release-watch-realtime.test.js b/test/release-watch-realtime.test.js
nnew file mode 100644
iindex 0000000..419b265
---- /dev/null
++++ b/test/release-watch-realtime.test.js
@@@ -0,0 +1,251 @@
++'use strict';
++// release-watch.js release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events
++// realtime stream (topic host.deploy.activated); an event for this page's service with a new release runs
++// the ordinary check after a 0-20 s jitter, bursts collapse into one check per 30 s, the release the tab
++// runs or already knows is ignored, errors back off, hidden tabs close the stream, stop() closes it, and
++// polling is unchanged. A fake EventSource stands in for the browser's.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { openPage, manifestFor } = require('../release-compat');
++
++const A = 'aaaaaaa1'; const B = 'bbbbbbb2'; const C = 'ccccccc3';
++const DEFAULT = 'https://events.openvibe.network/realtime/stream';
++const PAGE = (attrs = '') => `<!doctype html><html><head><meta name="ov-release" content="${A}" data-url="/release.json"${attrs}></head><body></body></html>`;
++
++function fakeEventSource() {
++ const all = [];
++ class FakeEventSource {
++ constructor(url, init) { this.url = url; this.init = init; this.closed = false; this.listeners = {}; all.push(this); }
++ addEventListener(type, f) { (this.listeners[type] = this.listeners[type] || []).push(f); }
++ close() { this.closed = true; }
++ opened() { if (this.onopen) this.onopen({}); }
++ send(seq, event) { if (this.onmessage) this.onmessage({ data: JSON.stringify({ seq, event }), lastEventId: String(seq) }); }
++ emit(type, data) { (this.listeners[type] || []).forEach((f) => f({ data: JSON.stringify(data) })); }
++ fail() { if (this.onerror) this.onerror({}); }
++ }
++ return { FakeEventSource, all };
++}
++
++let n = 0;
++const released = (service, release, extra = {}) => ({
++ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++n).padStart(3, '0')}`, event_type: 'host.deploy.activated', version: 1, source: 'host',
++ visibility: 'public', subject: { type: 'release', id: `${service}:${release}` },
++ payload: { service, release, commit: null, origin: 'https://openvibe.live', deployed_at: '2026-09-26T08:00:00.000Z' }, ...extra,
++});
++
++async function open({ url = 'https://openvibe.live/', html = PAGE(), config = null, hidden = false, eventSource = true, random = 0.5, served = manifestFor({ service: 'live', release: A }) } = {}) {
++ const es = fakeEventSource();
++ const state = { served };
++ const globals = { Math: Object.assign(Object.create(Math), { random: () => random }) };
++ if (eventSource) globals.EventSource = es.FakeEventSource;
++ const p = await openPage({
++ url, html, hidden, config, globals,
++ serve: (u) => (new URL(u).pathname === '/release.json' && state.served ? { json: state.served } : { status: 404 }),
++ });
++ await p.settle();
++ p.es = es; p.state = state;
++ p.fetches = () => p.requests.filter((r) => r.url.endsWith('/release.json')).length;
++ p.rt = () => p.release.state().realtime;
++ return p;
++}
++
++(async () => {
++ // ── One stream, anonymous, on the default URL, once the page's release and service are known ──
++ {
++ const p = await open();
++ assert.strictEqual(p.es.all.length, 1);
++ const s = p.es.all[0];
++ assert.strictEqual(s.url, `${DEFAULT}?topics=host.deploy.activated`);
++ assert.strictEqual(s.init, undefined, 'no withCredentials: public events need no account');
++ assert.strictEqual(p.rt().state, 'connecting');
++ s.opened();
++ assert.deepStrictEqual([p.rt().state, p.rt().service], ['open', 'live']);
++ assert.deepStrictEqual(p.timeouts(), [], 'nothing scheduled before an event');
++ assert.strictEqual(p.intervals.length, 2, 'the 30 s tick and the 10 min poll stay');
++
++ // Other services, tenant activations, the running release (or a hex prefix of it), other topics.
++ s.send(1, released('tools', B));
++ s.send(2, { ...released('live', B), payload: { project_id: 'prj_x', site_id: 'site_x', site: 'x', deploy_id: 'dpl_x', previous_deploy_id: null, rollback: false } });
++ s.send(3, released('live', A));
++ s.send(4, released('live', 'aaaaaaa'));
++ s.send(5, { ...released('live', B), event_type: 'live.stream.started' });
++ assert.deepStrictEqual(p.timeouts(), [], 'none of those schedules a check');
++ assert.deepStrictEqual([p.rt().events, p.rt().ignored], [2, 2]);
++
++ // A new release: one check after the jitter (random 0.5 → 10 s); a burst collapses into it.
++ const before = p.fetches();
++ s.send(6, released('live', B));
++ assert.deepStrictEqual(p.timeouts(), [10000]);
++ const set = p.timeoutsSet;
++ s.send(7, released('live', B));
++ s.send(8, released('live', C));
++ s.send(8, released('live', 'ddddddd4')); // a replayed seq is dropped before anything else
++ assert.deepStrictEqual(p.timeouts(), [10000], 'the burst collapses into the one pending check');
++ assert.strictEqual(p.timeoutsSet, set, 'and does not push it back');
++ assert.strictEqual(p.fetches(), before, 'nothing fetched before the jitter ends');
++ p.state.served = manifestFor({ service: 'live', release: B });
++ assert.strictEqual(p.fireTimeouts((ms) => ms === 10000), 1);
++ await p.settle();
++ assert.strictEqual(p.fetches(), before + 1, 'exactly one /release.json fetch');
++ assert.strictEqual(p.toasts.length, 1, 'the ordinary path: a new release without components prompts');
++ assert.strictEqual(p.rt().checks, 1);
++ assert.deepStrictEqual(p.timeouts(), [30000], 'then 30 s of quiet');
++
++ // During the quiet: the release it now knows (B) and the one it runs (A, still: the prompt waits
++ // for the person) are ignored; a newer one waits for the quiet to end.
++ const ignored = p.rt().ignored;
++ s.send(9, released('live', B));
++ s.send(11, released('live', A));
++ assert.strictEqual(p.rt().ignored, ignored + 2);
++ assert.strictEqual(p.release.current, A);
++ assert.deepStrictEqual(p.timeouts(), [30000]);
++ s.send(12, released('live', C));
++ assert.deepStrictEqual(p.timeouts(), [30000], 'no second check inside 30 s');
++ p.fireTimeouts((ms) => ms === 30000);
++ assert.deepStrictEqual(p.timeouts(), [10000], 'one more check after the quiet');
++ p.state.served = manifestFor({ service: 'live', release: C });
++ p.fireTimeouts((ms) => ms === 10000);
++ await p.settle();
++ assert.strictEqual(p.fetches(), before + 2);
++ assert.strictEqual(p.release.state().latest.release, C);
++
++ // A repeated event id is ignored even with a higher seq (and after its check found nothing new).
++ const e = released('live', 'eeeeeee5');
++ p.fireTimeouts();
++ s.send(20, e);
++ p.fireTimeouts((ms) => ms === 10000); await p.settle(); // the server still says C
++ p.fireTimeouts();
++ const ign = p.rt().ignored;
++ s.send(21, e);
++ assert.strictEqual(p.rt().ignored, ign + 1, 'the same event id twice is one event');
++ assert.deepStrictEqual(p.timeouts(), []);
++
++ // A gap (events missed while away) checks too, through the same coalescing.
++ p.fireTimeouts(); await p.settle();
++ s.emit('gap', { reason: 'public_window' });
++ assert.strictEqual(p.timeouts().length, 1);
++
++ // An account switch changes nothing: still one stream. A second copy of the script is a no-op.
++ p.dispatch('openvibe-auth-changed');
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), p.window);
++ assert.strictEqual(p.es.all.length, 1, 'never a second EventSource');
++
++ // stop(): the stream closes, every timer goes, and a late message does nothing.
++ p.release.stop();
++ assert.strictEqual(s.closed, true);
++ assert.deepStrictEqual(p.timeouts(), []);
++ assert.strictEqual(p.rt().state, 'off');
++ s.send(30, released('live', 'fffffff6'));
++ s.fail();
++ assert.deepStrictEqual(p.timeouts(), [], 'a closed stream\'s late message or error does nothing');
++ assert.strictEqual(p.es.all.length, 1);
++ }
++
++ // ── Errors back off (30 s doubling, jittered), resume with last_event_id, and give up after 6 ──
++ {
++ const p = await open({ random: 0 });
++ const s0 = p.es.all[0];
++ s0.opened();
++ s0.send(41, released('tools', B));
++ s0.fail();
++ assert.strictEqual(s0.closed, true, 'closed at once, so the browser does not retry every 3 s on its own');
++ assert.strictEqual(p.rt().state, 'backoff');
++ assert.deepStrictEqual(p.timeouts(), [15000], 'the first retry after 15-30 s');
++ p.fireTimeouts();
++ assert.strictEqual(p.es.all.length, 2);
++ assert.strictEqual(p.es.all[1].url, `${DEFAULT}?topics=host.deploy.activated&last_event_id=41`, 'resumes after the last seq seen');
++ const waits = [];
++ for (let i = 1; i < 6; i++) {
++ p.es.all[i].fail();
++ waits.push(...p.timeouts());
++ p.fireTimeouts();
++ }
++ assert.deepStrictEqual(waits, [30000, 60000, 120000, 240000], 'doubling, then no retry after the 6th failure');
++ assert.strictEqual(p.rt().state, 'failed');
++ assert.strictEqual(p.es.all.length, 6);
++ const before = p.fetches();
++ p.tick(); await p.settle();
++ assert.ok(p.fetches() >= before, 'polling goes on');
++ assert.strictEqual(p.intervals.length, 2);
++ p.dispatch('online');
++ assert.strictEqual(p.es.all.length, 7, 'back online: one more try');
++ p.es.all[6].opened();
++ assert.strictEqual(p.rt().failures, 0, 'an open stream resets the count');
++ p.release.stop();
++ }
++
++ // ── A tab hidden for 5 minutes closes its stream; it reopens on return ──
++ {
++ const p = await open();
++ const s = p.es.all[0];
++ s.opened();
++ s.send(50, released('tools', B));
++ p.setHidden(true);
++ assert.deepStrictEqual(p.timeouts(), [300000]);
++ p.setHidden(false);
++ assert.deepStrictEqual(p.timeouts(), [], 'back within 5 minutes: nothing changes');
++ p.setHidden(true);
++ p.fireTimeouts((ms) => ms === 300000);
++ assert.deepStrictEqual([s.closed, p.rt().state], [true, 'hidden']);
++ p.setHidden(false);
++ assert.strictEqual(p.es.all.length, 2);
++ assert.match(p.es.all[1].url, /last_event_id=50$/);
++ p.release.stop();
++
++ const bg = await open({ hidden: true });
++ assert.strictEqual(bg.es.all.length, 1, 'a page opened in the background still listens');
++ assert.deepStrictEqual(bg.timeouts(), [300000], 'for 5 minutes');
++ bg.release.stop();
++ }
++
++ // ── Where the stream comes from, and when there is none ──
++ {
++ const off = await open({ config: { eventsUrl: false } });
++ assert.strictEqual(off.es.all.length, 0, 'eventsUrl: false turns it off');
++ assert.strictEqual(off.rt().state, 'off');
++ off.release.stop();
++
++ const metaUrl = await open({ html: PAGE(' data-events="https://events.example.test/realtime/stream" data-service="docs"') });
++ assert.strictEqual(metaUrl.es.all[0].url, 'https://events.example.test/realtime/stream?topics=host.deploy.activated');
++ assert.strictEqual(metaUrl.rt().service, 'docs', 'data-service names the service');
++ metaUrl.release.stop();
++
++ const metaOff = await open({ html: PAGE(' data-events="off"') });
++ assert.strictEqual(metaOff.es.all.length, 0);
++ metaOff.release.stop();
++
++ const cfgService = await open({ config: { service: 'tools', eventsUrl: '/realtime/stream' } });
++ assert.strictEqual(cfgService.es.all[0].url, 'https://openvibe.live/realtime/stream?topics=host.deploy.activated');
++ cfgService.es.all[0].send(1, released('tools', B));
++ assert.deepStrictEqual(cfgService.timeouts(), [10000], 'OVReleaseConfig.service decides which events count');
++ cfgService.release.stop();
++
++ const local = await open({ url: 'http://localhost:3000/' });
++ assert.strictEqual(local.es.all.length, 0, 'no default stream on a plain-http page');
++ local.release.stop();
++ const localSet = await open({ url: 'http://localhost:3000/', config: { eventsUrl: 'http://localhost:4300/realtime/stream' } });
++ assert.strictEqual(localSet.es.all.length, 1, 'unless it is configured');
++ localSet.release.stop();
++
++ const noES = await open({ eventSource: false });
++ assert.strictEqual(noES.rt().state, 'off', 'no EventSource: polling only');
++ noES.state.served = manifestFor({ service: 'live', release: B });
++ await noES.release.check(); await noES.settle();
++ assert.strictEqual(noES.toasts.length, 1, 'and the check path still works');
++ noES.release.stop();
++
++ const none = await open({ html: '<!doctype html><html><head></head><body></body></html>', served: null });
++ assert.strictEqual(none.es.all.length, 0, 'a site that serves no /release.json and has no meta is left alone');
++
++ const lateService = await open({ served: null });
++ assert.strictEqual(lateService.es.all.length, 0, 'service unknown yet: no stream');
++ lateService.state.served = manifestFor({ service: 'live', release: A });
++ await lateService.release.check(); await lateService.settle();
++ assert.strictEqual(lateService.es.all.length, 1, 'opened once a check learns the service');
++ lateService.release.stop();
++ }
++
++ console.log('release-watch realtime: ok');
++})().catch((err) => { console.error(err); process.exit(1); });