/*
* openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place
* (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a
* release with components or contract ranges; pages never load it themselves. In Node, require() returns
* the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).
*
* In place is transactional: every new stylesheet loads and every region is fetched before anything old
* is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.
* styles(ids, next) new s for the changed style components' assets, loaded beside the old ones
* regions(ids) fresh [data-ov-content=""] HTML (from data-ov-src, default this URL),
* without scripts, frames or inline handlers; unchanged data-ov-rev is skipped
* commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting; each
* gets ov:content-dispose first (a widget's code drops its listeners and timers) and
* ov:content-updated after (it mounts again), both bubbling with { component, release }
* apply(plan, next, busy, record) all of the above for an in-place plan
*/
(function (root) {
'use strict';
const IN_PLACE = { style: 1, content: 1, server: 1 };
const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };
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; };
/** version inside ">=a.b.c =(\S+) <(\S+)$/.exec(String(range || ''));
const lo = m && ver(m[1]); const hi = m && ver(m[2]);
return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);
}
/** Every contract the page's release produces is still accepted by the server, and the reverse. */
function compatible(page, server) {
const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};
for (const id of Object.keys(mine)) {
const a = mine[id]; const b = theirs[id];
if (!a || a.role === 'consumes') continue;
if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);
else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);
else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);
}
return { ok: !problems.length, problems };
}
/**
* What a tab on `page` (the manifest of the release it runs) does about `server`:
* { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.
* In place only when every changed component is style, content (same kind on both sides) or server.
*/
function plan(page, server, now) {
if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
const c = compatible(page, server);
if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
// Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.
const pg = page.client_generation; const mg = server.min_client_generation;
if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };
const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);
const a = page.components; const b = server.components; let changed = null;
if (a && b) {
let ok = true; changed = [];
for (const id of Object.keys(Object.assign({}, a, b))) {
const x = a[id]; const y = b[id];
if (x && y && x.kind === y.kind && x.version === y.version) continue;
const kind = (y || x).kind;
changed.push({ id, kind });
if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
}
if (ok && !shellMoved) return { action: 'in-place', changed };
}
const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };
return { action: 'prompt', changed };
}
if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };
if (typeof document === 'undefined' || root.OVReleaseUpdate) return;
const loc = () => root.location.href;
const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };
function styles(ids, next) {
const jobs = [];
for (const p of Object.keys(next.assets || {})) {
const a = next.assets[p];
if (!ids[a.component]) continue;
const want = new URL(a.url, loc());
for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {
const have = new URL(l.getAttribute('href') || '', loc());
if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;
if (have.href === want.href) continue;
if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');
const n = l.cloneNode(false);
n.setAttribute('href', want.href);
if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');
jobs.push({ old: l, n });
}
}
return Promise.all(jobs.map((j) => new Promise((ok, no) => {
const timer = root.setTimeout(() => no('style-timeout'), 15000);
j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });
j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });
j.old.parentNode.insertBefore(j.n, j.old.nextSibling);
}))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });
}
async function regions(ids) {
const out = []; const docs = {};
for (const el of document.querySelectorAll('[data-ov-content]')) {
const id = el.getAttribute('data-ov-content');
if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;
const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';
if (!sameOrigin(src.href)) throw 'origin';
if (!docs[src.href]) {
const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });
if (!r.ok) throw 'content';
docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');
}
const doc = docs[src.href];
const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);
if (!fresh) throw 'content';
for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();
for (const x of [fresh, ...fresh.querySelectorAll('*')]) {
for (const at of Array.from(x.attributes)) {
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);
}
}
const rev = fresh.getAttribute('data-ov-rev');
if (rev && rev === el.getAttribute('data-ov-rev')) continue;
out.push({ el, fresh, id });
}
return out;
}
function commit(list, next, busy, record) {
return list.filter((w) => {
if (!w.el.isConnected) return false;
const why = busy(w.el);
if (why) { record('deferred', why, true); return true; }
const r = w.el.getBoundingClientRect();
const st = w.el.scrollTop;
const ev = (n) => { try { w.el.dispatchEvent(new root.CustomEvent(n, { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ } };
ev('ov:content-dispose');
w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
const rev = w.fresh.getAttribute('data-ov-rev');
if (rev) w.el.setAttribute('data-ov-rev', rev);
w.el.scrollTop = st;
// A region above the viewport that changed height must not move what the reader is looking at.
if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);
ev('ov:content-updated');
return false;
});
}
/** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */
async function apply(p, next, busy, record) {
const ids = {}; const k = {};
for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }
const list = k.content ? await regions(ids) : [];
const jobs = k.style ? await styles(ids, next) : [];
jobs.forEach((j) => j.old.remove());
if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }
return commit(list, next, busy, record);
}
root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };
})(typeof window !== 'undefined' ? window : globalThis);