openvibers/openvibe.shared Commits

v1.15.0: openvibe-shared/perf-budget (WS-T task 1): size budgets for a page's first load measured from the running server (HTML, same-origin scripts and stylesheets, raw and brotli; cross-origin assets listed, not weighed), check() and format() for npm test

OP OpenVibers committed 26 Sept 2026 · 55c79b8fab · 6 files, +157 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8fc0881..022c752 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ 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.15.0 — 2026-09-26
++
++**`openvibe-shared/perf-budget`** (roadmap WS-T task 1): size budgets for a page's first load, measured from the running server with no browser. `measure({ base, path })` fetches the page and every same-origin script and stylesheet it names. It reports the files and their raw and brotli KB; cross-origin assets are listed as `external` and not weighed. `check(m, budgets)` lists the budgets exceeded, and `format()` is the failure message.
++
## 1.14.0 — 2026-09-26
**`openvibe-shared/config`, the configuration model** (roadmap WS-C task 7): `createConfigStore({ db, service, namespace, schema, validate, classify, defaults, legacy, onActivate })` keeps one namespace of a service's configuration as immutable revisions (`common.config-snapshot@1`) in the service's own better-sqlite3 database (`config_snapshots`; several namespaces per database). `get()` serves the active values from memory; `propose`/`activate`/`apply` validate (a built-in JSON Schema subset and/or `validate()`), classify every key (public, internal by default, secret) and activate in one transaction, and a throwing `onActivate` puts the last-known-good back in memory and in the database, rejects the new revision with the error and throws it. `rollback()` activates a copy of the previous good revision (or `to`) as a new revision; `history()`, `lastKnownGood()` and `summary()` are redacted: a secret is `{ redacted: true, fingerprint }`, an HMAC-SHA256 under a random 32-byte key made per namespace on first use (`config_keys`; never served, never logged), a snapshot's checksum covers the values as shown, and secret values never leave or reach the log. A marker sent back keeps the secret it stands for only when its fingerprint matches. Revision 1 comes from `defaults` or a legacy source (`legacy`, `import()`, `fromRows()` for typed key-value rows). `adminRoutes(stores, { requireAdmin })` serves `GET /api/admin/config`, `GET /api/admin/config/:namespace/history`, `POST /api/admin/config/:namespace` and `POST /api/admin/config/:namespace/rollback` with problem+json errors, on Express or plain http, without depending on either. No new dependency. Additive.
ddiff --git a/README.md b/README.md
iindex 2fb35db..ba358f7 100644
---- a/README.md
++++ b/README.md
@@@ -24,7 +24,7 @@ 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`, `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`, `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; `chrome-ssr` is a deprecated alias), `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`); `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()` |
++| 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; `chrome-ssr` is a deprecated alias), `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`), `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`); `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` |
ddiff --git a/package-lock.json b/package-lock.json
iindex a54bccf..e525ddb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.14.0",
++ "version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.14.0",
++ "version": "1.15.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 6c2a2ad..e4cdf94 100644
---- a/package.json
++++ b/package.json
@@@ -1,32 +1,29 @@
{
"name": "openvibe-shared",
-- "version": "1.14.0",
++ "version": "1.15.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
".": "./index.js",
-- "./package.json": "./package.json",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics/index.js",
"./analytics/event": "./analytics/event.js",
++ "./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./analytics/privacy": "./analytics/privacy.js",
"./analytics/prune-cli": "./analytics/prune-cli.js",
"./analytics/retention": "./analytics/retention.js",
"./analytics/schema": "./analytics/schema.js",
"./analytics/tracker": "./analytics/tracker.js",
-- "./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./app-icon": "./app-icon.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
-- "./config": "./config.js",
-- "./frame": "./frame.js",
-- "./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
++ "./config": "./config.js",
"./egress": "./egress.js",
"./files": "./files.js",
"./footer": "./footer.js",
-- "./shipped": "./shipped.js",
++ "./frame": "./frame.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
@@@ -40,18 +37,22 @@
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
++ "./package.json": "./package.json",
"./panels": "./panels.js",
++ "./perf-budget": "./perf-budget.js",
"./ready": "./ready.js",
"./release": "./release.js",
"./release-compat": "./release-compat.js",
-- "./release-watch": "./release-watch.js",
"./release-update": "./release-update.js",
++ "./release-watch": "./release-watch.js",
"./seo": "./seo.js",
++ "./serve": "./serve.js",
++ "./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
-- "./trace": "./trace.js",
"./tooltip": "./tooltip.js",
++ "./trace": "./trace.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js"
ddiff --git a/perf-budget.js b/perf-budget.js
nnew file mode 100644
iindex 0000000..50906f9
---- /dev/null
++++ b/perf-budget.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * openvibe-shared/perf-budget — size budgets for a page's first load (roadmap WS-T task 1), measured
++ * from what the running server actually answers: no browser, deterministic, cheap enough for `npm test`.
++ *
++ * const { measure, check, format } = require('openvibe-shared/perf-budget');
++ * const m = await measure({ base: 'http://127.0.0.1:4000', path: '/' });
++ * const over = check(m, { htmlBrotliKB: 30, jsFiles: 12, jsBrotliKB: 90, cssBrotliKB: 40 });
++ * assert.deepStrictEqual(over, [], format(m, over));
++ *
++ * measure({ base, path = '/', fetch, headers }) fetches the page, then every same-origin script
++ * (<script src>) and stylesheet (<link rel="stylesheet">, not media="print") it names, and answers
++ * { html: { rawKB, brotliKB }, js: { files, rawKB, brotliKB }, css: { files, rawKB, brotliKB },
++ * external: [url], urls: { js: [..], css: [..] } }
++ * sizes in KB (1 decimal), brotli at quality 11 as the edge serves it. Scripts and stylesheets on other
++ * origins (the OpenVibe Frame from openvibe.network, a CDN) are listed in `external` and counted in
++ * `files`, not in bytes: they are the other origin's budget. Inline <script>/<style> count in the HTML.
++ *
++ * check(m, budgets) → [{ name, value, budget }] for every budget exceeded. Budget names:
++ * htmlRawKB htmlBrotliKB jsFiles jsRawKB jsBrotliKB cssFiles cssRawKB cssBrotliKB externalFiles
++ * Set each a little above a measurement; raising one should be a decision said in the commit.
++ */
++const zlib = require('zlib');
++
++const kb = (n) => Math.round((n / 1024) * 10) / 10;
++const brotli = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
++const attr = (tag, name) => {
++ const m = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
++ return m ? (m[2] ?? m[3] ?? m[4] ?? '') : null;
++};
++
++/** The script and stylesheet URLs a page's HTML names, in order (comments ignored). */
++function assetsOf(html) {
++ const body = String(html).replace(/<!--[\s\S]*?-->/g, '');
++ const js = [], css = [];
++ for (const m of body.matchAll(/<script\b[^>]*>/gi)) {
++ const src = attr(m[0], 'src');
++ const type = (attr(m[0], 'type') || '').toLowerCase();
++ if (src && !['application/json', 'application/ld+json', 'text/template'].includes(type)) js.push(src);
++ }
++ for (const m of body.matchAll(/<link\b[^>]*>/gi)) {
++ const rel = (attr(m[0], 'rel') || '').toLowerCase().split(/\s+/);
++ const media = (attr(m[0], 'media') || '').toLowerCase();
++ const disabled = /\sdisabled(\s|=|\/?>)/i.test(m[0]);
++ if (rel.includes('stylesheet') && media !== 'print' && !disabled) css.push(attr(m[0], 'href'));
++ }
++ return { js: js.filter(Boolean), css: css.filter(Boolean) };
++}
++
++async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers = {} } = {}) {
++ const origin = new URL(base).origin;
++ const pageUrl = new URL(path, base).href;
++ const res = await f(pageUrl, { headers: { accept: 'text/html', ...headers } });
++ if (!res.ok) throw new Error(`perf-budget: ${pageUrl} answered ${res.status}`);
++ const html = Buffer.from(await res.arrayBuffer());
++ const { js, css } = assetsOf(html.toString('utf8'));
++ const external = [];
++ async function total(urls) {
++ let raw = 0, br = 0;
++ for (const u of urls) {
++ const abs = new URL(u, pageUrl);
++ if (abs.origin !== origin) { external.push(abs.href); continue; }
++ const r = await f(abs.href, { headers });
++ if (!r.ok) throw new Error(`perf-budget: ${abs.pathname} (named by ${path}) answered ${r.status}`);
++ const b = Buffer.from(await r.arrayBuffer());
++ raw += b.length; br += brotli(b);
++ }
++ return { files: urls.length, rawKB: kb(raw), brotliKB: kb(br) };
++ }
++ const jsT = await total(js);
++ const cssT = await total(css);
++ return { url: pageUrl, html: { rawKB: kb(html.length), brotliKB: kb(brotli(html)) }, js: jsT, css: cssT, external, urls: { js, css } };
++}
++
++const READ = {
++ htmlRawKB: (m) => m.html.rawKB, htmlBrotliKB: (m) => m.html.brotliKB,
++ jsFiles: (m) => m.js.files, jsRawKB: (m) => m.js.rawKB, jsBrotliKB: (m) => m.js.brotliKB,
++ cssFiles: (m) => m.css.files, cssRawKB: (m) => m.css.rawKB, cssBrotliKB: (m) => m.css.brotliKB,
++ externalFiles: (m) => m.external.length,
++};
++
++function check(m, budgets = {}) {
++ const over = [];
++ for (const [name, budget] of Object.entries(budgets)) {
++ if (!READ[name]) throw new Error(`perf-budget: unknown budget ${name}`);
++ const value = READ[name](m);
++ if (value > budget) over.push({ name, value, budget });
++ }
++ return over;
++}
++
++function format(m, over = []) {
++ const lines = [`${m.url}: html ${m.html.rawKB} KB (${m.html.brotliKB} br), js ${m.js.files} files ${m.js.rawKB} KB (${m.js.brotliKB} br), css ${m.css.files} files ${m.css.rawKB} KB (${m.css.brotliKB} br), ${m.external.length} external`];
++ for (const o of over) lines.push(` over budget: ${o.name} ${o.value} > ${o.budget}`);
++ return lines.join('\n');
++}
++
++module.exports = { measure, check, format, assetsOf };
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..c8366b9
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++// openvibe-shared/perf-budget (WS-T task 1): what a page's first load names and weighs, from a live server.
++const assert = require('assert');
++const http = require('http');
++const { measure, check, format, assetsOf } = require('../perf-budget');
++
++(async () => {
++ // The HTML parser: scripts with src (not JSON/LD), stylesheets (not print, not disabled), comments ignored.
++ const found = assetsOf(`<head><!-- <script src="/old.js"></script> -->
++ <script src="/a.js" defer></script><script type="application/ld+json">{}</script><script>inline()</script>
++ <script src='https://cdn.example/lib.js'></script><script type="module" src=/m.js></script>
++ <link rel="stylesheet" href="/s.css"><link rel="preload stylesheet" href="/p.css"><link rel=stylesheet href="/print.css" media="print">
++ <link rel="icon" href="/f.ico"><link rel="stylesheet" href="/off.css" disabled></head>`);
++ assert.deepStrictEqual(found, { js: ['/a.js', 'https://cdn.example/lib.js', '/m.js'], css: ['/s.css', '/p.css'] });
++
++ const files = {
++ '/': `<!doctype html><html><head><link rel="stylesheet" href="/s.css"><script src="/a.js" defer></script><script src="https://openvibe.network/shared/navbar.js" defer></script></head><body>${'<p>hello</p>'.repeat(200)}</body></html>`,
++ '/a.js': 'console.log(1);'.repeat(500),
++ '/s.css': 'body{color:red}'.repeat(300),
++ };
++ const server = http.createServer((req, res) => {
++ const body = files[req.url];
++ if (body == null) { res.statusCode = 404; return res.end('no'); }
++ res.end(body);
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const m = await measure({ base });
++ assert.deepStrictEqual([m.js.files, m.css.files, m.external], [2, 1, ['https://openvibe.network/shared/navbar.js']], 'the other origin is listed, not fetched');
++ assert.strictEqual(m.js.rawKB, Math.round(files['/a.js'].length / 1024 * 10) / 10);
++ assert.ok(m.js.brotliKB < m.js.rawKB && m.html.brotliKB < m.html.rawKB, 'brotli is smaller');
++ assert.deepStrictEqual(check(m, { jsFiles: 2, cssFiles: 1, externalFiles: 1 }), []);
++ assert.deepStrictEqual(check(m, { jsFiles: 1 }), [{ name: 'jsFiles', value: 2, budget: 1 }]);
++ assert.match(format(m, check(m, { jsFiles: 1 })), /over budget: jsFiles 2 > 1/);
++ assert.throws(() => check(m, { jsKb: 1 }), /unknown budget/);
++
++ // A named asset that is missing fails the measurement (a broken page is not a small page).
++ files['/'] = files['/'].replace('/a.js', '/gone.js');
++ await assert.rejects(measure({ base }), /gone\.js .*answered 404/);
++ server.close();
++ console.log('perf-budget: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });