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)
Changes
@@@ -4,6 +4,11 @@ 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).++## 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-24Fix: 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.@@@ -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 dropdownnav.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@@@ -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"@@@ -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",nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -4,6 +4,11 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
+## 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
## 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.
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.
@@ -369,11 +369,18 @@
@@ -369,11 +369,18 @@
Object.assign(NAV_ICONS, set); _iconState = 2;
Object.assign(NAV_ICONS, set); _iconState = 2;
return true;
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. */
/** nav-icons.js next to this navbar.js (same origin, same release), else the network's copy. */
function iconsUrl() {
function iconsUrl() {
if (_config.iconsUrl) return _config.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() {
function iconSlots() {
const out = new Set();
const out = new Set();
@@ -433,8 +440,7 @@
@@ -433,8 +440,7 @@
const rw = _config.releaseWatch;
const rw = _config.releaseWatch;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
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 { /* */ }
try { sc.fetchPriority = 'low'; } catch { /* */ }
document.head.appendChild(sc);
document.head.appendChild(sc);
} catch { /* optional */ }
} catch { /* optional */ }
@@ -648,7 +654,7 @@
@@ -648,7 +654,7 @@
bindDisplayControls(panel.querySelector('.ovl-display'));
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
nav.appendChild(panel);
regPanel(panel, 'launcher', close);
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, '');
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
const input = panel.querySelector('.ovl-q');
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
@@ -728,7 +734,7 @@
@@ -728,7 +734,7 @@
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
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()); };
sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
document.head.appendChild(sc);
document.head.appendChild(sc);
}
}
@@ -802,7 +808,7 @@
@@ -802,7 +808,7 @@
};
};
if (root.OpenVibeNotifications) return go();
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
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 }] }]. */
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
@@ -1140,7 +1146,7 @@
@@ -1140,7 +1146,7 @@
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (_ssoClientLoading) return _ssoClientLoading;
if (_ssoClientLoading) return _ssoClientLoading;
_ssoClientLoading = new Promise((resolve) => {
_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);
sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
document.head.appendChild(sc);
document.head.appendChild(sc);
});
});
@@ -1223,7 +1229,7 @@
@@ -1223,7 +1229,7 @@
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (!document.getElementById('ov-history-loader')) {
if (!document.getElementById('ov-history-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
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 { /* */ } };
sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
document.head.appendChild(sc);
document.head.appendChild(sc);
} else {
} else {
@@ -1314,7 +1320,7 @@
@@ -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 (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;
if (document.getElementById('ov-history-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
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 { /* */ } };
sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
document.head.appendChild(sc);
document.head.appendChild(sc);
}
}
@@ -1341,7 +1347,7 @@
@@ -1341,7 +1347,7 @@
// The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
// The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
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 = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
@@ -1441,7 +1447,7 @@
@@ -1441,7 +1447,7 @@
// Avatar click toggles dropdown
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
dropdown.classList.toggle('open');
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
// Close on outside click
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.11.1",
+ "version": "1.12.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.11.1",
+ "version": "1.12.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.11.1",
+ "version": "1.12.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",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
"./frame": "./frame.js",
"./frame": "./frame.js",
+ "./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
"./egress": "./egress.js",
"./egress": "./egress.js",
"./files": "./files.js",
"./files": "./files.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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); });