openvibers/openvibe.bot Commits

The panel's own files by their content hash, so a deploy never draws with a cached stylesheet (#394)

OP OpenVibers committed 2 days ago · 5c145f2a54 · 5 files, +25 −6

* The panel's own files by their content hash, so a deploy never draws with a cached stylesheet

Bot #393 shipped new panel markup while the edge kept serving the old /panel/panel.css (max-age 300 with a day of
stale-while-revalidate), so a channel's Robot tab drew the new stick without its styles until the cache was purged.
page() now writes /panel/<file>?v=<first 10 of its sha1> for the stylesheet and every script; a deploy is a new
address. Tests read the versioned addresses.

* The phone page's test reads its versioned script address

Changes

ddiff --git a/server/web/render.js b/server/web/render.js
iindex 3d7cdf5..e475756 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -65,6 +65,24 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
++/*
++ * The panel's own files by their content (2026-11-11: Bot #393 shipped new markup, and the edge kept serving the old
++ * panel.css under its plain address for a while, so the new stick drew without its styles). Each address carries the
++ * file's hash, so a deploy is a new address and a cached copy can only ever be the right one.
++ */
++const _assetHash = new Map();
++function versioned(src) {
++ const m = /^\/panel\/([a-z0-9-]+\.(?:js|css))$/.exec(String(src));
++ if (!m) return src;
++ if (!_assetHash.has(m[1])) {
++ let h = '';
++ try { h = require('crypto').createHash('sha1').update(require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'public', m[1]))).digest('hex').slice(0, 10); } catch { h = ''; }
++ _assetHash.set(m[1], h);
++ }
++ const h = _assetHash.get(m[1]);
++ return h ? `${src}?v=${h}` : src;
++}
++
function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null, robots = null, htmlClass = '', htmlAttrs = '', colorScheme = 'dark' } = {}) {
return `<!doctype html>
<html lang="en"${htmlClass ? ` class="${esc(htmlClass)}"` : ''}${htmlAttrs}>
@@@ -83,11 +101,11 @@ function page(title, body, { scripts = [], bodyClass = '', description = null, c
<meta property="og:image" content="https://openvibe.bot${esc(social.url('bot'))}">
<meta name="twitter:card" content="summary_large_image">` : ''}
<link rel="stylesheet" href="${require('openvibe-shared/serve').url('kit.css')}">
--<link rel="stylesheet" href="/panel/panel.css">
++<link rel="stylesheet" href="${versioned('/panel/panel.css')}">
</head>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
${body}
--${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
++${scripts.map((s) => `<script src="${esc(versioned(s))}" defer></script>`).join('\n')}
</body>
</html>
`;
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 12ddc68..8802281 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -23,7 +23,8 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);
}
-- assert.ok(html.includes('<script src="/panel/panel.js" defer></script>'));
++ assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');
++ assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');
}
});
ddiff --git a/test/phone-device.test.js b/test/phone-device.test.js
iindex 58f3ede..4b33f38 100644
---- a/test/phone-device.test.js
++++ b/test/phone-device.test.js
@@@ -27,7 +27,7 @@ const { loadProfiles } = require('../server/profiles');
assert.doesNotMatch(csp, /esm\.sh|unsafe-inline/);
assert.match(r.headers.get('permissions-policy'), /camera=\(self\)/);
const html = await r.text();
-- assert.ok(html.includes('<script src="/panel/phone-device.js" defer></script>') && html.includes('data-pair-form') && html.includes('data-local-stop'));
++ assert.ok(/<script src="\/panel\/phone-device\.js\?v=[0-9a-f]{10}" defer><\/script>/.test(html) && html.includes('data-pair-form') && html.includes('data-local-stop'));
assert.doesNotMatch(html, /<script>|style="/, 'nothing inline');
assert.strictEqual((await get('/panel/phone-device.js')).status, 200);
});
ddiff --git a/test/stream-buttons.test.js b/test/stream-buttons.test.js
iindex 819bdb9..03ada51 100644
---- a/test/stream-buttons.test.js
++++ b/test/stream-buttons.test.js
@@@ -98,7 +98,7 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');
assert.ok(js.includes('const fromParent = framed && e.source === window.parent && PARENTS.includes(e.origin);'), 'takes it from that page only');
assert.ok(js.includes('const fromPopup = e.origin === location.origin && popup && e.source === popup;'), 'or from its own popup');
const refused = renderEmbedRefused({ robotId: 'rob_d1', parentOrigins: ['https://openvibe.live'] });
-- assert.ok(refused.includes('src="/panel/refused.js"') && refused.includes('data-parent-origins="https://openvibe.live"'));
++ assert.ok(refused.includes('src="/panel/refused.js?v=') && refused.includes('data-parent-origins="https://openvibe.live"'));
assert.ok(refused.includes('/panel/rob_d1#on-stream') && refused.includes('Put the buttons under my OpenVibe.Live stream'));
const rjs = fs.readFileSync(path.join(__dirname, '..', 'public', 'refused.js'), 'utf8');
assert.ok(rjs.includes("window.parent.postMessage({ type: 'ov-bot-embed-refused', robot_id: robotId }, origin)"));
ddiff --git a/test/try-sim.test.js b/test/try-sim.test.js
iindex da36dd4..18c3eec 100644
---- a/test/try-sim.test.js
++++ b/test/try-sim.test.js
@@@ -49,7 +49,7 @@ const { loadProfiles } = require('../server/profiles');
// The description fits a search result (at most 160 characters).
{ const d = (/<meta name="description" content="([^"]*)"/.exec(html) || [])[1] || ''; assert.ok(d.length >= 50 && d.length <= 160, `description ${d.length}: ${d}`); }
assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));
-- const scripts = [...html.matchAll(/<script src="([^"]+)" defer><\/script>/g)].map((m) => m[1]);
++ const scripts = [...html.matchAll(/<script src="([^"?]+)(?:\?v=[0-9a-f]{10})?" defer><\/script>/g)].map((m) => m[1]);
assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');
const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);
assert.ok(json, 'the profile rides along as data');