openvibers/openvibe.shared Commits

Shared 2.1.0 (plan T11): boost.js — smooth same-site page moves for a server-rendered site (prefetch on intent, styles first, <main> swapped in place with the shared transition, head tags, history and scroll, focus; any doubt is a normal load), tested in real Chrome; openvibe-shared/cache-policy (hashed asset immutable, HTML stale-while-revalidate: one rule, serve.js uses it); openvibe-shared/indexnow (deduped, own-host https pings, a debounced batch, never throws, no key no request). npm test 46/46.

OP OpenVibers committed 29 Sept 2026 · 6a8cd5d8c5 · 11 files, +719 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0252180..eed2b21 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,24 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 2.1.0 — 2026-09-29
++
++Three new modules for plan T11, plus `serve.js` now taking its cache headers from the shared policy (its own headers are
++unchanged):
++- `openvibe-shared/cache-policy` — `isHashed(path)`, `assetHeaders(path, { hashed })` (immutable for a content-addressed
++ asset, otherwise `public, max-age=300, stale-while-revalidate=86400`), `htmlHeaders({ private, maxAge, swr })` and an
++ `applyHtml()` middleware, so "hashed asset = immutable, HTML = stale-while-revalidate" is one rule instead of one
++ per site.
++- `openvibe-shared/indexnow` — `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile`,
++ `ping(urls)` (one POST, deduped, https-only, own host, ≤10,000 URLs, never throws) and `pingSoon(urls)` (one
++ debounced batch in 30 s). No key means no request.
++- `boost.js` (`OVBoost.start({ main })`, served at `/shared/boost.js`) — smooth same-site page moves for a server-rendered
++ site: a click fetches the next page (prefetched on hover, focus or touch), loads its new stylesheets first, swaps
++ `<main>` in place with the shared transition, updates the title and the head tags that describe the page, runs the new
++ main's inline scripts once, keeps history and scroll, and moves focus to the content. Any doubt (another release
++ marker `<meta name="ov-boost">`, other external scripts, a non-HTML answer, a redirect off the site, an error) is a
++ normal page load. Pages listen for `ov:boost:load` to wire their widgets.
++
## 2.0.0 — 2026-09-29
**X-Internal-Key is gone from the shared package** (plan T2: every internal call in the estate uses a service token).
ddiff --git a/README.md b/README.md
iindex aa8efbc..5cba1d6 100644
---- a/README.md
++++ b/README.md
@@@ -19,8 +19,8 @@ were reconciled first.
| Kind | Files |
|---|---|
--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
@@@ -346,6 +346,27 @@ can't load, those icons fall back to `<i class="fa-solid …">`. Two options cha
- A Content-Security-Policy refusal, a signed-out 401/403, or Network's `REALTIME_TICKETS=off` (503) stop the stream, and the bell polls.
- **The site's CSP** needs `connect-src https://events.openvibe.network` (and `https://openvibe.network` for the ticket, which the bell already calls). `OpenVibeNotifications.realtimeState()` reports the feed's state.
++### Boost (`boost.js`): smooth page moves for a server-rendered site
++
++Every page stays a normal page (it works without JavaScript and crawlers read the same HTML); boost only makes a same-site
++move feel like an app. Mark the page and its changing part, then start it:
++
++```html
++<meta name="ov-boost" content="<site>@<release>"> <!-- the release id: a different one means a normal page load -->
++<main id="main">…</main>
++<script src="/shared/boost.js" defer></script>
++<script>addEventListener('DOMContentLoaded', () => OVBoost.start({ main: '#main' }));</script>
++```
++
++A click on a same-site link (no modifier key, no target, no download, not `data-no-boost`, not `/auth/`, `/api/` or a
++file) fetches the page (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps `<main>` in
++place with `route-transition.js`'s bar and fade, updates the title, description, canonical, Open Graph, Twitter and
++JSON-LD tags, runs the new main's inline scripts once, pushes history, restores scroll on back and forward, and focuses
++the new content. Another release marker, other external scripts, a non-HTML answer, a redirect off the site, an error
++or a timeout is a normal load. Listen for `ov:boost:before` and `ov:boost:load` to tear down and wire widgets.
++`OVBoost.start(opts)` returns `{ go(url), stop(), stats() }`; options: `main`, `prefix` (`ov`), `exclude` (path
++prefixes), `timeout` (8000 ms), `prefetch` (true), `fx` (true).
++
### The web runtime (`web-runtime.js`, `route-transition.js`)
```text
ddiff --git a/boost.js b/boost.js
nnew file mode 100644
iindex 0000000..e07941e
---- /dev/null
++++ b/boost.js
@@@ -0,0 +1,213 @@
++/*
++ * openvibe-shared/boost.js — smooth page moves for a server-rendered site (plan T11). Every page stays a normal page
++ * (it works with no JavaScript, crawlers see the same HTML); boost only makes a same-site move feel like an app:
++ *
++ * <meta name="ov-boost" content="<site>@<release>"> on every page that may be swapped (the server's release id)
++ * <main id="main">…</main> the part that changes
++ * <script src="/shared/boost.js" defer></script>
++ * <script>addEventListener('DOMContentLoaded', () => OVBoost.start({ main: '#main' }))</script>
++ *
++ * A click on a same-site link (no modifier key, no target, no download, not data-no-boost, not /auth/ or /api/) fetches
++ * the next page's HTML (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps <main> with
++ * the shared transition (route-transition.js: the top bar, "Loading Content…", a fade), updates the title, the head
++ * tags that describe the page (description, canonical, Open Graph, Twitter, JSON-LD), pushes history, restores scroll on
++ * back/forward and moves focus to the new content. Any doubt is a normal page load: a different release marker, a
++ * different set of external scripts, a non-HTML answer, a redirect off the site, an error, a timeout.
++ * Inline scripts inside the new <main> run after the swap (once); pages listen for `<prefix>:boost:load` to wire widgets.
++ *
++ * OVBoost.start({ main, prefix = 'ov', exclude = [], timeout = 8000, prefetch = true, fx = true }) → controller
++ * controller.go(url) controller.stop() controller.stats()
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVBoost) return;
++ const selfSrc = document.currentScript && document.currentScript.src;
++ const HEAD_TAGS = 'meta[name="description"],link[rel="canonical"],meta[property^="og:"],meta[name^="twitter:"],script[type="application/ld+json"],link[rel="alternate"][hreflang],meta[name="robots"]';
++ const FILE_EXT = /\.(?:pdf|zip|gz|tgz|png|jpe?g|gif|webp|avif|svg|mp4|webm|mp3|wav|ogg|json|xml|txt|csv|ics)$/i;
++
++ function start(o = {}) {
++ const mainSel = o.main || 'main';
++ const prefix = o.prefix || 'ov';
++ const timeout = o.timeout || 8000;
++ const exclude = ['/auth/', '/api/', '/logout', ...(o.exclude || [])];
++ const cache = new Map(); // url → { at, promise }
++ const scrolls = Object.create(null); // history key → y
++ const s = { moves: 0, fallbacks: 0, prefetches: 0, last: null };
++ let inflight = null, stopped = false, key = 0;
++ const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail }));
++ const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; };
++ const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n');
++ const here = { marker: marker(document), scripts: externalScripts(document) };
++ if (!here.marker || !document.querySelector(mainSel)) return { go: (u) => root.location.assign(u), stop() {}, stats: () => ({ ...s, off: 'no ov-boost marker or main element' }) };
++
++ function boostable(a, ev) {
++ if (!a || a.target && a.target !== '_self' || a.hasAttribute('download') || a.closest('[data-no-boost]')) return null;
++ if (ev && (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)) return null;
++ let u; try { u = new URL(a.href, root.location.href); } catch { return null; }
++ if (u.origin !== root.location.origin || !/^https?:$/.test(u.protocol)) return null;
++ if (FILE_EXT.test(u.pathname) || exclude.some((p) => u.pathname.startsWith(p))) return null;
++ if (u.pathname === root.location.pathname && u.search === root.location.search && u.hash) return null; // in-page anchor
++ return u;
++ }
++
++ function fetchPage(url) {
++ const hit = cache.get(url);
++ if (hit && Date.now() - hit.at < 15000) return hit.promise;
++ const ctl = new AbortController();
++ const t = root.setTimeout(() => ctl.abort(), timeout);
++ const promise = root.fetch(url, { credentials: 'same-origin', headers: { 'X-OV-Boost': '1', Accept: 'text/html' }, signal: ctl.signal, redirect: 'follow' })
++ .then(async (r) => {
++ const ct = r.headers.get('content-type') || '';
++ if (!r.ok || !/text\/html/.test(ct)) throw new Error(`not a page (${r.status} ${ct})`);
++ const final = new URL(r.url || url);
++ if (final.origin !== root.location.origin) throw new Error('redirected off the site');
++ return { html: await r.text(), url: final.href };
++ })
++ .finally(() => root.clearTimeout(t));
++ cache.set(url, { at: Date.now(), promise });
++ promise.catch(() => cache.delete(url));
++ return promise;
++ }
++
++ function prefetch(u) {
++ if (o.prefetch === false) return;
++ const c = root.navigator && root.navigator.connection;
++ if (c && (c.saveData || /2g/.test(c.effectiveType || ''))) return;
++ if (cache.has(u.href)) return;
++ s.prefetches++;
++ fetchPage(u.href).catch(() => {});
++ }
++
++ function loadStylesFirst(doc) {
++ const have = new Set([...document.querySelectorAll('link[rel="stylesheet"][href]')].map((l) => new URL(l.href, root.location.href).href));
++ const added = [...doc.querySelectorAll('link[rel="stylesheet"][href]')]
++ .map((l) => new URL(l.getAttribute('href'), root.location.href).href)
++ .filter((h) => !have.has(h))
++ .map((href) => new Promise((done) => {
++ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
++ l.onload = l.onerror = () => done(); root.setTimeout(done, 4000);
++ document.head.appendChild(l);
++ }));
++ return Promise.all(added);
++ }
++
++ function updateHead(doc) {
++ document.title = doc.title;
++ document.head.querySelectorAll(HEAD_TAGS).forEach((el) => el.remove());
++ doc.head.querySelectorAll(HEAD_TAGS).forEach((el) => document.head.appendChild(document.importNode(el, true)));
++ const lang = doc.documentElement.getAttribute('lang');
++ if (lang) document.documentElement.setAttribute('lang', lang);
++ }
++
++ function runInlineScripts(scope) {
++ scope.querySelectorAll('script:not([src])').forEach((old) => {
++ if (old.type && !/^(text|application)\/(javascript|ecmascript)$|^module$/.test(old.type)) return;
++ const s2 = document.createElement('script');
++ for (const a of old.attributes) s2.setAttribute(a.name, a.value);
++ s2.textContent = old.textContent;
++ old.replaceWith(s2);
++ });
++ }
++
++ function fx(mainEl) {
++ if (o.fx === false) return Promise.resolve(null);
++ if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx);
++ if (!selfSrc) return Promise.resolve(null);
++ return new Promise((done) => {
++ const t = document.createElement('script');
++ t.src = selfSrc.replace(/boost\.js(\?.*)?$/, 'route-transition.js');
++ t.onload = () => done(root.OVRouteFx || null); t.onerror = () => done(null);
++ document.head.appendChild(t);
++ });
++ }
++
++ async function go(href, { push = true, restoreY = null } = {}) {
++ if (stopped) return root.location.assign(href);
++ const target = new URL(href, root.location.href);
++ const mine = inflight = {};
++ const mainEl = document.querySelector(mainSel);
++ const Fx = await fx(mainEl);
++ const h = Fx ? Fx.start(mainEl, { prefix }) : null;
++ emit('before', { url: target.href });
++ try {
++ const page = await fetchPage(target.href);
++ if (inflight !== mine) return; // a newer move won
++ const doc = new root.DOMParser().parseFromString(page.html, 'text/html');
++ const next = doc.querySelector(mainSel);
++ // A different release, other external scripts, or no <main>: this page cannot be swapped safely.
++ if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');
++ if (h) h.status('applying styles');
++ await loadStylesFirst(doc);
++ if (inflight !== mine) return;
++ if (push) {
++ scrolls[key] = root.scrollY;
++ key += 1;
++ root.history.pushState({ ovBoost: key }, '', page.url + (target.hash && !new URL(page.url).hash ? target.hash : ''));
++ }
++ // The same element keeps its identity (the transition fades it, listeners on it survive): its
++ // attributes and children become the new page's.
++ const imported = document.importNode(next, true);
++ for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name);
++ for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value);
++ mainEl.replaceChildren(...imported.childNodes);
++ const fresh = mainEl;
++ updateHead(doc);
++ runInlineScripts(fresh);
++ const anchor = target.hash && document.getElementById(decodeURIComponent(target.hash.slice(1)));
++ if (restoreY != null) root.scrollTo(0, restoreY);
++ else if (anchor) anchor.scrollIntoView();
++ else root.scrollTo(0, 0);
++ if (!fresh.hasAttribute('tabindex')) fresh.setAttribute('tabindex', '-1');
++ try { fresh.focus({ preventScroll: true }); } catch { /* */ }
++ s.moves++; s.last = page.url;
++ if (h) h.done();
++ emit('load', { url: page.url });
++ } catch (err) {
++ if (inflight !== mine) return;
++ s.fallbacks++;
++ if (h) h.done();
++ root.location.assign(target.href); // any doubt: a normal page load
++ }
++ }
++
++ function onClick(ev) {
++ const a = ev.target && ev.target.closest && ev.target.closest('a[href]');
++ const u = boostable(a, ev);
++ if (!u) return;
++ ev.preventDefault();
++ go(u.href);
++ }
++ function onIntent(ev) { const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); const u = boostable(a); if (u) prefetch(u); }
++ function onPop(ev) {
++ const st = ev.state;
++ if (!st || st.ovBoost == null) { root.location.reload(); return; }
++ const y = scrolls[st.ovBoost];
++ scrolls[key] = root.scrollY;
++ key = st.ovBoost;
++ go(root.location.href, { push: false, restoreY: y != null ? y : 0 });
++ }
++
++ root.history.replaceState({ ...(root.history.state || {}), ovBoost: key }, '');
++ root.history.scrollRestoration = 'manual';
++ document.addEventListener('click', onClick);
++ document.addEventListener('mouseover', onIntent, { passive: true });
++ document.addEventListener('focusin', onIntent);
++ document.addEventListener('touchstart', onIntent, { passive: true });
++ root.addEventListener('popstate', onPop);
++ return {
++ go: (u) => go(u),
++ stop() {
++ stopped = true;
++ document.removeEventListener('click', onClick);
++ document.removeEventListener('mouseover', onIntent);
++ document.removeEventListener('focusin', onIntent);
++ document.removeEventListener('touchstart', onIntent);
++ root.removeEventListener('popstate', onPop);
++ root.history.scrollRestoration = 'auto';
++ },
++ stats: () => ({ ...s, cached: cache.size }),
++ };
++ }
++
++ root.OVBoost = { start };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/cache-policy.js b/cache-policy.js
nnew file mode 100644
iindex 0000000..4c2b10d
---- /dev/null
++++ b/cache-policy.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * openvibe-shared/cache-policy — the one cache rule every OpenVibe site uses, so no site has to
++ * remember it and no site invents its own numbers: a content-addressed asset is immutable for a
++ * year, everything else is a short public window with a long stale-while-revalidate, and a page
++ * that is personalised for the person reading it is never cached by anybody.
++ *
++ * const cache = require('openvibe-shared/cache-policy');
++ * res.setHeader('Cache-Control', cache.assetHeaders(req.url)); // ?v=<hash> → immutable
++ * res.setHeader('Cache-Control', cache.htmlHeaders()); // public, max-age=120, swr 3600
++ * app.use(cache.applyHtml({ private: req.path === '/account' }));
++ *
++ * A URL is content-addressed when it carries `?v=<hash>` (what serve.url() hands out) or a hashed
++ * filename (`app.6f2a91c3.js`, `styles-a1b2c3d4e5.css`). Immutable is only ever right for those:
++ * the bytes cannot change under the same URL. Everything else may change at any moment, so it gets
++ * five minutes plus a day of stale-while-revalidate — a visitor is served the old copy for a
++ * second while the new one is fetched, and nobody waits on the network twice.
++ */
++const IMMUTABLE = 'public, max-age=31536000, immutable';
++const ASSET_MAX_AGE = 300;
++const ASSET_SWR = 86400;
++
++/** True when `path` is content-addressed: `?v=<hash>` or a hash in the filename. */
++function isHashed(path) {
++ const s = String(path == null ? '' : path);
++ if (/(?:[?&]|&amp;)v=[0-9a-f]{8,64}\b/.test(s)) return true;
++ const file = s.split(/[?#]/)[0];
++ return /[.\-_][0-9a-f]{8,64}(?=[.\-_/]|$)/.test(file);
++}
++
++/**
++ * The Cache-Control for a static asset. `hashed` defaults to the URL's own content-address; pass
++ * it explicitly when the caller knows (a build manifest, a filename it generated itself).
++ */
++function assetHeaders(path, { hashed = isHashed(path), maxAge = ASSET_MAX_AGE, swr = ASSET_SWR } = {}) {
++ if (hashed) return IMMUTABLE;
++ return `public, max-age=${maxAge}, stale-while-revalidate=${swr}`;
++}
++
++/**
++ * The Cache-Control for an HTML page. `private: true` (the default when a page is personalised)
++ * is `private, no-store`: no shared cache, no browser cache, nothing left on a shared machine.
++ */
++function htmlHeaders({ private: isPrivate = false, maxAge = 120, swr = 3600 } = {}) {
++ if (isPrivate) return 'private, no-store';
++ return `public, max-age=${maxAge}, stale-while-revalidate=${swr}`;
++}
++
++/** Express/connect middleware: sets the HTML policy on every GET/HEAD response that has no policy yet. */
++function applyHtml(opts = {}) {
++ const value = htmlHeaders(opts);
++ return function openvibeCachePolicy(req, res, next) {
++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
++ if (!res.getHeader('Cache-Control')) res.setHeader('Cache-Control', value);
++ return next();
++ };
++}
++
++module.exports = { IMMUTABLE, isHashed, assetHeaders, htmlHeaders, applyHtml };
ddiff --git a/files.js b/files.js
iindex df37cce..9364440 100644
---- a/files.js
++++ b/files.js
@@@ -29,6 +29,7 @@ const BROWSER = Object.freeze([
'panels.js', // one-open-at-a-time panel coordinator
'ui.js', // toasts and page notices
'route-transition.js', // page-move bar, loading line and fade-in; web-runtime.js fetches it on the first move
++ 'boost.js', // smooth same-site page moves for a server-rendered site (OVBoost; plan T11)
'island.js', // activity island
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
ddiff --git a/indexnow.js b/indexnow.js
nnew file mode 100644
iindex 0000000..8194afa
---- /dev/null
++++ b/indexnow.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * openvibe-shared/indexnow — tell search engines a page changed, in one POST, per the IndexNow
++ * protocol. A site mounts it once at boot (serving its key file) and calls pingSoon() wherever it
++ * publishes; batching and debouncing are the module's job, because one publish touches a page, a
++ * sitemap and a feed and no engine wants three separate requests for that.
++ *
++ * const indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: 'openvibe.wiki', key: process.env.INDEXNOW_KEY });
++ * app.get(`/${key}.txt`, indexnow.keyFile); // engines fetch the key from here
++ * indexnow.pingSoon(['/wiki/Mars', '/sitemap.xml']);
++ *
++ * The protocol: POST { host, key, keyLocation, urlList } to api.indexnow.org. Only https URLs that
++ * are on `host` belong in the batch (a foreign host is a different key's job), the list is deduped,
++ * and one batch may carry at most 10,000 URLs — more are dropped, because a request that large is
++ * refused anyway. Nothing here throws: a failed ping must never take a publish down with it, so the
++ * failure is logged once and { sent: 0, status } says what happened.
++ *
++ * A key is 8–128 hex or alphanumeric characters (what IndexNow's own tools generate); anything else
++ * is refused at createIndexNow() so a typo cannot quietly produce batches every engine rejects.
++ */
++const DEFAULT_ENDPOINT = 'https://api.indexnow.org/indexnow';
++const MAX_URL_LIST = 10000;
++const BATCH_MS = 30000;
++const KEY_RE = /^[A-Za-z0-9]{8,128}$/;
++
++/** Accepts 'openvibe.wiki', 'https://openvibe.wiki/' or a full URL; yields the bare host the protocol wants. */
++function normaliseHost(host) {
++ const s = String(host || '').trim();
++ try { return new URL(s).host.toLowerCase(); } catch { /* not a URL: a bare host */ }
++ return s.replace(/^[a-z]+:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
++}
++
++/**
++ * @param {object} o
++ * @param {string} o.host the site's host, e.g. 'openvibe.wiki' (a full origin also works)
++ * @param {string} o.key the IndexNow key; 8–128 hex/alnum. Unset disables pinging entirely.
++ * @param {string} [o.keyLocation] where the key file is served; defaults to `host` + '/' + key + '.txt'
++ * @param {string} [o.endpoint] the API endpoint (test and self-hosted servers override it)
++ * @param {Function} [o.fetch] the fetch to POST with; defaults to globalThis.fetch
++ * @param {Function} [o.log] where a failure is reported once; defaults to console.warn
++ * @returns {{keyFile: Function, ping: Function, pingSoon: Function, flush: Function, enabled: boolean}}
++ */
++function createIndexNow({ host, key, keyLocation, endpoint = DEFAULT_ENDPOINT, fetch: fetchImpl, log } = {}) {
++ const theHost = normaliseHost(host);
++ const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
++ const warn = typeof log === 'function' ? log : (...args) => console.warn('[indexnow]', ...args);
++ const keySet = key != null && String(key).trim() !== '';
++ if (keySet && !KEY_RE.test(String(key).trim())) throw new Error('openvibe-shared/indexnow: key must be 8–128 hex or alphanumeric characters');
++ const theKey = keySet ? String(key).trim() : null;
++ const location = keyLocation || (theHost && theKey ? `https://${theHost}/${theKey}.txt` : null);
++
++ let queued = new Set();
++ let timer = null;
++
++ /** Keep only absolute https URLs on this host, deduped, in insertion order. */
++ function clean(urls) {
++ const list = Array.isArray(urls) ? urls : urls == null ? [] : [urls];
++ const kept = [];
++ for (const raw of list) {
++ let u;
++ try { u = new URL(String(raw)); } catch { continue; }
++ if (u.protocol !== 'https:') continue;
++ if (normaliseHost(u.host) !== theHost) continue;
++ if (queued.has(u.toString()) || kept.includes(u.toString())) continue;
++ kept.push(u.toString());
++ }
++ return kept;
++ }
++
++ /**
++ * POST one batch now. Never throws.
++ * @returns {Promise<{sent: number, status: number|string, skipped?: string}>}
++ */
++ async function ping(urls) {
++ const urlList = clean(urls).slice(0, MAX_URL_LIST);
++ if (!theKey) return { sent: 0, status: 0, skipped: 'no key' };
++ if (!urlList.length) return { sent: 0, status: 0, skipped: 'nothing to send' };
++ const body = { host: theHost, key: theKey, keyLocation: location, urlList };
++ try {
++ const res = await doFetch(endpoint, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json; charset=utf-8' },
++ body: JSON.stringify(body),
++ });
++ const status = res && typeof res.status === 'number' ? res.status : 0;
++ // 200 and 202 are both accepted by the protocol; anything else is the engine saying no.
++ if (status !== 200 && status !== 202) warn(`ping rejected with status ${status} for ${urlList.length} url(s)`);
++ return { sent: status === 200 || status === 202 ? urlList.length : 0, status };
++ } catch (err) {
++ warn(`ping failed for ${urlList.length} url(s): ${err && err.message ? err.message : err}`);
++ return { sent: 0, status: 0 };
++ }
++ }
++
++ /** Sends everything queued now and clears the queue (used by the debounce timer and by tests). */
++ async function flush() {
++ if (!timer) return { sent: 0, status: 0, skipped: 'empty' };
++ clearTimeout(timer);
++ timer = null;
++ const batch = [...queued];
++ queued = new Set();
++ return ping(batch);
++ }
++
++ /** Queue URLs and send one batch in 30s, coalescing everything published in the meantime. */
++ function pingSoon(urls) {
++ if (!theKey) return 0;
++ for (const u of clean(urls)) queued.add(u);
++ if (queued.size && !timer) {
++ timer = setTimeout(() => { void flush(); }, BATCH_MS); // ping never rejects
++ if (timer.unref) timer.unref();
++ }
++ return queued.size;
++ }
++
++ /** GET /<key>.txt — the key file the protocol requires engines to be able to fetch. */
++ function keyFile(req, res, next) {
++ if (typeof next === 'function' && (!req || !req.url || !new RegExp(`^/${theKey}\\.txt/?$`).test(String(req.url).split('?')[0]))) return next();
++ if (!theKey) { res.statusCode = 404; return res.end('no key'); }
++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
++ res.setHeader('Cache-Control', 'public, max-age=3600');
++ return res.end(theKey);
++ }
++
++ return { keyFile, ping, pingSoon, flush, enabled: Boolean(theKey) };
++}
++
++module.exports = { createIndexNow, KEY_RE, MAX_URL_LIST, BATCH_MS, DEFAULT_ENDPOINT };
ddiff --git a/package.json b/package.json
iindex becc589..7dccbd1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.0.0",
++ "version": "2.1.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -20,6 +20,7 @@
"./brand": "./brand.js",
"./browser-harness": "./browser-harness.js",
"./builtin-themes": "./builtin-themes.js",
++ "./cache-policy": "./cache-policy.js",
"./config": "./config.js",
"./config-pg": "./config-pg.js",
"./egress": "./egress.js",
@@@ -28,6 +29,7 @@
"./frame": "./frame.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
++ "./indexnow": "./indexnow.js",
"./island": "./island.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
@@@ -62,7 +64,8 @@
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
-- "./route-transition": "./route-transition.js"
++ "./route-transition": "./route-transition.js",
++ "./boost": "./boost.js"
},
"files": [
"*.js",
ddiff --git a/serve.js b/serve.js
iindex ccef63b..87e2b0c 100644
---- a/serve.js
++++ b/serve.js
@@@ -16,6 +16,7 @@
const fs = require('fs');
const crypto = require('crypto');
const files = require('./files');
++const cache = require('./cache-policy');
const hashes = new Map();
function hashOf(name) {
@@@ -38,7 +39,9 @@ function handler() {
const hash = hashOf(name);
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
-- res.setHeader('Cache-Control', v === hash ? 'public, max-age=31536000, immutable' : 'public, max-age=300, stale-while-revalidate=60');
++ // The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,
++ // because a stale shared script is the one thing a site's navbar must never run. Kept as it was.
++ res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
res.setHeader('ETag', `"${hash}"`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
ddiff --git a/test/boost-chrome.test.js b/test/boost-chrome.test.js
nnew file mode 100644
iindex 0000000..2aba52d
---- /dev/null
++++ b/test/boost-chrome.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++// boost.js in a real headless Chrome against a local server-rendered site (plan T11): a same-site click swaps <main>
++// without a page load (styles first, head tags, inline scripts once, the load event), back restores the page and its
++// scroll, and anything that is not safely swappable (another release, other external scripts, an excluded path, a new
++// tab) is a normal load. Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('boost-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const hits = {};
++const page = ({ title, marker = 'site@r1', css = [], scripts = ['/js/site.js'], body, canonical }) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
++<meta name="ov-boost" content="${marker}"><meta name="description" content="about ${title}"><link rel="canonical" href="https://site.example${canonical}">
++<link rel="icon" href="data:,">${css.map((c) => `<link rel="stylesheet" href="${c}">`).join('')}
++${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boost.js" defer></script>
++<script>addEventListener('DOMContentLoaded', () => { window.boost = OVBoost.start({ main: '#main' }); });</script></head>
++<body><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
++const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
++const PAGES = {
++ '/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p>` }),
++ '/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
++ '/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
++ '/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
++ '/y': page({ title: 'Page Y', canonical: '/y', scripts: ['/js/site.js', '/js/other.js'], body: `<h1>Y</h1>${LINKS}` }),
++ '/api/thing': page({ title: 'API', canonical: '/api/thing', body: '<h1>api</h1>' }),
++};
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ hits[u.pathname] = (hits[u.pathname] || 0) + 1;
++ if (PAGES[u.pathname]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); return res.end(PAGES[u.pathname]); }
++ if (u.pathname === '/boost.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'boost.js'))); }
++ if (u.pathname === '/route-transition.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'route-transition.js'))); }
++ if (u.pathname.startsWith('/js/')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end('window.loads = (window.loads || 0) + 1;'); }
++ if (u.pathname === '/css/a.css') { setTimeout(() => { res.writeHead(200, { 'content-type': 'text/css' }); res.end('main h1 { color: rgb(1, 2, 3); }'); }, 200); return undefined; }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boost-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390 });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await p.evaluate('new Promise((r) => (window.boost ? r() : addEventListener("DOMContentLoaded", () => setTimeout(r, 0))))');
++ const startY = await p.evaluate('window.alive = 1; window.scrollTo(0, 1200); scrollY');
++ assert.ok(startY > 300, `the home page scrolled (${startY})`);
++
++ // Hover prefetches; the click then swaps without a page load.
++ const before = hits['/a'] || 0;
++ await p.evaluate('document.getElementById("to-a").dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); 1');
++ await wait(150);
++ assert.strictEqual(hits['/a'], before + 1, 'hover prefetched the page');
++ let v = await p.evaluate(`new Promise((done) => {
++ document.addEventListener('ov:boost:load', () => done({ alive: window.alive, page: document.getElementById('main').dataset.page, h1color: getComputedStyle(document.querySelector('main h1')).color,
++ title: document.title, canonical: document.querySelector('link[rel=canonical]').href, desc: document.querySelector('meta[name=description]').content,
++ ld: document.querySelectorAll('script[type="application/ld+json"]').length, inline: window.inlineRuns, loads: window.loads, path: location.pathname, y: scrollY,
++ focused: document.activeElement && document.activeElement.id }), { once: true });
++ document.getElementById('to-a').click();
++ })`);
++ assert.strictEqual(hits['/a'], before + 1, 'the click used the prefetched page (no second fetch)');
++ assert.deepStrictEqual([v.alive, v.page, v.path, v.title], [1, 'Page A', '/a', 'Page A'], 'swapped in place, no page load');
++ assert.strictEqual(v.h1color, 'rgb(1, 2, 3)', 'the new stylesheet applied before the content showed');
++ assert.deepStrictEqual([v.canonical, v.desc], ['https://site.example/a', 'about Page A']);
++ assert.strictEqual(v.inline, 1, 'the inline script in the new main ran once');
++ assert.strictEqual(v.loads, 1, 'the site script was not loaded again');
++ assert.strictEqual(v.y, 0, 'a new page starts at the top'); assert.strictEqual(v.focused, 'main', 'focus moves to the new content');
++
++ // Back: the home page again, at its scroll position, still no page load.
++ v = await p.evaluate(`new Promise((done) => { document.addEventListener('ov:boost:load', () => done({ alive: window.alive, page: document.getElementById('main').dataset.page, y: scrollY, path: location.pathname }), { once: true }); history.back(); })`);
++ assert.deepStrictEqual([v.alive, v.page, v.path], [1, 'Home', '/']);
++ assert.ok(Math.abs(v.y - startY) < 5, `back restores the scroll (${v.y} vs ${startY})`);
++
++ // Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
++ // Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
++ // then cancels the browser's own navigation so the test stays on this page.
++ const claimed = (id) => p.evaluate(`(() => { let seen = null; const w = (e) => { seen = e.defaultPrevented; e.preventDefault(); }; addEventListener('click', w, { once: true }); document.getElementById('${id}').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); return seen; })()`);
++ v = await claimed('to-api');
++ assert.strictEqual(v, false, '/api/ is not boosted');
++ v = await claimed('to-new');
++ assert.strictEqual(v, false, 'target=_blank is not boosted');
++ await p.evaluate('document.getElementById("to-x").click(); 1');
++ await wait(1500);
++ v = await p.evaluate('({ alive: window.alive, path: location.pathname, page: document.getElementById("main").dataset.page })');
++ assert.deepStrictEqual([v.alive, v.path, v.page], [undefined, '/x', 'Page X'], 'another release: a normal page load');
++ await p.evaluate('new Promise((r) => (window.boost ? r() : addEventListener("DOMContentLoaded", () => setTimeout(r, 0))))');
++ await p.evaluate('window.alive = 2; document.getElementById("to-y").click(); 1');
++ await wait(1500);
++ v = await p.evaluate('({ alive: window.alive, path: location.pathname })');
++ assert.deepStrictEqual([v.alive, v.path], [undefined, '/y'], 'other external scripts: a normal page load');
++
++ const errors = p.state.errors.filter((e) => !/Failed to load resource/.test(e.text));
++ assert.deepStrictEqual(errors, [], JSON.stringify(errors));
++ console.log('boost-chrome: all checks passed');
++ } finally {
++ await browser.close().catch(() => {});
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/cache-policy.test.js b/test/cache-policy.test.js
nnew file mode 100644
iindex 0000000..a823622
---- /dev/null
++++ b/test/cache-policy.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++// openvibe-shared/cache-policy: hashed asset = immutable, HTML = SWR, personalised = no-store.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const cache = require('../cache-policy');
++const serve = require('../serve');
++
++(async () => {
++ // A URL is content-addressed when it says so: ?v=<hash>, or a hash in the filename.
++ for (const hashed of ['/shared/navbar.js?v=6f2a91c3d4e5', '/shared/navbar.js?v=abc12345&x=1', '/app.6f2a91c3d4e5.js', '/styles-a1b2c3d4e5f6.css']) {
++ assert.ok(cache.isHashed(hashed), `${hashed} is content-addressed`);
++ }
++ for (const plain of ['/shared/navbar.js', '/app.js', '/sitemap.xml', '/app.6f2a.js', '/', '/index.html?v=x']) {
++ assert.ok(!cache.isHashed(plain), `${plain} is not content-addressed`);
++ }
++
++ // Assets: the hash means immutable for a year; anything else is short plus a long stale window.
++ assert.strictEqual(cache.assetHeaders('/shared/navbar.js?v=6f2a91c3d4e5'), 'public, max-age=31536000, immutable');
++ assert.strictEqual(cache.assetHeaders('/shared/navbar.js'), 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(cache.assetHeaders('/logo.png', { hashed: true }), cache.IMMUTABLE, 'a caller that knows can say so');
++ assert.strictEqual(cache.assetHeaders('/logo.png', { hashed: false }), 'public, max-age=300, stale-while-revalidate=86400');
++
++ // HTML: stale-while-revalidate at the edge, or nothing stored at all when personalised.
++ assert.strictEqual(cache.htmlHeaders(), 'public, max-age=120, stale-while-revalidate=3600');
++ assert.strictEqual(cache.htmlHeaders({ maxAge: 300, swr: 86400 }), 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
++ assert.strictEqual(cache.htmlHeaders({ private: true, maxAge: 3600 }), 'private, no-store', 'a private page is never a window');
++
++ // serve.js keeps its exact headers, now from the shared policy: immutable on the current hash, a
++ // minute of stale window (not the estate's day) on anything else, exactly as before this module existed.
++ assert.strictEqual(cache.assetHeaders('navbar.js', { hashed: true, swr: 60 }), 'public, max-age=31536000, immutable');
++ assert.strictEqual(cache.assetHeaders('navbar.js', { hashed: false, swr: 60 }), 'public, max-age=300, stale-while-revalidate=60');
++ assert.strictEqual(cache.assetHeaders('navbar.js?v=' + serve.hashOf('navbar.js')), 'public, max-age=31536000, immutable', 'serve.url() is immutable');
++
++ // The middleware sets the policy once and leaves an explicit one alone.
++ const app = express();
++ app.get('/private', (req, res) => res.end('mine'));
++ app.get('/explicit', (req, res) => { res.setHeader('Cache-Control', 'no-store'); res.end('x'); });
++ app.use(cache.applyHtml({ maxAge: 300, swr: 86400 }));
++ app.get('/', (req, res) => res.end('<h1>home</h1>'));
++ const srv = http.createServer(app);
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ try {
++ assert.strictEqual((await fetch(`${base}/`)).headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual((await fetch(`${base}/explicit`)).headers.get('cache-control'), 'no-store', 'an explicit policy wins');
++ } finally { srv.close(); }
++
++ const priv = express();
++ priv.use(cache.applyHtml({ private: true }));
++ priv.get('/', (req, res) => res.end('account'));
++ const srv2 = http.createServer(priv);
++ await new Promise((r) => srv2.listen(0, '127.0.0.1', r));
++ try {
++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/`)).headers.get('cache-control'), 'private, no-store');
++ } finally { srv2.close(); }
++ console.log('cache-policy: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..c32f240
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++// openvibe-shared/indexnow: one POST per publish, batched, host-filtered, never fatal.
++const assert = require('assert');
++const { createIndexNow, KEY_RE } = require('../indexnow');
++
++// A fake fetch that records what was posted and answers with `status`.
++function recorder(status = 200) {
++ const calls = [];
++ const fetchImpl = async (url, init) => { calls.push({ url, init, body: JSON.parse(init.body) }); return { status }; };
++ return { calls, fetch: fetchImpl };
++}
++
++(async () => {
++ // The key rule: 8-128 hex or alphanumeric, and nothing else.
++ assert.ok(KEY_RE.test('a1b2c3d4') && KEY_RE.test('9f8e7d6c5b4a3928') && KEY_RE.test('k'.repeat(128)));
++ assert.ok(!KEY_RE.test('short') && !KEY_RE.test('has-a-dash') && !KEY_RE.test('k'.repeat(129)));
++ assert.throws(() => createIndexNow({ host: 'openvibe.wiki', key: 'short' }), /8–128/);
++ assert.throws(() => createIndexNow({ host: 'openvibe.wiki', key: 'not hex!' }), /8–128/);
++
++ // No key: nothing is ever sent, and the caller is told why.
++ const off = recorder();
++ const none = createIndexNow({ host: 'openvibe.wiki', key: '', fetch: off.fetch });
++ assert.strictEqual(none.enabled, false);
++ assert.deepStrictEqual(await none.ping(['https://openvibe.wiki/a']), { sent: 0, status: 0, skipped: 'no key' });
++ assert.strictEqual(off.calls.length, 0, 'no request without a key');
++ assert.strictEqual(none.pingSoon(['https://openvibe.wiki/a']), 0);
++
++ // The batch: one POST, host/key/keyLocation, deduped, https-only, this host only.
++ const r1 = recorder();
++ const inw = createIndexNow({ host: 'https://openvibe.wiki/', key: 'a1b2c3d4', fetch: r1.fetch });
++ const out = await inw.ping([
++ 'https://openvibe.wiki/wiki/Mars',
++ 'https://openvibe.wiki/wiki/Mars', // duplicate
++ 'https://openvibe.wiki/a', 'https://openvibe.wiki/a/', // distinct URLs, both kept
++ 'http://openvibe.wiki/insecure', // not https
++ 'https://other.example/x', // another host's key
++ '/wiki/Mars', // relative, no host
++ 'not a url',
++ ]);
++ assert.strictEqual(r1.calls.length, 1, 'exactly one request');
++ assert.strictEqual(r1.calls[0].url, 'https://api.indexnow.org/indexnow');
++ assert.strictEqual(r1.calls[0].init.method, 'POST');
++ assert.match(r1.calls[0].init.headers['Content-Type'], /application\/json/);
++ assert.deepStrictEqual(r1.calls[0].body, {
++ host: 'openvibe.wiki',
++ key: 'a1b2c3d4',
++ keyLocation: 'https://openvibe.wiki/a1b2c3d4.txt',
++ urlList: ['https://openvibe.wiki/wiki/Mars', 'https://openvibe.wiki/a', 'https://openvibe.wiki/a/'],
++ });
++ assert.deepStrictEqual(out, { sent: 3, status: 200 });
++ assert.deepStrictEqual(await inw.ping([]), { sent: 0, status: 0, skipped: 'nothing to send' });
++ assert.deepStrictEqual(await inw.ping(['https://elsewhere.example/a']), { sent: 0, status: 0, skipped: 'nothing to send' });
++
++ // keyLocation and endpoint are overridable (self-hosted engines, tests).
++ const r2 = recorder(202);
++ const custom = createIndexNow({ host: 'openvibe.wiki', key: 'a1b2c3d4', keyLocation: 'https://cdn.example/k.txt', endpoint: 'https://engine.example/api', fetch: r2.fetch });
++ assert.deepStrictEqual(await custom.ping(['https://openvibe.wiki/a']), { sent: 1, status: 202 }, '202 is an accepted answer');
++ assert.strictEqual(r2.calls[0].url, 'https://engine.example/api');
++ assert.strictEqual(r2.calls[0].body.keyLocation, 'https://cdn.example/k.txt');
++
++ // A failed POST is logged once and never thrown; a rejected status reports sent 0.
++ const logs = [];
++ const r3 = recorder(422);
++ const bad = createIndexNow({ host: 'openvibe.wiki', key: 'a1b2c3d4', fetch: r3.fetch, log: (m) => logs.push(m) });
++ assert.deepStrictEqual(await bad.ping(['https://openvibe.wiki/a']), { sent: 0, status: 422 });
++ assert.deepStrictEqual(await bad.ping(['https://openvibe.wiki/b']), { sent: 0, status: 422 });
++ assert.strictEqual(logs.length, 2, 'one line per failed attempt, nothing else');
++ assert.match(logs[0], /status 422/);
++
++ const boom = createIndexNow({ host: 'openvibe.wiki', key: 'a1b2c3d4', fetch: async () => { throw new Error('network down'); }, log: (m) => logs.push(m) });
++ assert.deepStrictEqual(await boom.ping(['https://openvibe.wiki/a']), { sent: 0, status: 0 }, 'a dead network is not an exception');
++ assert.match(logs[2], /network down/);
++
++ // pingSoon debounces: everything published in the window is one batch, sent once.
++ const r4 = recorder();
++ const soon = createIndexNow({ host: 'openvibe.wiki', key: 'a1b2c3d4', fetch: r4.fetch });
++ assert.strictEqual(soon.pingSoon(['https://openvibe.wiki/one']), 1);
++ soon.pingSoon(['https://openvibe.wiki/two', 'https://openvibe.wiki/one']);
++ assert.strictEqual(r4.calls.length, 0, 'nothing is sent synchronously');
++ assert.deepStrictEqual(await soon.flush(), { sent: 2, status: 200 });
++ assert.deepStrictEqual(r4.calls[0].body.urlList, ['https://openvibe.wiki/one', 'https://openvibe.wiki/two']);
++ assert.strictEqual(r4.calls.length, 1, 'one batch');
++ assert.deepStrictEqual(await soon.flush(), { sent: 0, status: 0, skipped: 'empty' }, 'the timer is cleared');
++ soon.pingSoon(['https://openvibe.wiki/three']);
++ await new Promise((res) => setTimeout(res, 40)); // the debounce timer is unref'd, so it cannot keep us alive
++
++ // The key file: GET /<key>.txt serves the key, anything else falls through.
++ const headers = {};
++ let body = null;
++ const res = { setHeader: (k, v) => { headers[k] = v; }, end: (b) => { body = b; }, statusCode: 200 };
++ inw.keyFile({ url: '/a1b2c3d4.txt' }, res);
++ assert.strictEqual(body, 'a1b2c3d4');
++ assert.match(headers['Content-Type'], /text\/plain/);
++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=3600');
++ let fell = false;
++ inw.keyFile({ url: '/wiki/Mars' }, { setHeader() {}, end() {} }, () => { fell = true; });
++ assert.ok(fell, 'a different path is not the key file');
++ console.log('indexnow: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });