openvibers/openvibe.shared release-watch.js

release-watch.js

main 18.1 KB · 322 lines · javascript

1/*
2 * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).
3 * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract
4 * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server
5 * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or
6 * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
7 * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
8 * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
9 * An optional release applies itself after 30 s hidden with nothing protected, at the same scroll position (4.16.0).
10 * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, autoApply: false, service, eventsUrl (false: off) }; the meta
11 * tag may carry data-service, data-events and data-generation. See README "Releases".
12 */
13(function (root) {
14 if (typeof document === 'undefined' || root.OVRelease) return;
15 const cfg = root.OVReleaseConfig || {};
16 const self = (document.currentScript && document.currentScript.src) || '';
17 const SELF_RE = /release-watch\.js(?:[?#].*)?$/;
18 const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;
19 const meta = document.querySelector('meta[name="ov-release"]');
20 let current = (meta && meta.content) || null;
21 const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined;
22 const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';
23 const IDLE_MS = 2 * 60 * 1000;
24 const MIN_GAP_MS = 60 * 1000;
25 let base = null; // the manifest of the release this page runs
26 let latest = null;
27 let lastCheck = 0;
28 let lastInput = Date.now();
29 let prompted = false;
30 let mustReload = false; let reloadWhy = null;
31 let busyWith = null; // the plan/apply in flight
32 let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null; let acct = 0;
33 let counts = {}; const totals = {}; const noted = {};
34 let session = null; let lastBeat = 0;
35 try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }
36
37 ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
38
39 // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ──
40 function record(outcome, reason, once) {
41 const k = `${latest && latest.release}|${outcome}|${reason}`;
42 if (once) { if (noted[k]) return; noted[k] = 1; }
43 for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
44 }
45 // beat: send even with nothing counted; ended: the tab is going
46 function flush(beat, ended) {
47 const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
48 const any = Object.keys(counts).length > 0;
49 if (!to || (!any && !(beat === true && session && current))) return;
50 try {
51 if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
52 const report = { service: latest && latest.service, release: current, to: latest && latest.release };
53 if (any) report.counts = counts;
54 if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }
55 const body = JSON.stringify(report);
56 counts = {};
57 const n = root.navigator;
58 if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
59 } catch { /* best effort */ }
60 }
61
62 /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */
63 function busy(scope) {
64 const d = scope || document;
65 const a = document.activeElement;
66 const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''));
67 if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active';
68 if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty';
69 if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected';
70 // Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.
71 try {
72 for (const m of d.querySelectorAll('video, audio')) {
73 if (!m.paused && !m.ended) return 'media';
74 const so = m.srcObject;
75 if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture';
76 }
77 } catch { return 'media'; }
78 if (scope) return null;
79 try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; }
80 return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;
81 }
82
83 const SCROLL_KEY = 'ov-release-scroll';
84 const here = () => { try { const u = new URL(root.location.href); return u.pathname + u.search; } catch { return ''; } };
85 function reload(why) {
86 record('reloaded', why); flush();
87 try { root.sessionStorage.setItem(SCROLL_KEY, JSON.stringify({ path: here(), y: Math.round(root.scrollY || 0), at: Date.now() })); } catch { /* */ }
88 root.location.reload();
89 }
90 try {
91 const saved = JSON.parse(root.sessionStorage.getItem(SCROLL_KEY) || 'null');
92 root.sessionStorage.removeItem(SCROLL_KEY);
93 if (saved && saved.y > 0 && saved.path === here() && Date.now() - saved.at < 10 * 60 * 1000) {
94 const back = () => root.scrollTo(0, saved.y);
95 if (document.readyState === 'complete') back(); else root.addEventListener('load', () => root.setTimeout(back, 50), { once: true });
96 }
97 } catch { /* */ }
98 let optional = false;
99 function applyOptional() {
100 if (!optional || mustReload || cfg.autoApply === false || !document.hidden) return;
101 const why = busy();
102 if (why) { record('deferred', why, true); return; }
103 reload('hidden');
104 }
105 function prompt() {
106 if (prompted) return;
107 prompted = true;
108 record('prompted', mustReload ? reloadWhy : 'optional');
109 if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
110 root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
111 }
112 }
113 function maybeReload() {
114 if (!mustReload) return;
115 const why = busy();
116 if (why) record('deferred', why, true); else reload(reloadWhy);
117 }
118 function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } }
119
120 function loadUpdate() {
121 if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate);
122 return lib || (lib = new Promise((ok, no) => {
123 const sc = document.createElement('script');
124 sc.src = updateUrl; sc.async = true;
125 sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script'));
126 sc.onerror = () => { lib = null; no('script'); };
127 document.head.appendChild(sc);
128 }));
129 }
130 /** Without release-update.js: the release id, min_client_release and the window only. */
131 function legacy(m, pg) {
132 const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;
133 if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' };
134 return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };
135 }
136 function commitRegions(next) {
137 if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record);
138 return !waiting.length;
139 }
140 function adopt(next) {
141 base = next; current = next.release; pending = null; mustReload = false;
142 record('applied', kinds || 'server');
143 flush();
144 emit('ov:release-applied', next.release);
145 }
146
147 async function decide(m) {
148 const who = acct;
149 let x = null;
150 if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
151 const page = base || { release: current, client_generation: gen };
152 let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation);
153 if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
154 if (p.action === 'none') return;
155 if (p.action !== 'in-place') {
156 if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; } else optional = true;
157 if (optional && document.hidden) later('apply', applyOptional, 30 * 1000);
158 prompt();
159 maybeReload();
160 return;
161 }
162 try {
163 kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');
164 // An account switch meanwhile (openvibe-auth-changed): fetch the regions again.
165 waiting = await x.apply(p, m, (s) => (who !== acct ? 'account' : busy(s)), record); pending = m;
166 if (who !== acct) { waiting = []; pending = null; return decide(m); }
167 if (!waiting.length) adopt(m);
168 } catch (why) {
169 waiting = []; pending = null;
170 record('failed', typeof why === 'string' ? why : 'content');
171 failedRelease = m.release;
172 flush();
173 await decide(m);
174 }
175 }
176 function consider(m) {
177 latest = m;
178 if (m.release === current || busyWith || (pending && pending.release === m.release)) return;
179 waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one
180 busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; });
181 }
182
183 async function check(force) {
184 const now = Date.now();
185 if (!force && now - lastCheck < MIN_GAP_MS) return latest;
186 lastCheck = now;
187 let m;
188 try {
189 const r = await root.fetch(url, { cache: 'no-store', credentials: 'omit' });
190 if (!r.ok) return latest;
191 m = await r.json();
192 } catch { return latest; }
193 if (!m || typeof m.release !== 'string') return latest;
194 if (!current) current = m.release;
195 if (!base && m.release === current) base = m;
196 consider(m);
197 live();
198 if (busyWith) await busyWith;
199 return m;
200 }
201
202 // ── Release notifications (host.release.published, public, via OpenVibe.Events; WS-P task 9) ──
203 // One credential-less EventSource per tab. Closed after 5 min hidden, reopened with last_event_id; errors
204 // back off 30 s to 15 min, after 6 failures only polling is left; online reconnects at once.
205 const TOPIC = 'host.release.published';
206 const HEX = /^[0-9a-f]{7,40}$/;
207 const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastId: null };
208 let es = null; let queued = null; let again = false; let seen = [];
209 const t = {}; // timers: run (the jittered check), cool (30 s after it), retry, hide
210 const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
211 const same = (a, b) => !!a && !!b && (a === b || (HEX.test(a) && HEX.test(b) && (a.startsWith(b) || b.startsWith(a))));
212 function eventsUrl() {
213 const v = 'eventsUrl' in cfg ? cfg.eventsUrl : meta && meta.hasAttribute('data-events') ? meta.getAttribute('data-events') : undefined;
214 if (v !== undefined) return v && v !== 'off' ? String(v) : null;
215 try { return new URL(root.location.href).protocol === 'https:' ? 'https://openvibe.events/realtime/stream' : null; } catch { return null; }
216 }
217 function live() {
218 if (stopped || es || rt.state !== 'off' || typeof root.EventSource !== 'function') return;
219 const svc = cfg.service || (meta && meta.getAttribute('data-service')) || (base && base.service) || (latest && latest.service);
220 const at = eventsUrl();
221 if (!at || !/^[a-z][a-z0-9-]{1,39}$/.test(String(svc || ''))) return;
222 rt.service = svc; rt.url = at;
223 open();
224 if (document.hidden) later('hide', hide, 5 * 60 * 1000);
225 }
226 function open() {
227 let u;
228 try { u = new URL(rt.url, root.location.href); u.searchParams.set('topics', TOPIC); if (rt.lastId) u.searchParams.set('last_event_id', rt.lastId); } catch { rt.state = 'failed'; return; }
229 rt.state = 'connecting';
230 let s;
231 try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
232 s.onopen = () => { if (es === s) { rt.state = 'open'; rt.failures = 0; } };
233 s.onmessage = (e) => { if (es === s) heard(e); };
234 s.onerror = () => { if (es === s) fail(); };
235 s.addEventListener('gap', () => { if (es === s) queue(); }); // events were missed: check anyway
236 }
237 function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
238 function fail() {
239 close();
240 if (rt.state === 'blocked') return;
241 if (++rt.failures >= 6) { rt.state = 'failed'; return; }
242 rt.state = 'backoff';
243 later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
244 }
245 function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
246 // A CSP whose connect-src leaves Events out: one refusal, then polling only.
247 document.addEventListener('securitypolicyviolation', (e) => {
248 try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }
249 close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';
250 });
251 function heard(e) {
252 let m; try { m = JSON.parse(e.data); } catch { return; }
253 const ev = m && m.event; const p = ev && ev.payload;
254 if (!p || ev.event_type !== TOPIC) return;
255 rt.lastId = e.lastEventId || rt.lastId;
256 if (p.service !== rt.service || typeof p.release !== 'string') return;
257 // Ids repeat across origins (Sites' placeholders): a named origin must be ours.
258 if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
259 rt.events++;
260 if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
261 seen = seen.concat(ev.event_id).slice(-20);
262 queued = p.release;
263 queue();
264 }
265 /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */
266 function queue() {
267 if (t.run) return;
268 if (t.cool) { again = true; return; }
269 later('run', () => {
270 const q = queued;
271 rt.checks++;
272 later('cool', () => { if (again) { again = false; queue(); } }, 30 * 1000);
273 if (!stopped) check(true).then(() => { if (queued === q) queued = null; });
274 }, Math.floor(Math.random() * 20 * 1000));
275 }
276
277 let stopped = false;
278 root.addEventListener('focus', () => { if (!stopped) check(false); });
279 root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed' || rt.state === 'backoff') { root.clearTimeout(t.retry); rt.state = 'off'; rt.failures = 0; live(); } check(true); });
280 root.addEventListener('openvibe-auth-changed', () => { acct++; if (!stopped && pending && !busyWith) { waiting = []; pending = null; consider(latest); } });
281 root.addEventListener('pagehide', () => flush(true, true));
282 document.addEventListener('visibilitychange', () => {
283 if (stopped) return;
284 if (document.hidden) { flush(); maybeReload(); if (optional) later('apply', applyOptional, 30 * 1000); if (es) later('hide', hide, 5 * 60 * 1000); return; }
285 root.clearTimeout(t.hide); t.hide = null;
286 root.clearTimeout(t.apply); t.apply = null;
287 if (rt.state === 'hidden') open();
288 check(false);
289 });
290 const tick = root.setInterval(() => {
291 if (stopped) return;
292 if (pending && commitRegions(pending)) adopt(pending);
293 check(false); maybeReload();
294 if (Date.now() - lastBeat >= 3e5) flush(true);
295 }, 30 * 1000);
296 const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
297 function stop() {
298 stopped = true; root.clearInterval(tick); root.clearInterval(poll);
299 close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); rt.state = 'off';
300 }
301 root.OVRelease = {
302 get current() { return current; },
303 check: () => check(true),
304 state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }),
305 flush,
306 stop,
307 };
308 // The page's own manifest (if still served). No /release.json and no meta tag: left alone.
309 lastCheck = Date.now();
310 root.fetch(url, { cache: 'no-store', credentials: 'omit' })
311 .then((r) => (r.ok ? r.json() : null))
312 .then((m) => {
313 if (!m || typeof m.release !== 'string') { if (!current) stop(); return; }
314 if (!current) current = m.release;
315 if (m.release === current) base = m;
316 consider(m);
317 live();
318 flush(true);
319 })
320 .catch(() => { if (!current) stop(); });
321})(typeof window !== 'undefined' ? window : globalThis);
322