openvibers/openvibe.shared Commits
4.199.0: the shared files are served minified; avatars reserve their box (#433)
Changes
@@@ -1,5 +1,24 @@# Changelog++## 4.199.0 — 2026-11-10++++**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:++"Make sure everything loads … non-blocking asynchronously … minified".++++- **Served minified, cached by content hash (`serve.js`, new `minify.js`; OpenVibe.Shared #429).** Sites have no build++ step by choice, so the shared browser files are minified where they are served: terser for JavaScript, clean-css for++ stylesheets. It runs once per release per file, and the immutable `?v=` address does the rest. On 4.197.0's eager set++ (the bar, the theme, the footer, the kit and the rest) that is 806 KB → 523 KB raw and 189 KB → 133 KB after brotli,++ about 56 KB less per page view; `navbar.js` alone is 49.8 KB → 34.4 KB as delivered. A file the minifier cannot++ handle, or one that would not come out smaller, is served as it is; a minifier error never fails a request. ETag and++ `?v=` immutability are unchanged. Stylesheets are minified at clean-css level 1 only (inside each rule), never level++ 2, which moves and merges rules and could change which rule wins. New dependencies: `terser`, `clean-css`.++- **Avatars reserve their box (OpenVibe.Shared #430).** The bar's, the footer's, the account switcher's and the user++ card's pictures carry their width and height and decode asynchronously (the footer's also load lazily, below the++ fold), so a signed-in page no longer shifts when the picture arrives.++- Checked on production openvibe.chat with every shared file served minified, on a phone and a computer: the bar, the++ menu, Messages and AI inside it, the Dock; no page errors.++## 4.198.0 — 2026-11-10**On a phone the menu is one sheet: Messages and AI open inside it.** From the owner's notes of 2026-11-10, with the menu@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.198.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.199.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.198.0",++ "version": "4.199.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -469,7 +469,7 @@<div class="openvibe-switcher-list">${loggedAccounts.map(a => `<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">-- <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async"><div class="info"><div class="name">${a.display_name || a.username}</div><div class="sub">${a.email || `@${a.username}`}</div>@@@ -342,7 +342,7 @@const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');const avatar = sized-- ? `<img src="${esc(sized)}" alt="">`++ ? `<img src="${esc(sized)}" alt="" width="${media ? 64 : 32}" height="${media ? 64 : 32}" loading="lazy" decoding="async">`: `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;const quick = [service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++/**++ * openvibe-shared/minify — serve-time minification of the shared browser JS/CSS, cached by content++ * hash. Sites have no build step by choice (plain files, content-hashed at serve time), so++ * "minified" comes from the serve path (serve.js), not a bundler per site.++ *++ * const { minifyJs, minifyCss } = require('openvibe-shared/minify');++ * const js = await minifyJs(src); // terser, names mangled, comments dropped++ * const css = minifyCss(src); // clean-css (sync)++ *++ * Both fail open: a file the minifier cannot handle is returned unchanged, and serve.js falls back++ * to the raw file on any error, so a minifier surprise can never 500 a page.++ */++const { minify: terserMinify } = require('terser');++const CleanCSS = require('clean-css');++++// Level 1 only: it removes what is not needed inside each rule and never moves or merges rules, so the cascade's order++// (and a later rule winning) cannot change (the orchestrator's review, 2026-11-10: level 2 restructures).++const cleanCss = new CleanCSS({ level: { 1: { specialComments: 0 } } });++++/** Minify browser JS (terser, async). Input/output are strings. Throws on a parse error. */++async function minifyJs(src) {++ const out = await terserMinify(String(src), { compress: true, mangle: true, format: { comments: false } });++ return out.code;++}++++/** Minify browser CSS (clean-css, sync). Input/output are strings. */++function minifyCss(src) {++ return cleanCss.minify(String(src)).styles;++}++++/** Minify by filename extension; anything else is returned unchanged. */++async function minifyFile(name, data) {++ const s = data.toString('utf8');++ if (name.endsWith('.js')) return Buffer.from(await minifyJs(s));++ if (name.endsWith('.css')) return Buffer.from(minifyCss(s));++ return data;++}++++module.exports = { minifyJs, minifyCss, minifyFile };@@@ -1992,7 +1992,7 @@const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';const idAttr = id ? ` id="${escapeAttr(id)}"` : '';const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');-- return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr} width="${escapeAttr(size)}" height="${escapeAttr(size)}" decoding="async">`;}// A picture that does not load: the current one once (data-retry-src), then the initial.@@@ -1,16 +1,18 @@{"name": "openvibe-shared",-- "version": "4.198.0",++ "version": "4.199.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.198.0",++ "version": "4.199.0","license": "MIT","dependencies": {++ "clean-css": "^5.3.3","jsonwebtoken": "^9.0.0",-- "linkedom": "^0.18.13"++ "linkedom": "^0.18.13",++ "terser": "^5.51.2"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -29,6 +31,51 @@"dev": true,"license": "Apache-2.0"},++ "node_modules/@jridgewell/gen-mapping": {++ "version": "0.3.13",++ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",++ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",++ "license": "MIT",++ "dependencies": {++ "@jridgewell/sourcemap-codec": "^1.5.0",++ "@jridgewell/trace-mapping": "^0.3.24"++ }++ },++ "node_modules/@jridgewell/resolve-uri": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",++ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",++ "license": "MIT",++ "engines": {++ "node": ">=6.0.0"++ }++ },++ "node_modules/@jridgewell/source-map": {++ "version": "0.3.11",++ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",++ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",++ "license": "MIT",++ "dependencies": {++ "@jridgewell/gen-mapping": "^0.3.5",++ "@jridgewell/trace-mapping": "^0.3.25"++ }++ },++ "node_modules/@jridgewell/sourcemap-codec": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",++ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",++ "license": "MIT"++ },++ "node_modules/@jridgewell/trace-mapping": {++ "version": "0.3.31",++ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",++ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",++ "license": "MIT",++ "dependencies": {++ "@jridgewell/resolve-uri": "^3.1.0",++ "@jridgewell/sourcemap-codec": "^1.4.14"++ }++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -43,6 +90,18 @@"node": ">= 0.6"}},++ "node_modules/acorn": {++ "version": "8.19.0",++ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",++ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",++ "license": "MIT",++ "bin": {++ "acorn": "bin/acorn"++ },++ "engines": {++ "node": ">=0.4.0"++ }++ },"node_modules/ajv": {"version": "8.20.0","resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",@@@ -129,6 +188,12 @@"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==","license": "BSD-3-Clause"},++ "node_modules/buffer-from": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",++ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",++ "license": "MIT"++ },"node_modules/bytes": {"version": "3.1.2","resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",@@@ -170,6 +235,24 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/clean-css": {++ "version": "5.3.3",++ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",++ "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",++ "license": "MIT",++ "dependencies": {++ "source-map": "~0.6.0"++ },++ "engines": {++ "node": ">= 10.0"++ }++ },++ "node_modules/commander": {++ "version": "2.20.3",++ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",++ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",++ "license": "MIT"++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -1375,6 +1458,25 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/source-map": {++ "version": "0.6.1",++ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",++ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",++ "license": "BSD-3-Clause",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/source-map-support": {++ "version": "0.5.21",++ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",++ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",++ "license": "MIT",++ "dependencies": {++ "buffer-from": "^1.0.0",++ "source-map": "^0.6.0"++ }++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1385,6 +1487,24 @@"node": ">= 0.8"}},++ "node_modules/terser": {++ "version": "5.51.2",++ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",++ "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "@jridgewell/source-map": "^0.3.3",++ "acorn": "^8.15.0",++ "commander": "^2.20.0",++ "source-map-support": "~0.5.20"++ },++ "bin": {++ "terser": "bin/terser"++ },++ "engines": {++ "node": ">=10"++ }++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.198.0",++ "version": "4.199.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -96,6 +96,7 @@"./release-update": "./release-update.js","./release-watch": "./release-watch.js","./seo": "./seo.js",++ "./minify": "./minify.js","./serve": "./serve.js","./shell": "./shell.js","./showcase": "./showcase.js",@@@ -185,8 +186,10 @@],"license": "MIT","dependencies": {++ "clean-css": "^5.3.3","jsonwebtoken": "^9.0.0",-- "linkedom": "^0.18.13"++ "linkedom": "^0.18.13",++ "terser": "^5.51.2"},"devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -15,6 +15,10 @@* cards (social/<service>.jpg, openvibe-shared/social), the installable app icons* (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built* per service at manifest/<service>.webmanifest.++ *++ * JS/CSS are minified at serve time (openvibe-shared/minify) and cached by content hash: the minify++ * runs once per release per file, then the immutable ?v= URL does the rest. A file the minifier++ * cannot handle is served raw; a minified body is used only when it is smaller than the raw file.*/const fs = require('fs');const files = require('./files');@@@ -23,6 +27,7 @@ const appIcons = require('./app-icons');const appIcon = require('./app-icon');const cache = require('./cache-policy');const { hashAsset } = require('./assets');++const { minifyFile } = require('./minify');const hashes = new Map();const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));@@@ -96,6 +101,35 @@ function cacheHeaders(res, name, hash, v) {res.setHeader('X-Content-Type-Options', 'nosniff');}++// Minified bodies by `${name}:${hash}`: minification runs once per release, then the cache and the++// immutable ?v= URL do the rest. Bounded, because a site never serves more than this package lists.++const bodies = new Map();++++/** The bytes to serve for `name`: minified when JS/CSS, raw otherwise. Fail-open to raw. */++async function bodyFor(name) {++ const hash = hashOf(name);++ const key = `${name}:${hash}`;++ const hit = bodies.get(key);++ if (hit) return hit;++ if (bodies.size > 300) bodies.clear();++ const raw = fs.readFileSync(fileOf(name));++ if (!name.endsWith('.js') && !name.endsWith('.css')) {++ const entry = { body: raw, minified: false };++ bodies.set(key, entry);++ return entry;++ }++ try {++ const body = await minifyFile(name, raw);++ const entry = body.length < raw.length ? { body, minified: true } : { body: raw, minified: false };++ bodies.set(key, entry);++ return entry;++ } catch {++ const entry = { body: raw, minified: false };++ bodies.set(key, entry);++ return entry;++ }++}++/** Express/connect handler for app.use('/shared', handler()). */function handler() {return function openvibeShared(req, res, next) {@@@ -123,8 +157,8 @@ function handler() {cacheHeaders(res, name, hash, v);if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }if (req.method === 'HEAD') return res.end();-- fs.createReadStream(fileOf(name)).on('error', next).pipe(res);++ bodyFor(name).then(({ body }) => res.end(body)).catch(next);};}--module.exports = { handler, url, hashOf, manifestUrl };++module.exports = { handler, url, hashOf, manifestUrl, _bodyFor: bodyFor };nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++// Shared avatars reserve their box: width/height on every <img> the shared parts draw, lazy + async++// where the image sits below the fold (footer), async alone in the header and click panels.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const dir = path.join(__dirname, '..');++const src = (f) => fs.readFileSync(path.join(dir, f), 'utf8');++++const navbar = src('navbar.js');++// avatarImg carries the requested size as width/height and decodes async; no lazy above the fold.++const avatarImg = navbar.slice(navbar.indexOf('function avatarImg'), navbar.indexOf('function avatarImg') + 1200);++assert.ok(avatarImg.includes('width="${escapeAttr(size)}"') && avatarImg.includes('height="${escapeAttr(size)}"'), 'navbar avatar reserves its size');++assert.ok(avatarImg.includes('decoding="async"'), 'navbar avatar decodes async');++assert.ok(!avatarImg.includes('loading="lazy"'), 'navbar avatar is above the fold: no lazy');++++const footer = src('footer.js');++assert.ok(footer.includes('width="${media ? 64 : 32}"') && footer.includes('height="${media ? 64 : 32}"'), 'footer avatar reserves 64/32');++assert.ok(footer.includes('loading="lazy"') && footer.includes('decoding="async"'), 'footer avatar is lazy + async (below the fold)');++++const switcher = src('account-switcher.js');++assert.ok(switcher.includes('width="40" height="40"') && switcher.includes('decoding="async"'), 'switcher avatar is 40x40');++++const card = src('user-card.js');++assert.ok(card.includes('width="56" height="56"') && card.includes('decoding="async"'), 'user-card avatar is 56x56');++++console.log('avatar sizes: navbar, footer, switcher and user-card reserve their boxes');nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++// openvibe-shared/minify: JS/CSS minify smaller, still parses, fail-open via serve.js.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { minifyJs, minifyCss } = require('../minify');++const files = require('../files');++++(async () => {++ // JS: every browser file minifies smaller and still parses as a classic script.++ for (const f of files.BROWSER) {++ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');++ const code = await minifyJs(raw);++ assert.ok(code.length < raw.length, `${f}: minified (${code.length}) < raw (${raw.length})`);++ assert.doesNotThrow(() => new vm.Script(code, { filename: f }), `${f}: minified parses`);++ assert.ok(!code.includes("require('openvibe-shared/"), `${f}: no require leaked`);++ }++ // CSS: every stylesheet minifies to no more than the raw file.++ for (const f of files.STYLES) {++ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');++ const code = minifyCss(raw);++ assert.ok(code.length <= raw.length, `${f}: minified no larger (${code.length} <= ${raw.length})`);++ assert.ok(code.trim().length > 0, `${f}: minified is not empty`);++ }++ // A parse error throws (serve.js catches and serves raw) rather than returning broken code.++ await assert.rejects(() => minifyJs('function broken( {'), /Unexpected token/);++ console.log(`minify: ${files.BROWSER.length} JS and ${files.STYLES.length} CSS files minify clean`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -152,7 +152,7 @@const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;card.innerHTML = `<div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>-- <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'"><div class="openvibe-usercard-body"><div class="openvibe-usercard-name"><span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# Changelog
+## 4.199.0 — 2026-11-10
+
+**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
+"Make sure everything loads … non-blocking asynchronously … minified".
+
+- **Served minified, cached by content hash (`serve.js`, new `minify.js`; OpenVibe.Shared #429).** Sites have no build
+ step by choice, so the shared browser files are minified where they are served: terser for JavaScript, clean-css for
+ stylesheets. It runs once per release per file, and the immutable `?v=` address does the rest. On 4.197.0's eager set
+ (the bar, the theme, the footer, the kit and the rest) that is 806 KB → 523 KB raw and 189 KB → 133 KB after brotli,
+ about 56 KB less per page view; `navbar.js` alone is 49.8 KB → 34.4 KB as delivered. A file the minifier cannot
+ handle, or one that would not come out smaller, is served as it is; a minifier error never fails a request. ETag and
+ `?v=` immutability are unchanged. Stylesheets are minified at clean-css level 1 only (inside each rule), never level
+ 2, which moves and merges rules and could change which rule wins. New dependencies: `terser`, `clean-css`.
+- **Avatars reserve their box (OpenVibe.Shared #430).** The bar's, the footer's, the account switcher's and the user
+ card's pictures carry their width and height and decode asynchronously (the footer's also load lazily, below the
+ fold), so a signed-in page no longer shifts when the picture arrives.
+- Checked on production openvibe.chat with every shared file served minified, on a phone and a computer: the bar, the
+ menu, Messages and AI inside it, the Dock; no page errors.
+
## 4.198.0 — 2026-11-10
## 4.198.0 — 2026-11-10
**On a phone the menu is one sheet: Messages and AI open inside it.** From the owner's notes of 2026-11-10, with the menu
**On a phone the menu is one sheet: Messages and AI open inside it.** From the owner's notes of 2026-11-10, with the menu
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.198.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.199.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.198.0",
+ "version": "4.199.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -469,7 +469,7 @@
@@ -469,7 +469,7 @@
<div class="openvibe-switcher-list">
<div class="openvibe-switcher-list">
${loggedAccounts.map(a => `
${loggedAccounts.map(a => `
<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
- <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
+ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async">
<div class="info">
<div class="info">
<div class="name">${a.display_name || a.username}</div>
<div class="name">${a.display_name || a.username}</div>
<div class="sub">${a.email || `@${a.username}`}</div>
<div class="sub">${a.email || `@${a.username}`}</div>
@@ -342,7 +342,7 @@
@@ -342,7 +342,7 @@
const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');
const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');
const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');
const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');
const avatar = sized
const avatar = sized
- ? `<img src="${esc(sized)}" alt="">`
+ ? `<img src="${esc(sized)}" alt="" width="${media ? 64 : 32}" height="${media ? 64 : 32}" loading="lazy" decoding="async">`
: `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
: `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
const quick = [
const quick = [
service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+/**
+ * openvibe-shared/minify — serve-time minification of the shared browser JS/CSS, cached by content
+ * hash. Sites have no build step by choice (plain files, content-hashed at serve time), so
+ * "minified" comes from the serve path (serve.js), not a bundler per site.
+ *
+ * const { minifyJs, minifyCss } = require('openvibe-shared/minify');
+ * const js = await minifyJs(src); // terser, names mangled, comments dropped
+ * const css = minifyCss(src); // clean-css (sync)
+ *
+ * Both fail open: a file the minifier cannot handle is returned unchanged, and serve.js falls back
+ * to the raw file on any error, so a minifier surprise can never 500 a page.
+ */
+const { minify: terserMinify } = require('terser');
+const CleanCSS = require('clean-css');
+
+// Level 1 only: it removes what is not needed inside each rule and never moves or merges rules, so the cascade's order
+// (and a later rule winning) cannot change (the orchestrator's review, 2026-11-10: level 2 restructures).
+const cleanCss = new CleanCSS({ level: { 1: { specialComments: 0 } } });
+
+/** Minify browser JS (terser, async). Input/output are strings. Throws on a parse error. */
+async function minifyJs(src) {
+ const out = await terserMinify(String(src), { compress: true, mangle: true, format: { comments: false } });
+ return out.code;
+}
+
+/** Minify browser CSS (clean-css, sync). Input/output are strings. */
+function minifyCss(src) {
+ return cleanCss.minify(String(src)).styles;
+}
+
+/** Minify by filename extension; anything else is returned unchanged. */
+async function minifyFile(name, data) {
+ const s = data.toString('utf8');
+ if (name.endsWith('.js')) return Buffer.from(await minifyJs(s));
+ if (name.endsWith('.css')) return Buffer.from(minifyCss(s));
+ return data;
+}
+
+module.exports = { minifyJs, minifyCss, minifyFile };
@@ -1992,7 +1992,7 @@
@@ -1992,7 +1992,7 @@
const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';
const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
- return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
+ return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr} width="${escapeAttr(size)}" height="${escapeAttr(size)}" decoding="async">`;
}
}
// A picture that does not load: the current one once (data-retry-src), then the initial.
// A picture that does not load: the current one once (data-retry-src), then the initial.
@@ -1,16 +1,18 @@
@@ -1,16 +1,18 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.198.0",
+ "version": "4.199.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.198.0",
+ "version": "4.199.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "clean-css": "^5.3.3",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
- "linkedom": "^0.18.13"
+ "linkedom": "^0.18.13",
+ "terser": "^5.51.2"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -29,6 +31,51 @@
@@ -29,6 +31,51 @@
"dev": true,
"dev": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -43,6 +90,18 @@
@@ -43,6 +90,18 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/acorn": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/ajv": {
"node_modules/ajv": {
"version": "8.20.0",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -129,6 +188,12 @@
@@ -129,6 +188,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
"node_modules/bytes": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -170,6 +235,24 @@
@@ -170,6 +235,24 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/clean-css": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
+ "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
+ "license": "MIT",
+ "dependencies": {
+ "source-map": "~0.6.0"
+ },
+ "engines": {
+ "node": ">= 10.0"
+ }
+ },
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1375,6 +1458,25 @@
@@ -1375,6 +1458,25 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1385,6 +1487,24 @@
@@ -1385,6 +1487,24 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/terser": {
+ "version": "5.51.2",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
+ "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.198.0",
+ "version": "4.199.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",
"bin": {
"bin": {
@@ -96,6 +96,7 @@
@@ -96,6 +96,7 @@
"./release-update": "./release-update.js",
"./release-update": "./release-update.js",
"./release-watch": "./release-watch.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
+ "./minify": "./minify.js",
"./serve": "./serve.js",
"./serve": "./serve.js",
"./shell": "./shell.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
@@ -185,8 +186,10 @@
@@ -185,8 +186,10 @@
],
],
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "clean-css": "^5.3.3",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
- "linkedom": "^0.18.13"
+ "linkedom": "^0.18.13",
+ "terser": "^5.51.2"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -15,6 +15,10 @@
@@ -15,6 +15,10 @@
* cards (social/<service>.jpg, openvibe-shared/social), the installable app icons
* cards (social/<service>.jpg, openvibe-shared/social), the installable app icons
* (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built
* (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built
* per service at manifest/<service>.webmanifest.
* per service at manifest/<service>.webmanifest.
+ *
+ * JS/CSS are minified at serve time (openvibe-shared/minify) and cached by content hash: the minify
+ * runs once per release per file, then the immutable ?v= URL does the rest. A file the minifier
+ * cannot handle is served raw; a minified body is used only when it is smaller than the raw file.
*/
*/
const fs = require('fs');
const fs = require('fs');
const files = require('./files');
const files = require('./files');
@@ -23,6 +27,7 @@ const appIcons = require('./app-icons');
@@ -23,6 +27,7 @@ const appIcons = require('./app-icons');
const appIcon = require('./app-icon');
const appIcon = require('./app-icon');
const cache = require('./cache-policy');
const cache = require('./cache-policy');
const { hashAsset } = require('./assets');
const { hashAsset } = require('./assets');
+const { minifyFile } = require('./minify');
const hashes = new Map();
const hashes = new Map();
const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));
const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));
@@ -96,6 +101,35 @@ function cacheHeaders(res, name, hash, v) {
@@ -96,6 +101,35 @@ function cacheHeaders(res, name, hash, v) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
}
}
+// Minified bodies by `${name}:${hash}`: minification runs once per release, then the cache and the
+// immutable ?v= URL do the rest. Bounded, because a site never serves more than this package lists.
+const bodies = new Map();
+
+/** The bytes to serve for `name`: minified when JS/CSS, raw otherwise. Fail-open to raw. */
+async function bodyFor(name) {
+ const hash = hashOf(name);
+ const key = `${name}:${hash}`;
+ const hit = bodies.get(key);
+ if (hit) return hit;
+ if (bodies.size > 300) bodies.clear();
+ const raw = fs.readFileSync(fileOf(name));
+ if (!name.endsWith('.js') && !name.endsWith('.css')) {
+ const entry = { body: raw, minified: false };
+ bodies.set(key, entry);
+ return entry;
+ }
+ try {
+ const body = await minifyFile(name, raw);
+ const entry = body.length < raw.length ? { body, minified: true } : { body: raw, minified: false };
+ bodies.set(key, entry);
+ return entry;
+ } catch {
+ const entry = { body: raw, minified: false };
+ bodies.set(key, entry);
+ return entry;
+ }
+}
+
/** Express/connect handler for app.use('/shared', handler()). */
/** Express/connect handler for app.use('/shared', handler()). */
function handler() {
function handler() {
return function openvibeShared(req, res, next) {
return function openvibeShared(req, res, next) {
@@ -123,8 +157,8 @@ function handler() {
@@ -123,8 +157,8 @@ function handler() {
cacheHeaders(res, name, hash, v);
cacheHeaders(res, name, hash, v);
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.method === 'HEAD') return res.end();
if (req.method === 'HEAD') return res.end();
- fs.createReadStream(fileOf(name)).on('error', next).pipe(res);
+ bodyFor(name).then(({ body }) => res.end(body)).catch(next);
};
};
}
}
-module.exports = { handler, url, hashOf, manifestUrl };
+module.exports = { handler, url, hashOf, manifestUrl, _bodyFor: bodyFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+// Shared avatars reserve their box: width/height on every <img> the shared parts draw, lazy + async
+// where the image sits below the fold (footer), async alone in the header and click panels.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const dir = path.join(__dirname, '..');
+const src = (f) => fs.readFileSync(path.join(dir, f), 'utf8');
+
+const navbar = src('navbar.js');
+// avatarImg carries the requested size as width/height and decodes async; no lazy above the fold.
+const avatarImg = navbar.slice(navbar.indexOf('function avatarImg'), navbar.indexOf('function avatarImg') + 1200);
+assert.ok(avatarImg.includes('width="${escapeAttr(size)}"') && avatarImg.includes('height="${escapeAttr(size)}"'), 'navbar avatar reserves its size');
+assert.ok(avatarImg.includes('decoding="async"'), 'navbar avatar decodes async');
+assert.ok(!avatarImg.includes('loading="lazy"'), 'navbar avatar is above the fold: no lazy');
+
+const footer = src('footer.js');
+assert.ok(footer.includes('width="${media ? 64 : 32}"') && footer.includes('height="${media ? 64 : 32}"'), 'footer avatar reserves 64/32');
+assert.ok(footer.includes('loading="lazy"') && footer.includes('decoding="async"'), 'footer avatar is lazy + async (below the fold)');
+
+const switcher = src('account-switcher.js');
+assert.ok(switcher.includes('width="40" height="40"') && switcher.includes('decoding="async"'), 'switcher avatar is 40x40');
+
+const card = src('user-card.js');
+assert.ok(card.includes('width="56" height="56"') && card.includes('decoding="async"'), 'user-card avatar is 56x56');
+
+console.log('avatar sizes: navbar, footer, switcher and user-card reserve their boxes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+// openvibe-shared/minify: JS/CSS minify smaller, still parses, fail-open via serve.js.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { minifyJs, minifyCss } = require('../minify');
+const files = require('../files');
+
+(async () => {
+ // JS: every browser file minifies smaller and still parses as a classic script.
+ for (const f of files.BROWSER) {
+ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');
+ const code = await minifyJs(raw);
+ assert.ok(code.length < raw.length, `${f}: minified (${code.length}) < raw (${raw.length})`);
+ assert.doesNotThrow(() => new vm.Script(code, { filename: f }), `${f}: minified parses`);
+ assert.ok(!code.includes("require('openvibe-shared/"), `${f}: no require leaked`);
+ }
+ // CSS: every stylesheet minifies to no more than the raw file.
+ for (const f of files.STYLES) {
+ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');
+ const code = minifyCss(raw);
+ assert.ok(code.length <= raw.length, `${f}: minified no larger (${code.length} <= ${raw.length})`);
+ assert.ok(code.trim().length > 0, `${f}: minified is not empty`);
+ }
+ // A parse error throws (serve.js catches and serves raw) rather than returning broken code.
+ await assert.rejects(() => minifyJs('function broken( {'), /Unexpected token/);
+ console.log(`minify: ${files.BROWSER.length} JS and ${files.STYLES.length} CSS files minify clean`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -152,7 +152,7 @@
@@ -152,7 +152,7 @@
const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
card.innerHTML = `
card.innerHTML = `
<div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
<div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
- <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
+ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'">
<div class="openvibe-usercard-body">
<div class="openvibe-usercard-body">
<div class="openvibe-usercard-name">
<div class="openvibe-usercard-name">
<span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
<span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>