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
Changes
@@@ -4,6 +4,10 @@ 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.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.@@@ -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` |@@@ -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"@@@ -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"nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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.
**`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.
@@ -24,7 +24,7 @@ were reconciled first.
@@ -24,7 +24,7 @@ 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`, `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` |
| 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`) |
| 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` |
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.14.0",
+ "version": "1.15.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.14.0",
+ "version": "1.15.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,32 +1,29 @@
@@ -1,32 +1,29 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
".": "./index.js",
".": "./index.js",
- "./package.json": "./package.json",
"./account-switcher": "./account-switcher.js",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics/index.js",
"./analytics": "./analytics/index.js",
"./analytics/event": "./analytics/event.js",
"./analytics/event": "./analytics/event.js",
+ "./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./analytics/privacy": "./analytics/privacy.js",
"./analytics/privacy": "./analytics/privacy.js",
"./analytics/prune-cli": "./analytics/prune-cli.js",
"./analytics/prune-cli": "./analytics/prune-cli.js",
"./analytics/retention": "./analytics/retention.js",
"./analytics/retention": "./analytics/retention.js",
"./analytics/schema": "./analytics/schema.js",
"./analytics/schema": "./analytics/schema.js",
"./analytics/tracker": "./analytics/tracker.js",
"./analytics/tracker": "./analytics/tracker.js",
- "./analytics/event.v1.json": "./docs/schemas/analytics-event.v1.json",
"./app-icon": "./app-icon.js",
"./app-icon": "./app-icon.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
- "./config": "./config.js",
- "./frame": "./frame.js",
- "./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
+ "./config": "./config.js",
"./egress": "./egress.js",
"./egress": "./egress.js",
"./files": "./files.js",
"./files": "./files.js",
"./footer": "./footer.js",
"./footer": "./footer.js",
- "./shipped": "./shipped.js",
+ "./frame": "./frame.js",
"./history": "./history.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./island": "./island.js",
@@ -40,18 +37,22 @@
@@ -40,18 +37,22 @@
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
+ "./package.json": "./package.json",
"./panels": "./panels.js",
"./panels": "./panels.js",
+ "./perf-budget": "./perf-budget.js",
"./ready": "./ready.js",
"./ready": "./ready.js",
"./release": "./release.js",
"./release": "./release.js",
"./release-compat": "./release-compat.js",
"./release-compat": "./release-compat.js",
- "./release-watch": "./release-watch.js",
"./release-update": "./release-update.js",
"./release-update": "./release-update.js",
+ "./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
+ "./serve": "./serve.js",
+ "./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
"./theme-sync": "./theme-sync.js",
- "./trace": "./trace.js",
"./tooltip": "./tooltip.js",
"./tooltip": "./tooltip.js",
+ "./trace": "./trace.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js"
"./user-card": "./user-card.js"
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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); });