openvibers/openvibe.shared boost.js
boost.js
| 1 | /* |
| 2 | * openvibe-shared/boost.js — smooth page moves for a server-rendered site (plan T11). Every page stays a normal page |
| 3 | * (it works with no JavaScript, crawlers see the same HTML); boost only makes a same-site move feel like an app: |
| 4 | * |
| 5 | * <meta name="ov-boost" content="<site>@<release>"> on every page that may be swapped (the server's release id) |
| 6 | * <main id="main">…</main> the part that changes |
| 7 | * <script src="/shared/boost.js" data-main="#main" defer></script> starts itself (or call OVBoost.start below) |
| 8 | * |
| 9 | * A click on a same-site link (no modifier key, no target, no download, not data-no-boost, not /auth/ or /api/) fetches |
| 10 | * the next page's HTML (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps <main> with |
| 11 | * the shared transition (route-transition.js: the top bar, "Loading Content…", a fade), updates the title, the head |
| 12 | * tags that describe the page (description, canonical, Open Graph, Twitter, JSON-LD), pushes history, restores scroll on |
| 13 | * back/forward and moves focus to the new content. Any doubt is a normal page load: a different release marker, a |
| 14 | * different set of external scripts, a non-HTML answer, a redirect off the site, an error, a timeout. |
| 15 | * Inline scripts inside the new <main> run after the swap (once); pages listen for `<prefix>:boost:load` to wire widgets. |
| 16 | * |
| 17 | * OVBoost.start({ main, prefix = 'ov', exclude = [], timeout = 8000, prefetch = true, fx = true }) → controller |
| 18 | * controller.go(url) controller.stop() controller.stats() |
| 19 | */ |
| 20 | (function (root) { |
| 21 | ; |
| 22 | if (typeof document === 'undefined' || root.OVBoost) return; |
| 23 | const selfSrc = document.currentScript && document.currentScript.src; |
| 24 | const selfData = (document.currentScript && document.currentScript.dataset) || {}; |
| 25 | const HEAD_TAGS = 'meta[name="description"],link[rel="canonical"],meta[property^="og:"],meta[name^="twitter:"],script[type="application/ld+json"],link[rel="alternate"][hreflang],meta[name="robots"]'; |
| 26 | const FILE_EXT = /\.(?:pdf|zip|gz|tgz|png|jpe?g|gif|webp|avif|svg|mp4|webm|mp3|wav|ogg|json|xml|txt|csv|ics)$/i; |
| 27 | |
| 28 | function start(o = {}) { |
| 29 | const mainSel = o.main || 'main'; |
| 30 | const prefix = o.prefix || 'ov'; |
| 31 | const timeout = o.timeout || 8000; |
| 32 | const exclude = ['/auth/', '/api/', '/logout', ...(o.exclude || [])]; |
| 33 | const cache = new Map(); // url → { at, promise } |
| 34 | const scrolls = Object.create(null); // history key → y |
| 35 | const s = { moves: 0, fallbacks: 0, prefetches: 0, last: null }; |
| 36 | let inflight = null, stopped = false, key = 0; |
| 37 | // The page whose <main> is shown (path and query) and where it was scrolled before a link to one of its parts. |
| 38 | let shown = root.location.pathname + root.location.search, partFromY = null; |
| 39 | const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail })); |
| 40 | const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; }; |
| 41 | const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n'); |
| 42 | const here = { marker: marker(document), scripts: externalScripts(document) }; |
| 43 | if (!here.marker || !document.querySelector(mainSel)) return { go: (u) => root.location.assign(u), stop() {}, stats: () => ({ ...s, off: 'no ov-boost marker or main element' }) }; |
| 44 | |
| 45 | function boostable(a, ev) { |
| 46 | if (!a || a.target && a.target !== '_self' || a.hasAttribute('download') || a.closest('[data-no-boost]')) return null; |
| 47 | if (ev && (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)) return null; |
| 48 | let u; try { u = new URL(a.href, root.location.href); } catch { return null; } |
| 49 | if (u.origin !== root.location.origin || !/^https?:$/.test(u.protocol)) return null; |
| 50 | if (FILE_EXT.test(u.pathname) || exclude.some((p) => u.pathname.startsWith(p))) return null; |
| 51 | if (u.pathname === root.location.pathname && u.search === root.location.search && u.hash) return null; // in-page anchor |
| 52 | return u; |
| 53 | } |
| 54 | |
| 55 | function fetchPage(url) { |
| 56 | const hit = cache.get(url); |
| 57 | if (hit && Date.now() - hit.at < 15000) return hit.promise; |
| 58 | const ctl = new AbortController(); |
| 59 | const t = root.setTimeout(() => ctl.abort(), timeout); |
| 60 | const promise = root.fetch(url, { credentials: 'same-origin', headers: { 'X-OV-Boost': '1', Accept: 'text/html' }, signal: ctl.signal, redirect: 'follow' }) |
| 61 | .then(async (r) => { |
| 62 | const ct = r.headers.get('content-type') || ''; |
| 63 | if (!r.ok || !/text\/html/.test(ct)) throw new Error(`not a page (${r.status} ${ct})`); |
| 64 | const final = new URL(r.url || url); |
| 65 | if (final.origin !== root.location.origin) throw new Error('redirected off the site'); |
| 66 | return { html: await r.text(), url: final.href }; |
| 67 | }) |
| 68 | .finally(() => root.clearTimeout(t)); |
| 69 | cache.set(url, { at: Date.now(), promise }); |
| 70 | promise.catch(() => cache.delete(url)); |
| 71 | return promise; |
| 72 | } |
| 73 | |
| 74 | function prefetch(u) { |
| 75 | if (o.prefetch === false) return; |
| 76 | const c = root.navigator && root.navigator.connection; |
| 77 | if (c && (c.saveData || /2g/.test(c.effectiveType || ''))) return; |
| 78 | if (cache.has(u.href)) return; |
| 79 | s.prefetches++; |
| 80 | fetchPage(u.href).catch(() => {}); |
| 81 | } |
| 82 | |
| 83 | function loadStylesFirst(doc) { |
| 84 | const have = new Set([...document.querySelectorAll('link[rel="stylesheet"][href]')].map((l) => new URL(l.href, root.location.href).href)); |
| 85 | const added = [...doc.querySelectorAll('link[rel="stylesheet"][href]')] |
| 86 | .map((l) => new URL(l.getAttribute('href'), root.location.href).href) |
| 87 | .filter((h) => !have.has(h)) |
| 88 | .map((href) => new Promise((done) => { |
| 89 | const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; |
| 90 | l.onload = l.onerror = () => done(); root.setTimeout(done, 4000); |
| 91 | document.head.appendChild(l); |
| 92 | })); |
| 93 | return Promise.all(added); |
| 94 | } |
| 95 | |
| 96 | function updateHead(doc) { |
| 97 | document.title = doc.title; |
| 98 | document.head.querySelectorAll(HEAD_TAGS).forEach((el) => el.remove()); |
| 99 | doc.head.querySelectorAll(HEAD_TAGS).forEach((el) => document.head.appendChild(document.importNode(el, true))); |
| 100 | const lang = doc.documentElement.getAttribute('lang'); |
| 101 | if (lang) document.documentElement.setAttribute('lang', lang); |
| 102 | if (doc.body) document.body.className = doc.body.className; // page-type classes |
| 103 | } |
| 104 | |
| 105 | function runInlineScripts(scope) { |
| 106 | scope.querySelectorAll('script:not([src])').forEach((old) => { |
| 107 | if (old.type && !/^(text|application)\/(javascript|ecmascript)$|^module$/.test(old.type)) return; |
| 108 | const s2 = document.createElement('script'); |
| 109 | for (const a of old.attributes) s2.setAttribute(a.name, a.value); |
| 110 | s2.textContent = old.textContent; |
| 111 | old.replaceWith(s2); |
| 112 | }); |
| 113 | } |
| 114 | |
| 115 | // route-transition.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL (4.138.0). |
| 116 | const BOOST_REV = { 'route-transition.js': '1bd3c4576b8c' }; |
| 117 | function fx(mainEl) { |
| 118 | if (o.fx === false) return Promise.resolve(null); |
| 119 | if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx); |
| 120 | if (!selfSrc) return Promise.resolve(null); |
| 121 | return new Promise((done) => { |
| 122 | const t = document.createElement('script'); |
| 123 | t.src = selfSrc.replace(/boost\.js(\?.*)?$/, 'route-transition.js') + (BOOST_REV['route-transition.js'] ? `?v=${BOOST_REV['route-transition.js']}` : ''); |
| 124 | t.onload = () => done(root.OVRouteFx || null); t.onerror = () => done(null); |
| 125 | document.head.appendChild(t); |
| 126 | }); |
| 127 | } |
| 128 | |
| 129 | async function go(href, { push = true, restoreY = null } = {}) { |
| 130 | if (stopped) return root.location.assign(href); |
| 131 | const target = new URL(href, root.location.href); |
| 132 | const mine = inflight = {}; |
| 133 | const mainEl = document.querySelector(mainSel); |
| 134 | const Fx = await fx(mainEl); |
| 135 | const h = Fx ? Fx.start(mainEl, { prefix }) : null; |
| 136 | emit('before', { url: target.href }); |
| 137 | let doc = null; |
| 138 | try { |
| 139 | const page = await fetchPage(target.href); |
| 140 | if (inflight !== mine) return; // a newer move won |
| 141 | doc = new root.DOMParser().parseFromString(page.html, 'text/html'); |
| 142 | const next = doc.querySelector(mainSel); |
| 143 | // A different release, other external scripts, or no <main>: this page cannot be swapped safely. |
| 144 | if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable'); |
| 145 | if (h) h.status('applying styles'); |
| 146 | await loadStylesFirst(doc); |
| 147 | if (inflight !== mine) return; |
| 148 | if (push) { |
| 149 | scrolls[key] = root.scrollY; |
| 150 | key += 1; |
| 151 | root.history.pushState({ ovBoost: key }, '', page.url + (target.hash && !new URL(page.url).hash ? target.hash : '')); |
| 152 | } |
| 153 | // The same element keeps its identity (the transition fades it, listeners on it survive): its |
| 154 | // attributes and children become the new page's. |
| 155 | const imported = document.importNode(next, true); |
| 156 | for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name); |
| 157 | for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value); |
| 158 | mainEl.replaceChildren(...imported.childNodes); |
| 159 | try { const shownUrl = new URL(page.url, root.location.href); shown = shownUrl.pathname + shownUrl.search; } catch { shown = root.location.pathname + root.location.search; } |
| 160 | partFromY = null; |
| 161 | const fresh = mainEl; |
| 162 | updateHead(doc); |
| 163 | runInlineScripts(fresh); |
| 164 | const anchor = target.hash && document.getElementById(decodeURIComponent(target.hash.slice(1))); |
| 165 | if (restoreY != null) root.scrollTo(0, restoreY); |
| 166 | else if (anchor) anchor.scrollIntoView(); |
| 167 | else root.scrollTo(0, 0); |
| 168 | if (!fresh.hasAttribute('tabindex')) fresh.setAttribute('tabindex', '-1'); |
| 169 | try { fresh.focus({ preventScroll: true }); } catch { /* */ } |
| 170 | s.moves++; s.last = page.url; |
| 171 | if (h) h.done(); |
| 172 | emit('load', { url: page.url }); |
| 173 | } catch (err) { |
| 174 | if (inflight !== mine) return; |
| 175 | s.fallbacks++; |
| 176 | if (h) h.done(); |
| 177 | root.location.assign(target.href); // any doubt: a normal page load |
| 178 | } finally { |
| 179 | // Let the parsed page go. Its own copy of a <video> or <audio> (the swap imported a copy) is held by |
| 180 | // Chrome as a pending activity (RemotePlayback), and with it the whole parsed page: one more document |
| 181 | // and every node of the page on each visit (openvibe.cam/app, the release check's navigation test, |
| 182 | // 2026-11-02). Emptying the parsed page releases it. |
| 183 | if (doc && doc.documentElement) doc.documentElement.replaceChildren(); |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | function onClick(ev) { |
| 188 | const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); |
| 189 | if (a && !ev.defaultPrevented && a.hash) { try { const t = new URL(a.href, root.location.href); if (t.origin === root.location.origin && t.pathname + t.search === shown) partFromY = root.scrollY; } catch { /* */ } } |
| 190 | const u = boostable(a, ev); |
| 191 | if (!u) return; |
| 192 | ev.preventDefault(); |
| 193 | go(u.href); |
| 194 | } |
| 195 | function onIntent(ev) { const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); const u = boostable(a); if (u) prefetch(u); } |
| 196 | function onPop(ev) { |
| 197 | const st = ev.state; |
| 198 | // A link to a part of this page (#id), and Back or Forward between its parts, fire popstate too (with no state |
| 199 | // in Chrome): that is the browser moving within the page it shows, not a page change. Every in-page link used |
| 200 | // to reload the page and land at the top (openvibe.quest's "See the quests", 2026-10-28). |
| 201 | if (root.location.pathname + root.location.search === shown && (!st || st.ovBoost == null || st.ovBoost === key)) { |
| 202 | const part = root.location.hash && document.getElementById(decodeURIComponent(root.location.hash.slice(1))); |
| 203 | if (part) part.scrollIntoView(); |
| 204 | else if (!root.location.hash && partFromY != null) root.scrollTo(0, partFromY); |
| 205 | return; |
| 206 | } |
| 207 | if (!st || st.ovBoost == null) { root.location.reload(); return; } |
| 208 | const y = scrolls[st.ovBoost]; |
| 209 | scrolls[key] = root.scrollY; |
| 210 | key = st.ovBoost; |
| 211 | go(root.location.href, { push: false, restoreY: y != null ? y : 0 }); |
| 212 | } |
| 213 | |
| 214 | root.history.replaceState({ ...(root.history.state || {}), ovBoost: key }, ''); |
| 215 | root.history.scrollRestoration = 'manual'; |
| 216 | document.addEventListener('click', onClick); |
| 217 | document.addEventListener('mouseover', onIntent, { passive: true }); |
| 218 | document.addEventListener('focusin', onIntent); |
| 219 | document.addEventListener('touchstart', onIntent, { passive: true }); |
| 220 | root.addEventListener('popstate', onPop); |
| 221 | return { |
| 222 | go: (u) => go(u), |
| 223 | stop() { |
| 224 | stopped = true; |
| 225 | document.removeEventListener('click', onClick); |
| 226 | document.removeEventListener('mouseover', onIntent); |
| 227 | document.removeEventListener('focusin', onIntent); |
| 228 | document.removeEventListener('touchstart', onIntent); |
| 229 | root.removeEventListener('popstate', onPop); |
| 230 | root.history.scrollRestoration = 'auto'; |
| 231 | }, |
| 232 | stats: () => ({ ...s, cached: cache.size }), |
| 233 | }; |
| 234 | } |
| 235 | |
| 236 | root.OVBoost = { start, controller: null }; |
| 237 | // <script src=".../boost.js" data-main="#main" [data-prefix] [data-exclude="/a/,/b/"] defer>: start once the page is parsed. |
| 238 | if (selfData.main) { |
| 239 | const boot = () => { |
| 240 | if (root.OVBoost.controller) return; |
| 241 | root.OVBoost.controller = start({ main: selfData.main, prefix: selfData.prefix || 'ov', exclude: (selfData.exclude || '').split(',').map((x) => x.trim()).filter(Boolean) }); |
| 242 | }; |
| 243 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); |
| 244 | else boot(); |
| 245 | } |
| 246 | })(typeof window !== 'undefined' ? window : globalThis); |
| 247 |