openvibers/openvibe.shared Commits
4.165.0: faster between pages and sites, bots by name, Your stats (#384)
Changes
@@@ -1,5 +1,18 @@# Changelog++## 4.165.0 — 2026-11-09++++- **Faster between pages and sites** (the owner, 11-09: "when you change sites… faster across sites… better caching on++ the client"): the navbar adds a same-site prerender rule beside its prefetch, and prefetches links to the other++ OpenVibe sites; `openvibe-shared/sw` sends the shell's files in a 103 Early Hint. The shared worker++ (`/openvibe-sw.js`, also the Web Push worker) keeps **only content-addressed files** (a hash in the name or++ `?v=<hash>`: immutable, public) and answers them from its cache; pages and every other file go to the network as the++ browser sends them, so no page copy outlives a sign-out, nothing cuts a slow page off, and no stale script meets new++ HTML (security review). A kill switch (`/api/v1/sw-config` on Network) unregisters it everywhere. Sites opt in to++ registering it with `OpenVibeNavbar.init({ sw: true })`.++- `robots.txt` welcomes Perplexity-User, Claude-User and Claude-SearchBot by name (discoverability, ask 93).++- You: **Most used** links to **Your stats** on OpenVibe Report (`/me`: private by default, each section publishable).++## 4.164.0 — 2026-11-09- **Chat's two views** (`openvibe-shared/messages`, `OVMessages.render(el, { messages, view: 'room' | 'bubbles', me })`;@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.164.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.165.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -845,6 +845,65 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end theDropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.++### The shell's cache: the worker (`openvibe-sw.js`, `sw.js`, 4.164.0)++++The owner, 2026-11-09: *"how come when you change sites it doesn't load more asynchronously and dynamically, so it's faster++across sites? … better caching on the client, cross-site and smarter, intelligent, so it's faster, while staying SEO friendly++and crawlable?"*++++One worker, every site, at `/openvibe-sw.js` (same origin, scope "/"), from this package. It is the file the notification++bell already registered for Web Push, so there is one registration and nothing is unregistered by accident. A site opts in:++++```js++// in the site's navbar options++OpenVibeNavbar.init({ service: 'live', sw: true }); // or sw: { path: '/my-sw.js', scope: '/' }++```++++The rules it keeps, and why:++++| Request | What happens | Why |++|---|---|---|++| A content-addressed file (`?v=<hash>`, or a hash in the name) | the cache first, for good | the bytes cannot change under that URL |++| Any other file (a script, a stylesheet, a font, an image) | the cached copy now, the fresh one in the background | a visit never waits twice |++| A page (a navigation) | the network first (3 s), the cached copy only when the network does not answer | a crawler and a person read the HTML the server just wrote |++| `/api/`, anything that is not a GET, a range request | untouched | the API is never the cache's business |++| A response with `Set-Cookie`, `Cache-Control: private` or `no-store`, or a `Vary` on Cookie or Authorization | passed through, never stored | nothing private is ever written down |++++Nothing moves behind JavaScript: the HTML the server writes is the HTML the browser shows. The worker only decides *when* a++file is asked for, never *what* it says, and it never serves a cached page while the network answers.++++The **kill switch**: the worker asks `https://openvibe.network/api/v1/sw-config` (cached 5 minutes by the browser; a worker++that cannot reach it keeps working — a hiccup must not take the cache away). The answer `{ "enabled": false }` makes every++worker on every site unregister itself and drop its caches. A release that needs to be undone does not need a deploy on++every site.++++The server's half is `openvibe-shared/sw`:++++```js++const sw = require('openvibe-shared/sw');++app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker++app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash++app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));++```++++`sw.hints()` sends the **103 Early Hints** (RFC 8297): the browser gets the shell's URLs before the HTML arrives and starts++fetching them while the server is still writing the page. The same links ride in the `Link` header of the page itself, so a++proxy that drops the 103 still hands the browser the hint. Cloudflare's Early Hints has to be on for the zone (the origin's++103 is passed through when it is).++++### The rules the browser gets before the click++++The navbar hands the browser **speculation rules** (Chrome 103 and later):++++- a **same-site** page is prefetched on hover or touch, and — where the browser has prerendering — rendered in the++ background before the click lands (`prerender: false` turns that off for a site whose own router draws its pages; a link++ marked `data-no-prefetch` is left alone);++- a link to **another OpenVibe site** (the ones marked `data-ov-net-link`) is prefetched too, so the next site is already++ warm when the person arrives. Chrome leaves a site alone when the person has cookies on it, which is right: that page is++ theirs, not a public copy.++++The sign-in, the sign-out, the API, the internal pages and the downloads are never fetched ahead of the click.++### Vibes Pay (`vibes-pay.js`, 4.111.0)The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.164.0",++ "version": "4.165.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '184115c2aa7f', 'nav-slots.js': 'a5af310d62e0' };++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '455179332bc2', 'nav-slots.js': 'a5af310d62e0' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1311,9 +1311,9 @@if (!pick.length) return '';if (inDrawer) {return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`-- + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;++ + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;}-- return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');}/** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme,@@@ -2490,7 +2490,19 @@// its nonced scripts (the attribute is blanked once parsed; the property keeps it).const nonce = [].slice.call(document.scripts || []).map((x) => x.nonce).find(Boolean);if (nonce) sr.nonce = nonce;-- sr.textContent = JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] }, eagerness: 'moderate' }] });++ // The rules (the owner, 2026-11-09: "how come when you change sites it doesn't load more asynchronously and++ // dynamically, so it's faster across sites?"): a same-site page is prefetched, and — where the browser has++ // prerendering — rendered in the background before the click lands; a link to another OpenVibe site is++ // prefetched too, so the next site is already warm when the person arrives.++ const sameSite = { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] };++ const rules = { prefetch: [{ where: sameSite, eagerness: 'moderate' }] };++ // Prerendering a page the site's own router draws would be work thrown away: a site says no with++ // prerender: false, and marks the links it draws itself with data-no-prefetch.++ if (_config.prerender !== false) rules.prerender = [{ where: sameSite, eagerness: 'moderate' }];++ // Another OpenVibe site: the browser fetches it when the pointer arrives (Chrome leaves a site alone when++ // the person has cookies on it, which is right: that page is theirs, not a public copy).++ rules.prefetch.push({ where: { selector_matches: '[data-ov-net-link]' }, eagerness: 'moderate' });++ sr.textContent = JSON.stringify(rules);document.head.appendChild(sr);} catch { /* an old browser: links load as they always did */ }}@@@ -2506,6 +2518,24 @@// Back from the browser's page cache: whatever was loading is done.window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });}++ // ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──++ // A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for++ // push, so there is one registration at scope "/" and nothing is unregistered by accident.++ let _workerHooked = false;++ function hookWorker() {++ const spec = _config.sw;++ if (_workerHooked || !spec) return;++ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;++ if (typeof window !== 'undefined' && window.isSecureContext === false) return;++ _workerHooked = true;++ const opts = (spec === true) ? {} : (spec || {});++ const url = opts.path || '/openvibe-sw.js';++ const scope = opts.scope || '/';++ // After the load: registering early would compete with the page's own files for the network.++ const go = () => { try { navigator.serviceWorker.register(url, { scope }).catch(() => { /* no worker, no cache */ }); } catch { /* */ } };++ if (document.readyState === 'complete') go();++ else window.addEventListener('load', go, { once: true });++ }let _busyTimer = 0, _busyOn = false;function busy(on) {clearTimeout(_busyTimer);@@@ -2535,6 +2565,7 @@}hookBoost();hookMoves();++ hookWorker();const el = render();upgradeIconsWhenFontsReady();if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);@@@ -1,10 +1,192 @@--/* OpenVibe — shared Web Push service worker.-- * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").-- * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */--self.addEventListener('install', () => self.skipWaiting());--self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));++/* OpenVibe — the one worker every site runs (the owner, 2026-11-09: "how come when you change sites it doesn't load more++ * asynchronously and dynamically, so it's faster across sites? … better caching on the client, cross-site and smarter, while++ * staying SEO friendly and crawlable?").++ *++ * Served by every OpenVibe site at /openvibe-sw.js (same origin, scope "/"), from openvibe-shared. Two jobs in one file:++ *++ * 1. Web Push (the job it always had): the network sends a notification, the worker shows it.++ * 2. The cache of content-addressed files: a shared or site file served under its content hash (?v=<hash> or a hash in++ * the name: what serve.url() hands out) is kept and answered from the cache without asking the network again. Its++ * bytes cannot change under that URL, and it is public, so this is safe for everyone this worker runs for.++ *++ * The rules that keep it honest (review 11-09):++ *++ * - Only content-addressed files are answered by the worker. Pages and every other file go to the network exactly as++ * the browser sends them: no copy of a page is ever kept (a signed-in page could outlive a sign-out on a shared++ * device), nothing cuts a slow page off, and a file without a hash is never served stale against new HTML.++ * - Nothing private is stored: a response with Set-Cookie, Cache-Control: private or no-store, or a Vary on Cookie or++ * Authorization passes through and is never written to the cache.++ * - The API is never touched: /api/ and anything that is not a plain GET go to the network untouched.++ * - The kill switch: GET <kill switch> says { "enabled": false } and every worker on every site unregisters itself and++ * drops its caches. A worker that cannot reach the switch keeps working.++ * - A new VERSION is a new cache, and the cache keeps the newest MAX_ENTRIES files.++ *++ * This file is also the Web Push worker the notification bell registers for anyone who turns push on, so whatever it does++ * reaches them without any other opt-in: that is why it only keeps immutable public bytes.++ * The site that wants this says so: OpenVibeNavbar.init({ sw: true }). A site can also point the worker elsewhere++ * ({ sw: { path: '/my-sw.js' } }) or leave it alone.++ */++'use strict';--self.addEventListener('push', (event) => {++const VERSION = '4.164.0'; // the release this worker ships in: a bump replaces the caches++const CACHE_PREFIX = 'ov-shell-';++const CACHE_NAME = CACHE_PREFIX + VERSION;++const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config';++const STATIC_TYPES = ['javascript', 'ecmascript', 'css', 'woff2', 'woff', 'ttf', 'otf', 'svg+xml', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico'];++++const self_ = typeof self !== 'undefined' ? self : globalThis;++++/** The shared files every OpenVibe page loads. The site's own manifest (below) replaces this list when it has one. */++const SHELL = [++ '/shared/navbar.js',++ '/shared/theme-loader.js',++ '/shared/web-runtime.js',++ '/shared/footer.js',++ '/shared/nav-icons.js',++ '/shared/kit.css',++ '/shared/kit-layout.css',++ '/shared/ov-brand.woff2',++ '/shared/ov-glyphs.woff2',++ '/shared/ov-glyphs.svg',++];++++// The switch may be pointed elsewhere by the registration URL: /openvibe-sw.js?cfg=https://…++const params = new URL(self_.location.href).searchParams;++const KILL_URL = params.get('cfg') || KILL_SWITCH;++++// ── The cache's own rules ────────────────────────────────────────────────────────────────────────────────────────────────++++/** A URL is content-addressed when it carries ?v=<hash> or a hash in the file name: its bytes cannot change. */++function isHashed(url) {++ if (/[?&]v=[0-9a-f]{8,64}\b/.test(url.pathname + url.search)) return true;++ return /[.\-_][0-9a-f]{8,64}(?=[.\-_/]|$)/.test(url.pathname);++}++++/** True when the response may be written to the cache: public, not private, no cookie, no Vary on the reader. */++function cacheable(res) {++ if (!res || !res.ok || res.type === 'opaque' || res.type === 'opaqueredirect') return false;++ const cc = String(res.headers.get('cache-control') || '');++ if (/\bprivate\b/i.test(cc) || /\bno-store\b/i.test(cc)) return false;++ if (res.headers.get('set-cookie')) return false;++ const vary = String(res.headers.get('vary') || '').toLowerCase();++ if (/(^|,|\s)\s*(cookie|authorization)\s*(,|$)/.test(vary)) return false;++ return true;++}++++++/** True when the response is a file the shell keeps (a script, a stylesheet, a font, an image). */++function staticType(res) {++ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();++ const sub = type.indexOf('/') >= 0 ? type.slice(type.indexOf('/') + 1) : type;++ return STATIC_TYPES.indexOf(sub) >= 0;++}++++// ── The strategies ─────────────────────────────────────────────────────────────────────────────────────────────────────++++/** Hashed assets: the cache first, the network only when the cache does not have it. */++async function cacheFirst(req, cache) {++ const hit = await cache.match(req);++ if (hit) return hit;++ const res = await fetch(req);++ if (cacheable(res) && staticType(res)) { try { await cache.put(req, res.clone()); await trim(cache); } catch { /* full or gone */ } }++ return res;++}++++/** Each release adds files under new hashes: the oldest go once the cache holds more than MAX_ENTRIES. */++const MAX_ENTRIES = 400;++async function trim(cache) {++ const keys = await cache.keys();++ for (let i = 0; i < keys.length - MAX_ENTRIES; i++) await cache.delete(keys[i]);++}++++++++// ── The shell's own files ────────────────────────────────────────────────────────────────────────────────────────────────++++/** The site's manifest of the shell it serves, by content hash: the site's own answer, when it has one. */++async function shellUrls() {++ try {++ const res = await fetch('/ov-sw-manifest.json', { cache: 'no-store' });++ if (!res.ok) return SHELL;++ const body = await res.json();++ const list = Array.isArray(body && body.shell) ? body.shell.filter((u) => typeof u === 'string' && u.startsWith('/')) : [];++ return list.length ? list : SHELL;++ } catch { return SHELL; }++}++++/** Put the shell in the cache so the next page does not wait for it. One file that fails never stops the rest. */++async function warmShell(cache) {++ // Only the content-addressed ones: the worker never answers for any other file (see the fetch handler).++ const urls = (await shellUrls()).filter((u) => { try { return isHashed(new URL(u, self_.location.origin)); } catch { return false; } });++ await Promise.all(urls.map(async (url) => {++ try {++ const res = await fetch(new Request(url, { cache: 'reload' }));++ if (cacheable(res) && staticType(res)) await cache.put(url, res);++ } catch { /* that file is not on this site */ }++ }));++}++++// ── The kill switch ────────────────────────────────────────────────────────────────────────────────────────────────────────++++/** Ask the network whether the worker may keep working. No answer means yes: a hiccup must not take the cache away. */++async function checkKillSwitch() {++ try {++ const res = await fetch(KILL_URL, { cache: 'no-store' });++ if (!res.ok) return false;++ const cfg = await res.json();++ if (!cfg || cfg.enabled !== false) return false;++ } catch { return false; }++ try {++ await self_.registration.unregister();++ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);++ } catch { /* nothing more to do */ }++ return true;++}++++// ── The worker's life ───────────────────────────────────────────────────────────────────────────────────────────────────++++self_.addEventListener('install', () => self_.skipWaiting());++++self_.addEventListener('activate', (event) => {++ event.waitUntil((async () => {++ await self_.clients.claim();++ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME) await caches.delete(name);++ await checkKillSwitch();++ try { await warmShell(await caches.open(CACHE_NAME)); } catch { /* the shell warms on its next visit */ }++ })());++});++++self_.addEventListener('fetch', (event) => {++ const req = event.request;++ if (req.method !== 'GET') return; // never touches a write++ if (req.headers.get('range')) return; // a partial request is the browser's business++ let url;++ try { url = new URL(req.url); } catch { return; }++ if (url.origin !== self_.location.origin) return; // another site's bytes are another site's cache++ if (url.pathname.startsWith('/api/')) return; // the API is never cached++ if (url.pathname === '/ov-sw.js' || url.pathname === '/openvibe-sw.js') return;++++ // Only content-addressed files (a hash in the name, or ?v=<hash>): their bytes never change and are public, so the++ // cache answers first, for good. Pages and every other file go to the network exactly as the browser sends them.++ // (Review 11-09: this file is also the Web Push worker every signed-in person with push already runs, so whatever it++ // does reaches people who never opted in. Kept copies of pages could outlive a sign-out on a shared device, a 3 s++ // cut-off turned slow pages into "offline", and stale unhashed scripts could run against new HTML after a deploy.)++ if (req.destination === 'document' || req.mode === 'navigate' || !isHashed(url)) return;++ event.respondWith((async () => cacheFirst(req, await caches.open(CACHE_NAME)))());++});++++self_.addEventListener('message', (event) => {++ const data = (event.data && typeof event.data === 'object') ? event.data : {};++ if (data.type === 'skipWaiting') self_.skipWaiting();++ if (data.type === 'checkKill') event.waitUntil(checkKillSwitch());++ if (data.type === 'clearCaches') event.waitUntil((async () => {++ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);++ })());++});++++// ── Web Push (the job this worker always had) ───────────────────────────────────────────────────────────────────────++++self_.addEventListener('push', (event) => {let data = {};try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }const title = data.title || 'OpenVibe';@@@ -17,14 +199,14 @@ self.addEventListener('push', (event) => {data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },requireInteraction: false,};-- event.waitUntil(self.registration.showNotification(title, options));++ event.waitUntil(self_.registration.showNotification(title, options));});--self.addEventListener('notificationclick', (event) => {++self_.addEventListener('notificationclick', (event) => {event.notification.close();const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';event.waitUntil((async () => {-- const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });++ const all = await self_.clients.matchAll({ type: 'window', includeUncontrolled: true });let target = null;try { target = new URL(url); } catch { /* */ }for (const c of all) {@@@ -36,6 +218,6 @@ self.addEventListener('notificationclick', (event) => {}} catch { /* */ }}-- await self.clients.openWindow(url);++ await self_.clients.openWindow(url);})());});@@@ -396,7 +396,9 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;if (rows.length < 2) return null;const wrap = h('div');const head = sec('Most used');-- head.append(h('small', '', 'pages you opened'));++ // Your stats on Report (private by default; you choose what is public), as the section's link.++ const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';++ head.append(more);wrap.append(head);const box = h('div', 'ovh-use');const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.164.0",++ "version": "4.165.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.164.0",++ "version": "4.165.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.164.0",++ "version": "4.165.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -123,7 +123,8 @@"./agent-layer": "./agent-layer.js","./thread": "./thread.js","./messages": "./messages.js",-- "./ov-thread": "./ov-thread.js"++ "./ov-thread": "./ov-thread.js",++ "./sw": "./sw.js"},"files": ["*.js",@@@ -155,7 +156,21 @@"type": "git","url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"},-- "homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",++ "homepage": "https://openvibe.network",++ "keywords": [++ "openvibe",++ "ui",++ "components",++ "navbar",++ "footer",++ "themes",++ "icons",++ "seo",++ "json-ld",++ "llms-txt",++ "feeds",++ "self-hosted"++ ],"license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -255,7 +255,7 @@ function sitemapIndexXml(maps = []) {}// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.--const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];++const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'Perplexity-User', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {const lines = [];nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * openvibe-shared/sw — the server's side of the shared worker (openvibe-sw.js).++ *++ * A site that wants the shell's cache says so in its navbar (OpenVibeNavbar.init({ sw: true })) and serves the worker at++ * /openvibe-sw.js, from this package, at scope "/". This module is the other half: what the worker needs to know, and the++ * early hints that get the shell downloading before the HTML is even parsed.++ *++ * const sw = require('openvibe-shared/sw');++ * app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker itself++ * app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash++ * app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));++ *++ * The early hints (RFC 8297): the browser gets the shell's URLs in a 103 response, before the HTML arrives, and starts++ * fetching them while the server is still writing the page. The same links ride along in the Link header of the page itself,++ * so a proxy that drops the 103 still hands the browser the hint.++ *++ * Nothing here touches the API, and nothing here decides what a page shows: the hints are about when a file starts loading,++ * never about what it says.++ */++const path = require('path');++++/** The release this helper ships in; the worker keeps the same number, and a test checks they agree. */++const VERSION = '4.164.0';++++/** The shared files every OpenVibe page loads: the shell. The worker keeps them in its cache. */++const SHELL = Object.freeze([++ '/shared/navbar.js',++ '/shared/theme-loader.js',++ '/shared/web-runtime.js',++ '/shared/footer.js',++ '/shared/nav-icons.js',++ '/shared/kit.css',++ '/shared/kit-layout.css',++ '/shared/ov-brand.woff2',++ '/shared/ov-glyphs.woff2',++ '/shared/ov-glyphs.svg',++]);++++/** Where a worker asks whether it may keep working. A site may point it elsewhere for a test. */++const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config';++++/** The path every site serves the worker at. Same origin, scope "/". */++const WORKER_PATH = '/openvibe-sw.js';++++/** The file on disk: the worker this package ships. */++function workerPath() {++ return path.join(__dirname, 'openvibe-sw.js');++}++++/**++ * The shell as JSON, for the site's /ov-sw-manifest.json. A site that serves the shell by content hash answers with its own++ * hashed URLs; the plain list is the fallback.++ */++function manifest(shell) {++ const given = (Array.isArray(shell) ? shell : []).filter((u) => typeof u === 'string' && u.startsWith('/'));++ return { version: VERSION, shell: given.length ? given : SHELL };++}++++/** One preload link, as the Link header and the 103 response write it. */++function linkHeader(link) {++ if (!link || !link.href) return '';++ let out = '<' + link.href + '>; rel=preload';++ if (link.as) out += '; as=' + link.as;++ if (link.type) out += '; type=' + link.type;++ if (link.crossOrigin) out += '; crossorigin';++ return out;++}++++/**++ * Send the early hints for a response.++ *++ * sw.earlyHints(res, [ { href: '/shared/navbar.js?v=abc123', as: 'script' } ]);++ *++ * The 103 goes out first when the runtime can send one (Node 18.11 and later); the Link header rides on the page itself so++ * the hint survives a proxy that drops the 103. Returns true when a 103 was sent.++ */++function earlyHints(res, links) {++ const list = (Array.isArray(links) ? links : []).filter((l) => l && l.href);++ if (!list.length || !res) return false;++ let sent = false;++ if (typeof res.writeEarlyHints === 'function') {++ try {++ res.writeEarlyHints({ link: list.map(linkHeader).filter(Boolean) });++ sent = true;++ } catch { /* the response was already on its way */ }++ }++ if (!res.headersSent) {++ try { res.setHeader('Link', list.map(linkHeader).filter(Boolean).join(', ')); } catch { /* the header was set already */ }++ }++ return sent;++}++++/**++ * Express middleware: send the early hints for a document request.++ *++ * app.use(sw.hints({ links: (req) => [ { href: navbarUrl, as: 'script' } ] }));++ *++ * Only GET and HEAD are hinted, and only when the request asks for a document: a file request already knows its own URL.++ */++function hints({ links, filter } = {}) {++ const pick = typeof links === 'function' ? links : () => (Array.isArray(links) ? links : []);++ return function openvibeEarlyHints(req, res, next) {++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();++ if (filter && filter(req, res) === false) return next();++ try { earlyHints(res, pick(req, res)); } catch { /* a hint is never worth a page */ }++ return next();++ };++}++++module.exports = { VERSION, SHELL, KILL_SWITCH, WORKER_PATH, workerPath, manifest, earlyHints, hints, linkHeader };@@@ -184,6 +184,7 @@ const server = http.createServer((req, res) => {assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');++ assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 48 * 1024; // 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++const NAVBAR_BROTLI_BUDGET = 48.5 * 1024; // 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{@@@ -236,7 +236,7 @@ function load(hostname, opts = {}) {{const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');-- assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');++ assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" data-ov-net-link href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++// navbar.js — the rules it hands the browser (speculation rules: a same-site page is prefetched and prerendered, another++// OpenVibe site is prefetched) and the worker it registers when a site asks for it (sw: true).++const assert = require('assert');++const path = require('path');++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');++++function stubElement(tag) {++ const el = {++ tag, style: { setProperty() {} }, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [], removed: false,++ classes: new Set(),++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },++ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() { this.removed = true; }, addEventListener() {},++ querySelector() { return null; }, querySelectorAll() { return []; },++ };++ el.classList = { add: (c) => el.classes.add(c), remove: (c) => el.classes.delete(c), toggle() {}, contains: (c) => el.classes.has(c) };++ return el;++}++++function load({ serviceWorker = null } = {}) {++ global.location = { hostname: 'openvibe.chat', href: 'https://openvibe.chat/', pathname: '/', protocol: 'https:', origin: 'https://openvibe.chat' };++ const listeners = {};++ global.window = { location: global.location, isSecureContext: true, addEventListener(t, fn) { (listeners[t] = listeners[t] || []).push(fn); } };++ const nav = serviceWorker ? { serviceWorker } : undefined;++ try { Object.defineProperty(global, 'navigator', { value: nav || { userAgent: 'test' }, configurable: true }); } catch { /* */ }++ const body = stubElement('body');++ const head = stubElement('head');++ global.document = {++ cookie: '', head, body, documentElement: stubElement('html'), title: 'T', readyState: 'complete',++ getElementById: () => null, createElement: (tag) => stubElement(tag),++ querySelector: () => null, querySelectorAll: () => [],++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},++ };++ global.HTMLScriptElement = { supports: () => true };++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };++ const store = {};++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };++ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });++ delete require.cache[require.resolve(NAVBAR_PATH)];++ return { navbar: require(NAVBAR_PATH), head, listeners };++}++++const rulesOf = (head) => {++ const script = head.children.filter((c) => c.tag === 'script' && c.type === 'speculationrules').pop();++ return script ? JSON.parse(script.textContent) : null;++};++++{ // The rules the browser gets: a same-site page is prefetched and prerendered, another OpenVibe site is prefetched.++ const { navbar, head } = load();++ navbar.init({ service: 'chat', links: [{ label: 'Vibe', href: '/vibe' }] });++ const rules = rulesOf(head);++ assert.ok(rules, 'the rules are in the page');++ assert.ok(Array.isArray(rules.prefetch) && rules.prefetch.length >= 2, 'a prefetch rule');++ assert.strictEqual(rules.prefetch[0].where.and[0].href_matches, '/*', 'the same-site rule');++ assert.strictEqual(rules.prefetch[0].eagerness, 'moderate');++ assert.strictEqual(rules.prefetch[1].where.selector_matches, '[data-ov-net-link]', 'another OpenVibe site');++ assert.ok(Array.isArray(rules.prerender) && rules.prerender.length === 1, 'the page is prerendered too');++ assert.strictEqual(rules.prerender[0].where.and[0].href_matches, '/*');++ // The sign-in, the API and the downloads are never fetched ahead of the click.++ const excluded = rules.prefetch[0].where.and[1].not.href_matches;++ for (const p of ['/auth/*', '/api/*', '/logout*', '/*.zip']) assert.ok(excluded.includes(p), p + ' is left alone');++}++++{ // A site that draws its own pages says no to the prerender.++ const { navbar, head } = load();++ navbar.init({ service: 'chat', prerender: false, links: [{ label: 'Vibe', href: '/vibe' }] });++ const rules = rulesOf(head);++ assert.ok(rules && !rules.prerender, 'no prerender');++ assert.ok(rules && rules.prefetch.length >= 2, 'the prefetch stays');++}++++{ // The worker: only when a site asks for it.++ const calls = [];++ const { navbar } = load({ serviceWorker: { register: (url, opts) => { calls.push([url, opts]); return Promise.resolve(); } } });++ navbar.init({ service: 'chat', sw: true, links: [{ label: 'Vibe', href: '/vibe' }] });++ assert.strictEqual(calls.length, 1, 'the worker was registered');++ assert.deepStrictEqual(calls[0], ['/openvibe-sw.js', { scope: '/' }]);++++ const calls2 = [];++ const again = load({ serviceWorker: { register: (url, opts) => { calls2.push([url, opts]); return Promise.resolve(); } } });++ again.navbar.init({ service: 'chat', sw: { path: '/my-sw.js', scope: '/app/' }, links: [{ label: 'Vibe', href: '/vibe' }] });++ assert.deepStrictEqual(calls2[0], ['/my-sw.js', { scope: '/app/' }], 'a site can point the worker elsewhere');++}++++{ // No opt-in, no worker.++ const calls = [];++ const { navbar } = load({ serviceWorker: { register: (url, opts) => { calls.push([url, opts]); return Promise.resolve(); } } });++ navbar.init({ service: 'chat', links: [{ label: 'Vibe', href: '/vibe' }] });++ assert.strictEqual(calls.length, 0, 'a site that did not ask for it gets no worker');++++ const calls2 = [];++ const off = load({ serviceWorker: { register: (url, opts) => { calls2.push([url, opts]); return Promise.resolve(); } } });++ off.navbar.init({ service: 'chat', sw: false, links: [{ label: 'Vibe', href: '/vibe' }] });++ assert.strictEqual(calls2.length, 0, 'sw: false is no worker');++}++++console.log('navbar-speculation: ok');@@@ -7,6 +7,9 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past tassert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');++for (const bot of ['Googlebot', 'Bingbot', 'OAI-SearchBot', 'ChatGPT-User', 'GPTBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'Applebot', 'DuckDuckBot']) {++ assert.ok(seo.robotsTxt({}).includes(`User-agent: ${bot}\n`), `${bot} is welcomed by name`);++}assert.ok(seo.robotsTxt({}).includes('Content-Signal: search=yes, ai-input=yes, ai-train=yes'), 'AI use allowed in so many words');assert.ok(!seo.robotsTxt({ allowAI: false }).includes('Content-Signal'), 'and not when a site opts out');assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// The shared worker (openvibe-sw.js) in a real headless Chrome against a local server: the hashed file comes from the++// worker's cache, a page always comes from the network, a private page is never stored, a slow network falls back to the++// cached page, and the kill switch takes the worker away. 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');++const sw = require('../sw');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('sw-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const WORKER = fs.readFileSync(path.join(__dirname, '..', 'openvibe-sw.js'), 'utf8');++const hits = {};++const state = { slow: false, kill: { enabled: true }, body: 1 };++const count = (p) => { hits[p] = (hits[p] || 0) + 1; return hits[p]; };++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ count(u.pathname);++ if (u.pathname === '/openvibe-sw.js') {++ res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' });++ return res.end(WORKER);++ }++ if (u.pathname === '/sw-config') {++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });++ return res.end(JSON.stringify(state.kill));++ }++ if (u.pathname === '/shared/navbar.js') {++ res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'public, max-age=31536000, immutable' });++ return res.end('window.shellLoads = (window.shellLoads || 0) + 1;');++ }++ if (u.pathname === '/api/thing') {++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });++ return res.end(JSON.stringify({ n: count('/api/thing') }));++ }++ if (u.pathname === '/private') {++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'private, no-store' });++ return res.end('<!doctype html><title>private</title><p id="m">private page</p>');++ }++ if (u.pathname === '/slow') {++ const send = () => {++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }); // HTML revalidates, as on every site++ res.end('<!doctype html><title>slow</title><p id="m">cached page</p>');++ };++ if (state.slow) setTimeout(send, 6000); else send();++ return undefined;++ }++ // A page: public, and its body changes on every write, so a fresh copy is visible.++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', etag: 'W/"' + state.body + '"' });++ res.end('<!doctype html><title>home</title><p id="m">body ' + state.body + '</p><script src="/shared/navbar.js?v=abcdef123456"></script>');++});++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++async function waitFor(fn, ms = 5000) {++ const end = Date.now() + ms;++ while (Date.now() < end) { if (await fn()) return true; await sleep(100); }++ return false;++}++++(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-sw-'));++ const browser = await h.launch({ tmpDir });++ try {++ // ── The worker takes over the page ────────────────────────────────────────────────────────────────────────++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 1280 });++ await p.goto(base + '/', { maxMs: 8000 });++ const registered = await p.evaluate(`navigator.serviceWorker.register('/openvibe-sw.js?cfg=' + ${JSON.stringify(base)} + '/sw-config', { scope: '/' }).then(() => navigator.serviceWorker.ready).then(() => true).catch((e) => String(e))`);++ assert.strictEqual(registered, true, 'the worker registered');++ const controlled = await waitFor(async () => p.evaluate('!!navigator.serviceWorker.controller'));++ assert.ok(controlled, 'the worker controls the page');++++ // ── A hashed file comes from the worker's cache ────────────────────────────────────────────────────────++ await p.goto(base + '/', { maxMs: 8000 });++ const cached = await waitFor(async () => p.evaluate(`caches.open('ov-shell-' + ${JSON.stringify(sw.VERSION)}).then((c) => c.keys()).then((k) => k.map((r) => r.url)).catch(() => [])`).then((list) => list.some((u) => u.indexOf('/shared/navbar.js?v=abcdef123456') >= 0)));++ assert.ok(cached, 'the hashed file is in the worker cache');++ const shellHits = hits['/shared/navbar.js'] || 0;++ await p.goto(base + '/', { maxMs: 8000 });++ assert.strictEqual(hits['/shared/navbar.js'] || 0, shellHits, 'the hashed file was not asked for again');++++ // ── A page always comes from the network ──────────────────────────────────────────────────────────────++ const pageHits = hits['/'] || 0;++ state.body = 2;++ await p.goto(base + '/', { maxMs: 8000 });++ assert.ok((hits['/'] || 0) > pageHits, 'the page was asked for again (never served from the cache while the network answers)');++ const body = await p.evaluate('document.getElementById("m").textContent');++ assert.strictEqual(body, 'body 2', 'the page shows what the server just wrote');++++ // ── The API is never cached ───────────────────────────────────────────────────────────────────────────++ const api1 = await p.evaluate('fetch("/api/thing").then((r) => r.json()).then((j) => j.n)');++ const api2 = await p.evaluate('fetch("/api/thing").then((r) => r.json()).then((j) => j.n)');++ assert.notStrictEqual(api1, api2, 'the API is asked every time');++++ // ── A slow page is never cut off, and no page is kept (review 11-09) ──────────────────────────────────++ await p.goto(base + '/slow', { maxMs: 12000 });++ state.slow = true;++ const started = Date.now();++ await p.goto(base + '/slow', { maxMs: 15000 });++ const waited = Date.now() - started;++ assert.strictEqual(await p.evaluate('document.getElementById("m").textContent'), 'cached page', 'the slow page arrived from the server');++ assert.ok(waited >= 5500, 'the page waited for the network as long as it took (' + waited + ' ms), never a 3 s cut-off');++ const pagesKept = await p.evaluate('caches.keys().then((ks) => Promise.all(ks.map((k) => caches.open(k).then((c) => c.keys())))).then((all) => all.flat().map((r) => new URL(r.url).pathname).filter((x) => x === "/" || x === "/slow"))');++ assert.deepStrictEqual(pagesKept, [], 'no copy of a page is in the worker cache');++ state.slow = false;++++ // ── The kill switch takes the worker away ─────────────────────────────────────────────────────────────++ state.kill = { enabled: false };++ await p.evaluate('navigator.serviceWorker.controller.postMessage({ type: "checkKill" })');++ const gone = await waitFor(async () => p.evaluate('navigator.serviceWorker.getRegistration().then((r) => !r)'));++ assert.ok(gone, 'the worker unregistered itself when the switch said no');++ const caches = await p.evaluate('caches.keys().then((k) => k)');++ assert.deepStrictEqual(caches.filter((k) => k.indexOf('ov-shell-') === 0), [], 'the caches are gone');++++ // ── A switch that does not answer leaves the worker alone ─────────────────────────────────────────────++ const context2 = await browser.newContext();++ const p2 = await h.openPage(browser, context2, { width: 1280 });++ await p2.goto(base + '/', { maxMs: 8000 });++ await p2.evaluate(`navigator.serviceWorker.register('/openvibe-sw.js?cfg=' + ${JSON.stringify(base)} + '/no-such-switch', { scope: '/' }).then(() => navigator.serviceWorker.ready).then(() => true).catch((e) => String(e))`);++ await sleep(1200);++ const still = await p2.evaluate('navigator.serviceWorker.getRegistration().then((r) => !!r)');++ assert.ok(still, 'a switch that cannot be reached leaves the worker working');++ await p2.close();++ await p.close();++ } finally {++ await browser.close().catch(() => {});++ server.close();++ }++ console.log('sw-chrome: ok');++})().catch((e) => { console.error('sw-chrome:', e && e.message ? e.message : e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++// openvibe-shared/sw: the early hints (103), the shell manifest, and the one rule that keeps the worker and the helper in++// step (the same version, the same shell).++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const sw = require('../sw');++++(async () => {++ // ── The shell ────────────────────────────────────────────────────────────────────────────────────────────────++ assert.ok(Array.isArray(sw.SHELL) && sw.SHELL.length >= 5, 'the shell is a list of files');++ for (const file of sw.SHELL) assert.ok(file.startsWith('/shared/'), file + ' is a shared file');++ assert.ok(sw.SHELL.includes('/shared/navbar.js'), 'the navbar is in the shell');++ assert.ok(sw.SHELL.includes('/shared/theme-loader.js'), 'the theme loader is in the shell');++++ // The worker keeps its own copy of the shell (it is a file the browser runs, not a module it requires): the two++ // lists must be the same, or a site warms one shell and the worker caches another.++ const worker = fs.readFileSync(path.join(__dirname, '..', 'openvibe-sw.js'), 'utf8');++ const workerShell = JSON.parse((worker.match(/const SHELL = (\[[\s\S]*?\]);/) || [])[1].replace(/'/g, '"').replace(/,\s*]/g, ']'));++ assert.deepStrictEqual(workerShell, sw.SHELL, 'the worker and the helper keep the same shell');++ const workerVersion = (worker.match(/const VERSION = '([^']+)'/) || [])[1];++ assert.strictEqual(workerVersion, sw.VERSION, 'the worker and the helper ship in the same release');++ assert.strictEqual(sw.KILL_SWITCH, 'https://openvibe.network/api/v1/sw-config', 'the kill switch has one address');++ assert.ok(fs.existsSync(sw.workerPath()), 'the worker file is there to serve');++ assert.strictEqual(path.basename(sw.workerPath()), 'openvibe-sw.js', 'the worker is served by its name');++++ // ── The manifest ─────────────────────────────────────────────────────────────────────────────────────────++ const man = sw.manifest();++ assert.strictEqual(man.version, sw.VERSION);++ assert.deepStrictEqual(man.shell, sw.SHELL);++ assert.deepStrictEqual(sw.manifest(['/shared/a.js']).shell, ['/shared/a.js'], 'a site answers with its own list');++ assert.deepStrictEqual(sw.manifest(['https://elsewhere/x.js']).shell, sw.SHELL, 'only the site\'s own paths are listed');++++ // ── The early hints ────────────────────────────────────────────────────────────────────────────────────++ assert.strictEqual(sw.linkHeader({ href: '/shared/navbar.js?v=abc', as: 'script' }), '</shared/navbar.js?v=abc>; rel=preload; as=script');++ assert.strictEqual(sw.linkHeader({ href: '/x.css', as: 'style', type: 'text/css' }), '</x.css>; rel=preload; as=style; type=text/css');++ assert.strictEqual(sw.linkHeader(null), '');++++ // The 103 goes out first, and the Link header rides on the page so a proxy that drops the 103 still hints.++ let hints103 = null; const headers = {};++ const res = {++ headersSent: false,++ writeEarlyHints(h) { hints103 = h; return true; },++ setHeader(k, v) { headers[k] = v; },++ };++ assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), true);++ assert.deepStrictEqual(hints103, { link: ['</shared/navbar.js>; rel=preload; as=script'] });++ assert.strictEqual(headers.Link, '</shared/navbar.js>; rel=preload; as=script');++++ // A runtime that cannot send a 103 still gets the header.++ let noHints = null; const headers2 = {};++ const res2 = { headersSent: false, writeEarlyHints(h) { noHints = h; }, setHeader(k, v) { headers2[k] = v; } };++ assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }]), true);++ assert.ok(noHints, 'the 103 was sent');++ const res3 = { headersSent: true, setHeader() { throw new Error('the header was already sent'); } };++ assert.strictEqual(sw.earlyHints(res3, [{ href: '/a.css', as: 'style' }]), false, 'a response already on its way is left alone');++ assert.strictEqual(sw.earlyHints(res, []), false, 'no links, no hint');++++ // The middleware only hints a document request.++ const seen = [];++ const mw = sw.hints({ links: () => [{ href: '/shared/navbar.js', as: 'script' }] });++ let nexted = 0;++ mw({ method: 'POST', headers: {} }, { headersSent: false, setHeader(k, v) { seen.push([k, v]); }, writeEarlyHints() { } }, () => { nexted++; });++ assert.strictEqual(seen.length, 0, 'a write is never hinted');++ mw({ method: 'GET', headers: {} }, { headersSent: false, setHeader(k, v) { seen.push([k, v]); }, writeEarlyHints() { } }, () => { nexted++; });++ assert.strictEqual(seen.length, 1, 'a page is hinted');++ assert.strictEqual(nexted, 2, 'the request always goes on');++++ console.log('sw: ok');++})().catch((e) => { console.error('sw:', e && e.message ? e.message : e); process.exit(1); });
@@ -1,5 +1,18 @@
@@ -1,5 +1,18 @@
# Changelog
# Changelog
+## 4.165.0 — 2026-11-09
+
+- **Faster between pages and sites** (the owner, 11-09: "when you change sites… faster across sites… better caching on
+ the client"): the navbar adds a same-site prerender rule beside its prefetch, and prefetches links to the other
+ OpenVibe sites; `openvibe-shared/sw` sends the shell's files in a 103 Early Hint. The shared worker
+ (`/openvibe-sw.js`, also the Web Push worker) keeps **only content-addressed files** (a hash in the name or
+ `?v=<hash>`: immutable, public) and answers them from its cache; pages and every other file go to the network as the
+ browser sends them, so no page copy outlives a sign-out, nothing cuts a slow page off, and no stale script meets new
+ HTML (security review). A kill switch (`/api/v1/sw-config` on Network) unregisters it everywhere. Sites opt in to
+ registering it with `OpenVibeNavbar.init({ sw: true })`.
+- `robots.txt` welcomes Perplexity-User, Claude-User and Claude-SearchBot by name (discoverability, ask 93).
+- You: **Most used** links to **Your stats** on OpenVibe Report (`/me`: private by default, each section publishable).
+
## 4.164.0 — 2026-11-09
## 4.164.0 — 2026-11-09
- **Chat's two views** (`openvibe-shared/messages`, `OVMessages.render(el, { messages, view: 'room' | 'bubbles', me })`;
- **Chat's two views** (`openvibe-shared/messages`, `OVMessages.render(el, { messages, view: 'room' | 'bubbles', me })`;
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.164.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.165.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -845,6 +845,65 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
@@ -845,6 +845,65 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
+### The shell's cache: the worker (`openvibe-sw.js`, `sw.js`, 4.164.0)
+
+The owner, 2026-11-09: *"how come when you change sites it doesn't load more asynchronously and dynamically, so it's faster
+across sites? … better caching on the client, cross-site and smarter, intelligent, so it's faster, while staying SEO friendly
+and crawlable?"*
+
+One worker, every site, at `/openvibe-sw.js` (same origin, scope "/"), from this package. It is the file the notification
+bell already registered for Web Push, so there is one registration and nothing is unregistered by accident. A site opts in:
+
+```js
+// in the site's navbar options
+OpenVibeNavbar.init({ service: 'live', sw: true }); // or sw: { path: '/my-sw.js', scope: '/' }
+```
+
+The rules it keeps, and why:
+
+| Request | What happens | Why |
+|---|---|---|
+| A content-addressed file (`?v=<hash>`, or a hash in the name) | the cache first, for good | the bytes cannot change under that URL |
+| Any other file (a script, a stylesheet, a font, an image) | the cached copy now, the fresh one in the background | a visit never waits twice |
+| A page (a navigation) | the network first (3 s), the cached copy only when the network does not answer | a crawler and a person read the HTML the server just wrote |
+| `/api/`, anything that is not a GET, a range request | untouched | the API is never the cache's business |
+| A response with `Set-Cookie`, `Cache-Control: private` or `no-store`, or a `Vary` on Cookie or Authorization | passed through, never stored | nothing private is ever written down |
+
+Nothing moves behind JavaScript: the HTML the server writes is the HTML the browser shows. The worker only decides *when* a
+file is asked for, never *what* it says, and it never serves a cached page while the network answers.
+
+The **kill switch**: the worker asks `https://openvibe.network/api/v1/sw-config` (cached 5 minutes by the browser; a worker
+that cannot reach it keeps working — a hiccup must not take the cache away). The answer `{ "enabled": false }` makes every
+worker on every site unregister itself and drop its caches. A release that needs to be undone does not need a deploy on
+every site.
+
+The server's half is `openvibe-shared/sw`:
+
+```js
+const sw = require('openvibe-shared/sw');
+app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker
+app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash
+app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));
+```
+
+`sw.hints()` sends the **103 Early Hints** (RFC 8297): the browser gets the shell's URLs before the HTML arrives and starts
+fetching them while the server is still writing the page. The same links ride in the `Link` header of the page itself, so a
+proxy that drops the 103 still hands the browser the hint. Cloudflare's Early Hints has to be on for the zone (the origin's
+103 is passed through when it is).
+
+### The rules the browser gets before the click
+
+The navbar hands the browser **speculation rules** (Chrome 103 and later):
+
+- a **same-site** page is prefetched on hover or touch, and — where the browser has prerendering — rendered in the
+ background before the click lands (`prerender: false` turns that off for a site whose own router draws its pages; a link
+ marked `data-no-prefetch` is left alone);
+- a link to **another OpenVibe site** (the ones marked `data-ov-net-link`) is prefetched too, so the next site is already
+ warm when the person arrives. Chrome leaves a site alone when the person has cookies on it, which is right: that page is
+ theirs, not a public copy.
+
+The sign-in, the sign-out, the API, the internal pages and the downloads are never fetched ahead of the click.
+
### Vibes Pay (`vibes-pay.js`, 4.111.0)
### Vibes Pay (`vibes-pay.js`, 4.111.0)
The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.
The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.164.0",
+ "version": "4.165.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)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '184115c2aa7f', 'nav-slots.js': 'a5af310d62e0' };
+ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '455179332bc2', 'nav-slots.js': 'a5af310d62e0' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1311,9 +1311,9 @@
@@ -1311,9 +1311,9 @@
if (!pick.length) return '';
if (!pick.length) return '';
if (inDrawer) {
if (inDrawer) {
return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`
return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`
- + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;
+ + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;
}
}
- return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
}
/** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme,
/** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme,
@@ -2490,7 +2490,19 @@
@@ -2490,7 +2490,19 @@
// its nonced scripts (the attribute is blanked once parsed; the property keeps it).
// its nonced scripts (the attribute is blanked once parsed; the property keeps it).
const nonce = [].slice.call(document.scripts || []).map((x) => x.nonce).find(Boolean);
const nonce = [].slice.call(document.scripts || []).map((x) => x.nonce).find(Boolean);
if (nonce) sr.nonce = nonce;
if (nonce) sr.nonce = nonce;
- sr.textContent = JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] }, eagerness: 'moderate' }] });
+ // The rules (the owner, 2026-11-09: "how come when you change sites it doesn't load more asynchronously and
+ // dynamically, so it's faster across sites?"): a same-site page is prefetched, and — where the browser has
+ // prerendering — rendered in the background before the click lands; a link to another OpenVibe site is
+ // prefetched too, so the next site is already warm when the person arrives.
+ const sameSite = { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] };
+ const rules = { prefetch: [{ where: sameSite, eagerness: 'moderate' }] };
+ // Prerendering a page the site's own router draws would be work thrown away: a site says no with
+ // prerender: false, and marks the links it draws itself with data-no-prefetch.
+ if (_config.prerender !== false) rules.prerender = [{ where: sameSite, eagerness: 'moderate' }];
+ // Another OpenVibe site: the browser fetches it when the pointer arrives (Chrome leaves a site alone when
+ // the person has cookies on it, which is right: that page is theirs, not a public copy).
+ rules.prefetch.push({ where: { selector_matches: '[data-ov-net-link]' }, eagerness: 'moderate' });
+ sr.textContent = JSON.stringify(rules);
document.head.appendChild(sr);
document.head.appendChild(sr);
} catch { /* an old browser: links load as they always did */ }
} catch { /* an old browser: links load as they always did */ }
}
}
@@ -2506,6 +2518,24 @@
@@ -2506,6 +2518,24 @@
// Back from the browser's page cache: whatever was loading is done.
// Back from the browser's page cache: whatever was loading is done.
window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });
window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });
}
}
+ // ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──
+ // A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for
+ // push, so there is one registration at scope "/" and nothing is unregistered by accident.
+ let _workerHooked = false;
+ function hookWorker() {
+ const spec = _config.sw;
+ if (_workerHooked || !spec) return;
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
+ if (typeof window !== 'undefined' && window.isSecureContext === false) return;
+ _workerHooked = true;
+ const opts = (spec === true) ? {} : (spec || {});
+ const url = opts.path || '/openvibe-sw.js';
+ const scope = opts.scope || '/';
+ // After the load: registering early would compete with the page's own files for the network.
+ const go = () => { try { navigator.serviceWorker.register(url, { scope }).catch(() => { /* no worker, no cache */ }); } catch { /* */ } };
+ if (document.readyState === 'complete') go();
+ else window.addEventListener('load', go, { once: true });
+ }
let _busyTimer = 0, _busyOn = false;
let _busyTimer = 0, _busyOn = false;
function busy(on) {
function busy(on) {
clearTimeout(_busyTimer);
clearTimeout(_busyTimer);
@@ -2535,6 +2565,7 @@
@@ -2535,6 +2565,7 @@
}
}
hookBoost();
hookBoost();
hookMoves();
hookMoves();
+ hookWorker();
const el = render();
const el = render();
upgradeIconsWhenFontsReady();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
@@ -1,10 +1,192 @@
@@ -1,10 +1,192 @@
-/* OpenVibe — shared Web Push service worker.
- * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").
- * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */
-self.addEventListener('install', () => self.skipWaiting());
-self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
+/* OpenVibe — the one worker every site runs (the owner, 2026-11-09: "how come when you change sites it doesn't load more
+ * asynchronously and dynamically, so it's faster across sites? … better caching on the client, cross-site and smarter, while
+ * staying SEO friendly and crawlable?").
+ *
+ * Served by every OpenVibe site at /openvibe-sw.js (same origin, scope "/"), from openvibe-shared. Two jobs in one file:
+ *
+ * 1. Web Push (the job it always had): the network sends a notification, the worker shows it.
+ * 2. The cache of content-addressed files: a shared or site file served under its content hash (?v=<hash> or a hash in
+ * the name: what serve.url() hands out) is kept and answered from the cache without asking the network again. Its
+ * bytes cannot change under that URL, and it is public, so this is safe for everyone this worker runs for.
+ *
+ * The rules that keep it honest (review 11-09):
+ *
+ * - Only content-addressed files are answered by the worker. Pages and every other file go to the network exactly as
+ * the browser sends them: no copy of a page is ever kept (a signed-in page could outlive a sign-out on a shared
+ * device), nothing cuts a slow page off, and a file without a hash is never served stale against new HTML.
+ * - Nothing private is stored: a response with Set-Cookie, Cache-Control: private or no-store, or a Vary on Cookie or
+ * Authorization passes through and is never written to the cache.
+ * - The API is never touched: /api/ and anything that is not a plain GET go to the network untouched.
+ * - The kill switch: GET <kill switch> says { "enabled": false } and every worker on every site unregisters itself and
+ * drops its caches. A worker that cannot reach the switch keeps working.
+ * - A new VERSION is a new cache, and the cache keeps the newest MAX_ENTRIES files.
+ *
+ * This file is also the Web Push worker the notification bell registers for anyone who turns push on, so whatever it does
+ * reaches them without any other opt-in: that is why it only keeps immutable public bytes.
+ * The site that wants this says so: OpenVibeNavbar.init({ sw: true }). A site can also point the worker elsewhere
+ * ({ sw: { path: '/my-sw.js' } }) or leave it alone.
+ */
+'use strict';
-self.addEventListener('push', (event) => {
+const VERSION = '4.164.0'; // the release this worker ships in: a bump replaces the caches
+const CACHE_PREFIX = 'ov-shell-';
+const CACHE_NAME = CACHE_PREFIX + VERSION;
+const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config';
+const STATIC_TYPES = ['javascript', 'ecmascript', 'css', 'woff2', 'woff', 'ttf', 'otf', 'svg+xml', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico'];
+
+const self_ = typeof self !== 'undefined' ? self : globalThis;
+
+/** The shared files every OpenVibe page loads. The site's own manifest (below) replaces this list when it has one. */
+const SHELL = [
+ '/shared/navbar.js',
+ '/shared/theme-loader.js',
+ '/shared/web-runtime.js',
+ '/shared/footer.js',
+ '/shared/nav-icons.js',
+ '/shared/kit.css',
+ '/shared/kit-layout.css',
+ '/shared/ov-brand.woff2',
+ '/shared/ov-glyphs.woff2',
+ '/shared/ov-glyphs.svg',
+];
+
+// The switch may be pointed elsewhere by the registration URL: /openvibe-sw.js?cfg=https://…
+const params = new URL(self_.location.href).searchParams;
+const KILL_URL = params.get('cfg') || KILL_SWITCH;
+
+// ── The cache's own rules ────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** A URL is content-addressed when it carries ?v=<hash> or a hash in the file name: its bytes cannot change. */
+function isHashed(url) {
+ if (/[?&]v=[0-9a-f]{8,64}\b/.test(url.pathname + url.search)) return true;
+ return /[.\-_][0-9a-f]{8,64}(?=[.\-_/]|$)/.test(url.pathname);
+}
+
+/** True when the response may be written to the cache: public, not private, no cookie, no Vary on the reader. */
+function cacheable(res) {
+ if (!res || !res.ok || res.type === 'opaque' || res.type === 'opaqueredirect') return false;
+ const cc = String(res.headers.get('cache-control') || '');
+ if (/\bprivate\b/i.test(cc) || /\bno-store\b/i.test(cc)) return false;
+ if (res.headers.get('set-cookie')) return false;
+ const vary = String(res.headers.get('vary') || '').toLowerCase();
+ if (/(^|,|\s)\s*(cookie|authorization)\s*(,|$)/.test(vary)) return false;
+ return true;
+}
+
+
+/** True when the response is a file the shell keeps (a script, a stylesheet, a font, an image). */
+function staticType(res) {
+ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
+ const sub = type.indexOf('/') >= 0 ? type.slice(type.indexOf('/') + 1) : type;
+ return STATIC_TYPES.indexOf(sub) >= 0;
+}
+
+// ── The strategies ─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** Hashed assets: the cache first, the network only when the cache does not have it. */
+async function cacheFirst(req, cache) {
+ const hit = await cache.match(req);
+ if (hit) return hit;
+ const res = await fetch(req);
+ if (cacheable(res) && staticType(res)) { try { await cache.put(req, res.clone()); await trim(cache); } catch { /* full or gone */ } }
+ return res;
+}
+
+/** Each release adds files under new hashes: the oldest go once the cache holds more than MAX_ENTRIES. */
+const MAX_ENTRIES = 400;
+async function trim(cache) {
+ const keys = await cache.keys();
+ for (let i = 0; i < keys.length - MAX_ENTRIES; i++) await cache.delete(keys[i]);
+}
+
+
+
+// ── The shell's own files ────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** The site's manifest of the shell it serves, by content hash: the site's own answer, when it has one. */
+async function shellUrls() {
+ try {
+ const res = await fetch('/ov-sw-manifest.json', { cache: 'no-store' });
+ if (!res.ok) return SHELL;
+ const body = await res.json();
+ const list = Array.isArray(body && body.shell) ? body.shell.filter((u) => typeof u === 'string' && u.startsWith('/')) : [];
+ return list.length ? list : SHELL;
+ } catch { return SHELL; }
+}
+
+/** Put the shell in the cache so the next page does not wait for it. One file that fails never stops the rest. */
+async function warmShell(cache) {
+ // Only the content-addressed ones: the worker never answers for any other file (see the fetch handler).
+ const urls = (await shellUrls()).filter((u) => { try { return isHashed(new URL(u, self_.location.origin)); } catch { return false; } });
+ await Promise.all(urls.map(async (url) => {
+ try {
+ const res = await fetch(new Request(url, { cache: 'reload' }));
+ if (cacheable(res) && staticType(res)) await cache.put(url, res);
+ } catch { /* that file is not on this site */ }
+ }));
+}
+
+// ── The kill switch ────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+/** Ask the network whether the worker may keep working. No answer means yes: a hiccup must not take the cache away. */
+async function checkKillSwitch() {
+ try {
+ const res = await fetch(KILL_URL, { cache: 'no-store' });
+ if (!res.ok) return false;
+ const cfg = await res.json();
+ if (!cfg || cfg.enabled !== false) return false;
+ } catch { return false; }
+ try {
+ await self_.registration.unregister();
+ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);
+ } catch { /* nothing more to do */ }
+ return true;
+}
+
+// ── The worker's life ───────────────────────────────────────────────────────────────────────────────────────────────────
+
+self_.addEventListener('install', () => self_.skipWaiting());
+
+self_.addEventListener('activate', (event) => {
+ event.waitUntil((async () => {
+ await self_.clients.claim();
+ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME) await caches.delete(name);
+ await checkKillSwitch();
+ try { await warmShell(await caches.open(CACHE_NAME)); } catch { /* the shell warms on its next visit */ }
+ })());
+});
+
+self_.addEventListener('fetch', (event) => {
+ const req = event.request;
+ if (req.method !== 'GET') return; // never touches a write
+ if (req.headers.get('range')) return; // a partial request is the browser's business
+ let url;
+ try { url = new URL(req.url); } catch { return; }
+ if (url.origin !== self_.location.origin) return; // another site's bytes are another site's cache
+ if (url.pathname.startsWith('/api/')) return; // the API is never cached
+ if (url.pathname === '/ov-sw.js' || url.pathname === '/openvibe-sw.js') return;
+
+ // Only content-addressed files (a hash in the name, or ?v=<hash>): their bytes never change and are public, so the
+ // cache answers first, for good. Pages and every other file go to the network exactly as the browser sends them.
+ // (Review 11-09: this file is also the Web Push worker every signed-in person with push already runs, so whatever it
+ // does reaches people who never opted in. Kept copies of pages could outlive a sign-out on a shared device, a 3 s
+ // cut-off turned slow pages into "offline", and stale unhashed scripts could run against new HTML after a deploy.)
+ if (req.destination === 'document' || req.mode === 'navigate' || !isHashed(url)) return;
+ event.respondWith((async () => cacheFirst(req, await caches.open(CACHE_NAME)))());
+});
+
+self_.addEventListener('message', (event) => {
+ const data = (event.data && typeof event.data === 'object') ? event.data : {};
+ if (data.type === 'skipWaiting') self_.skipWaiting();
+ if (data.type === 'checkKill') event.waitUntil(checkKillSwitch());
+ if (data.type === 'clearCaches') event.waitUntil((async () => {
+ for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX)) await caches.delete(name);
+ })());
+});
+
+// ── Web Push (the job this worker always had) ───────────────────────────────────────────────────────────────────────
+
+self_.addEventListener('push', (event) => {
let data = {};
let data = {};
try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
const title = data.title || 'OpenVibe';
const title = data.title || 'OpenVibe';
@@ -17,14 +199,14 @@ self.addEventListener('push', (event) => {
@@ -17,14 +199,14 @@ self.addEventListener('push', (event) => {
data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
requireInteraction: false,
requireInteraction: false,
};
};
- event.waitUntil(self.registration.showNotification(title, options));
+ event.waitUntil(self_.registration.showNotification(title, options));
});
});
-self.addEventListener('notificationclick', (event) => {
+self_.addEventListener('notificationclick', (event) => {
event.notification.close();
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
event.waitUntil((async () => {
event.waitUntil((async () => {
- const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const all = await self_.clients.matchAll({ type: 'window', includeUncontrolled: true });
let target = null;
let target = null;
try { target = new URL(url); } catch { /* */ }
try { target = new URL(url); } catch { /* */ }
for (const c of all) {
for (const c of all) {
@@ -36,6 +218,6 @@ self.addEventListener('notificationclick', (event) => {
@@ -36,6 +218,6 @@ self.addEventListener('notificationclick', (event) => {
}
}
} catch { /* */ }
} catch { /* */ }
}
}
- await self.clients.openWindow(url);
+ await self_.clients.openWindow(url);
})());
})());
});
});
@@ -396,7 +396,9 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -396,7 +396,9 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
if (rows.length < 2) return null;
if (rows.length < 2) return null;
const wrap = h('div');
const wrap = h('div');
const head = sec('Most used');
const head = sec('Most used');
- head.append(h('small', '', 'pages you opened'));
+ // Your stats on Report (private by default; you choose what is public), as the section's link.
+ const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';
+ head.append(more);
wrap.append(head);
wrap.append(head);
const box = h('div', 'ovh-use');
const box = h('div', 'ovh-use');
const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');
const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.164.0",
+ "version": "4.165.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.164.0",
+ "version": "4.165.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": "4.164.0",
+ "version": "4.165.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",
"bin": {
"bin": {
@@ -123,7 +123,8 @@
@@ -123,7 +123,8 @@
"./agent-layer": "./agent-layer.js",
"./agent-layer": "./agent-layer.js",
"./thread": "./thread.js",
"./thread": "./thread.js",
"./messages": "./messages.js",
"./messages": "./messages.js",
- "./ov-thread": "./ov-thread.js"
+ "./ov-thread": "./ov-thread.js",
+ "./sw": "./sw.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
@@ -155,7 +156,21 @@
@@ -155,7 +156,21 @@
"type": "git",
"type": "git",
"url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"
"url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"
},
},
- "homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
+ "homepage": "https://openvibe.network",
+ "keywords": [
+ "openvibe",
+ "ui",
+ "components",
+ "navbar",
+ "footer",
+ "themes",
+ "icons",
+ "seo",
+ "json-ld",
+ "llms-txt",
+ "feeds",
+ "self-hosted"
+ ],
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -255,7 +255,7 @@ function sitemapIndexXml(maps = []) {
@@ -255,7 +255,7 @@ function sitemapIndexXml(maps = []) {
}
}
// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
-const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
+const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'Perplexity-User', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
const lines = [];
const lines = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * openvibe-shared/sw — the server's side of the shared worker (openvibe-sw.js).
+ *
+ * A site that wants the shell's cache says so in its navbar (OpenVibeNavbar.init({ sw: true })) and serves the worker at
+ * /openvibe-sw.js, from this package, at scope "/". This module is the other half: what the worker needs to know, and the
+ * early hints that get the shell downloading before the HTML is even parsed.
+ *
+ * const sw = require('openvibe-shared/sw');
+ * app.get('/openvibe-sw.js', (req, res) => res.sendFile(sw.workerPath())); // the worker itself
+ * app.get('/ov-sw-manifest.json', (req, res) => res.json(sw.manifest())); // the shell, by content hash
+ * app.use(sw.hints({ links: (req) => [ { href: assets.url('/shared/navbar.js'), as: 'script' } ] }));
+ *
+ * The early hints (RFC 8297): the browser gets the shell's URLs in a 103 response, before the HTML arrives, and starts
+ * fetching them while the server is still writing the page. The same links ride along in the Link header of the page itself,
+ * so a proxy that drops the 103 still hands the browser the hint.
+ *
+ * Nothing here touches the API, and nothing here decides what a page shows: the hints are about when a file starts loading,
+ * never about what it says.
+ */
+const path = require('path');
+
+/** The release this helper ships in; the worker keeps the same number, and a test checks they agree. */
+const VERSION = '4.164.0';
+
+/** The shared files every OpenVibe page loads: the shell. The worker keeps them in its cache. */
+const SHELL = Object.freeze([
+ '/shared/navbar.js',
+ '/shared/theme-loader.js',
+ '/shared/web-runtime.js',
+ '/shared/footer.js',
+ '/shared/nav-icons.js',
+ '/shared/kit.css',
+ '/shared/kit-layout.css',
+ '/shared/ov-brand.woff2',
+ '/shared/ov-glyphs.woff2',
+ '/shared/ov-glyphs.svg',
+]);
+
+/** Where a worker asks whether it may keep working. A site may point it elsewhere for a test. */
+const KILL_SWITCH = 'https://openvibe.network/api/v1/sw-config';
+
+/** The path every site serves the worker at. Same origin, scope "/". */
+const WORKER_PATH = '/openvibe-sw.js';
+
+/** The file on disk: the worker this package ships. */
+function workerPath() {
+ return path.join(__dirname, 'openvibe-sw.js');
+}
+
+/**
+ * The shell as JSON, for the site's /ov-sw-manifest.json. A site that serves the shell by content hash answers with its own
+ * hashed URLs; the plain list is the fallback.
+ */
+function manifest(shell) {
+ const given = (Array.isArray(shell) ? shell : []).filter((u) => typeof u === 'string' && u.startsWith('/'));
+ return { version: VERSION, shell: given.length ? given : SHELL };
+}
+
+/** One preload link, as the Link header and the 103 response write it. */
+function linkHeader(link) {
+ if (!link || !link.href) return '';
+ let out = '<' + link.href + '>; rel=preload';
+ if (link.as) out += '; as=' + link.as;
+ if (link.type) out += '; type=' + link.type;
+ if (link.crossOrigin) out += '; crossorigin';
+ return out;
+}
+
+/**
+ * Send the early hints for a response.
+ *
+ * sw.earlyHints(res, [ { href: '/shared/navbar.js?v=abc123', as: 'script' } ]);
+ *
+ * The 103 goes out first when the runtime can send one (Node 18.11 and later); the Link header rides on the page itself so
+ * the hint survives a proxy that drops the 103. Returns true when a 103 was sent.
+ */
+function earlyHints(res, links) {
+ const list = (Array.isArray(links) ? links : []).filter((l) => l && l.href);
+ if (!list.length || !res) return false;
+ let sent = false;
+ if (typeof res.writeEarlyHints === 'function') {
+ try {
+ res.writeEarlyHints({ link: list.map(linkHeader).filter(Boolean) });
+ sent = true;
+ } catch { /* the response was already on its way */ }
+ }
+ if (!res.headersSent) {
+ try { res.setHeader('Link', list.map(linkHeader).filter(Boolean).join(', ')); } catch { /* the header was set already */ }
+ }
+ return sent;
+}
+
+/**
+ * Express middleware: send the early hints for a document request.
+ *
+ * app.use(sw.hints({ links: (req) => [ { href: navbarUrl, as: 'script' } ] }));
+ *
+ * Only GET and HEAD are hinted, and only when the request asks for a document: a file request already knows its own URL.
+ */
+function hints({ links, filter } = {}) {
+ const pick = typeof links === 'function' ? links : () => (Array.isArray(links) ? links : []);
+ return function openvibeEarlyHints(req, res, next) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
+ if (filter && filter(req, res) === false) return next();
+ try { earlyHints(res, pick(req, res)); } catch { /* a hint is never worth a page */ }
+ return next();
+ };
+}
+
+module.exports = { VERSION, SHELL, KILL_SWITCH, WORKER_PATH, workerPath, manifest, earlyHints, hints, linkHeader };
@@ -184,6 +184,7 @@ const server = http.createServer((req, res) => {
@@ -184,6 +184,7 @@ const server = http.createServer((req, res) => {
assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
+ assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec a.ovh-link[href="https://openvibe.report/me"]') || {}).textContent`), 'Your stats', 'Most used links to your stats on Report');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);
await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 48 * 1024; // 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+const NAVBAR_BROTLI_BUDGET = 48.5 * 1024; // 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -236,7 +236,7 @@ function load(hostname, opts = {}) {
@@ -236,7 +236,7 @@ function load(hostname, opts = {}) {
{
{
const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
- assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
+ assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" data-ov-net-link href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+// navbar.js — the rules it hands the browser (speculation rules: a same-site page is prefetched and prerendered, another
+// OpenVibe site is prefetched) and the worker it registers when a site asks for it (sw: true).
+const assert = require('assert');
+const path = require('path');
+const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
+
+function stubElement(tag) {
+ const el = {
+ tag, style: { setProperty() {} }, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [], removed: false,
+ classes: new Set(),
+ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
+ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() { this.removed = true; }, addEventListener() {},
+ querySelector() { return null; }, querySelectorAll() { return []; },
+ };
+ el.classList = { add: (c) => el.classes.add(c), remove: (c) => el.classes.delete(c), toggle() {}, contains: (c) => el.classes.has(c) };
+ return el;
+}
+
+function load({ serviceWorker = null } = {}) {
+ global.location = { hostname: 'openvibe.chat', href: 'https://openvibe.chat/', pathname: '/', protocol: 'https:', origin: 'https://openvibe.chat' };
+ const listeners = {};
+ global.window = { location: global.location, isSecureContext: true, addEventListener(t, fn) { (listeners[t] = listeners[t] || []).push(fn); } };
+ const nav = serviceWorker ? { serviceWorker } : undefined;
+ try { Object.defineProperty(global, 'navigator', { value: nav || { userAgent: 'test' }, configurable: true }); } catch { /* */ }
+ const body = stubElement('body');
+ const head = stubElement('head');
+ global.document = {
+ cookie: '', head, body, documentElement: stubElement('html'), title: 'T', readyState: 'complete',
+ getElementById: () => null, createElement: (tag) => stubElement(tag),
+ querySelector: () => null, querySelectorAll: () => [],
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
+ };
+ global.HTMLScriptElement = { supports: () => true };
+ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
+ const store = {};
+ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
+ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+ global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
+ delete require.cache[require.resolve(NAVBAR_PATH)];
+ return { navbar: require(NAVBAR_PATH), head, listeners };
+}
+
+const rulesOf = (head) => {
+ const script = head.children.filter((c) => c.tag === 'script' && c.type === 'speculationrules').pop();
+ return script ? JSON.parse(script.textContent) : null;
+};
+
+{ // The rules the browser gets: a same-site page is prefetched and prerendered, another OpenVibe site is prefetched.
+ const { navbar, head } = load();
+ navbar.init({ service: 'chat', links: [{ label: 'Vibe', href: '/vibe' }] });
+ const rules = rulesOf(head);
+ assert.ok(rules, 'the rules are in the page');
+ assert.ok(Array.isArray(rules.prefetch) && rules.prefetch.length >= 2, 'a prefetch rule');
+ assert.strictEqual(rules.prefetch[0].where.and[0].href_matches, '/*', 'the same-site rule');
+ assert.strictEqual(rules.prefetch[0].eagerness, 'moderate');
+ assert.strictEqual(rules.prefetch[1].where.selector_matches, '[data-ov-net-link]', 'another OpenVibe site');
+ assert.ok(Array.isArray(rules.prerender) && rules.prerender.length === 1, 'the page is prerendered too');
+ assert.strictEqual(rules.prerender[0].where.and[0].href_matches, '/*');
+ // The sign-in, the API and the downloads are never fetched ahead of the click.
+ const excluded = rules.prefetch[0].where.and[1].not.href_matches;
+ for (const p of ['/auth/*', '/api/*', '/logout*', '/*.zip']) assert.ok(excluded.includes(p), p + ' is left alone');
+}
+
+{ // A site that draws its own pages says no to the prerender.
+ const { navbar, head } = load();
+ navbar.init({ service: 'chat', prerender: false, links: [{ label: 'Vibe', href: '/vibe' }] });
+ const rules = rulesOf(head);
+ assert.ok(rules && !rules.prerender, 'no prerender');
+ assert.ok(rules && rules.prefetch.length >= 2, 'the prefetch stays');
+}
+
+{ // The worker: only when a site asks for it.
+ const calls = [];
+ const { navbar } = load({ serviceWorker: { register: (url, opts) => { calls.push([url, opts]); return Promise.resolve(); } } });
+ navbar.init({ service: 'chat', sw: true, links: [{ label: 'Vibe', href: '/vibe' }] });
+ assert.strictEqual(calls.length, 1, 'the worker was registered');
+ assert.deepStrictEqual(calls[0], ['/openvibe-sw.js', { scope: '/' }]);
+
+ const calls2 = [];
+ const again = load({ serviceWorker: { register: (url, opts) => { calls2.push([url, opts]); return Promise.resolve(); } } });
+ again.navbar.init({ service: 'chat', sw: { path: '/my-sw.js', scope: '/app/' }, links: [{ label: 'Vibe', href: '/vibe' }] });
+ assert.deepStrictEqual(calls2[0], ['/my-sw.js', { scope: '/app/' }], 'a site can point the worker elsewhere');
+}
+
+{ // No opt-in, no worker.
+ const calls = [];
+ const { navbar } = load({ serviceWorker: { register: (url, opts) => { calls.push([url, opts]); return Promise.resolve(); } } });
+ navbar.init({ service: 'chat', links: [{ label: 'Vibe', href: '/vibe' }] });
+ assert.strictEqual(calls.length, 0, 'a site that did not ask for it gets no worker');
+
+ const calls2 = [];
+ const off = load({ serviceWorker: { register: (url, opts) => { calls2.push([url, opts]); return Promise.resolve(); } } });
+ off.navbar.init({ service: 'chat', sw: false, links: [{ label: 'Vibe', href: '/vibe' }] });
+ assert.strictEqual(calls2.length, 0, 'sw: false is no worker');
+}
+
+console.log('navbar-speculation: ok');
@@ -7,6 +7,9 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past t
@@ -7,6 +7,9 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past t
assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');
assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');
assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');
assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');
assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');
assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');
+for (const bot of ['Googlebot', 'Bingbot', 'OAI-SearchBot', 'ChatGPT-User', 'GPTBot', 'PerplexityBot', 'Perplexity-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'Applebot', 'DuckDuckBot']) {
+ assert.ok(seo.robotsTxt({}).includes(`User-agent: ${bot}\n`), `${bot} is welcomed by name`);
+}
assert.ok(seo.robotsTxt({}).includes('Content-Signal: search=yes, ai-input=yes, ai-train=yes'), 'AI use allowed in so many words');
assert.ok(seo.robotsTxt({}).includes('Content-Signal: search=yes, ai-input=yes, ai-train=yes'), 'AI use allowed in so many words');
assert.ok(!seo.robotsTxt({ allowAI: false }).includes('Content-Signal'), 'and not when a site opts out');
assert.ok(!seo.robotsTxt({ allowAI: false }).includes('Content-Signal'), 'and not when a site opts out');
assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// The shared worker (openvibe-sw.js) in a real headless Chrome against a local server: the hashed file comes from the
+// worker's cache, a page always comes from the network, a private page is never stored, a slow network falls back to the
+// cached page, and the kill switch takes the worker away. 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');
+const sw = require('../sw');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('sw-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const WORKER = fs.readFileSync(path.join(__dirname, '..', 'openvibe-sw.js'), 'utf8');
+const hits = {};
+const state = { slow: false, kill: { enabled: true }, body: 1 };
+const count = (p) => { hits[p] = (hits[p] || 0) + 1; return hits[p]; };
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ count(u.pathname);
+ if (u.pathname === '/openvibe-sw.js') {
+ res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' });
+ return res.end(WORKER);
+ }
+ if (u.pathname === '/sw-config') {
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
+ return res.end(JSON.stringify(state.kill));
+ }
+ if (u.pathname === '/shared/navbar.js') {
+ res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'public, max-age=31536000, immutable' });
+ return res.end('window.shellLoads = (window.shellLoads || 0) + 1;');
+ }
+ if (u.pathname === '/api/thing') {
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
+ return res.end(JSON.stringify({ n: count('/api/thing') }));
+ }
+ if (u.pathname === '/private') {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'private, no-store' });
+ return res.end('<!doctype html><title>private</title><p id="m">private page</p>');
+ }
+ if (u.pathname === '/slow') {
+ const send = () => {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }); // HTML revalidates, as on every site
+ res.end('<!doctype html><title>slow</title><p id="m">cached page</p>');
+ };
+ if (state.slow) setTimeout(send, 6000); else send();
+ return undefined;
+ }
+ // A page: public, and its body changes on every write, so a fresh copy is visible.
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', etag: 'W/"' + state.body + '"' });
+ res.end('<!doctype html><title>home</title><p id="m">body ' + state.body + '</p><script src="/shared/navbar.js?v=abcdef123456"></script>');
+});
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+async function waitFor(fn, ms = 5000) {
+ const end = Date.now() + ms;
+ while (Date.now() < end) { if (await fn()) return true; await sleep(100); }
+ return false;
+}
+
+(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-sw-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ // ── The worker takes over the page ────────────────────────────────────────────────────────────────────────
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 1280 });
+ await p.goto(base + '/', { maxMs: 8000 });
+ const registered = await p.evaluate(`navigator.serviceWorker.register('/openvibe-sw.js?cfg=' + ${JSON.stringify(base)} + '/sw-config', { scope: '/' }).then(() => navigator.serviceWorker.ready).then(() => true).catch((e) => String(e))`);
+ assert.strictEqual(registered, true, 'the worker registered');
+ const controlled = await waitFor(async () => p.evaluate('!!navigator.serviceWorker.controller'));
+ assert.ok(controlled, 'the worker controls the page');
+
+ // ── A hashed file comes from the worker's cache ────────────────────────────────────────────────────────
+ await p.goto(base + '/', { maxMs: 8000 });
+ const cached = await waitFor(async () => p.evaluate(`caches.open('ov-shell-' + ${JSON.stringify(sw.VERSION)}).then((c) => c.keys()).then((k) => k.map((r) => r.url)).catch(() => [])`).then((list) => list.some((u) => u.indexOf('/shared/navbar.js?v=abcdef123456') >= 0)));
+ assert.ok(cached, 'the hashed file is in the worker cache');
+ const shellHits = hits['/shared/navbar.js'] || 0;
+ await p.goto(base + '/', { maxMs: 8000 });
+ assert.strictEqual(hits['/shared/navbar.js'] || 0, shellHits, 'the hashed file was not asked for again');
+
+ // ── A page always comes from the network ──────────────────────────────────────────────────────────────
+ const pageHits = hits['/'] || 0;
+ state.body = 2;
+ await p.goto(base + '/', { maxMs: 8000 });
+ assert.ok((hits['/'] || 0) > pageHits, 'the page was asked for again (never served from the cache while the network answers)');
+ const body = await p.evaluate('document.getElementById("m").textContent');
+ assert.strictEqual(body, 'body 2', 'the page shows what the server just wrote');
+
+ // ── The API is never cached ───────────────────────────────────────────────────────────────────────────
+ const api1 = await p.evaluate('fetch("/api/thing").then((r) => r.json()).then((j) => j.n)');
+ const api2 = await p.evaluate('fetch("/api/thing").then((r) => r.json()).then((j) => j.n)');
+ assert.notStrictEqual(api1, api2, 'the API is asked every time');
+
+ // ── A slow page is never cut off, and no page is kept (review 11-09) ──────────────────────────────────
+ await p.goto(base + '/slow', { maxMs: 12000 });
+ state.slow = true;
+ const started = Date.now();
+ await p.goto(base + '/slow', { maxMs: 15000 });
+ const waited = Date.now() - started;
+ assert.strictEqual(await p.evaluate('document.getElementById("m").textContent'), 'cached page', 'the slow page arrived from the server');
+ assert.ok(waited >= 5500, 'the page waited for the network as long as it took (' + waited + ' ms), never a 3 s cut-off');
+ const pagesKept = await p.evaluate('caches.keys().then((ks) => Promise.all(ks.map((k) => caches.open(k).then((c) => c.keys())))).then((all) => all.flat().map((r) => new URL(r.url).pathname).filter((x) => x === "/" || x === "/slow"))');
+ assert.deepStrictEqual(pagesKept, [], 'no copy of a page is in the worker cache');
+ state.slow = false;
+
+ // ── The kill switch takes the worker away ─────────────────────────────────────────────────────────────
+ state.kill = { enabled: false };
+ await p.evaluate('navigator.serviceWorker.controller.postMessage({ type: "checkKill" })');
+ const gone = await waitFor(async () => p.evaluate('navigator.serviceWorker.getRegistration().then((r) => !r)'));
+ assert.ok(gone, 'the worker unregistered itself when the switch said no');
+ const caches = await p.evaluate('caches.keys().then((k) => k)');
+ assert.deepStrictEqual(caches.filter((k) => k.indexOf('ov-shell-') === 0), [], 'the caches are gone');
+
+ // ── A switch that does not answer leaves the worker alone ─────────────────────────────────────────────
+ const context2 = await browser.newContext();
+ const p2 = await h.openPage(browser, context2, { width: 1280 });
+ await p2.goto(base + '/', { maxMs: 8000 });
+ await p2.evaluate(`navigator.serviceWorker.register('/openvibe-sw.js?cfg=' + ${JSON.stringify(base)} + '/no-such-switch', { scope: '/' }).then(() => navigator.serviceWorker.ready).then(() => true).catch((e) => String(e))`);
+ await sleep(1200);
+ const still = await p2.evaluate('navigator.serviceWorker.getRegistration().then((r) => !!r)');
+ assert.ok(still, 'a switch that cannot be reached leaves the worker working');
+ await p2.close();
+ await p.close();
+ } finally {
+ await browser.close().catch(() => {});
+ server.close();
+ }
+ console.log('sw-chrome: ok');
+})().catch((e) => { console.error('sw-chrome:', e && e.message ? e.message : e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+// openvibe-shared/sw: the early hints (103), the shell manifest, and the one rule that keeps the worker and the helper in
+// step (the same version, the same shell).
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const sw = require('../sw');
+
+(async () => {
+ // ── The shell ────────────────────────────────────────────────────────────────────────────────────────────────
+ assert.ok(Array.isArray(sw.SHELL) && sw.SHELL.length >= 5, 'the shell is a list of files');
+ for (const file of sw.SHELL) assert.ok(file.startsWith('/shared/'), file + ' is a shared file');
+ assert.ok(sw.SHELL.includes('/shared/navbar.js'), 'the navbar is in the shell');
+ assert.ok(sw.SHELL.includes('/shared/theme-loader.js'), 'the theme loader is in the shell');
+
+ // The worker keeps its own copy of the shell (it is a file the browser runs, not a module it requires): the two
+ // lists must be the same, or a site warms one shell and the worker caches another.
+ const worker = fs.readFileSync(path.join(__dirname, '..', 'openvibe-sw.js'), 'utf8');
+ const workerShell = JSON.parse((worker.match(/const SHELL = (\[[\s\S]*?\]);/) || [])[1].replace(/'/g, '"').replace(/,\s*]/g, ']'));
+ assert.deepStrictEqual(workerShell, sw.SHELL, 'the worker and the helper keep the same shell');
+ const workerVersion = (worker.match(/const VERSION = '([^']+)'/) || [])[1];
+ assert.strictEqual(workerVersion, sw.VERSION, 'the worker and the helper ship in the same release');
+ assert.strictEqual(sw.KILL_SWITCH, 'https://openvibe.network/api/v1/sw-config', 'the kill switch has one address');
+ assert.ok(fs.existsSync(sw.workerPath()), 'the worker file is there to serve');
+ assert.strictEqual(path.basename(sw.workerPath()), 'openvibe-sw.js', 'the worker is served by its name');
+
+ // ── The manifest ─────────────────────────────────────────────────────────────────────────────────────────
+ const man = sw.manifest();
+ assert.strictEqual(man.version, sw.VERSION);
+ assert.deepStrictEqual(man.shell, sw.SHELL);
+ assert.deepStrictEqual(sw.manifest(['/shared/a.js']).shell, ['/shared/a.js'], 'a site answers with its own list');
+ assert.deepStrictEqual(sw.manifest(['https://elsewhere/x.js']).shell, sw.SHELL, 'only the site\'s own paths are listed');
+
+ // ── The early hints ────────────────────────────────────────────────────────────────────────────────────
+ assert.strictEqual(sw.linkHeader({ href: '/shared/navbar.js?v=abc', as: 'script' }), '</shared/navbar.js?v=abc>; rel=preload; as=script');
+ assert.strictEqual(sw.linkHeader({ href: '/x.css', as: 'style', type: 'text/css' }), '</x.css>; rel=preload; as=style; type=text/css');
+ assert.strictEqual(sw.linkHeader(null), '');
+
+ // The 103 goes out first, and the Link header rides on the page so a proxy that drops the 103 still hints.
+ let hints103 = null; const headers = {};
+ const res = {
+ headersSent: false,
+ writeEarlyHints(h) { hints103 = h; return true; },
+ setHeader(k, v) { headers[k] = v; },
+ };
+ assert.strictEqual(sw.earlyHints(res, [{ href: '/shared/navbar.js', as: 'script' }]), true);
+ assert.deepStrictEqual(hints103, { link: ['</shared/navbar.js>; rel=preload; as=script'] });
+ assert.strictEqual(headers.Link, '</shared/navbar.js>; rel=preload; as=script');
+
+ // A runtime that cannot send a 103 still gets the header.
+ let noHints = null; const headers2 = {};
+ const res2 = { headersSent: false, writeEarlyHints(h) { noHints = h; }, setHeader(k, v) { headers2[k] = v; } };
+ assert.strictEqual(sw.earlyHints(res2, [{ href: '/a.css', as: 'style' }]), true);
+ assert.ok(noHints, 'the 103 was sent');
+ const res3 = { headersSent: true, setHeader() { throw new Error('the header was already sent'); } };
+ assert.strictEqual(sw.earlyHints(res3, [{ href: '/a.css', as: 'style' }]), false, 'a response already on its way is left alone');
+ assert.strictEqual(sw.earlyHints(res, []), false, 'no links, no hint');
+
+ // The middleware only hints a document request.
+ const seen = [];
+ const mw = sw.hints({ links: () => [{ href: '/shared/navbar.js', as: 'script' }] });
+ let nexted = 0;
+ mw({ method: 'POST', headers: {} }, { headersSent: false, setHeader(k, v) { seen.push([k, v]); }, writeEarlyHints() { } }, () => { nexted++; });
+ assert.strictEqual(seen.length, 0, 'a write is never hinted');
+ mw({ method: 'GET', headers: {} }, { headersSent: false, setHeader(k, v) { seen.push([k, v]); }, writeEarlyHints() { } }, () => { nexted++; });
+ assert.strictEqual(seen.length, 1, 'a page is hinted');
+ assert.strictEqual(nexted, 2, 'the request always goes on');
+
+ console.log('sw: ok');
+})().catch((e) => { console.error('sw:', e && e.message ? e.message : e); process.exit(1); });