openvibers/openvibe.live Commits

Live declares its release components, so a stylesheet-only release is swapped into open tabs in place (D46 scenario 1; Host docs/release-acceptance.md gate 1e, which was open). server/web/release-components.js has three components. styles: every stylesheet under public/css, kind style, applied in place with no reload, so a broadcaster's desk or a viewer's player never reloads for a colour. shell: scripts, pages, fragments, the feature registry, the service worker, OBS overlays, server/web, server/seo and the lockfile (the /shared files come from the pin), kind script, prompted. server: the rest, nothing to do in tabs. Before, Live declared none, so every release prompted a reload. The release root is the current link (OV_APP_ROOT) and the manifest is rebuilt when files change (recheckMs, lazily, no timer). The release id is read from where current points at every rebuild, so a static-only switch, which does not restart the process, reports the release it is. test/release-components.test.js: a stylesheet changes styles only, a script the shell, server code server only, page-stamping server code the shell, and the real tree lists all its stylesheets. npm test 142/142.

OP OpenVibers committed 27 Sept 2026 · efab01493b · 3 files, +108 −5


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 88cc3e7a..e9a53856 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -152,13 +152,24 @@ const server = http.createServer(app);
// What this server runs (ADR-016): GET /release.json below, and release_info in /metrics.
// In the release layout (/opt/openvibe.live/releases/<time>-<sha8>, a root-owned git worktree) the
// service user cannot run git there, so the release id also comes from the directory's name.
--const release = require('openvibe-shared/release').createRelease({ service: 'live', root: path.join(__dirname, '..'), env: releaseEnv() });
++// Components (server/web/release-components.js): a styles-only release is swapped into open tabs in place; the
++// root is the `current` link (OV_APP_ROOT), and the manifest is rebuilt when files change (recheckMs), so a
++// static-only release switch, which does not restart this process, is reported as the release it is.
++const appRoot = process.env.OV_APP_ROOT || path.join(__dirname, '..');
++const release = require('openvibe-shared/release').createRelease({
++ service: 'live', root: appRoot, env: releaseEnv(), components: require('./web/release-components').componentsFor(appRoot), recheckMs: 30000,
++});
function releaseEnv() {
if (process.env.RELEASE_COMMIT) return process.env;
-- let dir = path.join(__dirname, '..');
-- try { dir = require('fs').realpathSync(dir); } catch { /* keep */ }
-- const m = /-([0-9a-f]{7,40})$/.exec(path.basename(dir));
-- return m ? { ...process.env, RELEASE_COMMIT: m[1] } : process.env;
++ // The release id is the directory `current` points at (…/releases/<time>-<sha8>), read at every rebuild.
++ const env = { ...process.env };
++ Object.defineProperty(env, 'RELEASE_COMMIT', { enumerable: true, get() {
++ let dir = appRoot;
++ try { dir = require('fs').realpathSync(appRoot); } catch { /* keep */ }
++ const m = /-([0-9a-f]{7,40})$/.exec(path.basename(dir));
++ return m ? m[1] : undefined;
++ } });
++ return env;
}
// Metrics first, so every request is counted (by route template, never by raw URL). GET /metrics
// answers direct loopback callers only; through nginx it is a 404 (server/web/observability.js).
ddiff --git a/server/web/release-components.js b/server/web/release-components.js
nnew file mode 100644
iindex 00000000..149aabf9
---- /dev/null
++++ b/server/web/release-components.js
@@@ -0,0 +1,49 @@
++'use strict';
++/**
++ * What an open tab does with each Live release (ADR-016, D43/D46 scenario 1; openvibe-shared/release components):
++ *
++ * styles every stylesheet under public/css (kind style): a styles-only release is swapped into open tabs in
++ * place, so a broadcaster's desk or a viewer's player is never reloaded for a colour or a margin
++ * shell every other file the page runs (kind script): scripts, pages, fragments, the feature registry, the
++ * service worker, the OBS overlays, the server code that stamps pages (server/web, server/seo) and the
++ * lockfile (the /shared files come from the openvibe-shared pin): a change prompts, never reloads mid-use
++ * server the rest of server/ (kind server): nothing to do in open tabs
++ * Images and other media need nothing. Before this, Live declared no components, so every release (a stylesheet
++ * included) prompted open tabs to reload (Host docs/release-acceptance.md, gate 1e).
++ */
++const fs = require('fs');
++const path = require('path');
++
++const SHELL_FILES = [
++ 'public/js', 'public/fragments', 'public/obs', 'public/index.html', 'public/features.json', 'public/service-worker.js',
++ 'public/live-notify.js', 'public/manifest.webmanifest', 'public/banned.html', 'public/dmca.html', 'public/kiosk.html',
++ 'public/media-player.html', 'public/popout-chat.html', 'public/privacy.html', 'public/tos.html', 'public/whip-publisher.html',
++ 'server/web', 'server/seo', 'package-lock.json',
++];
++
++/** Every stylesheet under <root>/public/css as a URL path (/css/…), sorted. */
++function stylesheets(root) {
++ const base = path.join(root, 'public');
++ const out = [];
++ const walk = (dir) => {
++ let entries = [];
++ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
++ for (const e of entries) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.css')) out.push('/' + path.relative(base, p).split(path.sep).join('/'));
++ }
++ };
++ walk(path.join(base, 'css'));
++ return out.sort();
++}
++
++function componentsFor(root) {
++ return {
++ styles: { kind: 'style', assets: stylesheets(root) },
++ shell: { kind: 'script', files: SHELL_FILES.filter((f) => fs.existsSync(path.join(root, f))) },
++ server: { kind: 'server', files: ['server'] },
++ };
++}
++
++module.exports = { componentsFor, stylesheets, SHELL_FILES };
ddiff --git a/test/release-components.test.js b/test/release-components.test.js
nnew file mode 100644
iindex 00000000..b03644f1
---- /dev/null
++++ b/test/release-components.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// Live's release components (server/web/release-components.js; Host docs/release-acceptance.md gate 1e): a
++// stylesheet-only release changes only `styles` (kind style: open tabs swap it in place, nothing reloads), a script
++// or page change changes `shell` (prompted), server code only `server`; the real tree lists every stylesheet.
++// node test/release-components.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { createRelease } = require('openvibe-shared/release');
++const { componentsFor, stylesheets } = require('../server/web/release-components');
++
++const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rel-'));
++const put = (p, s) => { fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true }); fs.writeFileSync(path.join(root, p), s); };
++put('public/css/style.css', 'body{color:red}');
++put('public/css/features/chat.css', '.c{}');
++put('public/js/app.js', 'console.log(1)');
++put('public/index.html', '<html></html>');
++put('server/index.js', 'module.exports=1');
++put('server/web/assets.js', 'module.exports=2');
++put('package-lock.json', '{}');
++try {
++ const rel = createRelease({ service: 'live', root, env: { RELEASE_COMMIT: 'abcdef1' }, components: componentsFor(root), recheckMs: 0 });
++ const v = () => { const m = rel.refresh ? rel.refresh() : rel.full(); const c = (m && m.components) || rel.full().components; return { styles: c.styles.version, shell: c.shell.version, server: c.server.version, kinds: [c.styles.kind, c.shell.kind, c.server.kind] }; };
++ const a = v();
++ assert.deepStrictEqual(a.kinds, ['style', 'script', 'server']);
++ put('public/css/style.css', 'body{color:blue}');
++ const b = v();
++ assert.deepStrictEqual([b.styles !== a.styles, b.shell === a.shell, b.server === a.server], [true, true, true], 'a stylesheet changes styles only');
++ put('public/js/app.js', 'console.log(2)');
++ const c = v();
++ assert.deepStrictEqual([c.shell !== b.shell, c.styles === b.styles], [true, true], 'a script changes the shell');
++ put('server/index.js', 'module.exports=3');
++ const d = v();
++ assert.deepStrictEqual([d.server !== c.server, d.shell === c.shell, d.styles === c.styles], [true, true, true], 'server-only code changes server only');
++ put('server/web/assets.js', 'module.exports=4');
++ assert.notStrictEqual(v().shell, d.shell, 'the page-stamping server code is shell');
++ // The real tree: every stylesheet, and the files named exist.
++ const real = componentsFor(path.join(__dirname, '..'));
++ assert.ok(real.styles.assets.includes('/css/style.css') && real.styles.assets.length === stylesheets(path.join(__dirname, '..')).length && real.styles.assets.length >= 20, `${real.styles.assets.length} stylesheets`);
++ assert.ok(real.shell.files.includes('public/js') && real.shell.files.includes('server/web'));
++} finally { fs.rmSync(root, { recursive: true, force: true }); }
++console.log('release components: all checks passed');