openvibers/openvibe.shared web-runtime.js

web-runtime.js

main 26.9 KB · 464 lines · javascript

1/* openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P). API and rules: README "Web runtime". */
2(function (root) {
3 'use strict';
4 if (typeof document === 'undefined' || root.OVWebRuntime) return;
5
6 // Where this file was loaded from: route-transition.js is fetched from next to it.
7 const selfSrc = document.currentScript && document.currentScript.src;
8
9 /** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */
10 function assetKey(src) {
11 try {
12 const u = new URL(src, root.location.href);
13 return u.origin === root.location.origin ? u.pathname : u.origin + u.pathname;
14 } catch { return String(src).split(/[?#]/)[0]; }
15 }
16
17 function create(o = {}) {
18 const FEATURES = o.features || {};
19 const ROUTES = (o.routes || []).map((r) => ({ re: r.path instanceof RegExp ? r.path : new RegExp(r.path), features: r.features || [] }));
20 const versions = o.versions || {};
21 const G = o.global || root;
22 const prefix = o.eventPrefix || 'ov';
23 const log = o.log || root.console;
24 const styleTimeout = o.styleTimeout || 4000;
25 const fragmentPath = o.fragmentPath || ((name) => `/fragments/${name}.html`);
26 const url = o.url || ((p) => { const h = versions[p]; return h ? `${p}${p.indexOf('?') === -1 ? '?' : '&'}v=${h}` : p; });
27 const d = { scripts: 0, styles: 0, late: 0, rolledBack: 0, failed: Object.create(null), scopes: 0, disposed: 0 };
28 const emit = (type, detail, target) => (target || document).dispatchEvent(new root.CustomEvent(type, { bubbles: true, detail }));
29 const failed = (kind, what, err) => { const f = d.failed[what] || (d.failed[what] = { kind, attempts: 0, error: '' }); f.attempts++; f.error = String((err && err.message) || err || ''); };
30
31 // ── Scripts and styles, once each ────────────────────────────────────────────────────────
32 const scripts = Object.create(null); // key → promise
33 const styles = Object.create(null); // key → promise
34 const styleEls = Object.create(null); // key → the <link> this runtime inserted
35 const wanted = Object.create(null); // key → Set of features loading or loaded that list it
36
37 function tagPromise(el, isScript) {
38 // A deferred script that has run fired `load` long ago; after DOMContentLoaded every parser-inserted script has run.
39 if (isScript && document.readyState !== 'loading' && !el.dataset.ovInjected) return Promise.resolve();
40 return new Promise((resolve) => {
41 el.addEventListener('load', () => resolve(), { once: true });
42 el.addEventListener('error', () => resolve(), { once: true });
43 if (isScript && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
44 });
45 }
46 /** Tags already in the document (the server's, or another loader's) count as loaded or loading. */
47 function adoptExisting() {
48 document.querySelectorAll('script[src]').forEach((t) => { const k = assetKey(t.getAttribute('src')); if (!scripts[k]) scripts[k] = tagPromise(t, true); });
49 document.querySelectorAll('link[rel="stylesheet"][href]').forEach((l) => { const k = assetKey(l.getAttribute('href')); if (!styles[k]) styles[k] = l.sheet ? Promise.resolve() : tagPromise(l, false); });
50 }
51
52 /** attrs: { integrity, crossOrigin } for another origin's file. */
53 function loadScript(p, attrs) {
54 const k = assetKey(p);
55 if (scripts[k]) return scripts[k];
56 const s = document.createElement('script');
57 s.src = url(p);
58 s.async = false; // injected together → run in insertion order
59 s.dataset.ovInjected = '1';
60 if (attrs && attrs.integrity) s.integrity = attrs.integrity;
61 if (attrs && attrs.crossOrigin) s.crossOrigin = attrs.crossOrigin;
62 scripts[k] = new Promise((resolve, reject) => {
63 s.onload = () => { delete d.failed[k]; resolve(); };
64 s.onerror = () => {
65 delete scripts[k]; // a retry adds a fresh tag
66 s.remove();
67 const err = new Error(`Could not load ${p}`);
68 failed('script', k, err);
69 reject(err);
70 };
71 });
72 document.head.appendChild(s);
73 d.scripts++;
74 return scripts[k];
75 }
76
77 /** Never rejects: a missing stylesheet must not block a route (it waits at most styleTimeout ms). */
78 function loadStyle(p, added) {
79 const k = assetKey(p);
80 if (styles[k]) return styles[k];
81 const l = document.createElement('link');
82 l.rel = 'stylesheet';
83 l.href = url(p);
84 const pr = styles[k] = new Promise((resolve) => {
85 let done = false;
86 const finish = () => { if (!done) { done = true; resolve(); } };
87 l.onload = () => { delete d.failed[k]; finish(); };
88 l.onerror = () => {
89 if (styles[k] === pr) { delete styles[k]; delete styleEls[k]; }
90 l.remove();
91 failed('style', k, 'error');
92 finish();
93 };
94 root.setTimeout(finish, styleTimeout);
95 });
96 // o.styleSlot(p) → the node it goes before (a site's cascade order), or nothing: last in <head>.
97 const before = o.styleSlot ? o.styleSlot(p) : null;
98 if (before && before.parentNode) before.parentNode.insertBefore(l, before);
99 else document.head.appendChild(l);
100 styleEls[k] = l;
101 d.styles++;
102 if (added) added.push(k);
103 return pr;
104 }
105
106 // ── Markup fragments ─────────────────────────────────────────────────────────────────────
107 const fragments = Object.create(null);
108 function loadFragment(name, sectionId, stylesReady) {
109 const section = document.getElementById(sectionId);
110 if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
111 if (fragments[name]) return fragments[name];
112 section.setAttribute('aria-busy', 'true');
113 // Markup and styles download together; the markup goes in once its styles have loaded (or timed out),
114 // so a page is never shown unstyled for a moment.
115 fragments[name] = Promise.all([
116 root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); }),
117 stylesReady || null,
118 ])
119 .then(([html]) => {
120 // Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.
121 if (section.dataset.fragmentLoaded !== '1') {
122 section.innerHTML = html;
123 section.dataset.fragmentLoaded = '1';
124 emit(`${prefix}:fragment`, { name }, section);
125 }
126 section.removeAttribute('aria-busy');
127 }, (err) => {
128 delete fragments[name];
129 section.removeAttribute('aria-busy');
130 failed('fragment', name, err);
131 throw err;
132 });
133 return fragments[name];
134 }
135
136 // ── Features (transactional asset groups) ─────────────────────────────────────────────────
137 const features = Object.create(null); // name → promise, in flight or loaded
138 const loaded = Object.create(null);
139 const afterRan = Object.create(null);
140
141 function load(name) {
142 const f = FEATURES[name];
143 if (!f) return Promise.resolve();
144 if (features[name]) return features[name];
145 const css = (f.css || []).map(assetKey);
146 css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
147 const added = [];
148 // Every stylesheet the feature and its dependencies need starts at once, with the markup; the markup goes
149 // in once they have loaded, before the dependencies and scripts run (modules bind to it when they load).
150 emit(`${prefix}:phase`, { name, phase: 'styles' });
151 const stylesReady = Promise.all(stylesOf(name).map((p) => loadStyle(p, added)));
152 const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`, stylesReady) : stylesReady;
153 features[name] = markup
154 .then(() => { emit(`${prefix}:phase`, { name, phase: 'code' }); return Promise.all((f.deps || []).map(load)); })
155 .then(() => Promise.all((f.js || []).map((p) => loadScript(p))))
156 .then(() => {
157 loaded[name] = true;
158 delete d.failed[name];
159 if (f.after && !afterRan[name] && typeof G[f.after] === 'function') {
160 afterRan[name] = true;
161 try { G[f.after](); } catch (e) { log.error(`[${prefix}] ${name} after-hook failed:`, e); }
162 }
163 emit(`${prefix}:feature`, { name });
164 }, (err) => {
165 delete features[name];
166 css.forEach((k) => wanted[k] && wanted[k].delete(name));
167 for (const k of added) {
168 if (wanted[k] && wanted[k].size) continue;
169 if (styleEls[k]) styleEls[k].remove();
170 delete styleEls[k]; delete styles[k];
171 d.rolledBack++;
172 }
173 failed('feature', name, err);
174 throw err;
175 });
176 return features[name];
177 }
178
179 /** The stylesheets of a feature and everything it depends on, in order, each once. */
180 function stylesOf(name, seen = new Set(), out = []) {
181 const f = FEATURES[name];
182 if (!f || seen.has(name)) return out;
183 seen.add(name);
184 for (const dep of f.deps || []) stylesOf(dep, seen, out);
185 for (const p of f.css || []) if (!out.includes(p)) out.push(p);
186 return out;
187 }
188
189 // ── Page transitions ─────────────────────────────────────────────────────────────────────
190 // rt.enter(section, work, { label }): the section's content stays hidden (keeping its space) until `work` is done;
191 // route-transition.js (fetched next to this file on the first in-site move) draws the bar, line and fade-in.
192 let enters = 0, fx = null;
193 // Its own URL, without web-runtime's ?v= (the site's versions map hashes it, as any asset).
194 const fxSrc = selfSrc && assetKey(selfSrc).replace(/web-runtime\.js$/, 'route-transition.js');
195 // Ready before the first click: fetched a few seconds after the page settles.
196 if (fxSrc) root.setTimeout(() => { if (!root.OVRouteFx) loadScript(fxSrc).catch(() => {}); }, 4000);
197 function enter(section, work, opts = {}) {
198 // The page that arrived server-rendered: the first enter() before any in-site move (route generation ≤ 1).
199 const p = Promise.resolve(work), attr = `data-${prefix}-loading`, first = !enters++ && generation <= 1;
200 if (!section) return p;
201 if (!document.getElementById(`${prefix}-rt-css`)) { const st = document.createElement('style'); st.id = `${prefix}-rt-css`; st.textContent = `[${attr}]{position:relative;min-height:40vh}[${attr}]>*{visibility:hidden}`; document.head.appendChild(st); }
202 section.setAttribute(attr, '');
203 let h = null;
204 if (!first && fxSrc) (root.OVRouteFx ? p.constructor.resolve() : loadScript(fxSrc)).then(() => { if (section.hasAttribute(attr)) fx = h = root.OVRouteFx.start(section, { label: opts.label, prefix }); }, () => {});
205 const done = () => { section.removeAttribute(attr); if (h) h.done(); };
206 return p.then((v) => { done(); return v; }, (e) => { done(); throw e; });
207 }
208
209 function featuresFor(path) {
210 const out = [];
211 ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
212 return out;
213 }
214 function route(path) { return Promise.all(featuresFor(path || root.location.pathname).map(load)); }
215
216 // ── Prefetch (download, never execute), within a budget ──────────────────────────────────
217 const pf = o.prefetch || {};
218 const budget = { count: pf.count == null ? 60 : pf.count, bytes: pf.bytes == null ? 3 * 1024 * 1024 : pf.bytes };
219 const sizes = pf.sizes || {};
220 const spent = { count: 0, bytes: 0, skipped: 0, constrained: 0 };
221 const prefetched = Object.create(null);
222 function constrained() {
223 const c = root.navigator && root.navigator.connection;
224 if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return true;
225 return !!root.navigator && typeof root.navigator.deviceMemory === 'number' && root.navigator.deviceMemory < 2;
226 }
227 function prefetchAsset(p) {
228 const k = assetKey(p);
229 if (prefetched[k] || scripts[k] || styles[k]) return;
230 const size = Number(sizes[p]) || 0;
231 if (spent.count + 1 > budget.count || spent.bytes + size > budget.bytes) { spent.skipped++; return; }
232 prefetched[k] = true; spent.count++; spent.bytes += size;
233 const l = document.createElement('link');
234 l.rel = 'prefetch';
235 l.href = url(p);
236 l.setAttribute('as', /\.css$/.test(k) ? 'style' : /\.js$/.test(k) ? 'script' : 'fetch');
237 document.head.appendChild(l);
238 }
239 function prefetch(name, seen) {
240 seen = seen || Object.create(null);
241 const f = FEATURES[name];
242 if (!f || seen[name] || features[name]) return;
243 if (constrained()) { spent.constrained++; return; }
244 seen[name] = true;
245 (f.deps || []).forEach((n) => prefetch(n, seen));
246 (f.css || []).concat(f.js || []).forEach(prefetchAsset);
247 if (f.fragment) prefetchAsset(fragmentPath(f.fragment));
248 }
249 function prefetchRoute(path) { featuresFor(path).forEach((n) => prefetch(n)); }
250
251 /** Hover, focus or touch on an in-site link: fetch that route's code. Returns a function that stops watching. */
252 function watchIntent(target) {
253 const t = target || document;
254 const on = (e) => {
255 const a = e.target && e.target.closest && e.target.closest('a[href^="/"]');
256 const href = a && a.getAttribute('href');
257 if (!href || href.charAt(1) === '/') return;
258 const path = href.split(/[?#]/)[0];
259 if (path) prefetchRoute(path);
260 };
261 const kinds = [['pointerover', { passive: true }], ['focusin', undefined], ['touchstart', { passive: true }]];
262 kinds.forEach(([k, opts]) => t.addEventListener(k, on, opts));
263 return () => kinds.forEach(([k, opts]) => t.removeEventListener(k, on, opts));
264 }
265 /** The `idle` features of the path's routes, prefetched once the page has settled (load + 4 s + idle). */
266 function idlePrefetch(path) {
267 const idle = root.requestIdleCallback || ((fn) => root.setTimeout(fn, 3000));
268 const go = () => root.setTimeout(() => idle(() => {
269 featuresFor(path || root.location.pathname).forEach((n) => (FEATURES[n].idle || []).forEach((m) => prefetch(m)));
270 }, { timeout: 15000 }), 4000);
271 if (document.readyState === 'complete') go();
272 else root.addEventListener('load', go, { once: true });
273 }
274
275 // ── Route generations and scopes ─────────────────────────────────────────────────────────
276 let generation = 0;
277 let current = null;
278
279 function makeScope(gen) {
280 const held = new Set();
281 const controller = typeof root.AbortController === 'function' ? new root.AbortController() : null;
282 let disposed = false;
283 d.scopes++;
284 const late = (kind) => { d.late++; if (o.debug) log.warn(`[${prefix}] ${kind} registered after its route ended (generation ${gen}); ignored`); };
285 const hold = (kind, fn) => { const h = { kind, fn }; held.add(h); return h; };
286 const scope = {
287 gen,
288 signal: controller ? controller.signal : undefined,
289 interval(fn, ms) {
290 if (disposed) { late('interval'); return 0; }
291 const id = root.setInterval(fn, ms);
292 hold('interval', () => root.clearInterval(id));
293 return id;
294 },
295 timeout(fn, ms) {
296 if (disposed) { late('timeout'); return 0; }
297 let h = null;
298 const id = root.setTimeout(function () { held.delete(h); return fn.apply(this, arguments); }, ms);
299 h = hold('timeout', () => root.clearTimeout(id));
300 return id;
301 },
302 listen(target, type, fn, opts) {
303 if (!target) return;
304 if (disposed) { late('listener'); return; }
305 target.addEventListener(type, fn, opts);
306 hold('listener', () => target.removeEventListener(type, fn, opts));
307 },
308 observe(observer) {
309 if (disposed) { late('observer'); try { observer.disconnect(); } catch { /* */ } return observer; }
310 hold('observer', () => { try { observer.disconnect(); } catch { /* */ } });
311 return observer;
312 },
313 onDispose(fn) {
314 if (disposed) { late('onDispose'); try { fn(); } catch { /* */ } return; }
315 hold('onDispose', fn);
316 },
317 /** fetch() cancelled when the route ends (unless init brings its own signal). */
318 fetch(input, init) {
319 if (disposed) { late('fetch'); return Promise.reject(Object.assign(new Error('The route has ended'), { name: 'AbortError' })); }
320 return root.fetch(input, Object.assign({ signal: scope.signal }, init));
321 },
322 /** A scope for one component (a dialog, a player) that can end early and ends with this one anyway. */
323 child() {
324 const c = makeScope(gen);
325 if (disposed) { late('child'); c.dispose(); return c; }
326 const h = hold('child', () => c.dispose());
327 c.onDispose(() => held.delete(h));
328 return c;
329 },
330 /** What this scope holds right now, by kind. */
331 held() {
332 const out = {};
333 held.forEach((h) => { out[h.kind] = (out[h.kind] || 0) + 1; });
334 return out;
335 },
336 get disposed() { return disposed; },
337 dispose() {
338 if (disposed) return;
339 disposed = true;
340 d.disposed++;
341 if (controller) try { controller.abort(); } catch { /* */ }
342 const list = Array.from(held).reverse();
343 held.clear();
344 for (const h of list) { try { h.fn(); } catch (e) { log.warn(`[${prefix}] scope cleanup failed:`, e); } }
345 },
346 };
347 return scope;
348 }
349 function scope() { if (!current) current = makeScope(generation); return current; }
350 /** A new route: everything the previous one owned is released. Returns the new generation. */
351 function nextRoute() {
352 generation++;
353 if (current) current.dispose();
354 current = makeScope(generation);
355 return generation;
356 }
357
358 // ── Stubs for inline handlers ────────────────────────────────────────────────────────────
359 function installStubs() {
360 Object.keys(FEATURES).forEach((name) => {
361 (FEATURES[name].stubs || []).forEach((fn) => {
362 if (typeof G[fn] === 'function') return;
363 const stub = function () {
364 const args = arguments; const self = this;
365 return load(name).then(() => {
366 if (G[fn] !== stub && typeof G[fn] === 'function') return G[fn].apply(self, args);
367 log.warn(`[${prefix}] ${fn} is unavailable after loading ${name}`);
368 }, (err) => {
369 log.error(`[${prefix}]`, err);
370 if (typeof o.onStubError === 'function') o.onStubError(err, name);
371 });
372 };
373 stub.__ovStub = name;
374 G[fn] = stub;
375 });
376 });
377 }
378
379 // ── Recoverable failure UI for a route ───────────────────────────────────────────────────
380 const RE = Object.assign({ icon: '', button: 'btn btn-primary', title: 'This page could not load.', text: 'Check your connection, then try again.', retry: 'Try again' }, o.routeError);
381 function showRouteError(pageId, err, retry) {
382 const page = document.getElementById(pageId);
383 if (!page) return;
384 let box = page.querySelector(':scope > .ov-route-error');
385 if (!box) {
386 box = document.createElement('div');
387 box.className = 'ov-route-error';
388 box.setAttribute('role', 'alert');
389 page.insertBefore(box, page.firstChild);
390 }
391 box.textContent = '';
392 if (RE.icon) { const i = document.createElement('i'); i.className = RE.icon; i.setAttribute('aria-hidden', 'true'); box.appendChild(i); }
393 const msg = document.createElement('div');
394 const strong = document.createElement('strong'); strong.textContent = RE.title;
395 const span = document.createElement('span'); span.textContent = RE.text;
396 msg.appendChild(strong); msg.appendChild(span); box.appendChild(msg);
397 const btn = document.createElement('button');
398 btn.type = 'button'; btn.className = RE.button; btn.textContent = RE.retry;
399 btn.addEventListener('click', () => { box.remove(); retry(); }, { once: true });
400 box.appendChild(btn);
401 log.error(`[${prefix}] route failed to load:`, err);
402 }
403
404 // ── Diagnostics ──────────────────────────────────────────────────────────────────────────
405 function duplicates() {
406 const n = Object.create(null);
407 document.querySelectorAll('script[src], link[rel="stylesheet"][href]').forEach((el) => {
408 const k = assetKey(el.getAttribute('src') || el.getAttribute('href'));
409 n[k] = (n[k] || 0) + 1;
410 });
411 return Object.keys(n).filter((k) => n[k] > 1).map((k) => ({ asset: k, count: n[k] }));
412 }
413 function diagnostics() {
414 return {
415 generation,
416 features: {
417 loaded: Object.keys(loaded),
418 loading: Object.keys(features).filter((n) => !loaded[n]),
419 failed: Object.keys(d.failed).filter((k) => d.failed[k].kind === 'feature').map((k) => ({ name: k, attempts: d.failed[k].attempts, error: d.failed[k].error })),
420 },
421 assets: {
422 scripts: Object.keys(scripts).length, styles: Object.keys(styles).length,
423 injected: { scripts: d.scripts, styles: d.styles },
424 failed: Object.keys(d.failed).filter((k) => d.failed[k].kind !== 'feature').map((k) => ({ asset: k, kind: d.failed[k].kind, attempts: d.failed[k].attempts })),
425 rolledBack: d.rolledBack,
426 duplicates: duplicates(),
427 },
428 scopes: { created: d.scopes, disposed: d.disposed, late: d.late, current: current ? current.held() : {} },
429 prefetch: Object.assign({ budget: Object.assign({}, budget) }, spent),
430 };
431 }
432 /** Plain sentences about anything that looks like a leak; empty when all is well. */
433 function leaks() {
434 const out = [];
435 duplicates().forEach((x) => out.push(`${x.asset} is in the document ${x.count} times`));
436 if (d.late) out.push(`${d.late} registration(s) arrived after their route ended (refused)`);
437 return out;
438 }
439
440 return {
441 load, route, featuresFor, prefetch, prefetchRoute, loadScript, loadStyle, loadFragment, url, assetKey,
442 gen: () => generation,
443 isCurrent: (g) => g === generation,
444 scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
445 enter, status: (t) => fx && fx.status(t), stylesOf,
446 isLoaded: (name) => !!features[name],
447 /** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
448 boot(b = {}) {
449 const start = () => {
450 adoptExisting();
451 (b.initial || []).forEach((n) => { load(n).catch(() => { /* recorded in diagnostics */ }); });
452 if (b.intent !== false) watchIntent(document);
453 if (b.idle !== false) idlePrefetch();
454 };
455 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
456 else start();
457 },
458 };
459 }
460
461 root.OVWebRuntime = { create, assetKey };
462 if (typeof module !== 'undefined' && module.exports) module.exports = root.OVWebRuntime;
463})(typeof window !== 'undefined' ? window : globalThis);
464