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.
Changes
@@@ -4,6 +4,24 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 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).@@@ -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`)```textnnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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 (/(?:[?&]|&)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 };@@@ -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 paintnnew file mode 100644@@@ -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 };@@@ -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",@@@ -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');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -4,6 +4,24 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 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
## 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).
**X-Internal-Key is gone from the shared package** (plan T2: every internal call in the estate uses a service token).
@@ -19,8 +19,8 @@ were reconciled first.
@@ -19,8 +19,8 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
-| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `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`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| 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) |
| 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
@@ -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.
- 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.
- **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`)
### The web runtime (`web-runtime.js`, `route-transition.js`)
```text
```text
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 (/(?:[?&]|&)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 };
@@ -29,6 +29,7 @@ const BROWSER = Object.freeze([
@@ -29,6 +29,7 @@ const BROWSER = Object.freeze([
'panels.js', // one-open-at-a-time panel coordinator
'panels.js', // one-open-at-a-time panel coordinator
'ui.js', // toasts and page notices
'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
'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
'island.js', // activity island
'tooltip.js',
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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 };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"./brand": "./brand.js",
"./brand": "./brand.js",
"./browser-harness": "./browser-harness.js",
"./browser-harness": "./browser-harness.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
+ "./cache-policy": "./cache-policy.js",
"./config": "./config.js",
"./config": "./config.js",
"./config-pg": "./config-pg.js",
"./config-pg": "./config-pg.js",
"./egress": "./egress.js",
"./egress": "./egress.js",
@@ -28,6 +29,7 @@
@@ -28,6 +29,7 @@
"./frame": "./frame.js",
"./frame": "./frame.js",
"./history": "./history.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
+ "./indexnow": "./indexnow.js",
"./island": "./island.js",
"./island": "./island.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
"./express-async": "./express-async.js",
@@ -62,7 +64,8 @@
@@ -62,7 +64,8 @@
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js",
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
"./web-runtime": "./web-runtime.js",
- "./route-transition": "./route-transition.js"
+ "./route-transition": "./route-transition.js",
+ "./boost": "./boost.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const fs = require('fs');
const fs = require('fs');
const crypto = require('crypto');
const crypto = require('crypto');
const files = require('./files');
const files = require('./files');
+const cache = require('./cache-policy');
const hashes = new Map();
const hashes = new Map();
function hashOf(name) {
function hashOf(name) {
@@ -38,7 +39,9 @@ function handler() {
@@ -38,7 +39,9 @@ function handler() {
const hash = hashOf(name);
const hash = hashOf(name);
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
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('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('ETag', `"${hash}"`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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); });