openvibers/openvibe.shared boost.js

boost.js

main 15.3 KB · 247 lines · javascript

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 'use strict';
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