openvibers/openvibe.shared release-update.js
release-update.js
| 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 | ; |
| 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 |