openvibers/openvibe.shared Commits

1.12.0: openvibe-shared/serve — a site serves its own pinned shared browser files at /shared/* with content-addressed URLs (serve.url, ?v=hash immutable, ETag, CORS-open), so its pin decides what its pages run and the Frame survives an openvibe.network outage; the navbar loads its lazy siblings from its own origin (the network's copy is the fallback)

OP OpenVibers committed 24 Sept 2026 · 82255df56d · 6 files, +113 −15


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dc122ae..c1d14c4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,11 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.12.0 — 2026-09-24
++
++- **`openvibe-shared/serve`: a site serves its own pinned copy of the shared browser files.** Mount it with `app.use('/shared', serve.handler())` and reference files with `serve.url('navbar.js')`, which gives `/shared/navbar.js?v=<content hash>`. The current hash is cached immutably, anything else for five minutes. ETag is the hash, the files are CORS-open and cross-origin readable, and only the files `openvibe-shared/files` lists are served. A site's pin then decides what its pages run, and the Frame keeps working while openvibe.network is down.
++- The navbar loads its lazy siblings from wherever it was itself loaded: `nav-icons.js`, `release-watch.js`, `ov-icons.js`, `panels.js`, `notification-ui.js`, `ov-mark.js`, `sso-client.js` and `history.js`. Before, several were always fetched from openvibe.network (or the `apiBase`); the network's copy is now only the fallback. The footer already loaded `shipped.js` this way.
++
## 1.11.1 — 2026-09-24
Fix: the footer's "shipped X ago" line uses the footer's own `service` (`live`, `wiki`, …). Before, it resolved the page's hostname, so a local or unmapped host showed the whole network. A site id that is not a registry id (a Tools satellite's `dev`) falls back to the hostname rather than to the whole network.
ddiff --git a/navbar.js b/navbar.js
iindex 898b441..216e56f 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -369,11 +369,18 @@
Object.assign(NAV_ICONS, set); _iconState = 2;
return true;
}
++ /**
++ * A shared browser file next to this navbar.js: the same origin and the same release (a site that
++ * serves its own pinned copy at /shared/ gets its own), else the network's copy.
++ */
++ function sibling(name) {
++ const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
++ return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
++ }
/** nav-icons.js next to this navbar.js (same origin, same release), else the network's copy. */
function iconsUrl() {
if (_config.iconsUrl) return _config.iconsUrl;
-- const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
-- return `${m ? m[1] : 'https://openvibe.network/shared/'}nav-icons.js?v=${NAV_ICONS_REV}`;
++ return `${sibling('nav-icons.js')}?v=${NAV_ICONS_REV}`;
}
function iconSlots() {
const out = new Set();
@@@ -433,8 +440,7 @@
const rw = _config.releaseWatch;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
-- const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
-- const sc = document.createElement('script'); sc.src = `${m ? m[1] : 'https://openvibe.network/shared/'}release-watch.js`; sc.async = true;
++ const sc = document.createElement('script'); sc.src = sibling('release-watch.js'); sc.async = true;
try { sc.fetchPriority = 'low'; } catch { /* */ }
document.head.appendChild(sc);
} catch { /* optional */ }
@@@ -648,7 +654,7 @@
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
regPanel(panel, 'launcher', close);
-- if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); }
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
@@@ -728,7 +734,7 @@
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
-- const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = 'https://openvibe.network/shared/panels.js'; sc.async = true;
++ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = sibling('panels.js'); sc.async = true;
sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
document.head.appendChild(sc);
}
@@@ -802,7 +808,7 @@
};
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
-- const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = sibling('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
@@@ -1140,7 +1146,7 @@
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (_ssoClientLoading) return _ssoClientLoading;
_ssoClientLoading = new Promise((resolve) => {
-- const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
++ const sc = document.createElement('script'); sc.async = true; sc.src = sibling('sso-client.js');
sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
document.head.appendChild(sc);
});
@@@ -1223,7 +1229,7 @@
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (!document.getElementById('ov-history-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
-- sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.src = sibling('history.js');
sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
document.head.appendChild(sc);
} else {
@@@ -1314,7 +1320,7 @@
if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
if (document.getElementById('ov-history-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
-- sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.src = sibling('history.js');
sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
document.head.appendChild(sc);
}
@@@ -1341,7 +1347,7 @@
// The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
-- try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
nav.innerHTML = `
${brandHTML(brand)}
@@@ -1441,7 +1447,7 @@
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
dropdown.classList.toggle('open');
-- if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); } }
++ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } }
});
// Close on outside click
ddiff --git a/package-lock.json b/package-lock.json
iindex 435124b..17ab3ce 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.11.1",
++ "version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.11.1",
++ "version": "1.12.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 5dde550..a5eb604 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.11.1",
++ "version": "1.12.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",
"./builtin-themes": "./builtin-themes.js",
"./frame": "./frame.js",
++ "./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
"./egress": "./egress.js",
"./files": "./files.js",
ddiff --git a/serve.js b/serve.js
nnew file mode 100644
iindex 0000000..ccef63b
---- /dev/null
++++ b/serve.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * openvibe-shared/serve — a site serves its OWN pinned copy of the shared browser files (the OpenVibe
++ * Frame and friends) at /shared/*, with content-addressed URLs, instead of loading whatever
++ * openvibe.network happens to run. The site's pin then decides what its pages execute, and its pages
++ * keep their navbar and footer while openvibe.network is down.
++ *
++ * const serve = require('openvibe-shared/serve');
++ * app.use('/shared', serve.handler());
++ * `<script src="${serve.url('navbar.js')}" defer></script>` → /shared/navbar.js?v=<content hash>
++ *
++ * A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
++ * for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
++ * copies on openvibe.network. Only the files openvibe-shared/files lists are served.
++ */
++const fs = require('fs');
++const crypto = require('crypto');
++const files = require('./files');
++
++const hashes = new Map();
++function hashOf(name) {
++ if (!hashes.has(name)) hashes.set(name, crypto.createHash('sha256').update(fs.readFileSync(files.path(name))).digest('hex').slice(0, 12));
++ return hashes.get(name);
++}
++
++/** The content-addressed URL of a browser file under `base` (default /shared). */
++function url(name, base = '/shared') {
++ if (!files.isBrowserFile(name)) throw new Error(`openvibe-shared/serve: ${name} is not a browser file`);
++ return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashOf(name)}`;
++}
++
++/** Express/connect handler for app.use('/shared', handler()). */
++function handler() {
++ return function openvibeShared(req, res, next) {
++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
++ const name = String(req.path || '').replace(/^\/+/, '');
++ if (!files.isBrowserFile(name)) return next();
++ 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');
++ res.setHeader('ETag', `"${hash}"`);
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ res.setHeader('X-Content-Type-Options', 'nosniff');
++ if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
++ if (req.method === 'HEAD') return res.end();
++ fs.createReadStream(files.path(name)).on('error', next).pipe(res);
++ };
++}
++
++module.exports = { handler, url, hashOf };
ddiff --git a/test/serve.test.js b/test/serve.test.js
nnew file mode 100644
iindex 0000000..74c8b06
---- /dev/null
++++ b/test/serve.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// openvibe-shared/serve: a site serves its own pinned shared browser files, content-addressed.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const serve = require('../serve');
++
++(async () => {
++ const app = express();
++ app.use('/shared', serve.handler());
++ app.use((req, res) => res.status(404).end('nope'));
++ 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 {
++ const u = serve.url('navbar.js');
++ assert.match(u, /^\/shared\/navbar\.js\?v=[0-9a-f]{12}$/);
++ let r = await fetch(base + u);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /javascript/);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable', 'the current hash is immutable');
++ assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'cross-origin');
++ assert.ok((await r.text()).includes('OpenVibeNavbar'));
++ r = await fetch(`${base}/shared/navbar.js?v=old`);
++ assert.match(r.headers.get('cache-control'), /max-age=300/, 'a stale hash is short-lived');
++ r = await fetch(`${base}/shared/navbar.js`, { headers: { 'If-None-Match': `"${serve.hashOf('navbar.js')}"` } });
++ assert.strictEqual(r.status, 304);
++ for (const bad of ['/shared/serve.js', '/shared/../package.json', '/shared/frame.js', '/shared/%2e%2e/files.js']) {
++ assert.strictEqual((await fetch(base + bad)).status, 404, `${bad} is not served`);
++ }
++ assert.throws(() => serve.url('legal.js'), /not a browser file/);
++ console.log('serve: all checks passed');
++ } finally { srv.close(); }
++})().catch((e) => { console.error(e); process.exit(1); });