openvibers/openvibe.shared Commits

4.165.0: faster between pages and sites, bots by name, Your stats (#384)

OP OpenVibers committed 4 days ago · 1bc7e23e6d · 17 files, +747 −25

* Welcome Perplexity-User, Claude-User and Claude-SearchBot by name

The shared robots.txt list now names every retrieval and search bot the
network targets, adds npm keywords and points the package homepage at the
network. Test asserts the full named list.

* The worker's file cache and the rules before the click (from #382)

* Worker: only content-addressed files are answered from the cache (security review)

This file is also the Web Push worker the bell registers for anyone who turns push on, so its cache reached people who
never opted in. Kept copies of pages could outlive a sign-out on a shared device (signed-in HTML often carries no
private/no-store or Vary: Cookie), a 3 s cut-off turned slow pages into "offline", and stale-while-revalidate served
unhashed scripts from before a deploy against the new HTML. Now the worker answers only for files under a content hash
(immutable, public): pages and every other file go to the network as the browser sends them. The cache keeps the newest
400 files; the shell warm-up takes only hashed URLs.

* 4.165.0: faster between pages and sites (prerender, cross-site prefetch, a file-cache worker that keeps only hashed files), bots by name, Your stats

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 86c8a5f..466d56e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 })`;
ddiff --git a/README.md b/README.md
iindex a92e421..accdacc 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
@@@ -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.
++### 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.
ddiff --git a/STATUS.json b/STATUS.json
iindex bfc9f25..c04343c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex 044aa4c..7a4429c 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
ddiff --git a/openvibe-sw.js b/openvibe-sw.js
iindex 2de4f37..1ac8792 100644
---- a/openvibe-sw.js
++++ b/openvibe-sw.js
@@@ -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);
})());
});
ddiff --git a/ov-home.js b/ov-home.js
iindex 249360a..d884bbb 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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');
ddiff --git a/package-lock.json b/package-lock.json
iindex d251d54..8534c76 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f33a5af..0b80a66 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/seo.js b/seo.js
iindex 27f2a56..4712c77 100644
---- a/seo.js
++++ b/seo.js
@@@ -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 = [];
ddiff --git a/sw.js b/sw.js
nnew file mode 100644
iindex 0000000..55e166d
---- /dev/null
++++ b/sw.js
@@@ -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 };
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex b7ab75d..533bb37 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -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`);
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 1f8d181..3b3e900 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 1b9a9b6..d3e3fb4 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -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');
ddiff --git a/test/navbar-speculation.test.js b/test/navbar-speculation.test.js
nnew file mode 100644
iindex 0000000..c039c3e
---- /dev/null
++++ b/test/navbar-speculation.test.js
@@@ -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');
ddiff --git a/test/shared-modules.test.js b/test/shared-modules.test.js
iindex 284cbcd..fcbd662 100644
---- a/test/shared-modules.test.js
++++ b/test/shared-modules.test.js
@@@ -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(!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');
ddiff --git a/test/sw-chrome.test.js b/test/sw-chrome.test.js
nnew file mode 100644
iindex 0000000..f33b2ad
---- /dev/null
++++ b/test/sw-chrome.test.js
@@@ -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); });
ddiff --git a/test/sw.test.js b/test/sw.test.js
nnew file mode 100644
iindex 0000000..9fbc5af
---- /dev/null
++++ b/test/sw.test.js
@@@ -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); });