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)
Changes
@@@ -4,6 +4,27 @@ 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.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-26Fix: 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.@@@ -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 callcompatible fails against the real server, and when an incompatible pair would not make the tabreload. `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.jsTests 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 (andthe 1.1.0 schema), in-place updates are transactional and never replace a region in use, thereload rules hold, and the mixed-version matrix runs against real servers@@@ -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"@@@ -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": {@@@ -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 = nullOpenVibeUI: { 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 = nullconst 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')); },@@@ -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);@@@ -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`);}nnew file mode 100644@@@ -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); });
@@ -4,6 +4,27 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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.
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.
@@ -192,6 +192,26 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -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`.
`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"`.
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`.
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`,
- **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`
`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:
(set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:
@@ -211,7 +231,9 @@ await compat.assertMixedVersion({ releases: [
@@ -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
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
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
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)
### Analytics (server, ADR-021)
@@ -290,7 +312,7 @@ node scripts/build-theme-loader.js
@@ -290,7 +312,7 @@ node scripts/build-theme-loader.js
Tests are plain Node with stubbed browser globals. Some tests guard the release:
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
- **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
- **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
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
reload rules hold, and the mixed-version matrix runs against real servers
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.16.1",
+ "version": "1.17.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.16.1",
+ "version": "1.17.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -167,16 +167,18 @@ function toResponse(out) {
@@ -167,16 +167,18 @@ function toResponse(out) {
* A page at `url` rendered from `html`, running release-watch.js. `serve(url, init)` answers every fetch
* 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
* the scripts make (return { json } | { body } | { status } | a Response). Options: config
* (OVReleaseConfig), updateScript: 'lazy' (default: loaded when release-watch asks) | 'fail', hidden,
* (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;
let linkedom;
try { linkedom = require('linkedom'); } catch { throw new Error('openPage needs linkedom (npm i -D 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 { window: lw, document } = linkedom.parseHTML(html || '<!doctype html><html><head></head><body></body></html>');
const origin = new URL(url).origin;
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;
const timers = new Map(); let tid = 0;
let focused = null; let hidden = !!startHidden;
let focused = null; let hidden = !!startHidden;
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => focused || document.body });
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
@@ -191,7 +193,7 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) },
OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) },
setInterval: (f) => { out.intervals.push(f); return out.intervals.length; },
setInterval: (f) => { out.intervals.push(f); return out.intervals.length; },
clearInterval: () => {},
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); },
clearTimeout: (id) => { timers.delete(id); },
addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
removeEventListener: () => {},
removeEventListener: () => {},
@@ -205,6 +207,7 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
@@ -205,6 +207,7 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
};
};
if (config) ctx.OVReleaseConfig = config;
if (config) ctx.OVReleaseConfig = config;
if (protectedFn) ctx.OVProtected = protectedFn;
if (protectedFn) ctx.OVProtected = protectedFn;
+ if (globals) Object.assign(ctx, globals);
ctx.window = ctx; ctx.globalThis = ctx;
ctx.window = ctx; ctx.globalThis = ctx;
vm.createContext(ctx);
vm.createContext(ctx);
// A script element release-watch appends is "loaded": release-update.js runs, then onload fires.
// 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
@@ -227,6 +230,8 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
const page = {
const page = {
window: ctx, document, ...out,
window: ctx, document, ...out,
get reloads() { return out.reloads; },
get reloads() { return out.reloads; },
+ /** How many timeouts the page has set so far. */
+ get timeoutsSet() { return out.timeoutsSet; },
get release() { return ctx.OVRelease; },
get release() { return ctx.OVRelease; },
/** The tab's outcome counts so far (a plain copy). */
/** The tab's outcome counts so far (a plain copy). */
metrics() { return JSON.parse(JSON.stringify(ctx.OVRelease ? ctx.OVRelease.state().metrics : {})); },
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
@@ -241,8 +246,17 @@ async function openPage({ url = 'https://site.test/', html, serve, config = null
}
}
return n;
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; },
focus(el) { focused = el; },
blur() { focused = null; },
blur() { focused = null; },
setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },
setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },
@@ -6,7 +6,11 @@
@@ -6,7 +6,11 @@
* (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
* (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
* 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).
* 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) {
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
if (typeof document === 'undefined' || root.OVRelease) return;
@@ -158,26 +162,105 @@
@@ -158,26 +162,105 @@
if (!current) current = m.release;
if (!current) current = m.release;
if (!base && m.release === current) base = m;
if (!base && m.release === current) base = m;
consider(m);
consider(m);
+ live();
if (busyWith) await busyWith;
if (busyWith) await busyWith;
return m;
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;
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(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);
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(() => {
const tick = root.setInterval(() => {
if (stopped) return;
if (stopped) return;
if (pending && commitRegions(pending)) adopt(pending);
if (pending && commitRegions(pending)) adopt(pending);
check(false); maybeReload();
check(false); maybeReload();
}, 30 * 1000);
}, 30 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 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 = {
root.OVRelease = {
get current() { return current; },
get current() { return current; },
check: () => check(true),
check: () => check(true),
- state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals }),
+ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }),
flush,
flush,
stop,
stop,
};
};
@@ -191,6 +274,7 @@
@@ -191,6 +274,7 @@
if (!current) current = m.release;
if (!current) current = m.release;
if (m.release === current) base = m;
if (m.release === current) base = m;
consider(m);
consider(m);
+ live();
})
})
.catch(() => { if (!current) stop(); });
.catch(() => { if (!current) stop(); });
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -65,10 +65,12 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -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
// 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).
// 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 fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
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`);
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`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -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); });