openvibers/openvibe.shared web-runtime.js
web-runtime.js
| 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 | ; |
| 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 |