openvibers/openvibe.shared Commits
v1.23.0: web runtime, a site's feature loader and route lifecycle (roadmap WS-P task 6). New browser file web-runtime.js (OVWebRuntime.create; files.js, export openvibe-shared/web-runtime; 6.1 KB brotli, budget 6.5 KB): OpenVibe.Live's public/js/ov-loader.js made shareable. It keeps the feature registry and routes, once-only scripts and stylesheets (the server's tags adopted), versioned URLs, fragments, route generations, scopes, intent and idle prefetch, stubs and the route retry box. New: transactional asset groups (a feature commits only when every script loaded; a failure withdraws that attempt's stylesheets unless another feature wants them and removes the failed tag; a retry fetches only what is missing, nothing runs twice); scopes that refuse and count late registrations, scope.fetch on the route's signal, child scopes, held() counts, fired timeouts released; a prefetch budget (count, and bytes where sizes are known); diagnostics() and leaks() (duplicate tags, failed and rolled-back assets, late registrations, what the route holds). showRouteError builds DOM; another origin's assets are keyed by origin and path. Tests: linkedom (tags load or fail on cue) and headless Chrome (a slow script still in order, a real 404 rolls back and the retry completes, a route's interval stops and its fetch aborts).
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.23.0 — 2026-09-27++++**Web runtime** (roadmap WS-P task 6). New browser file `web-runtime.js` (`OVWebRuntime.create(options)`), in `files.js` and exported as `openvibe-shared/web-runtime`: OpenVibe.Live's feature loader (`public/js/ov-loader.js`), made shareable and extended. 6.1 KB brotli, budget 6.5 KB.++- **Carried over from Live.** Features from a registry (fragment, deps, css, js, `after` hook, `stubs`, `idle`) and routes; scripts and stylesheets once each, the server's own tags included; versioned URLs; route generations; route scopes (timers, listeners, observers, AbortSignal); intent and idle prefetch; stubs for inline handlers; a retry box for a route that failed.++- **Transactional asset groups.** A feature commits (hook, event, loaded) only when every script loaded. A failure withdraws the stylesheets that attempt added (unless another feature wants them) and the failed tag; a retry fetches only what is missing, and nothing runs twice.++- **Scopes refuse late registrations.** An interval, timeout, listener, observer, fetch or child scope registered after its route ended is refused and counted, not left running. `scope.fetch()` uses the route's signal; `scope.child()` scopes one component; `scope.held()` counts what a scope holds. Fired timeouts are released.++- **A prefetch budget**: a count, and bytes where sizes are known.++- **Diagnostics**: `diagnostics()` and `leaks()` (duplicate tags, failed and rolled-back assets, late registrations, what the current route holds).++- **Other changes.** `showRouteError` builds its box from DOM nodes. Assets from another origin are keyed by origin and path, so a CDN file never collides with a local one. The idle prefetch also starts when the page has already loaded.++++`test/web-runtime.test.js` covers it in a linkedom page where tags load or fail on cue. `test/web-runtime-chrome.test.js` covers it in headless Chrome against a local site: a slow script still runs in order, a real 404 rolls the group back and the retry completes it, and a route's interval stops and its fetch aborts.++## 1.22.0 — 2026-09-26**The notification bell in realtime** (roadmap WS-E task 3, WS-F task 1; openvibe-contracts 0.61.0, ADR-005 amendment 2). It is off by default. A site turns it on with `OpenVibeNavbar.init({ notificationsRealtime: true })`, or with `OpenVibeNotifications.init({ realtime: true })`.@@@ -23,7 +23,7 @@ were reconciled first.| Kind | Files ||---|---|--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` || Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |@@@ -402,6 +402,31 @@ tag's tree either way.- A new browser file is added to `files.js` in the same release, so sites that serve from`node_modules` pick it up.++**Web runtime: a site's feature loader (1.23.0).** `web-runtime.js` (`OVWebRuntime`) is OpenVibe.Live's route loader, made shareable (roadmap WS-P task 6). A site describes its features once and loads each when it is needed:++++```js++const rt = OVWebRuntime.create({++ features: { player: { fragment: 'player', deps: ['base'], css: ['/css/player.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] } },++ routes: [{ path: '^/watch/', features: ['player'] }], // path: a RegExp or its source++ versions: { '/js/p1.js': '<hash>' }, // → /js/p1.js?v=<hash>; or url: (p) => …++ styleSlot: (href) => node, // insert a stylesheet before this node (cascade order); default: last in <head>++ fragmentPath: (name) => `/fragments/${name}.html`, // loaded into #page-<name> (or `section`) once++ prefetch: { count: 60, bytes: 3 * 1024 * 1024, sizes: { '/js/p1.js': 12345 } },++ onStubError: (err, feature) => toast('That part of the site could not load.'),++ routeError: { icon: 'fa-solid fa-plug-circle-xmark' }, debug: false, eventPrefix: 'ov',++});++rt.boot({ initial: ['player'] }); // adopt the server's tags, load these, prefetch on link intent and when idle++```++++- **Loading.** `load(name)` puts in the feature's fragment first, then its dependencies, then its stylesheets and scripts (injected together, run in order), then calls `after` once. It fires `ov:feature` (and `ov:fragment` on the section). One promise per feature; tags already in the document count as loaded. `route(path)` loads every feature its routes name. `loadScript(src, { integrity, crossOrigin })` and `loadStyle(href)` load single files once, another origin's included.++- **Transactional groups.** A feature is loaded only when every script loaded. When one fails, the attempt's own stylesheets leave the document (unless another feature wants them), the failed tag is removed, no hook runs, and a retry fetches only what is missing. A script that ran is never fetched or run again. A missing stylesheet never blocks: it waits at most 4 s.++- **Route scopes.** `nextRoute()` starts a generation (`gen()`, `isCurrent(gen)`) and ends the previous route's scope. `scope()` owns `interval`, `timeout`, `listen`, `observe`, `onDispose`, `fetch` (its `signal` aborts when the route ends) and `child()` scopes for one component. Anything registered on an ended scope is refused and counted, never left running (`debug: true` also warns).++- **Prefetch.** `prefetch(name)` and `prefetchRoute(path)` download without running (`link rel=prefetch`). Hover, focus and touch on in-site links prefetch their route. They skip Save-Data, 2g and devices under 2 GB of memory, and stop at the budget: a count, and bytes where `sizes` are known.++- **Diagnostics.** `diagnostics()` lists loaded, loading and failed features, failed and rolled-back assets, duplicate tags in the document, late registrations, what the current scope holds and the prefetch budget spent. `leaks()` returns the problems as sentences, or `[]`. Browser smoke tests can compare them across navigations.++- **Also:** `installStubs()` (global functions that load their feature, then call the real one, for inline handlers) and `showRouteError(pageId, err, retry)` (a retry box built from DOM nodes).++++It is 6.1 KB brotli (budget 6.5 KB). Serve it beside the other browser files and load it before the site's own loader. `test/web-runtime.test.js` (linkedom) and `test/web-runtime-chrome.test.js` (headless Chrome: real 404s, ordering, scopes) cover it.++## CharterThe canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer,@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "1.8.0",++ "version": "1.23.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-24"++ "updated": "2026-09-27"}@@@ -32,6 +32,7 @@ const BROWSER = Object.freeze(['tooltip.js','release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand++ 'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader'openvibe-sw.js', // Web Push service worker]);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.22.0",++ "version": "1.23.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.22.0",++ "version": "1.23.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.22.0",++ "version": "1.23.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -57,7 +57,8 @@"./trace": "./trace.js","./ui": "./ui.js","./url-resolver": "./url-resolver.js",-- "./user-card": "./user-card.js"++ "./user-card": "./user-card.js",++ "./web-runtime": "./web-runtime.js"},"files": ["*.js",nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++// web-runtime.js in a real headless Chrome against a local site (roadmap WS-P task 6): scripts injected together run in++// order even when one is slow; the server's own tags are not fetched again; a real 404 rolls the group back (its new++// stylesheet leaves the document, the script that ran is not fetched or run again) and a retry completes it; a route++// scope's interval stops and its fetch is aborted on nextRoute(). Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('web-runtime-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const hits = {};++let flakyFails = 1;++const JS = {++ '/js/server.js': 'window.order.push("server");',++ '/js/a1.js': 'window.order.push("a1");',++ '/js/a2.js': 'window.order.push("a2");', // served slowly++ '/js/a3.js': 'window.order.push("a3");',++ '/js/ok.js': 'window.order.push("ok");',++ '/js/flaky.js': 'window.order.push("flaky"); window.flakyReady = function () { return "ready"; };',++};++const CSS = { '/css/a.css': 'body { --a: 1; }', '/css/g.css': 'body { --g: 1; }' };++const FEATURES = {++ seq: { css: ['/css/a.css'], js: ['/js/server.js', '/js/a1.js', '/js/a2.js', '/js/a3.js'] },++ group: { css: ['/css/g.css'], js: ['/js/ok.js', '/js/flaky.js'], stubs: ['flakyReady'] },++};++const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>runtime</title><link rel="icon" href="data:,">++<script>window.order = [];</script><script src="/js/server.js?v=1"></script><script src="/web-runtime.js"></script></head><body><p>runtime</p>++<script>window.rt = OVWebRuntime.create({ features: ${JSON.stringify(FEATURES)}, versions: { '/js/a1.js': 'h1' } }); rt.boot({ idle: false });</script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ hits[u.pathname] = (hits[u.pathname] || 0) + 1;++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ if (u.pathname === '/web-runtime.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'web-runtime.js'))); }++ if (u.pathname === '/js/flaky.js' && flakyFails > 0) { flakyFails--; res.writeHead(404); return res.end(); }++ if (u.pathname === '/slow') { setTimeout(() => { try { res.end('late'); } catch { /* */ } }, 3000); return undefined; }++ if (JS[u.pathname]) {++ const send = () => { res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' }); res.end(JS[u.pathname]); };++ return u.pathname === '/js/a2.js' ? void setTimeout(send, 400) : send();++ }++ if (CSS[u.pathname]) { res.writeHead(200, { 'content-type': 'text/css', 'cache-control': 'no-store' }); return res.end(CSS[u.pathname]); }++ res.writeHead(404); return res.end();++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-web-runtime-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const page = await h.openPage(browser, context, { width: 390 });++ await page.goto(`${base}/`, { maxMs: 5000 });++++ // In order, the slow one included; the server's tag adopted, not fetched again.++ let v = await page.evaluate('rt.load("seq").then(() => ({ order: window.order, sheets: [...document.querySelectorAll("link[rel=stylesheet]")].map((l) => new URL(l.href).pathname) }))');++ assert.deepStrictEqual(v.order, ['server', 'a1', 'a2', 'a3']);++ assert.deepStrictEqual(v.sheets, ['/css/a.css']);++ assert.strictEqual(hits['/js/server.js'], 1, 'the server\'s tag counts as loaded');++++ // A real 404: the group rolls back; the retry (here through a stub) fetches only what is missing.++ v = await page.evaluate(`rt.load('group').then(() => 'loaded', (e) => ({ error: e.message, sheets: [...document.querySelectorAll('link[rel=stylesheet]')].map((l) => new URL(l.href).pathname),++ scripts: [...document.querySelectorAll('script[src]')].map((s) => new URL(s.src).pathname), failed: rt.diagnostics().features.failed.map((f) => f.name), rolledBack: rt.diagnostics().assets.rolledBack }))`);++ assert.strictEqual(v.error, 'Could not load /js/flaky.js');++ assert.deepStrictEqual(v.sheets, ['/css/a.css'], 'g.css withdrawn');++ assert.ok(!v.scripts.includes('/js/flaky.js') && v.scripts.includes('/js/ok.js'));++ assert.deepStrictEqual([v.failed, v.rolledBack], [['group'], 1]);++ v = await page.evaluate(`(rt.installStubs(), window.flakyReady()).then((r) => ({ r, order: window.order, leaks: rt.leaks(), dg: rt.diagnostics() }))`);++ assert.strictEqual(v.r, 'ready');++ assert.deepStrictEqual(v.order, ['server', 'a1', 'a2', 'a3', 'ok', 'flaky'], 'ok.js ran once');++ assert.deepStrictEqual([hits['/js/ok.js'], hits['/js/flaky.js'], hits['/css/g.css']], [1, 2, 2]);++ assert.deepStrictEqual([v.leaks, v.dg.features.failed, v.dg.assets.duplicates], [[], [], []]);++++ // Route scopes: the interval stops, the fetch is aborted.++ v = await page.evaluate(`new Promise((done) => {++ rt.nextRoute(); const s = rt.scope(); window.ticks = 0;++ s.interval(() => { window.ticks++; }, 20);++ const f = s.fetch('/slow').then(() => 'finished', (e) => e.name);++ setTimeout(() => { rt.nextRoute(); const at = window.ticks; setTimeout(() => f.then((r) => done({ at, after: window.ticks, fetch: r, late: s.interval(() => {}, 5) })), 200); }, 150);++ })`);++ assert.ok(v.at > 0 && v.after === v.at, `ticks stop at nextRoute (${v.at} → ${v.after})`);++ assert.deepStrictEqual([v.fetch, v.late], ['AbortError', 0]);++ const errors = page.state.errors.filter((e) => !/Failed to load resource|flaky\.js/.test(e.text));++ assert.deepStrictEqual(errors, [], 'no page errors besides the planned 404');++ } finally {++ await browser.close().catch(() => {});++ server.close(); server.closeAllConnections?.();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++ console.log('web-runtime-chrome: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,293 @@++'use strict';++// web-runtime.js (roadmap WS-P task 6), in a linkedom page where appended <script>/<link> tags "load" or "fail" on cue:++// - a feature loads its fragment first, then deps, stylesheets and scripts (versioned, in order), then its hook, once;++// tags the server already wrote count as loaded; nothing is added twice;++// - asset groups are transactional: a failed script withdraws the stylesheets that attempt added (not one another++// feature wants), removes its own tag, runs no hook, and a retry fetches only what is missing;++// - route scopes release timers, listeners, observers, fetches and child scopes on nextRoute(); anything registered++// on an ended route is refused and counted;++// - prefetch stays within its count and byte budget, skips constrained connections, follows link intent (not //host);++// - stubs load their feature and call the real function; showRouteError builds DOM (no markup from the error);++// - diagnostics and leaks() report duplicate tags, failures, rollbacks and late registrations.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const linkedom = require('linkedom');++++const ROOT = path.join(__dirname, '..');++const SRC = fs.readFileSync(path.join(ROOT, 'web-runtime.js'), 'utf8');++// Values built inside the page's realm compare by their JSON form.++const deq = (a, b, m) => assert.deepStrictEqual(JSON.parse(JSON.stringify(a)), b, m);++++function page({ html = '', connection = null } = {}) {++ const { window: lw, document } = linkedom.parseHTML(`<!doctype html><html><head>${html}<meta name="anchor"></head><body><section id="page-player"></section><section id="page-ops"></section><a id="in" href="/ops?x=1">ops</a><a id="out" href="//evil.example/ops">x</a></body></html>`);++ Object.defineProperty(document, 'readyState', { configurable: true, value: 'complete' });++ const out = { appended: [], fetches: [], timers: [], intervals: new Set(), log: [] };++ const behave = Object.create(null); // path → 'fail' | 'hold'++ const effects = Object.create(null); // path → function run when the script "executes"++ const held = [];++ let tid = 0;++ const ctx = {++ document, URL, Promise, JSON, Object, Array, Set, Error,++ console: { error: (...a) => out.log.push(['error', a.join(' ')]), warn: (...a) => out.log.push(['warn', a.join(' ')]), log() {} },++ CustomEvent: lw.CustomEvent || linkedom.CustomEvent, Event: lw.Event || linkedom.Event,++ location: { href: 'https://site.example/player', origin: 'https://site.example', pathname: '/player' },++ navigator: connection ? { connection } : {},++ AbortController,++ setTimeout: (f, ms) => { out.timers.push({ id: ++tid, f, ms }); return tid; },++ clearTimeout: (id) => { out.timers = out.timers.filter((t) => t.id !== id); },++ setInterval: () => { const id = ++tid; out.intervals.add(id); return id; },++ clearInterval: (id) => { out.intervals.delete(id); },++ addEventListener() {}, removeEventListener() {},++ fetch: async (u, init = {}) => {++ out.fetches.push({ url: String(u), init });++ if (/missing/.test(u)) return { ok: false, status: 404, text: async () => '' };++ return { ok: true, status: 200, text: async () => `<div class="frag">${String(u).split('?')[0]}</div>` };++ },++ };++ ctx.window = ctx; ctx.globalThis = ctx;++ vm.createContext(ctx);++ const fire = (node, kind) => { const h = node[`on${kind}`]; if (h) h(new ctx.Event(kind)); };++ const hook = (parent) => {++ const append = parent.appendChild.bind(parent);++ const insert = parent.insertBefore.bind(parent);++ const once = new WeakSet(); // linkedom's appendChild goes through insertBefore++ const seen = (node) => {++ if (once.has(node)) return;++ once.add(node);++ const src = node.tagName === 'SCRIPT' ? node.getAttribute('src') : node.tagName === 'LINK' && node.getAttribute('rel') === 'stylesheet' ? node.getAttribute('href') : null;++ if (!src) return;++ out.appended.push(src);++ const bare = src.split('?')[0];++ setImmediate(() => {++ if (behave[bare] === 'hold') { held.push(node); return; }++ if (behave[bare] === 'fail') return fire(node, 'error');++ if (effects[bare]) effects[bare](ctx);++ fire(node, 'load');++ });++ };++ parent.appendChild = (node) => { const r = append(node); seen(node); return r; };++ parent.insertBefore = (node, ref) => { const r = insert(node, ref); seen(node); return r; };++ };++ hook(document.head);++ vm.runInContext(SRC, ctx, { filename: 'web-runtime.js' });++ const tags = (sel) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute('src') || e.getAttribute('href'));++ return { ctx, document, out, behave, effects, held, fire, tags, RT: ctx.OVWebRuntime };++}++++const FEATURES = {++ base: { css: ['/css/base.css'], js: ['/js/base.js'] },++ player: { fragment: 'player', deps: ['base'], css: ['/css/player.css', '/css/shared.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] },++ ops: { fragment: 'ops', css: ['/css/shared.css', '/css/ops.css'], js: ['/js/ops.js'] },++ broken: { fragment: 'missing', section: 'page-ops', js: ['/js/never.js'] },++ big: { js: ['/js/big1.js', '/js/big2.js', '/js/big3.js'] },++};++const ROUTES = [{ path: '^/player', features: ['player'] }, { path: '^/ops', features: ['ops'] }, { path: '^/(player|ops)', features: ['base', 'player'] }];++++(async () => {++ // ── A feature: fragment, deps, versioned assets in order, the hook once ──────────────────++ {++ const p = page({ html: '<script src="/js/base.js?v=old"></script>' });++ let hooks = 0;++ p.ctx.initPlayer = () => { hooks++; };++ const events = [];++ p.document.addEventListener('ov:feature', (e) => events.push(e.detail.name));++ const rt = p.RT.create({ features: FEATURES, routes: ROUTES, versions: { '/js/p1.js': 'aaa', '/fragments/player.html': 'fff' }, styleSlot: () => p.document.querySelector('meta[name="anchor"]') });++ rt.adoptExisting();++ deq(rt.featuresFor('/player/x'), ['player', 'base'], 'routes merged in order, without repeats');++ const a = rt.load('player');++ assert.strictEqual(rt.load('player'), a, 'one promise per feature');++ await a;++ await rt.load('player');++ assert.strictEqual(p.out.fetches[0].url, '/fragments/player.html?v=fff', 'the fragment first, versioned');++ assert.match(p.document.getElementById('page-player').innerHTML, /frag/);++ assert.strictEqual(p.document.getElementById('page-player').dataset.fragmentLoaded, '1');++ deq(p.out.appended, ['/css/base.css', '/css/player.css', '/css/shared.css', '/js/p1.js?v=aaa', '/js/p2.js'], 'base.js was the server\'s; dep first; scripts in order');++ deq(p.tags('script[src]'), ['/js/base.js?v=old', '/js/p1.js?v=aaa', '/js/p2.js']);++ assert.ok(p.document.querySelector('meta[name="anchor"]').previousElementSibling.getAttribute('href') === '/css/shared.css', 'styleSlot places stylesheets');++ deq([hooks, events], [1, ['base', 'player']]);++ assert.ok(rt.isLoaded('player'));++ deq(rt.diagnostics().features.loaded.sort(), ['base', 'player']);++ deq(rt.leaks(), []);++ // A route: its features, as one promise; nothing twice.++ await rt.route('/ops');++ assert.strictEqual(p.out.appended.filter((x) => x === '/css/shared.css').length, 1);++ assert.strictEqual(p.RT.assetKey('https://site.example/js/a.js?v=1'), '/js/a.js');++ assert.strictEqual(p.RT.assetKey('https://cdn.example/x/a.js?v=1'), 'https://cdn.example/x/a.js', 'another origin keeps its origin');++ }++++ // ── Transactional groups: a failed script rolls the attempt back; a retry fetches only what is missing ──++ {++ const p = page();++ const rt = p.RT.create({ features: FEATURES, routes: ROUTES });++ let hooks = 0;++ p.ctx.initPlayer = () => { hooks++; };++ await rt.load('ops'); // owns /css/shared.css++ p.behave['/js/p2.js'] = 'fail';++ await assert.rejects(rt.load('player'), /Could not load \/js\/p2\.js/);++ deq(p.tags('link[rel="stylesheet"]').sort(), ['/css/base.css', '/css/ops.css', '/css/shared.css'], 'player.css withdrawn; shared.css stays (ops wants it); base loaded fine');++ assert.ok(!p.tags('script[src]').includes('/js/p2.js'), 'the failed tag is gone');++ assert.strictEqual(hooks, 0, 'no hook for a half-loaded feature');++ assert.ok(!rt.isLoaded('player'));++ let dg = rt.diagnostics();++ deq(dg.features.failed.map((f) => [f.name, f.attempts]), [['player', 1]]);++ deq(dg.assets.failed.map((f) => f.asset), ['/js/p2.js']);++ assert.strictEqual(dg.assets.rolledBack, 1);++ delete p.behave['/js/p2.js'];++ const before = p.out.appended.length;++ await rt.load('player');++ deq(p.out.appended.slice(before), ['/css/player.css', '/js/p2.js'], 'p1.js ran once and is not fetched again');++ assert.strictEqual(hooks, 1);++ dg = rt.diagnostics();++ deq([dg.features.failed, dg.assets.failed, dg.assets.duplicates], [[], [], []]);++++ // A stylesheet another feature (still loading) wants stays when one feature's attempt fails.++ const q = page();++ const rt2 = q.RT.create({ features: { a: { css: ['/css/s.css'], js: ['/js/a.js'] }, b: { css: ['/css/s.css'], js: ['/js/b.js'] } } });++ q.behave['/js/a.js'] = 'fail'; q.behave['/js/b.js'] = 'hold';++ const pa = rt2.load('a'); const pb = rt2.load('b');++ await assert.rejects(pa);++ deq(q.tags('link[rel="stylesheet"]'), ['/css/s.css'], 'b (in flight) still wants it');++ await new Promise((r) => setImmediate(r)); // b.js has been appended and is held++ q.fire(q.held.pop(), 'load');++ await pb;++ deq(q.tags('link[rel="stylesheet"]'), ['/css/s.css']);++++ // A failed stylesheet does not block; its tag is removed.++ const r = page();++ const rt3 = r.RT.create({ features: { c: { css: ['/css/gone.css'], js: ['/js/c.js'] } } });++ r.behave['/css/gone.css'] = 'fail';++ await rt3.load('c');++ deq(r.tags('link[rel="stylesheet"]'), []);++ deq(rt3.diagnostics().assets.failed.map((f) => f.kind), ['style']);++ // A missing fragment fails the feature and can be retried.++ const rt4 = r.RT.create({ features: FEATURES });++ await assert.rejects(rt4.load('broken'), /HTTP 404/);++ assert.strictEqual(r.document.getElementById('page-ops').getAttribute('aria-busy'), null);++ }++++ // ── Route scopes ──────────────────────────────────────────────────────────────────────────++ {++ const p = page();++ const rt = p.RT.create({ features: FEATURES, debug: true });++ const g1 = rt.nextRoute();++ const s = rt.scope();++ assert.strictEqual(s.gen, g1);++ const target = p.document.getElementById('in');++ let clicks = 0;++ s.interval(() => {}, 1000);++ const tId = s.timeout(() => {}, 500);++ s.timeout(() => {}, 900);++ s.listen(target, 'click', () => { clicks++; });++ let disconnected = 0;++ s.observe({ disconnect() { disconnected++; } });++ const child = s.child();++ child.interval(() => {}, 50);++ let fetchSignal = null;++ p.ctx.fetch = async (u, init) => { fetchSignal = init.signal; return { ok: true }; };++ await s.fetch('/api/x');++ deq(s.held(), { interval: 1, timeout: 2, listener: 1, observer: 1, child: 1 });++ const due = p.out.timers.find((t) => t.id === tId);++ p.out.timers = p.out.timers.filter((t) => t !== due);++ due.f(); // a timeout that fired is no longer held++ assert.strictEqual(s.held().timeout, 1);++ child.dispose();++ assert.strictEqual(s.held().child, undefined, 'a child that ended early leaves its parent');++ target.dispatchEvent(new p.ctx.Event('click'));++ assert.strictEqual(clicks, 1);++++ const g2 = rt.nextRoute();++ assert.ok(!rt.isCurrent(g1) && rt.isCurrent(g2) && rt.gen() === g2);++ assert.ok(s.disposed && fetchSignal.aborted, 'the route\'s signal aborts');++ assert.strictEqual(p.out.intervals.size, 0, 'every interval cleared (the child\'s too)');++ assert.strictEqual(p.out.timers.length, 0, 'every pending timeout cleared');++ target.dispatchEvent(new p.ctx.Event('click'));++ deq([clicks, disconnected], [1, 1]);++++ // Late registrations: code that awaited past its route cannot leave anything running.++ assert.strictEqual(s.interval(() => {}, 10), 0);++ s.listen(target, 'click', () => { clicks++; });++ let ran = 0;++ s.onDispose(() => { ran++; });++ await assert.rejects(s.fetch('/api/y'), (e) => e.name === 'AbortError');++ target.dispatchEvent(new p.ctx.Event('click'));++ deq([p.out.intervals.size, clicks, ran], [0, 1, 1]);++ const dg = rt.diagnostics();++ assert.strictEqual(dg.scopes.late, 4);++ deq(dg.scopes.current, {});++ assert.ok(rt.leaks().some((x) => /4 registration\(s\) arrived after their route ended/.test(x)));++ assert.ok(p.out.log.some(([k, m]) => k === 'warn' && /interval registered after its route ended/.test(m)), 'debug warns');++ }++++ // ── Prefetch: budget, constrained connections, link intent ───────────────────────────────++ {++ const p = page();++ const rt = p.RT.create({ features: FEATURES, routes: ROUTES, prefetch: { count: 4, bytes: 250, sizes: { '/js/big1.js': 100, '/js/big2.js': 100, '/js/big3.js': 100 } } });++ rt.prefetch('big');++ let links = p.tags('link[rel="prefetch"]');++ deq(links, ['/js/big1.js', '/js/big2.js'], 'the byte budget stops the third');++ rt.prefetch('ops');++ links = p.tags('link[rel="prefetch"]');++ assert.strictEqual(links.length, 4, 'the count budget');++ const dg = rt.diagnostics().prefetch;++ deq([dg.count, dg.bytes, dg.skipped > 0], [4, 200, true]);++ assert.strictEqual(p.document.querySelector('link[rel="prefetch"]').getAttribute('as'), 'script');++++ const q = page();++ const rt2 = q.RT.create({ features: FEATURES, routes: ROUTES });++ await rt2.load('base');++ rt2.watchIntent(q.document);++ q.document.getElementById('out').dispatchEvent(new q.ctx.Event('pointerover', { bubbles: true }));++ deq(q.tags('link[rel="prefetch"]'), [], '//host is not this site');++ q.document.getElementById('in').dispatchEvent(new q.ctx.Event('pointerover', { bubbles: true }));++ deq(q.tags('link[rel="prefetch"]').sort(), ['/css/ops.css', '/css/player.css', '/css/shared.css', '/fragments/ops.html', '/fragments/player.html', '/js/ops.js', '/js/p1.js', '/js/p2.js'], 'the hovered route\'s features (ops, player), not base, which is loaded');++++ const c = page({ connection: { saveData: true } });++ const rt3 = c.RT.create({ features: FEATURES });++ rt3.prefetch('ops');++ deq(c.tags('link[rel="prefetch"]'), []);++ assert.strictEqual(rt3.diagnostics().prefetch.constrained, 1);++ }++++ // ── Stubs, route errors, duplicates ──────────────────────────────────────────────────────++ {++ const p = page();++ const errors = [];++ const rt = p.RT.create({ features: FEATURES, onStubError: (e, name) => errors.push(name), routeError: { icon: 'fa-solid fa-plug' } });++ p.effects['/js/p2.js'] = (ctx) => { ctx.openPlayer = (x) => `opened ${x}`; };++ rt.installStubs();++ assert.strictEqual(p.ctx.openPlayer.__ovStub, 'player');++ assert.strictEqual(await p.ctx.openPlayer('now'), 'opened now', 'the stub loads the feature, then calls the real function');++ const q = page();++ const rt2 = q.RT.create({ features: FEATURES, onStubError: (e, name) => errors.push(name) });++ q.behave['/js/p1.js'] = 'fail';++ rt2.installStubs();++ await q.ctx.openPlayer();++ deq(errors, ['player']);++++ let retried = 0;++ rt.showRouteError('page-ops', new Error('<img src=x onerror=alert(1)>'), () => { retried++; });++ rt.showRouteError('page-ops', new Error('again'), () => { retried++; });++ const boxes = p.document.querySelectorAll('#page-ops > .ov-route-error');++ assert.strictEqual(boxes.length, 1, 'one box per page');++ assert.ok(!/img/.test(boxes[0].innerHTML), 'the error text is not markup');++ assert.strictEqual(boxes[0].querySelector('i').className, 'fa-solid fa-plug');++ boxes[0].querySelector('button').dispatchEvent(new p.ctx.Event('click'));++ assert.strictEqual(retried, 1);++ assert.strictEqual(p.document.querySelectorAll('.ov-route-error').length, 0);++++ const extra = p.document.createElement('script');++ extra.setAttribute('src', '/js/p1.js?v=zzz');++ p.document.body.appendChild(extra);++ deq(rt.diagnostics().assets.duplicates, [{ asset: '/js/p1.js', count: 2 }]);++ assert.ok(rt.leaks().some((x) => /\/js\/p1\.js is in the document 2 times/.test(x)));++ }++++ // ── Size: it replaces a site's own loader ────────────────────────────────────────────────++ const { brotli } = require('../scripts/size-report');++ const kb = brotli(Buffer.from(SRC)) / 1024;++ assert.ok(kb <= 6.5, `web-runtime.js is ${kb.toFixed(2)} KB brotli, budget 6.5 KB (Live's own loader was 4.25 KB)`);++++ console.log('web runtime: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,447 @@++/*++ * openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of++ * OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.++ *++ * const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"++ * rt.load('broadcast') → the feature's markup fragment, its dependencies, stylesheets and scripts (in order), then++ * its `after` hook; once, cached; a failed load can be retried.++ * rt.route(path) → every feature the path's routes name, as one promise.++ * rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB++ * of memory, and within a budget (count, and bytes where sizes are known).++ * rt.nextRoute() / rt.gen() / rt.isCurrent(gen)++ * → route generations: a loader that awaited something can tell whether the visitor moved on.++ * rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current++ * route, released by the next nextRoute(). Anything registered on a scope whose route has++ * ended is refused (and counted), never left running.++ * rt.diagnostics(), rt.leaks()++ * → loaded, failed and rolled-back features, duplicate tags, late registrations, what the++ * current route holds, and the prefetch budget spent.++ *++ * Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when++ * every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out++ * again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are++ * kept, so nothing runs twice.++ * Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick++ * handlers working before their feature has loaded.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVWebRuntime) return;++++ /** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */++ function assetKey(src) {++ try {++ const u = new URL(src, root.location.href);++ return u.origin === root.location.origin ? u.pathname : u.origin + u.pathname;++ } catch { return String(src).split(/[?#]/)[0]; }++ }++++ function create(o = {}) {++ const FEATURES = o.features || {};++ const ROUTES = (o.routes || []).map((r) => ({ re: r.path instanceof RegExp ? r.path : new RegExp(r.path), features: r.features || [] }));++ const versions = o.versions || {};++ const G = o.global || root;++ const prefix = o.eventPrefix || 'ov';++ const log = o.log || root.console;++ const styleTimeout = o.styleTimeout || 4000;++ const fragmentPath = o.fragmentPath || ((name) => `/fragments/${name}.html`);++ const url = o.url || ((p) => { const h = versions[p]; return h ? `${p}${p.indexOf('?') === -1 ? '?' : '&'}v=${h}` : p; });++ const d = { scripts: 0, styles: 0, late: 0, rolledBack: 0, failed: Object.create(null), scopes: 0, disposed: 0 };++ const emit = (type, detail, target) => (target || document).dispatchEvent(new root.CustomEvent(type, { bubbles: true, detail }));++ 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 || ''); };++++ // ── Scripts and styles, once each ────────────────────────────────────────────────────────++ const scripts = Object.create(null); // key → promise++ const styles = Object.create(null); // key → promise++ const styleEls = Object.create(null); // key → the <link> this runtime inserted++ const wanted = Object.create(null); // key → Set of features loading or loaded that list it++++ function tagPromise(el, isScript) {++ // A deferred script that has run fired `load` long ago; after DOMContentLoaded every parser-inserted script has run.++ if (isScript && document.readyState !== 'loading' && !el.dataset.ovInjected) return Promise.resolve();++ return new Promise((resolve) => {++ el.addEventListener('load', () => resolve(), { once: true });++ el.addEventListener('error', () => resolve(), { once: true });++ if (isScript && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });++ });++ }++ /** Tags already in the document (the server's, or another loader's) count as loaded or loading. */++ function adoptExisting() {++ document.querySelectorAll('script[src]').forEach((t) => { const k = assetKey(t.getAttribute('src')); if (!scripts[k]) scripts[k] = tagPromise(t, true); });++ 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); });++ }++++ /** attrs: { integrity, crossOrigin } for another origin's file. */++ function loadScript(p, attrs) {++ const k = assetKey(p);++ if (scripts[k]) return scripts[k];++ const s = document.createElement('script');++ s.src = url(p);++ s.async = false; // injected together → run in insertion order++ s.dataset.ovInjected = '1';++ if (attrs && attrs.integrity) s.integrity = attrs.integrity;++ if (attrs && attrs.crossOrigin) s.crossOrigin = attrs.crossOrigin;++ scripts[k] = new Promise((resolve, reject) => {++ s.onload = () => { delete d.failed[k]; resolve(); };++ s.onerror = () => {++ delete scripts[k]; // a retry adds a fresh tag++ s.remove();++ const err = new Error(`Could not load ${p}`);++ failed('script', k, err);++ reject(err);++ };++ });++ document.head.appendChild(s);++ d.scripts++;++ return scripts[k];++ }++++ /** Never rejects: a missing stylesheet must not block a route (it waits at most styleTimeout ms). */++ function loadStyle(p, added) {++ const k = assetKey(p);++ if (styles[k]) return styles[k];++ const l = document.createElement('link');++ l.rel = 'stylesheet';++ l.href = url(p);++ const pr = styles[k] = new Promise((resolve) => {++ let done = false;++ const finish = () => { if (!done) { done = true; resolve(); } };++ l.onload = () => { delete d.failed[k]; finish(); };++ l.onerror = () => {++ if (styles[k] === pr) { delete styles[k]; delete styleEls[k]; }++ l.remove();++ failed('style', k, 'error');++ finish();++ };++ root.setTimeout(finish, styleTimeout);++ });++ // o.styleSlot(p) → the node it goes before (a site's cascade order), or nothing: last in <head>.++ const before = o.styleSlot ? o.styleSlot(p) : null;++ if (before && before.parentNode) before.parentNode.insertBefore(l, before);++ else document.head.appendChild(l);++ styleEls[k] = l;++ d.styles++;++ if (added) added.push(k);++ return pr;++ }++++ // ── Markup fragments ─────────────────────────────────────────────────────────────────────++ const fragments = Object.create(null);++ function loadFragment(name, sectionId) {++ const section = document.getElementById(sectionId);++ if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();++ if (fragments[name]) return fragments[name];++ section.setAttribute('aria-busy', 'true');++ fragments[name] = root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' })++ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })++ .then((html) => {++ // Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.++ if (section.dataset.fragmentLoaded !== '1') {++ section.innerHTML = html;++ section.dataset.fragmentLoaded = '1';++ emit(`${prefix}:fragment`, { name }, section);++ }++ section.removeAttribute('aria-busy');++ }, (err) => {++ delete fragments[name];++ section.removeAttribute('aria-busy');++ failed('fragment', name, err);++ throw err;++ });++ return fragments[name];++ }++++ // ── Features (transactional asset groups) ─────────────────────────────────────────────────++ const features = Object.create(null); // name → promise, in flight or loaded++ const loaded = Object.create(null);++ const afterRan = Object.create(null);++++ function load(name) {++ const f = FEATURES[name];++ if (!f) return Promise.resolve();++ if (features[name]) return features[name];++ const css = (f.css || []).map(assetKey);++ css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));++ const added = [];++ // The feature's markup goes in before its dependencies and scripts run: modules bind to it when they load.++ const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`) : Promise.resolve();++ features[name] = markup++ .then(() => Promise.all((f.deps || []).map(load)))++ .then(() => Promise.all((f.css || []).map((p) => loadStyle(p, added)).concat((f.js || []).map((p) => loadScript(p)))))++ .then(() => {++ loaded[name] = true;++ delete d.failed[name];++ if (f.after && !afterRan[name] && typeof G[f.after] === 'function') {++ afterRan[name] = true;++ try { G[f.after](); } catch (e) { log.error(`[${prefix}] ${name} after-hook failed:`, e); }++ }++ emit(`${prefix}:feature`, { name });++ }, (err) => {++ delete features[name];++ css.forEach((k) => wanted[k] && wanted[k].delete(name));++ for (const k of added) {++ if (wanted[k] && wanted[k].size) continue;++ if (styleEls[k]) styleEls[k].remove();++ delete styleEls[k]; delete styles[k];++ d.rolledBack++;++ }++ failed('feature', name, err);++ throw err;++ });++ return features[name];++ }++++ function featuresFor(path) {++ const out = [];++ ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });++ return out;++ }++ function route(path) { return Promise.all(featuresFor(path || root.location.pathname).map(load)); }++++ // ── Prefetch (download, never execute), within a budget ──────────────────────────────────++ const pf = o.prefetch || {};++ const budget = { count: pf.count == null ? 60 : pf.count, bytes: pf.bytes == null ? 3 * 1024 * 1024 : pf.bytes };++ const sizes = pf.sizes || {};++ const spent = { count: 0, bytes: 0, skipped: 0, constrained: 0 };++ const prefetched = Object.create(null);++ function constrained() {++ const c = root.navigator && root.navigator.connection;++ if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return true;++ return !!root.navigator && typeof root.navigator.deviceMemory === 'number' && root.navigator.deviceMemory < 2;++ }++ function prefetchAsset(p) {++ const k = assetKey(p);++ if (prefetched[k] || scripts[k] || styles[k]) return;++ const size = Number(sizes[p]) || 0;++ if (spent.count + 1 > budget.count || spent.bytes + size > budget.bytes) { spent.skipped++; return; }++ prefetched[k] = true; spent.count++; spent.bytes += size;++ const l = document.createElement('link');++ l.rel = 'prefetch';++ l.href = url(p);++ l.setAttribute('as', /\.css$/.test(k) ? 'style' : /\.js$/.test(k) ? 'script' : 'fetch');++ document.head.appendChild(l);++ }++ function prefetch(name, seen) {++ seen = seen || Object.create(null);++ const f = FEATURES[name];++ if (!f || seen[name] || features[name]) return;++ if (constrained()) { spent.constrained++; return; }++ seen[name] = true;++ (f.deps || []).forEach((n) => prefetch(n, seen));++ (f.css || []).concat(f.js || []).forEach(prefetchAsset);++ if (f.fragment) prefetchAsset(fragmentPath(f.fragment));++ }++ function prefetchRoute(path) { featuresFor(path).forEach((n) => prefetch(n)); }++++ /** Hover, focus or touch on an in-site link: fetch that route's code. Returns a function that stops watching. */++ function watchIntent(target) {++ const t = target || document;++ const on = (e) => {++ const a = e.target && e.target.closest && e.target.closest('a[href^="/"]');++ const href = a && a.getAttribute('href');++ if (!href || href.charAt(1) === '/') return;++ const path = href.split(/[?#]/)[0];++ if (path) prefetchRoute(path);++ };++ const kinds = [['pointerover', { passive: true }], ['focusin', undefined], ['touchstart', { passive: true }]];++ kinds.forEach(([k, opts]) => t.addEventListener(k, on, opts));++ return () => kinds.forEach(([k, opts]) => t.removeEventListener(k, on, opts));++ }++ /** The `idle` features of the path's routes, prefetched once the page has settled (load + 4 s + idle). */++ function idlePrefetch(path) {++ const idle = root.requestIdleCallback || ((fn) => root.setTimeout(fn, 3000));++ const go = () => root.setTimeout(() => idle(() => {++ featuresFor(path || root.location.pathname).forEach((n) => (FEATURES[n].idle || []).forEach((m) => prefetch(m)));++ }, { timeout: 15000 }), 4000);++ if (document.readyState === 'complete') go();++ else root.addEventListener('load', go, { once: true });++ }++++ // ── Route generations and scopes ─────────────────────────────────────────────────────────++ let generation = 0;++ let current = null;++++ function makeScope(gen) {++ const held = new Set();++ const controller = typeof root.AbortController === 'function' ? new root.AbortController() : null;++ let disposed = false;++ d.scopes++;++ const late = (kind) => { d.late++; if (o.debug) log.warn(`[${prefix}] ${kind} registered after its route ended (generation ${gen}); ignored`); };++ const hold = (kind, fn) => { const h = { kind, fn }; held.add(h); return h; };++ const scope = {++ gen,++ signal: controller ? controller.signal : undefined,++ interval(fn, ms) {++ if (disposed) { late('interval'); return 0; }++ const id = root.setInterval(fn, ms);++ hold('interval', () => root.clearInterval(id));++ return id;++ },++ timeout(fn, ms) {++ if (disposed) { late('timeout'); return 0; }++ let h = null;++ const id = root.setTimeout(function () { held.delete(h); return fn.apply(this, arguments); }, ms);++ h = hold('timeout', () => root.clearTimeout(id));++ return id;++ },++ listen(target, type, fn, opts) {++ if (!target) return;++ if (disposed) { late('listener'); return; }++ target.addEventListener(type, fn, opts);++ hold('listener', () => target.removeEventListener(type, fn, opts));++ },++ observe(observer) {++ if (disposed) { late('observer'); try { observer.disconnect(); } catch { /* */ } return observer; }++ hold('observer', () => { try { observer.disconnect(); } catch { /* */ } });++ return observer;++ },++ onDispose(fn) {++ if (disposed) { late('onDispose'); try { fn(); } catch { /* */ } return; }++ hold('onDispose', fn);++ },++ /** fetch() cancelled when the route ends (unless init brings its own signal). */++ fetch(input, init) {++ if (disposed) { late('fetch'); return Promise.reject(Object.assign(new Error('The route has ended'), { name: 'AbortError' })); }++ return root.fetch(input, Object.assign({ signal: scope.signal }, init));++ },++ /** A scope for one component (a dialog, a player) that can end early and ends with this one anyway. */++ child() {++ const c = makeScope(gen);++ if (disposed) { late('child'); c.dispose(); return c; }++ const h = hold('child', () => c.dispose());++ c.onDispose(() => held.delete(h));++ return c;++ },++ /** What this scope holds right now, by kind. */++ held() {++ const out = {};++ held.forEach((h) => { out[h.kind] = (out[h.kind] || 0) + 1; });++ return out;++ },++ get disposed() { return disposed; },++ dispose() {++ if (disposed) return;++ disposed = true;++ d.disposed++;++ if (controller) try { controller.abort(); } catch { /* */ }++ const list = Array.from(held).reverse();++ held.clear();++ for (const h of list) { try { h.fn(); } catch (e) { log.warn(`[${prefix}] scope cleanup failed:`, e); } }++ },++ };++ return scope;++ }++ function scope() { if (!current) current = makeScope(generation); return current; }++ /** A new route: everything the previous one owned is released. Returns the new generation. */++ function nextRoute() {++ generation++;++ if (current) current.dispose();++ current = makeScope(generation);++ return generation;++ }++++ // ── Stubs for inline handlers ────────────────────────────────────────────────────────────++ function installStubs() {++ Object.keys(FEATURES).forEach((name) => {++ (FEATURES[name].stubs || []).forEach((fn) => {++ if (typeof G[fn] === 'function') return;++ const stub = function () {++ const args = arguments; const self = this;++ return load(name).then(() => {++ if (G[fn] !== stub && typeof G[fn] === 'function') return G[fn].apply(self, args);++ log.warn(`[${prefix}] ${fn} is unavailable after loading ${name}`);++ }, (err) => {++ log.error(`[${prefix}]`, err);++ if (typeof o.onStubError === 'function') o.onStubError(err, name);++ });++ };++ stub.__ovStub = name;++ G[fn] = stub;++ });++ });++ }++++ // ── Recoverable failure UI for a route ───────────────────────────────────────────────────++ 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);++ function showRouteError(pageId, err, retry) {++ const page = document.getElementById(pageId);++ if (!page) return;++ let box = page.querySelector(':scope > .ov-route-error');++ if (!box) {++ box = document.createElement('div');++ box.className = 'ov-route-error';++ box.setAttribute('role', 'alert');++ page.insertBefore(box, page.firstChild);++ }++ box.textContent = '';++ if (RE.icon) { const i = document.createElement('i'); i.className = RE.icon; i.setAttribute('aria-hidden', 'true'); box.appendChild(i); }++ const msg = document.createElement('div');++ const strong = document.createElement('strong'); strong.textContent = RE.title;++ const span = document.createElement('span'); span.textContent = RE.text;++ msg.appendChild(strong); msg.appendChild(span); box.appendChild(msg);++ const btn = document.createElement('button');++ btn.type = 'button'; btn.className = RE.button; btn.textContent = RE.retry;++ btn.addEventListener('click', () => { box.remove(); retry(); }, { once: true });++ box.appendChild(btn);++ log.error(`[${prefix}] route failed to load:`, err);++ }++++ // ── Diagnostics ──────────────────────────────────────────────────────────────────────────++ function duplicates() {++ const n = Object.create(null);++ document.querySelectorAll('script[src], link[rel="stylesheet"][href]').forEach((el) => {++ const k = assetKey(el.getAttribute('src') || el.getAttribute('href'));++ n[k] = (n[k] || 0) + 1;++ });++ return Object.keys(n).filter((k) => n[k] > 1).map((k) => ({ asset: k, count: n[k] }));++ }++ function diagnostics() {++ return {++ generation,++ features: {++ loaded: Object.keys(loaded),++ loading: Object.keys(features).filter((n) => !loaded[n]),++ 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 })),++ },++ assets: {++ scripts: Object.keys(scripts).length, styles: Object.keys(styles).length,++ injected: { scripts: d.scripts, styles: d.styles },++ 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 })),++ rolledBack: d.rolledBack,++ duplicates: duplicates(),++ },++ scopes: { created: d.scopes, disposed: d.disposed, late: d.late, current: current ? current.held() : {} },++ prefetch: Object.assign({ budget: Object.assign({}, budget) }, spent),++ };++ }++ /** Plain sentences about anything that looks like a leak; empty when all is well. */++ function leaks() {++ const out = [];++ duplicates().forEach((x) => out.push(`${x.asset} is in the document ${x.count} times`));++ if (d.late) out.push(`${d.late} registration(s) arrived after their route ended (refused)`);++ return out;++ }++++ return {++ load, route, featuresFor, prefetch, prefetchRoute, loadScript, loadStyle, loadFragment, url, assetKey,++ gen: () => generation,++ isCurrent: (g) => g === generation,++ scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,++ isLoaded: (name) => !!features[name],++ /** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */++ boot(b = {}) {++ const start = () => {++ adoptExisting();++ (b.initial || []).forEach((n) => { load(n).catch(() => { /* recorded in diagnostics */ }); });++ if (b.intent !== false) watchIntent(document);++ if (b.idle !== false) idlePrefetch();++ };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });++ else start();++ },++ };++ }++++ root.OVWebRuntime = { create, assetKey };++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVWebRuntime;++})(typeof window !== 'undefined' ? window : globalThis);
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.23.0 — 2026-09-27
+
+**Web runtime** (roadmap WS-P task 6). New browser file `web-runtime.js` (`OVWebRuntime.create(options)`), in `files.js` and exported as `openvibe-shared/web-runtime`: OpenVibe.Live's feature loader (`public/js/ov-loader.js`), made shareable and extended. 6.1 KB brotli, budget 6.5 KB.
+- **Carried over from Live.** Features from a registry (fragment, deps, css, js, `after` hook, `stubs`, `idle`) and routes; scripts and stylesheets once each, the server's own tags included; versioned URLs; route generations; route scopes (timers, listeners, observers, AbortSignal); intent and idle prefetch; stubs for inline handlers; a retry box for a route that failed.
+- **Transactional asset groups.** A feature commits (hook, event, loaded) only when every script loaded. A failure withdraws the stylesheets that attempt added (unless another feature wants them) and the failed tag; a retry fetches only what is missing, and nothing runs twice.
+- **Scopes refuse late registrations.** An interval, timeout, listener, observer, fetch or child scope registered after its route ended is refused and counted, not left running. `scope.fetch()` uses the route's signal; `scope.child()` scopes one component; `scope.held()` counts what a scope holds. Fired timeouts are released.
+- **A prefetch budget**: a count, and bytes where sizes are known.
+- **Diagnostics**: `diagnostics()` and `leaks()` (duplicate tags, failed and rolled-back assets, late registrations, what the current route holds).
+- **Other changes.** `showRouteError` builds its box from DOM nodes. Assets from another origin are keyed by origin and path, so a CDN file never collides with a local one. The idle prefetch also starts when the page has already loaded.
+
+`test/web-runtime.test.js` covers it in a linkedom page where tags load or fail on cue. `test/web-runtime-chrome.test.js` covers it in headless Chrome against a local site: a slow script still runs in order, a real 404 rolls the group back and the retry completes it, and a route's interval stops and its fetch aborts.
+
## 1.22.0 — 2026-09-26
## 1.22.0 — 2026-09-26
**The notification bell in realtime** (roadmap WS-E task 3, WS-F task 1; openvibe-contracts 0.61.0, ADR-005 amendment 2). It is off by default. A site turns it on with `OpenVibeNavbar.init({ notificationsRealtime: true })`, or with `OpenVibeNotifications.init({ realtime: true })`.
**The notification bell in realtime** (roadmap WS-E task 3, WS-F task 1; openvibe-contracts 0.61.0, ADR-005 amendment 2). It is off by default. A site turns it on with `OpenVibeNavbar.init({ notificationsRealtime: true })`, or with `OpenVibeNotifications.init({ realtime: true })`.
@@ -23,7 +23,7 @@ were reconciled first.
@@ -23,7 +23,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
-| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
+| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
@@ -402,6 +402,31 @@ tag's tree either way.
@@ -402,6 +402,31 @@ tag's tree either way.
- A new browser file is added to `files.js` in the same release, so sites that serve from
- A new browser file is added to `files.js` in the same release, so sites that serve from
`node_modules` pick it up.
`node_modules` pick it up.
+**Web runtime: a site's feature loader (1.23.0).** `web-runtime.js` (`OVWebRuntime`) is OpenVibe.Live's route loader, made shareable (roadmap WS-P task 6). A site describes its features once and loads each when it is needed:
+
+```js
+const rt = OVWebRuntime.create({
+ features: { player: { fragment: 'player', deps: ['base'], css: ['/css/player.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] } },
+ routes: [{ path: '^/watch/', features: ['player'] }], // path: a RegExp or its source
+ versions: { '/js/p1.js': '<hash>' }, // → /js/p1.js?v=<hash>; or url: (p) => …
+ styleSlot: (href) => node, // insert a stylesheet before this node (cascade order); default: last in <head>
+ fragmentPath: (name) => `/fragments/${name}.html`, // loaded into #page-<name> (or `section`) once
+ prefetch: { count: 60, bytes: 3 * 1024 * 1024, sizes: { '/js/p1.js': 12345 } },
+ onStubError: (err, feature) => toast('That part of the site could not load.'),
+ routeError: { icon: 'fa-solid fa-plug-circle-xmark' }, debug: false, eventPrefix: 'ov',
+});
+rt.boot({ initial: ['player'] }); // adopt the server's tags, load these, prefetch on link intent and when idle
+```
+
+- **Loading.** `load(name)` puts in the feature's fragment first, then its dependencies, then its stylesheets and scripts (injected together, run in order), then calls `after` once. It fires `ov:feature` (and `ov:fragment` on the section). One promise per feature; tags already in the document count as loaded. `route(path)` loads every feature its routes name. `loadScript(src, { integrity, crossOrigin })` and `loadStyle(href)` load single files once, another origin's included.
+- **Transactional groups.** A feature is loaded only when every script loaded. When one fails, the attempt's own stylesheets leave the document (unless another feature wants them), the failed tag is removed, no hook runs, and a retry fetches only what is missing. A script that ran is never fetched or run again. A missing stylesheet never blocks: it waits at most 4 s.
+- **Route scopes.** `nextRoute()` starts a generation (`gen()`, `isCurrent(gen)`) and ends the previous route's scope. `scope()` owns `interval`, `timeout`, `listen`, `observe`, `onDispose`, `fetch` (its `signal` aborts when the route ends) and `child()` scopes for one component. Anything registered on an ended scope is refused and counted, never left running (`debug: true` also warns).
+- **Prefetch.** `prefetch(name)` and `prefetchRoute(path)` download without running (`link rel=prefetch`). Hover, focus and touch on in-site links prefetch their route. They skip Save-Data, 2g and devices under 2 GB of memory, and stop at the budget: a count, and bytes where `sizes` are known.
+- **Diagnostics.** `diagnostics()` lists loaded, loading and failed features, failed and rolled-back assets, duplicate tags in the document, late registrations, what the current scope holds and the prefetch budget spent. `leaks()` returns the problems as sentences, or `[]`. Browser smoke tests can compare them across navigations.
+- **Also:** `installStubs()` (global functions that load their feature, then call the real one, for inline handlers) and `showRouteError(pageId, err, retry)` (a retry box built from DOM nodes).
+
+It is 6.1 KB brotli (budget 6.5 KB). Serve it beside the other browser files and load it before the site's own loader. `test/web-runtime.test.js` (linkedom) and `test/web-runtime-chrome.test.js` (headless Chrome: real 404s, ordering, scopes) cover it.
+
## Charter
## Charter
The canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer,
The canonical home of what was `OpenVibe.Network/packages/openvibe-shared` (navbar, footer,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.8.0",
+ "version": "1.23.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-24"
+ "updated": "2026-09-27"
}
}
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
@@ -32,6 +32,7 @@ const BROWSER = Object.freeze([
'tooltip.js',
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
+ 'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
'openvibe-sw.js', // Web Push service worker
'openvibe-sw.js', // Web Push service worker
]);
]);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.22.0",
+ "version": "1.23.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.22.0",
+ "version": "1.23.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.22.0",
+ "version": "1.23.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -57,7 +57,8 @@
@@ -57,7 +57,8 @@
"./trace": "./trace.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
- "./user-card": "./user-card.js"
+ "./user-card": "./user-card.js",
+ "./web-runtime": "./web-runtime.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+// web-runtime.js in a real headless Chrome against a local site (roadmap WS-P task 6): scripts injected together run in
+// order even when one is slow; the server's own tags are not fetched again; a real 404 rolls the group back (its new
+// stylesheet leaves the document, the script that ran is not fetched or run again) and a retry completes it; a route
+// scope's interval stops and its fetch is aborted on nextRoute(). Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('web-runtime-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const hits = {};
+let flakyFails = 1;
+const JS = {
+ '/js/server.js': 'window.order.push("server");',
+ '/js/a1.js': 'window.order.push("a1");',
+ '/js/a2.js': 'window.order.push("a2");', // served slowly
+ '/js/a3.js': 'window.order.push("a3");',
+ '/js/ok.js': 'window.order.push("ok");',
+ '/js/flaky.js': 'window.order.push("flaky"); window.flakyReady = function () { return "ready"; };',
+};
+const CSS = { '/css/a.css': 'body { --a: 1; }', '/css/g.css': 'body { --g: 1; }' };
+const FEATURES = {
+ seq: { css: ['/css/a.css'], js: ['/js/server.js', '/js/a1.js', '/js/a2.js', '/js/a3.js'] },
+ group: { css: ['/css/g.css'], js: ['/js/ok.js', '/js/flaky.js'], stubs: ['flakyReady'] },
+};
+const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>runtime</title><link rel="icon" href="data:,">
+<script>window.order = [];</script><script src="/js/server.js?v=1"></script><script src="/web-runtime.js"></script></head><body><p>runtime</p>
+<script>window.rt = OVWebRuntime.create({ features: ${JSON.stringify(FEATURES)}, versions: { '/js/a1.js': 'h1' } }); rt.boot({ idle: false });</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ hits[u.pathname] = (hits[u.pathname] || 0) + 1;
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ if (u.pathname === '/web-runtime.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'web-runtime.js'))); }
+ if (u.pathname === '/js/flaky.js' && flakyFails > 0) { flakyFails--; res.writeHead(404); return res.end(); }
+ if (u.pathname === '/slow') { setTimeout(() => { try { res.end('late'); } catch { /* */ } }, 3000); return undefined; }
+ if (JS[u.pathname]) {
+ const send = () => { res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' }); res.end(JS[u.pathname]); };
+ return u.pathname === '/js/a2.js' ? void setTimeout(send, 400) : send();
+ }
+ if (CSS[u.pathname]) { res.writeHead(200, { 'content-type': 'text/css', 'cache-control': 'no-store' }); return res.end(CSS[u.pathname]); }
+ res.writeHead(404); return res.end();
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-web-runtime-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const page = await h.openPage(browser, context, { width: 390 });
+ await page.goto(`${base}/`, { maxMs: 5000 });
+
+ // In order, the slow one included; the server's tag adopted, not fetched again.
+ let v = await page.evaluate('rt.load("seq").then(() => ({ order: window.order, sheets: [...document.querySelectorAll("link[rel=stylesheet]")].map((l) => new URL(l.href).pathname) }))');
+ assert.deepStrictEqual(v.order, ['server', 'a1', 'a2', 'a3']);
+ assert.deepStrictEqual(v.sheets, ['/css/a.css']);
+ assert.strictEqual(hits['/js/server.js'], 1, 'the server\'s tag counts as loaded');
+
+ // A real 404: the group rolls back; the retry (here through a stub) fetches only what is missing.
+ v = await page.evaluate(`rt.load('group').then(() => 'loaded', (e) => ({ error: e.message, sheets: [...document.querySelectorAll('link[rel=stylesheet]')].map((l) => new URL(l.href).pathname),
+ scripts: [...document.querySelectorAll('script[src]')].map((s) => new URL(s.src).pathname), failed: rt.diagnostics().features.failed.map((f) => f.name), rolledBack: rt.diagnostics().assets.rolledBack }))`);
+ assert.strictEqual(v.error, 'Could not load /js/flaky.js');
+ assert.deepStrictEqual(v.sheets, ['/css/a.css'], 'g.css withdrawn');
+ assert.ok(!v.scripts.includes('/js/flaky.js') && v.scripts.includes('/js/ok.js'));
+ assert.deepStrictEqual([v.failed, v.rolledBack], [['group'], 1]);
+ v = await page.evaluate(`(rt.installStubs(), window.flakyReady()).then((r) => ({ r, order: window.order, leaks: rt.leaks(), dg: rt.diagnostics() }))`);
+ assert.strictEqual(v.r, 'ready');
+ assert.deepStrictEqual(v.order, ['server', 'a1', 'a2', 'a3', 'ok', 'flaky'], 'ok.js ran once');
+ assert.deepStrictEqual([hits['/js/ok.js'], hits['/js/flaky.js'], hits['/css/g.css']], [1, 2, 2]);
+ assert.deepStrictEqual([v.leaks, v.dg.features.failed, v.dg.assets.duplicates], [[], [], []]);
+
+ // Route scopes: the interval stops, the fetch is aborted.
+ v = await page.evaluate(`new Promise((done) => {
+ rt.nextRoute(); const s = rt.scope(); window.ticks = 0;
+ s.interval(() => { window.ticks++; }, 20);
+ const f = s.fetch('/slow').then(() => 'finished', (e) => e.name);
+ setTimeout(() => { rt.nextRoute(); const at = window.ticks; setTimeout(() => f.then((r) => done({ at, after: window.ticks, fetch: r, late: s.interval(() => {}, 5) })), 200); }, 150);
+ })`);
+ assert.ok(v.at > 0 && v.after === v.at, `ticks stop at nextRoute (${v.at} → ${v.after})`);
+ assert.deepStrictEqual([v.fetch, v.late], ['AbortError', 0]);
+ const errors = page.state.errors.filter((e) => !/Failed to load resource|flaky\.js/.test(e.text));
+ assert.deepStrictEqual(errors, [], 'no page errors besides the planned 404');
+ } finally {
+ await browser.close().catch(() => {});
+ server.close(); server.closeAllConnections?.();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ console.log('web-runtime-chrome: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,293 @@
@@ -0,0 +1,293 @@
+'use strict';
+// web-runtime.js (roadmap WS-P task 6), in a linkedom page where appended <script>/<link> tags "load" or "fail" on cue:
+// - a feature loads its fragment first, then deps, stylesheets and scripts (versioned, in order), then its hook, once;
+// tags the server already wrote count as loaded; nothing is added twice;
+// - asset groups are transactional: a failed script withdraws the stylesheets that attempt added (not one another
+// feature wants), removes its own tag, runs no hook, and a retry fetches only what is missing;
+// - route scopes release timers, listeners, observers, fetches and child scopes on nextRoute(); anything registered
+// on an ended route is refused and counted;
+// - prefetch stays within its count and byte budget, skips constrained connections, follows link intent (not //host);
+// - stubs load their feature and call the real function; showRouteError builds DOM (no markup from the error);
+// - diagnostics and leaks() report duplicate tags, failures, rollbacks and late registrations.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const linkedom = require('linkedom');
+
+const ROOT = path.join(__dirname, '..');
+const SRC = fs.readFileSync(path.join(ROOT, 'web-runtime.js'), 'utf8');
+// Values built inside the page's realm compare by their JSON form.
+const deq = (a, b, m) => assert.deepStrictEqual(JSON.parse(JSON.stringify(a)), b, m);
+
+function page({ html = '', connection = null } = {}) {
+ const { window: lw, document } = linkedom.parseHTML(`<!doctype html><html><head>${html}<meta name="anchor"></head><body><section id="page-player"></section><section id="page-ops"></section><a id="in" href="/ops?x=1">ops</a><a id="out" href="//evil.example/ops">x</a></body></html>`);
+ Object.defineProperty(document, 'readyState', { configurable: true, value: 'complete' });
+ const out = { appended: [], fetches: [], timers: [], intervals: new Set(), log: [] };
+ const behave = Object.create(null); // path → 'fail' | 'hold'
+ const effects = Object.create(null); // path → function run when the script "executes"
+ const held = [];
+ let tid = 0;
+ const ctx = {
+ document, URL, Promise, JSON, Object, Array, Set, Error,
+ console: { error: (...a) => out.log.push(['error', a.join(' ')]), warn: (...a) => out.log.push(['warn', a.join(' ')]), log() {} },
+ CustomEvent: lw.CustomEvent || linkedom.CustomEvent, Event: lw.Event || linkedom.Event,
+ location: { href: 'https://site.example/player', origin: 'https://site.example', pathname: '/player' },
+ navigator: connection ? { connection } : {},
+ AbortController,
+ setTimeout: (f, ms) => { out.timers.push({ id: ++tid, f, ms }); return tid; },
+ clearTimeout: (id) => { out.timers = out.timers.filter((t) => t.id !== id); },
+ setInterval: () => { const id = ++tid; out.intervals.add(id); return id; },
+ clearInterval: (id) => { out.intervals.delete(id); },
+ addEventListener() {}, removeEventListener() {},
+ fetch: async (u, init = {}) => {
+ out.fetches.push({ url: String(u), init });
+ if (/missing/.test(u)) return { ok: false, status: 404, text: async () => '' };
+ return { ok: true, status: 200, text: async () => `<div class="frag">${String(u).split('?')[0]}</div>` };
+ },
+ };
+ ctx.window = ctx; ctx.globalThis = ctx;
+ vm.createContext(ctx);
+ const fire = (node, kind) => { const h = node[`on${kind}`]; if (h) h(new ctx.Event(kind)); };
+ const hook = (parent) => {
+ const append = parent.appendChild.bind(parent);
+ const insert = parent.insertBefore.bind(parent);
+ const once = new WeakSet(); // linkedom's appendChild goes through insertBefore
+ const seen = (node) => {
+ if (once.has(node)) return;
+ once.add(node);
+ const src = node.tagName === 'SCRIPT' ? node.getAttribute('src') : node.tagName === 'LINK' && node.getAttribute('rel') === 'stylesheet' ? node.getAttribute('href') : null;
+ if (!src) return;
+ out.appended.push(src);
+ const bare = src.split('?')[0];
+ setImmediate(() => {
+ if (behave[bare] === 'hold') { held.push(node); return; }
+ if (behave[bare] === 'fail') return fire(node, 'error');
+ if (effects[bare]) effects[bare](ctx);
+ fire(node, 'load');
+ });
+ };
+ parent.appendChild = (node) => { const r = append(node); seen(node); return r; };
+ parent.insertBefore = (node, ref) => { const r = insert(node, ref); seen(node); return r; };
+ };
+ hook(document.head);
+ vm.runInContext(SRC, ctx, { filename: 'web-runtime.js' });
+ const tags = (sel) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute('src') || e.getAttribute('href'));
+ return { ctx, document, out, behave, effects, held, fire, tags, RT: ctx.OVWebRuntime };
+}
+
+const FEATURES = {
+ base: { css: ['/css/base.css'], js: ['/js/base.js'] },
+ player: { fragment: 'player', deps: ['base'], css: ['/css/player.css', '/css/shared.css'], js: ['/js/p1.js', '/js/p2.js'], after: 'initPlayer', stubs: ['openPlayer'], idle: ['ops'] },
+ ops: { fragment: 'ops', css: ['/css/shared.css', '/css/ops.css'], js: ['/js/ops.js'] },
+ broken: { fragment: 'missing', section: 'page-ops', js: ['/js/never.js'] },
+ big: { js: ['/js/big1.js', '/js/big2.js', '/js/big3.js'] },
+};
+const ROUTES = [{ path: '^/player', features: ['player'] }, { path: '^/ops', features: ['ops'] }, { path: '^/(player|ops)', features: ['base', 'player'] }];
+
+(async () => {
+ // ── A feature: fragment, deps, versioned assets in order, the hook once ──────────────────
+ {
+ const p = page({ html: '<script src="/js/base.js?v=old"></script>' });
+ let hooks = 0;
+ p.ctx.initPlayer = () => { hooks++; };
+ const events = [];
+ p.document.addEventListener('ov:feature', (e) => events.push(e.detail.name));
+ const rt = p.RT.create({ features: FEATURES, routes: ROUTES, versions: { '/js/p1.js': 'aaa', '/fragments/player.html': 'fff' }, styleSlot: () => p.document.querySelector('meta[name="anchor"]') });
+ rt.adoptExisting();
+ deq(rt.featuresFor('/player/x'), ['player', 'base'], 'routes merged in order, without repeats');
+ const a = rt.load('player');
+ assert.strictEqual(rt.load('player'), a, 'one promise per feature');
+ await a;
+ await rt.load('player');
+ assert.strictEqual(p.out.fetches[0].url, '/fragments/player.html?v=fff', 'the fragment first, versioned');
+ assert.match(p.document.getElementById('page-player').innerHTML, /frag/);
+ assert.strictEqual(p.document.getElementById('page-player').dataset.fragmentLoaded, '1');
+ deq(p.out.appended, ['/css/base.css', '/css/player.css', '/css/shared.css', '/js/p1.js?v=aaa', '/js/p2.js'], 'base.js was the server\'s; dep first; scripts in order');
+ deq(p.tags('script[src]'), ['/js/base.js?v=old', '/js/p1.js?v=aaa', '/js/p2.js']);
+ assert.ok(p.document.querySelector('meta[name="anchor"]').previousElementSibling.getAttribute('href') === '/css/shared.css', 'styleSlot places stylesheets');
+ deq([hooks, events], [1, ['base', 'player']]);
+ assert.ok(rt.isLoaded('player'));
+ deq(rt.diagnostics().features.loaded.sort(), ['base', 'player']);
+ deq(rt.leaks(), []);
+ // A route: its features, as one promise; nothing twice.
+ await rt.route('/ops');
+ assert.strictEqual(p.out.appended.filter((x) => x === '/css/shared.css').length, 1);
+ assert.strictEqual(p.RT.assetKey('https://site.example/js/a.js?v=1'), '/js/a.js');
+ assert.strictEqual(p.RT.assetKey('https://cdn.example/x/a.js?v=1'), 'https://cdn.example/x/a.js', 'another origin keeps its origin');
+ }
+
+ // ── Transactional groups: a failed script rolls the attempt back; a retry fetches only what is missing ──
+ {
+ const p = page();
+ const rt = p.RT.create({ features: FEATURES, routes: ROUTES });
+ let hooks = 0;
+ p.ctx.initPlayer = () => { hooks++; };
+ await rt.load('ops'); // owns /css/shared.css
+ p.behave['/js/p2.js'] = 'fail';
+ await assert.rejects(rt.load('player'), /Could not load \/js\/p2\.js/);
+ deq(p.tags('link[rel="stylesheet"]').sort(), ['/css/base.css', '/css/ops.css', '/css/shared.css'], 'player.css withdrawn; shared.css stays (ops wants it); base loaded fine');
+ assert.ok(!p.tags('script[src]').includes('/js/p2.js'), 'the failed tag is gone');
+ assert.strictEqual(hooks, 0, 'no hook for a half-loaded feature');
+ assert.ok(!rt.isLoaded('player'));
+ let dg = rt.diagnostics();
+ deq(dg.features.failed.map((f) => [f.name, f.attempts]), [['player', 1]]);
+ deq(dg.assets.failed.map((f) => f.asset), ['/js/p2.js']);
+ assert.strictEqual(dg.assets.rolledBack, 1);
+ delete p.behave['/js/p2.js'];
+ const before = p.out.appended.length;
+ await rt.load('player');
+ deq(p.out.appended.slice(before), ['/css/player.css', '/js/p2.js'], 'p1.js ran once and is not fetched again');
+ assert.strictEqual(hooks, 1);
+ dg = rt.diagnostics();
+ deq([dg.features.failed, dg.assets.failed, dg.assets.duplicates], [[], [], []]);
+
+ // A stylesheet another feature (still loading) wants stays when one feature's attempt fails.
+ const q = page();
+ const rt2 = q.RT.create({ features: { a: { css: ['/css/s.css'], js: ['/js/a.js'] }, b: { css: ['/css/s.css'], js: ['/js/b.js'] } } });
+ q.behave['/js/a.js'] = 'fail'; q.behave['/js/b.js'] = 'hold';
+ const pa = rt2.load('a'); const pb = rt2.load('b');
+ await assert.rejects(pa);
+ deq(q.tags('link[rel="stylesheet"]'), ['/css/s.css'], 'b (in flight) still wants it');
+ await new Promise((r) => setImmediate(r)); // b.js has been appended and is held
+ q.fire(q.held.pop(), 'load');
+ await pb;
+ deq(q.tags('link[rel="stylesheet"]'), ['/css/s.css']);
+
+ // A failed stylesheet does not block; its tag is removed.
+ const r = page();
+ const rt3 = r.RT.create({ features: { c: { css: ['/css/gone.css'], js: ['/js/c.js'] } } });
+ r.behave['/css/gone.css'] = 'fail';
+ await rt3.load('c');
+ deq(r.tags('link[rel="stylesheet"]'), []);
+ deq(rt3.diagnostics().assets.failed.map((f) => f.kind), ['style']);
+ // A missing fragment fails the feature and can be retried.
+ const rt4 = r.RT.create({ features: FEATURES });
+ await assert.rejects(rt4.load('broken'), /HTTP 404/);
+ assert.strictEqual(r.document.getElementById('page-ops').getAttribute('aria-busy'), null);
+ }
+
+ // ── Route scopes ──────────────────────────────────────────────────────────────────────────
+ {
+ const p = page();
+ const rt = p.RT.create({ features: FEATURES, debug: true });
+ const g1 = rt.nextRoute();
+ const s = rt.scope();
+ assert.strictEqual(s.gen, g1);
+ const target = p.document.getElementById('in');
+ let clicks = 0;
+ s.interval(() => {}, 1000);
+ const tId = s.timeout(() => {}, 500);
+ s.timeout(() => {}, 900);
+ s.listen(target, 'click', () => { clicks++; });
+ let disconnected = 0;
+ s.observe({ disconnect() { disconnected++; } });
+ const child = s.child();
+ child.interval(() => {}, 50);
+ let fetchSignal = null;
+ p.ctx.fetch = async (u, init) => { fetchSignal = init.signal; return { ok: true }; };
+ await s.fetch('/api/x');
+ deq(s.held(), { interval: 1, timeout: 2, listener: 1, observer: 1, child: 1 });
+ const due = p.out.timers.find((t) => t.id === tId);
+ p.out.timers = p.out.timers.filter((t) => t !== due);
+ due.f(); // a timeout that fired is no longer held
+ assert.strictEqual(s.held().timeout, 1);
+ child.dispose();
+ assert.strictEqual(s.held().child, undefined, 'a child that ended early leaves its parent');
+ target.dispatchEvent(new p.ctx.Event('click'));
+ assert.strictEqual(clicks, 1);
+
+ const g2 = rt.nextRoute();
+ assert.ok(!rt.isCurrent(g1) && rt.isCurrent(g2) && rt.gen() === g2);
+ assert.ok(s.disposed && fetchSignal.aborted, 'the route\'s signal aborts');
+ assert.strictEqual(p.out.intervals.size, 0, 'every interval cleared (the child\'s too)');
+ assert.strictEqual(p.out.timers.length, 0, 'every pending timeout cleared');
+ target.dispatchEvent(new p.ctx.Event('click'));
+ deq([clicks, disconnected], [1, 1]);
+
+ // Late registrations: code that awaited past its route cannot leave anything running.
+ assert.strictEqual(s.interval(() => {}, 10), 0);
+ s.listen(target, 'click', () => { clicks++; });
+ let ran = 0;
+ s.onDispose(() => { ran++; });
+ await assert.rejects(s.fetch('/api/y'), (e) => e.name === 'AbortError');
+ target.dispatchEvent(new p.ctx.Event('click'));
+ deq([p.out.intervals.size, clicks, ran], [0, 1, 1]);
+ const dg = rt.diagnostics();
+ assert.strictEqual(dg.scopes.late, 4);
+ deq(dg.scopes.current, {});
+ assert.ok(rt.leaks().some((x) => /4 registration\(s\) arrived after their route ended/.test(x)));
+ assert.ok(p.out.log.some(([k, m]) => k === 'warn' && /interval registered after its route ended/.test(m)), 'debug warns');
+ }
+
+ // ── Prefetch: budget, constrained connections, link intent ───────────────────────────────
+ {
+ const p = page();
+ const rt = p.RT.create({ features: FEATURES, routes: ROUTES, prefetch: { count: 4, bytes: 250, sizes: { '/js/big1.js': 100, '/js/big2.js': 100, '/js/big3.js': 100 } } });
+ rt.prefetch('big');
+ let links = p.tags('link[rel="prefetch"]');
+ deq(links, ['/js/big1.js', '/js/big2.js'], 'the byte budget stops the third');
+ rt.prefetch('ops');
+ links = p.tags('link[rel="prefetch"]');
+ assert.strictEqual(links.length, 4, 'the count budget');
+ const dg = rt.diagnostics().prefetch;
+ deq([dg.count, dg.bytes, dg.skipped > 0], [4, 200, true]);
+ assert.strictEqual(p.document.querySelector('link[rel="prefetch"]').getAttribute('as'), 'script');
+
+ const q = page();
+ const rt2 = q.RT.create({ features: FEATURES, routes: ROUTES });
+ await rt2.load('base');
+ rt2.watchIntent(q.document);
+ q.document.getElementById('out').dispatchEvent(new q.ctx.Event('pointerover', { bubbles: true }));
+ deq(q.tags('link[rel="prefetch"]'), [], '//host is not this site');
+ q.document.getElementById('in').dispatchEvent(new q.ctx.Event('pointerover', { bubbles: true }));
+ deq(q.tags('link[rel="prefetch"]').sort(), ['/css/ops.css', '/css/player.css', '/css/shared.css', '/fragments/ops.html', '/fragments/player.html', '/js/ops.js', '/js/p1.js', '/js/p2.js'], 'the hovered route\'s features (ops, player), not base, which is loaded');
+
+ const c = page({ connection: { saveData: true } });
+ const rt3 = c.RT.create({ features: FEATURES });
+ rt3.prefetch('ops');
+ deq(c.tags('link[rel="prefetch"]'), []);
+ assert.strictEqual(rt3.diagnostics().prefetch.constrained, 1);
+ }
+
+ // ── Stubs, route errors, duplicates ──────────────────────────────────────────────────────
+ {
+ const p = page();
+ const errors = [];
+ const rt = p.RT.create({ features: FEATURES, onStubError: (e, name) => errors.push(name), routeError: { icon: 'fa-solid fa-plug' } });
+ p.effects['/js/p2.js'] = (ctx) => { ctx.openPlayer = (x) => `opened ${x}`; };
+ rt.installStubs();
+ assert.strictEqual(p.ctx.openPlayer.__ovStub, 'player');
+ assert.strictEqual(await p.ctx.openPlayer('now'), 'opened now', 'the stub loads the feature, then calls the real function');
+ const q = page();
+ const rt2 = q.RT.create({ features: FEATURES, onStubError: (e, name) => errors.push(name) });
+ q.behave['/js/p1.js'] = 'fail';
+ rt2.installStubs();
+ await q.ctx.openPlayer();
+ deq(errors, ['player']);
+
+ let retried = 0;
+ rt.showRouteError('page-ops', new Error('<img src=x onerror=alert(1)>'), () => { retried++; });
+ rt.showRouteError('page-ops', new Error('again'), () => { retried++; });
+ const boxes = p.document.querySelectorAll('#page-ops > .ov-route-error');
+ assert.strictEqual(boxes.length, 1, 'one box per page');
+ assert.ok(!/img/.test(boxes[0].innerHTML), 'the error text is not markup');
+ assert.strictEqual(boxes[0].querySelector('i').className, 'fa-solid fa-plug');
+ boxes[0].querySelector('button').dispatchEvent(new p.ctx.Event('click'));
+ assert.strictEqual(retried, 1);
+ assert.strictEqual(p.document.querySelectorAll('.ov-route-error').length, 0);
+
+ const extra = p.document.createElement('script');
+ extra.setAttribute('src', '/js/p1.js?v=zzz');
+ p.document.body.appendChild(extra);
+ deq(rt.diagnostics().assets.duplicates, [{ asset: '/js/p1.js', count: 2 }]);
+ assert.ok(rt.leaks().some((x) => /\/js\/p1\.js is in the document 2 times/.test(x)));
+ }
+
+ // ── Size: it replaces a site's own loader ────────────────────────────────────────────────
+ const { brotli } = require('../scripts/size-report');
+ const kb = brotli(Buffer.from(SRC)) / 1024;
+ assert.ok(kb <= 6.5, `web-runtime.js is ${kb.toFixed(2)} KB brotli, budget 6.5 KB (Live's own loader was 4.25 KB)`);
+
+ console.log('web runtime: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,447 @@
@@ -0,0 +1,447 @@
+/*
+ * openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
+ * OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.
+ *
+ * const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"
+ * rt.load('broadcast') → the feature's markup fragment, its dependencies, stylesheets and scripts (in order), then
+ * its `after` hook; once, cached; a failed load can be retried.
+ * rt.route(path) → every feature the path's routes name, as one promise.
+ * rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
+ * of memory, and within a budget (count, and bytes where sizes are known).
+ * rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
+ * → route generations: a loader that awaited something can tell whether the visitor moved on.
+ * rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
+ * route, released by the next nextRoute(). Anything registered on a scope whose route has
+ * ended is refused (and counted), never left running.
+ * rt.diagnostics(), rt.leaks()
+ * → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
+ * current route holds, and the prefetch budget spent.
+ *
+ * Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
+ * every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
+ * again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
+ * kept, so nothing runs twice.
+ * Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
+ * handlers working before their feature has loaded.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVWebRuntime) return;
+
+ /** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */
+ function assetKey(src) {
+ try {
+ const u = new URL(src, root.location.href);
+ return u.origin === root.location.origin ? u.pathname : u.origin + u.pathname;
+ } catch { return String(src).split(/[?#]/)[0]; }
+ }
+
+ function create(o = {}) {
+ const FEATURES = o.features || {};
+ const ROUTES = (o.routes || []).map((r) => ({ re: r.path instanceof RegExp ? r.path : new RegExp(r.path), features: r.features || [] }));
+ const versions = o.versions || {};
+ const G = o.global || root;
+ const prefix = o.eventPrefix || 'ov';
+ const log = o.log || root.console;
+ const styleTimeout = o.styleTimeout || 4000;
+ const fragmentPath = o.fragmentPath || ((name) => `/fragments/${name}.html`);
+ const url = o.url || ((p) => { const h = versions[p]; return h ? `${p}${p.indexOf('?') === -1 ? '?' : '&'}v=${h}` : p; });
+ const d = { scripts: 0, styles: 0, late: 0, rolledBack: 0, failed: Object.create(null), scopes: 0, disposed: 0 };
+ const emit = (type, detail, target) => (target || document).dispatchEvent(new root.CustomEvent(type, { bubbles: true, detail }));
+ 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 || ''); };
+
+ // ── Scripts and styles, once each ────────────────────────────────────────────────────────
+ const scripts = Object.create(null); // key → promise
+ const styles = Object.create(null); // key → promise
+ const styleEls = Object.create(null); // key → the <link> this runtime inserted
+ const wanted = Object.create(null); // key → Set of features loading or loaded that list it
+
+ function tagPromise(el, isScript) {
+ // A deferred script that has run fired `load` long ago; after DOMContentLoaded every parser-inserted script has run.
+ if (isScript && document.readyState !== 'loading' && !el.dataset.ovInjected) return Promise.resolve();
+ return new Promise((resolve) => {
+ el.addEventListener('load', () => resolve(), { once: true });
+ el.addEventListener('error', () => resolve(), { once: true });
+ if (isScript && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
+ });
+ }
+ /** Tags already in the document (the server's, or another loader's) count as loaded or loading. */
+ function adoptExisting() {
+ document.querySelectorAll('script[src]').forEach((t) => { const k = assetKey(t.getAttribute('src')); if (!scripts[k]) scripts[k] = tagPromise(t, true); });
+ 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); });
+ }
+
+ /** attrs: { integrity, crossOrigin } for another origin's file. */
+ function loadScript(p, attrs) {
+ const k = assetKey(p);
+ if (scripts[k]) return scripts[k];
+ const s = document.createElement('script');
+ s.src = url(p);
+ s.async = false; // injected together → run in insertion order
+ s.dataset.ovInjected = '1';
+ if (attrs && attrs.integrity) s.integrity = attrs.integrity;
+ if (attrs && attrs.crossOrigin) s.crossOrigin = attrs.crossOrigin;
+ scripts[k] = new Promise((resolve, reject) => {
+ s.onload = () => { delete d.failed[k]; resolve(); };
+ s.onerror = () => {
+ delete scripts[k]; // a retry adds a fresh tag
+ s.remove();
+ const err = new Error(`Could not load ${p}`);
+ failed('script', k, err);
+ reject(err);
+ };
+ });
+ document.head.appendChild(s);
+ d.scripts++;
+ return scripts[k];
+ }
+
+ /** Never rejects: a missing stylesheet must not block a route (it waits at most styleTimeout ms). */
+ function loadStyle(p, added) {
+ const k = assetKey(p);
+ if (styles[k]) return styles[k];
+ const l = document.createElement('link');
+ l.rel = 'stylesheet';
+ l.href = url(p);
+ const pr = styles[k] = new Promise((resolve) => {
+ let done = false;
+ const finish = () => { if (!done) { done = true; resolve(); } };
+ l.onload = () => { delete d.failed[k]; finish(); };
+ l.onerror = () => {
+ if (styles[k] === pr) { delete styles[k]; delete styleEls[k]; }
+ l.remove();
+ failed('style', k, 'error');
+ finish();
+ };
+ root.setTimeout(finish, styleTimeout);
+ });
+ // o.styleSlot(p) → the node it goes before (a site's cascade order), or nothing: last in <head>.
+ const before = o.styleSlot ? o.styleSlot(p) : null;
+ if (before && before.parentNode) before.parentNode.insertBefore(l, before);
+ else document.head.appendChild(l);
+ styleEls[k] = l;
+ d.styles++;
+ if (added) added.push(k);
+ return pr;
+ }
+
+ // ── Markup fragments ─────────────────────────────────────────────────────────────────────
+ const fragments = Object.create(null);
+ function loadFragment(name, sectionId) {
+ const section = document.getElementById(sectionId);
+ if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
+ if (fragments[name]) return fragments[name];
+ section.setAttribute('aria-busy', 'true');
+ fragments[name] = root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' })
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })
+ .then((html) => {
+ // Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.
+ if (section.dataset.fragmentLoaded !== '1') {
+ section.innerHTML = html;
+ section.dataset.fragmentLoaded = '1';
+ emit(`${prefix}:fragment`, { name }, section);
+ }
+ section.removeAttribute('aria-busy');
+ }, (err) => {
+ delete fragments[name];
+ section.removeAttribute('aria-busy');
+ failed('fragment', name, err);
+ throw err;
+ });
+ return fragments[name];
+ }
+
+ // ── Features (transactional asset groups) ─────────────────────────────────────────────────
+ const features = Object.create(null); // name → promise, in flight or loaded
+ const loaded = Object.create(null);
+ const afterRan = Object.create(null);
+
+ function load(name) {
+ const f = FEATURES[name];
+ if (!f) return Promise.resolve();
+ if (features[name]) return features[name];
+ const css = (f.css || []).map(assetKey);
+ css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
+ const added = [];
+ // The feature's markup goes in before its dependencies and scripts run: modules bind to it when they load.
+ const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`) : Promise.resolve();
+ features[name] = markup
+ .then(() => Promise.all((f.deps || []).map(load)))
+ .then(() => Promise.all((f.css || []).map((p) => loadStyle(p, added)).concat((f.js || []).map((p) => loadScript(p)))))
+ .then(() => {
+ loaded[name] = true;
+ delete d.failed[name];
+ if (f.after && !afterRan[name] && typeof G[f.after] === 'function') {
+ afterRan[name] = true;
+ try { G[f.after](); } catch (e) { log.error(`[${prefix}] ${name} after-hook failed:`, e); }
+ }
+ emit(`${prefix}:feature`, { name });
+ }, (err) => {
+ delete features[name];
+ css.forEach((k) => wanted[k] && wanted[k].delete(name));
+ for (const k of added) {
+ if (wanted[k] && wanted[k].size) continue;
+ if (styleEls[k]) styleEls[k].remove();
+ delete styleEls[k]; delete styles[k];
+ d.rolledBack++;
+ }
+ failed('feature', name, err);
+ throw err;
+ });
+ return features[name];
+ }
+
+ function featuresFor(path) {
+ const out = [];
+ ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
+ return out;
+ }
+ function route(path) { return Promise.all(featuresFor(path || root.location.pathname).map(load)); }
+
+ // ── Prefetch (download, never execute), within a budget ──────────────────────────────────
+ const pf = o.prefetch || {};
+ const budget = { count: pf.count == null ? 60 : pf.count, bytes: pf.bytes == null ? 3 * 1024 * 1024 : pf.bytes };
+ const sizes = pf.sizes || {};
+ const spent = { count: 0, bytes: 0, skipped: 0, constrained: 0 };
+ const prefetched = Object.create(null);
+ function constrained() {
+ const c = root.navigator && root.navigator.connection;
+ if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return true;
+ return !!root.navigator && typeof root.navigator.deviceMemory === 'number' && root.navigator.deviceMemory < 2;
+ }
+ function prefetchAsset(p) {
+ const k = assetKey(p);
+ if (prefetched[k] || scripts[k] || styles[k]) return;
+ const size = Number(sizes[p]) || 0;
+ if (spent.count + 1 > budget.count || spent.bytes + size > budget.bytes) { spent.skipped++; return; }
+ prefetched[k] = true; spent.count++; spent.bytes += size;
+ const l = document.createElement('link');
+ l.rel = 'prefetch';
+ l.href = url(p);
+ l.setAttribute('as', /\.css$/.test(k) ? 'style' : /\.js$/.test(k) ? 'script' : 'fetch');
+ document.head.appendChild(l);
+ }
+ function prefetch(name, seen) {
+ seen = seen || Object.create(null);
+ const f = FEATURES[name];
+ if (!f || seen[name] || features[name]) return;
+ if (constrained()) { spent.constrained++; return; }
+ seen[name] = true;
+ (f.deps || []).forEach((n) => prefetch(n, seen));
+ (f.css || []).concat(f.js || []).forEach(prefetchAsset);
+ if (f.fragment) prefetchAsset(fragmentPath(f.fragment));
+ }
+ function prefetchRoute(path) { featuresFor(path).forEach((n) => prefetch(n)); }
+
+ /** Hover, focus or touch on an in-site link: fetch that route's code. Returns a function that stops watching. */
+ function watchIntent(target) {
+ const t = target || document;
+ const on = (e) => {
+ const a = e.target && e.target.closest && e.target.closest('a[href^="/"]');
+ const href = a && a.getAttribute('href');
+ if (!href || href.charAt(1) === '/') return;
+ const path = href.split(/[?#]/)[0];
+ if (path) prefetchRoute(path);
+ };
+ const kinds = [['pointerover', { passive: true }], ['focusin', undefined], ['touchstart', { passive: true }]];
+ kinds.forEach(([k, opts]) => t.addEventListener(k, on, opts));
+ return () => kinds.forEach(([k, opts]) => t.removeEventListener(k, on, opts));
+ }
+ /** The `idle` features of the path's routes, prefetched once the page has settled (load + 4 s + idle). */
+ function idlePrefetch(path) {
+ const idle = root.requestIdleCallback || ((fn) => root.setTimeout(fn, 3000));
+ const go = () => root.setTimeout(() => idle(() => {
+ featuresFor(path || root.location.pathname).forEach((n) => (FEATURES[n].idle || []).forEach((m) => prefetch(m)));
+ }, { timeout: 15000 }), 4000);
+ if (document.readyState === 'complete') go();
+ else root.addEventListener('load', go, { once: true });
+ }
+
+ // ── Route generations and scopes ─────────────────────────────────────────────────────────
+ let generation = 0;
+ let current = null;
+
+ function makeScope(gen) {
+ const held = new Set();
+ const controller = typeof root.AbortController === 'function' ? new root.AbortController() : null;
+ let disposed = false;
+ d.scopes++;
+ const late = (kind) => { d.late++; if (o.debug) log.warn(`[${prefix}] ${kind} registered after its route ended (generation ${gen}); ignored`); };
+ const hold = (kind, fn) => { const h = { kind, fn }; held.add(h); return h; };
+ const scope = {
+ gen,
+ signal: controller ? controller.signal : undefined,
+ interval(fn, ms) {
+ if (disposed) { late('interval'); return 0; }
+ const id = root.setInterval(fn, ms);
+ hold('interval', () => root.clearInterval(id));
+ return id;
+ },
+ timeout(fn, ms) {
+ if (disposed) { late('timeout'); return 0; }
+ let h = null;
+ const id = root.setTimeout(function () { held.delete(h); return fn.apply(this, arguments); }, ms);
+ h = hold('timeout', () => root.clearTimeout(id));
+ return id;
+ },
+ listen(target, type, fn, opts) {
+ if (!target) return;
+ if (disposed) { late('listener'); return; }
+ target.addEventListener(type, fn, opts);
+ hold('listener', () => target.removeEventListener(type, fn, opts));
+ },
+ observe(observer) {
+ if (disposed) { late('observer'); try { observer.disconnect(); } catch { /* */ } return observer; }
+ hold('observer', () => { try { observer.disconnect(); } catch { /* */ } });
+ return observer;
+ },
+ onDispose(fn) {
+ if (disposed) { late('onDispose'); try { fn(); } catch { /* */ } return; }
+ hold('onDispose', fn);
+ },
+ /** fetch() cancelled when the route ends (unless init brings its own signal). */
+ fetch(input, init) {
+ if (disposed) { late('fetch'); return Promise.reject(Object.assign(new Error('The route has ended'), { name: 'AbortError' })); }
+ return root.fetch(input, Object.assign({ signal: scope.signal }, init));
+ },
+ /** A scope for one component (a dialog, a player) that can end early and ends with this one anyway. */
+ child() {
+ const c = makeScope(gen);
+ if (disposed) { late('child'); c.dispose(); return c; }
+ const h = hold('child', () => c.dispose());
+ c.onDispose(() => held.delete(h));
+ return c;
+ },
+ /** What this scope holds right now, by kind. */
+ held() {
+ const out = {};
+ held.forEach((h) => { out[h.kind] = (out[h.kind] || 0) + 1; });
+ return out;
+ },
+ get disposed() { return disposed; },
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ d.disposed++;
+ if (controller) try { controller.abort(); } catch { /* */ }
+ const list = Array.from(held).reverse();
+ held.clear();
+ for (const h of list) { try { h.fn(); } catch (e) { log.warn(`[${prefix}] scope cleanup failed:`, e); } }
+ },
+ };
+ return scope;
+ }
+ function scope() { if (!current) current = makeScope(generation); return current; }
+ /** A new route: everything the previous one owned is released. Returns the new generation. */
+ function nextRoute() {
+ generation++;
+ if (current) current.dispose();
+ current = makeScope(generation);
+ return generation;
+ }
+
+ // ── Stubs for inline handlers ────────────────────────────────────────────────────────────
+ function installStubs() {
+ Object.keys(FEATURES).forEach((name) => {
+ (FEATURES[name].stubs || []).forEach((fn) => {
+ if (typeof G[fn] === 'function') return;
+ const stub = function () {
+ const args = arguments; const self = this;
+ return load(name).then(() => {
+ if (G[fn] !== stub && typeof G[fn] === 'function') return G[fn].apply(self, args);
+ log.warn(`[${prefix}] ${fn} is unavailable after loading ${name}`);
+ }, (err) => {
+ log.error(`[${prefix}]`, err);
+ if (typeof o.onStubError === 'function') o.onStubError(err, name);
+ });
+ };
+ stub.__ovStub = name;
+ G[fn] = stub;
+ });
+ });
+ }
+
+ // ── Recoverable failure UI for a route ───────────────────────────────────────────────────
+ 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);
+ function showRouteError(pageId, err, retry) {
+ const page = document.getElementById(pageId);
+ if (!page) return;
+ let box = page.querySelector(':scope > .ov-route-error');
+ if (!box) {
+ box = document.createElement('div');
+ box.className = 'ov-route-error';
+ box.setAttribute('role', 'alert');
+ page.insertBefore(box, page.firstChild);
+ }
+ box.textContent = '';
+ if (RE.icon) { const i = document.createElement('i'); i.className = RE.icon; i.setAttribute('aria-hidden', 'true'); box.appendChild(i); }
+ const msg = document.createElement('div');
+ const strong = document.createElement('strong'); strong.textContent = RE.title;
+ const span = document.createElement('span'); span.textContent = RE.text;
+ msg.appendChild(strong); msg.appendChild(span); box.appendChild(msg);
+ const btn = document.createElement('button');
+ btn.type = 'button'; btn.className = RE.button; btn.textContent = RE.retry;
+ btn.addEventListener('click', () => { box.remove(); retry(); }, { once: true });
+ box.appendChild(btn);
+ log.error(`[${prefix}] route failed to load:`, err);
+ }
+
+ // ── Diagnostics ──────────────────────────────────────────────────────────────────────────
+ function duplicates() {
+ const n = Object.create(null);
+ document.querySelectorAll('script[src], link[rel="stylesheet"][href]').forEach((el) => {
+ const k = assetKey(el.getAttribute('src') || el.getAttribute('href'));
+ n[k] = (n[k] || 0) + 1;
+ });
+ return Object.keys(n).filter((k) => n[k] > 1).map((k) => ({ asset: k, count: n[k] }));
+ }
+ function diagnostics() {
+ return {
+ generation,
+ features: {
+ loaded: Object.keys(loaded),
+ loading: Object.keys(features).filter((n) => !loaded[n]),
+ 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 })),
+ },
+ assets: {
+ scripts: Object.keys(scripts).length, styles: Object.keys(styles).length,
+ injected: { scripts: d.scripts, styles: d.styles },
+ 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 })),
+ rolledBack: d.rolledBack,
+ duplicates: duplicates(),
+ },
+ scopes: { created: d.scopes, disposed: d.disposed, late: d.late, current: current ? current.held() : {} },
+ prefetch: Object.assign({ budget: Object.assign({}, budget) }, spent),
+ };
+ }
+ /** Plain sentences about anything that looks like a leak; empty when all is well. */
+ function leaks() {
+ const out = [];
+ duplicates().forEach((x) => out.push(`${x.asset} is in the document ${x.count} times`));
+ if (d.late) out.push(`${d.late} registration(s) arrived after their route ended (refused)`);
+ return out;
+ }
+
+ return {
+ load, route, featuresFor, prefetch, prefetchRoute, loadScript, loadStyle, loadFragment, url, assetKey,
+ gen: () => generation,
+ isCurrent: (g) => g === generation,
+ scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
+ isLoaded: (name) => !!features[name],
+ /** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
+ boot(b = {}) {
+ const start = () => {
+ adoptExisting();
+ (b.initial || []).forEach((n) => { load(n).catch(() => { /* recorded in diagnostics */ }); });
+ if (b.intent !== false) watchIntent(document);
+ if (b.idle !== false) idlePrefetch();
+ };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
+ else start();
+ },
+ };
+ }
+
+ root.OVWebRuntime = { create, assetKey };
+ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVWebRuntime;
+})(typeof window !== 'undefined' ? window : globalThis);