openvibers/openvibe.shared release-update.js

release-update.js

main 10.3 KB · 162 lines · javascript

1/*
2 * openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place
3 * (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a
4 * release with components or contract ranges; pages never load it themselves. In Node, require() returns
5 * the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).
6 *
7 * In place is transactional: every new stylesheet loads and every region is fetched before anything old
8 * is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.
9 * styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones
10 * regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),
11 * without scripts, frames or inline handlers; unchanged data-ov-rev is skipped
12 * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting; each
13 * gets ov:content-dispose first (a widget's code drops its listeners and timers) and
14 * ov:content-updated after (it mounts again), both bubbling with { component, release }
15 * apply(plan, next, busy, record) all of the above for an in-place plan
16 */
17(function (root) {
18 'use strict';
19 const IN_PLACE = { style: 1, content: 1, server: 1 };
20 const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };
21 const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; };
22 /** version inside ">=a.b.c <x.y.z", the only form a manifest writes. */
23 function satisfies(version, range) {
24 const v = ver(version); const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));
25 const lo = m && ver(m[1]); const hi = m && ver(m[2]);
26 return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);
27 }
28 /** Every contract the page's release produces is still accepted by the server, and the reverse. */
29 function compatible(page, server) {
30 const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};
31 for (const id of Object.keys(mine)) {
32 const a = mine[id]; const b = theirs[id];
33 if (!a || a.role === 'consumes') continue;
34 if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);
35 else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);
36 else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);
37 }
38 return { ok: !problems.length, problems };
39 }
40 /**
41 * What a tab on `page` (the manifest of the release it runs) does about `server`:
42 * { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.
43 * In place only when every changed component is style, content (same kind on both sides) or server.
44 */
45 function plan(page, server, now) {
46 if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
47 const c = compatible(page, server);
48 if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
49 // Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.
50 const pg = page.client_generation; const mg = server.min_client_generation;
51 if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };
52 const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);
53 const a = page.components; const b = server.components; let changed = null;
54 if (a && b) {
55 let ok = true; changed = [];
56 for (const id of Object.keys(Object.assign({}, a, b))) {
57 const x = a[id]; const y = b[id];
58 if (x && y && x.kind === y.kind && x.version === y.version) continue;
59 const kind = (y || x).kind;
60 changed.push({ id, kind });
61 if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
62 }
63 if (ok && !shellMoved) return { action: 'in-place', changed };
64 }
65 const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
66 if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
67 if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };
68 return { action: 'prompt', changed };
69 }
70 if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };
71 if (typeof document === 'undefined' || root.OVReleaseUpdate) return;
72
73 const loc = () => root.location.href;
74 const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };
75
76 function styles(ids, next) {
77 const jobs = [];
78 for (const p of Object.keys(next.assets || {})) {
79 const a = next.assets[p];
80 if (!ids[a.component]) continue;
81 const want = new URL(a.url, loc());
82 for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {
83 const have = new URL(l.getAttribute('href') || '', loc());
84 if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;
85 if (have.href === want.href) continue;
86 if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');
87 const n = l.cloneNode(false);
88 n.setAttribute('href', want.href);
89 if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');
90 jobs.push({ old: l, n });
91 }
92 }
93 return Promise.all(jobs.map((j) => new Promise((ok, no) => {
94 const timer = root.setTimeout(() => no('style-timeout'), 15000);
95 j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });
96 j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });
97 j.old.parentNode.insertBefore(j.n, j.old.nextSibling);
98 }))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });
99 }
100
101 async function regions(ids) {
102 const out = []; const docs = {};
103 for (const el of document.querySelectorAll('[data-ov-content]')) {
104 const id = el.getAttribute('data-ov-content');
105 if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;
106 const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';
107 if (!sameOrigin(src.href)) throw 'origin';
108 if (!docs[src.href]) {
109 const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });
110 if (!r.ok) throw 'content';
111 docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');
112 }
113 const doc = docs[src.href];
114 const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);
115 if (!fresh) throw 'content';
116 for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();
117 for (const x of [fresh, ...fresh.querySelectorAll('*')]) {
118 for (const at of Array.from(x.attributes)) {
119 if (/^on/i.test(at.name) || (/^(href|src|action|formaction|xlink:href|srcdoc)$/i.test(at.name) && /^\s*(javascript|vbscript|data):/i.test(at.value) && !/^\s*data:image\//i.test(at.value))) x.removeAttribute(at.name);
120 }
121 }
122 const rev = fresh.getAttribute('data-ov-rev');
123 if (rev && rev === el.getAttribute('data-ov-rev')) continue;
124 out.push({ el, fresh, id });
125 }
126 return out;
127 }
128
129 function commit(list, next, busy, record) {
130 return list.filter((w) => {
131 if (!w.el.isConnected) return false;
132 const why = busy(w.el);
133 if (why) { record('deferred', why, true); return true; }
134 const r = w.el.getBoundingClientRect();
135 const st = w.el.scrollTop;
136 const ev = (n) => { try { w.el.dispatchEvent(new root.CustomEvent(n, { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ } };
137 ev('ov:content-dispose');
138 w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
139 const rev = w.fresh.getAttribute('data-ov-rev');
140 if (rev) w.el.setAttribute('data-ov-rev', rev);
141 w.el.scrollTop = st;
142 // A region above the viewport that changed height must not move what the reader is looking at.
143 if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);
144 ev('ov:content-updated');
145 return false;
146 });
147 }
148
149 /** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */
150 async function apply(p, next, busy, record) {
151 const ids = {}; const k = {};
152 for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }
153 const list = k.content ? await regions(ids) : [];
154 const jobs = k.style ? await styles(ids, next) : [];
155 jobs.forEach((j) => j.old.remove());
156 if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }
157 return commit(list, next, busy, record);
158 }
159
160 root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };
161})(typeof window !== 'undefined' ? window : globalThis);
162