openvibers/openvibe.live Commits
A tab left open across a deploy loads its next page in full, and an openvibe.video address glued onto Live's redirects to it (the owner's phone tab from before recordings moved ran the old router, which put a VOD's new openvibe.video link under openvibe.live/: Page not found) (#268)
Changes
@@@ -874,14 +874,8 @@ function dashNav(event) {/* ── SPA Router (URL-based) ───────────────────────────────────── *//**-- * Everything that must stop when we leave a route.-- *-- * This used to live inside navigate(), which meant the browser's Back and Forward buttons —-- * which go through popstate → routeFromURL() and never touch navigate() — left everything-- * running: the chat WebSocket still bound to the previous channel, the coin heartbeat still-- * crediting watch time on a page with nothing playing, the canvas socket and its timer, the-- * global AI poll, the AI-viewer loops, and the broadcast desk's five polls. Every Back-- * press added another set. Now both paths go through here.++ * Everything that must stop when we leave a route, by a link (navigate()) or by Back and Forward++ * (popstate → routeFromURL()), which used to leave the chat socket, coin heartbeat and polls running.*/function teardownRoute(nextPath) {// A new route generation: anything the previous route registered through ov.scope() is@@@ -890,9 +884,6 @@ function teardownRoute(nextPath) {ovClearStrandedOverlays();if (typeof destroyPlayer === 'function') destroyPlayer();if (typeof destroyChat === 'function') destroyChat();-- /* destroyCanvasPage lived in js/canvas.js, which index.html has not loaded since canvas moved-- to OpenVibe.Games — so this guard has been permanently false. Removed rather than left as a-- line that looks like cleanup and never runs. */if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);if (typeof stopHomeRefresh === 'function') stopHomeRefresh();@@@ -931,11 +922,41 @@ function teardownRoute(nextPath) {}}++/**++ * A tab open across a deploy runs old code on new API data (10-20: an old router put a VOD's openvibe.video link under++ * Live's address, "Page not found"). Back in view or on a route change, at most every 5 min, it asks for the current++ * client release (server/web/assets.js); when it differs, the next link is a full load, once per release, never mid++ * broadcast or call.++ */++const _ovRelease = { loaded: null, stale: null, checkedAt: Date.now() };++try { _ovRelease.loaded = JSON.parse(document.getElementById('ov-assets').textContent).release || null; } catch { /* */ }++function ovCheckRelease() {++ if (!_ovRelease.loaded || _ovRelease.stale || document.visibilityState === 'hidden' || Date.now() - _ovRelease.checkedAt < 3e5) return;++ _ovRelease.checkedAt = Date.now();++ fetch('/api/client-release', { cache: 'no-store' }).then((r) => r.json()).then(({ release }) => {++ let tried = null;++ try { tried = sessionStorage.getItem('ov-release-load'); } catch { /* */ }++ if (release && release !== _ovRelease.loaded && release !== tried) _ovRelease.stale = release;++ }).catch(() => {});++}++document.addEventListener('visibilitychange', ovCheckRelease);++window.addEventListener('pageshow', (e) => { if (e.persisted) ovCheckRelease(); });++function _ovHoldsMedia() {++ try { if ([...broadcastState.streams.values()].some((s) => s.localStream || s.signalingWs || s.vodUploading)) return true; } catch { /* */ }++ try { return !!(callState.joined || callState.connecting); } catch { return false; }++}++/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);function navigate(urlPath, replace = false) {if (isOffSite(urlPath)) { if (replace) location.replace(urlPath); else location.href = urlPath; return; }++ if (_ovRelease.stale && !_ovHoldsMedia()) {++ const to = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;++ try { sessionStorage.setItem('ov-release-load', _ovRelease.stale); } catch { /* */ }++ if (replace) location.replace(to); else location.assign(to);++ return;++ }teardownRoute(urlPath);// Normalize path@@@ -949,6 +970,7 @@ function navigate(urlPath, replace = false) {}routeFromURL();++ ovCheckRelease();}const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';@@@ -1217,18 +1239,9 @@ const BRAND_AWAKE_MS = 5000;let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;/**-- * Wake the OV mark for a moment, then let it go still.-- *-- * The mark ran six looping animations — a float, a glow pulse, a comet sweep, a dot pulse and two-- * SMIL orbit motions, plus a sweeping gradient — forever, on every page. That is a composited-- * repaint every frame for the entire time someone sits reading, which is most of the time they-- * are here. It now animates when there is a reason to: the route changed, so the mark spins once-- * and stays lively for five seconds, then settles with an overshoot and holds still until the-- * next change.-- *-- * CSS loops stop via animation-play-state, which freezes them on their 0% keyframe — every one of-- * those is a sensible rest pose. SMIL cannot be paused from CSS at all, so the SVG element's own-- * pauseAnimations()/unpauseAnimations() handles the orbit dots and the gradient sweep.++ * Wake the OV mark for a moment, then let it go still: a route change spins it once and keeps it++ * lively for five seconds (looping forever repainted every frame while people read). CSS loops stop++ * via animation-play-state on their 0% rest pose; SMIL needs the SVG's pauseAnimations().*/function ovWakeBrandMark() {let marks;@@@ -820,6 +820,13 @@ app.get('/api/admin/analytics/bots', requireAuth, permissions.requireAdmin, asyn}});++// Which client code is current (server/web/assets.js clientRelease): a tab open across a deploy asks, and loads its next++// page in full when it is running older code (public/js/app.js).++app.get('/api/client-release', (req, res) => {++ res.set('Cache-Control', 'no-store');++ res.json({ release: assets.clientRelease() });++});++// ── Health Check ─────────────────────────────────────────────app.get('/api/health', (req, res) => {res.json({@@@ -171,6 +171,16 @@ function manifest() {return out;}++/**++ * One id for the client as a whole: the hash of every hash in the manifest, so it changes when any page script,++ * stylesheet or fragment does. The shell carries the one it was rendered with (the ov-assets block) and++ * GET /api/client-release answers the current one: a tab left open across a deploy compares the two (public/js/app.js).++ */++function clientRelease() {++ const m = manifest();++ return sha(Object.keys(m).sort().map((k) => `${k} ${m[k]}`).join('\n'));++}++/** public/features.json — the feature/route registry shared with public/js/ov-loader.js. */let _features = null, _featuresMtime = 0, _featuresAt = 0;function features() {@@@ -234,6 +244,7 @@ function rewriteHtml(html) {const reg = features();const payload = {v: manifest(),++ release: clientRelease(),features: reg.features,routes: reg.routes.map(({ path: p, features: f }) => ({ path: p, features: f })),};@@@ -433,7 +444,7 @@ function staticHeaders(res) {module.exports = {PUBLIC_DIR, MANIFEST_MARK,-- setSharedDir, hashOf, versioned, manifest, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,++ setSharedDir, hashOf, versioned, manifest, clientRelease, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,_findPrevious: findPrevious,};@@@ -11,7 +11,8 @@** /vods and /clips stay on Live: they are the Content feed with a filter (public/js/content-feed.js), linking out.*/--const { videoUrl } = require('./video-url');++const { videoUrl, VIDEO_BASE } = require('./video-url');++const { communityUrl } = require('./paste-handover');const ID_RE = /^\d{1,15}$/;/** `?t=` as seconds: 95, or 1:35, or 1:01:35. */@@@ -22,8 +23,28 @@ function secondsOf(t) {return m ? Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) : null;}--/** Mount the /vod/:id and /clip/:id redirects to OpenVibe.Video (anything not an id falls through to Live's 404). */++// A whole OpenVibe.Video or OpenVibe.Community address glued onto Live's: /https://openvibe.video/v/12 (or++// /https:/openvibe.video/v/12, once a proxy merges the slashes). A tab opened before the handover kept its old router,++// which put any link it was handed under '/', and the API now hands it these absolute links.++const GLUED_RE = /^\/https?:\/\/?([^/?#]+)(\/[^?#]*)?$/i;++++/** Where a glued path really points, on one of the two sites Live hands pages to; null for anything else. */++function unglue(pathname, search = '') {++ const m = GLUED_RE.exec(String(pathname || ''));++ if (!m) return null;++ const host = m[1].toLowerCase();++ const origin = [VIDEO_BASE, communityUrl()].find((o) => { try { return new URL(o).host === host; } catch { return false; } });++ return origin ? `${origin}${m[2] || '/'}${search}` : null;++}++++/** Mount the /vod/:id and /clip/:id redirects to OpenVibe.Video (anything not an id falls through to Live's 404), and++ * the glued addresses' redirects to the page they name. */function register(app) {++ app.get(/^\/https?:\//i, (req, res, next) => {++ const q = req.originalUrl.indexOf('?');++ const to = unglue(req.path, q >= 0 ? req.originalUrl.slice(q) : '');++ return to ? res.redirect(301, to) : next();++ });for (const [prefix, kind] of [['vod', 'vod'], ['clip', 'clip']]) {app.get([`/${prefix}/:id`, `/${prefix}/:id/`], (req, res, next) => {const to = ID_RE.test(req.params.id) ? videoUrl(kind, req.params.id, secondsOf(req.query && req.query.t)) : null;@@@ -32,4 +53,4 @@ function register(app) {}}--module.exports = { register, secondsOf };++module.exports = { register, secondsOf, unglue };@@@ -69,6 +69,8 @@ async function check(name, fn) {const json = JSON.parse(m[1]);assert.strictEqual(json.v['/js/app.js'], NEW);assert.strictEqual(json.v['/css/style.css'], CSS);++ assert.match(json.release, /^[0-9a-f]{12}$/, 'the client release the shell was rendered with');++ assert.strictEqual(json.release, assets.clientRelease());});await check('a path cannot escape public/', () => {@@@ -78,11 +80,14 @@ async function check(name, fn) {await check('document version changes when a referenced asset changes', async () => {const before = assets.document('index.html').version;++ const releaseBefore = assets.clientRelease();fs.writeFileSync(path.join(rel('new'), 'css', 'style.css'), 'body{color:blue}');await new Promise((r) => setTimeout(r, 2100));const after = assets.document('index.html');assert.notStrictEqual(after.version, before);assert.ok(after.html.includes(`/css/style.css?v=${sha('body{color:blue}')}`));++ assert.notStrictEqual(assets.clientRelease(), releaseBefore, 'any changed asset is a new client release');++ assert.ok(after.html.includes(`"release":"${assets.clientRelease()}"`), 'and the shell carries it');});// A real express app, because the cache policy lives in response headers.nnew file mode 100644@@@ -0,0 +1,150 @@++/**++ * A tab left open across a deploy loads its next page in full (public/js/app.js, _ovRelease and navigate()).++ *++ * On 2026-10-20 a phone tab opened before recordings moved to openvibe.video got https://openvibe.video/v/5857 as a VOD's++ * link from the new API, and its old navigate() put that under Live's address: openvibe.live/https://openvibe.video/v/…,++ * "Page not found". The shell now carries its client release (the ov-assets block, server/web/assets.js) and the tab++ * compares it with GET /api/client-release when it comes back into view or changes route. Runs app.js's release block++ * and navigate() in a sandbox with a fake document, history, location and fetch.++ *++ * node test/stale-tab-release.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');++const start = src.indexOf('const _ovRelease = ');++const end = src.indexOf('const DEFAULT_PAGE_TITLE');++assert.ok(start > 0 && end > start, 'the release block and navigate() are in app.js, in that order');++const block = src.slice(start, end);++++function sandbox({ loaded = 'aaaaaaaaaaaa', current = 'bbbbbbbbbbbb', stored = null } = {}) {++ const calls = [];++ const listeners = {};++ const store = new Map(stored ? [['ov-release-load', stored]] : []);++ const ctx = {++ calls,++ console,++ Date,++ document: {++ visibilityState: 'visible',++ getElementById: (id) => (id === 'ov-assets' && loaded ? { textContent: JSON.stringify({ v: {}, release: loaded }) } : null),++ addEventListener: (type, fn) => { listeners[type] = fn; },++ },++ window: { addEventListener: (type, fn) => { listeners[`window:${type}`] = fn; } },++ location: {++ origin: 'https://openvibe.live',++ assign: (u) => calls.push(['assign', u]),++ replace: (u) => calls.push(['replace', u]),++ set href(u) { calls.push(['href', u]); },++ },++ history: {++ pushState: (s, t, u) => calls.push(['push', u]),++ replaceState: (s, t, u) => calls.push(['replaceState', u]),++ },++ sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },++ fetch: async (url) => { calls.push(['fetch', url]); return { ok: true, json: async () => ({ release: current }) }; },++ teardownRoute: () => calls.push(['teardown']),++ routeFromURL: () => calls.push(['route']),++ };++ vm.createContext(ctx);++ vm.runInContext(block, ctx);++ return {++ ctx, calls, listeners, store,++ run: (code) => vm.runInContext(code, ctx),++ // As if five minutes had gone by since the last check.++ later: () => vm.runInContext('_ovRelease.checkedAt = 0', ctx),++ settle: () => new Promise((r) => setImmediate(r)),++ };++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }++}++++(async () => {++ await check('an up-to-date tab switches routes in place, and off-site links leave', async () => {++ const t = sandbox({ current: 'aaaaaaaaaaaa' });++ t.later();++ t.listeners.visibilitychange();++ await t.settle();++ t.run("navigate('/content?type=vods')");++ t.run("navigate('https://openvibe.video/v/5857')");++ assert.deepStrictEqual(t.calls, [['fetch', '/api/client-release'], ['teardown'], ['push', '/content?type=vods'], ['route'], ['href', 'https://openvibe.video/v/5857']]);++ });++++ await check('a tab that comes back into view running an older client loads its next page in full, once', async () => {++ const t = sandbox();++ t.later();++ t.listeners.visibilitychange();++ await t.settle();++ t.run("navigate('/@goosely')");++ assert.deepStrictEqual(t.calls.slice(1), [['assign', '/@goosely']], 'no route switch on old code');++ assert.strictEqual(t.store.get('ov-release-load'), 'bbbbbbbbbbbb');++ t.calls.length = 0;++ t.run("navigate('/vods', true)");++ assert.deepStrictEqual(t.calls, [['replace', '/vods']]);++ });++++ await check('it asks at most every five minutes, and not while hidden', async () => {++ const t = sandbox();++ t.listeners.visibilitychange();++ t.run("navigate('/a')");++ await t.settle();++ assert.ok(!t.calls.some((c) => c[0] === 'fetch'), 'just loaded: no check');++ t.later();++ t.ctx.document.visibilityState = 'hidden';++ t.listeners.visibilitychange();++ await t.settle();++ assert.ok(!t.calls.some((c) => c[0] === 'fetch'), 'hidden: no check');++ t.ctx.document.visibilityState = 'visible';++ t.run("navigate('/b')");++ await t.settle();++ assert.strictEqual(t.calls.filter((c) => c[0] === 'fetch').length, 1, 'a route change checks too');++ t.calls.length = 0;++ t.run("navigate('/c')");++ assert.deepStrictEqual(t.calls, [['assign', '/c']]);++ });++++ await check('never while the tab holds a broadcast or a call', async () => {++ for (const holder of [++ 'var broadcastState = { streams: new Map([[1, { localStream: {} }]]) };',++ 'var callState = { joined: true, connecting: false };',++ ]) {++ const t = sandbox();++ t.run(holder);++ t.later();++ t.listeners.visibilitychange();++ await t.settle();++ t.calls.length = 0;++ t.run("navigate('/chat')");++ assert.deepStrictEqual(t.calls, [['teardown'], ['push', '/chat'], ['route']], holder);++ }++ });++++ await check('a shell that still disagrees after the full load is not reloaded again', async () => {++ const t = sandbox({ stored: 'bbbbbbbbbbbb' });++ t.later();++ t.listeners.visibilitychange();++ await t.settle();++ t.calls.length = 0;++ t.run("navigate('/moments')");++ assert.deepStrictEqual(t.calls, [['teardown'], ['push', '/moments'], ['route']]);++ });++++ await check('a page without the manifest never asks', async () => {++ const t = sandbox({ loaded: null });++ t.later();++ t.listeners.visibilitychange();++ t.listeners['window:pageshow']({ persisted: true });++ await t.settle();++ assert.deepStrictEqual(t.calls, []);++ });++++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }++ console.log('\nstale tab release: all checks passed');++})();@@@ -1,7 +1,8 @@/*** Recordings and clips are watched on openvibe.video (server/web/video-handover.js): /vod/<id> and /clip/<id> answer a* 301 to the OpenVibe.Video page, with a ?t= start time as seconds (95, 1:35 or 0:01:35); anything that is not an id-- * falls through to Live's own 404; the SPA shell names the origin in its ov-video-base meta (public/js/app.js videoHref),++ * falls through to Live's own 404; an OpenVibe.Video or Community address glued onto Live's (/https://openvibe.video/v/12,++ * what a tab opened before the handover made of the new links) 301s to the page it names; the SPA shell names the origin in its ov-video-base meta (public/js/app.js videoHref),* and no client code links a /vod/ or /clip/ page any more.** node test/video-handover.test.js@@@ -36,6 +37,16 @@ process.env.NODE_ENV = 'test';assert.deepStrictEqual(await get('/vod/12?t=soon'), { status: 301, location: 'https://openvibe.video/v/12' }, 'a junk start time is dropped');});++ await check('a whole OpenVibe.Video or Community address glued onto Live\'s (an old tab\'s router did that) goes to the page it names', async () => {++ assert.deepStrictEqual(await get('/https://openvibe.video/v/5857'), { status: 301, location: 'https://openvibe.video/v/5857' });++ assert.deepStrictEqual(await get('/https:/openvibe.video/v/5857?t=95'), { status: 301, location: 'https://openvibe.video/v/5857?t=95' }, 'merged slashes, start time kept');++ assert.deepStrictEqual(await get('/https://openvibe.video/c/7'), { status: 301, location: 'https://openvibe.video/c/7' });++ assert.deepStrictEqual(await get('/https://openvibe.community/p/abc'), { status: 301, location: 'https://openvibe.community/p/abc' });++ assert.strictEqual((await get('/https://evil.example/v/1')).status, 404, 'any other host is no redirect');++ assert.strictEqual((await get('/https://openvibe.video.evil.example/v/1')).status, 404);++ assert.strictEqual((await get('/https:openvibe.video/v/1')).status, 404);++ });++await check('what is not an id stays Live\'s 404', async () => {assert.strictEqual((await get('/vod/abc')).status, 404);assert.strictEqual((await get('/vod/0')).status, 404);
@@ -874,14 +874,8 @@ function dashNav(event) {
@@ -874,14 +874,8 @@ function dashNav(event) {
/* ── SPA Router (URL-based) ───────────────────────────────────── */
/* ── SPA Router (URL-based) ───────────────────────────────────── */
/**
/**
- * Everything that must stop when we leave a route.
- *
- * This used to live inside navigate(), which meant the browser's Back and Forward buttons —
- * which go through popstate → routeFromURL() and never touch navigate() — left everything
- * running: the chat WebSocket still bound to the previous channel, the coin heartbeat still
- * crediting watch time on a page with nothing playing, the canvas socket and its timer, the
- * global AI poll, the AI-viewer loops, and the broadcast desk's five polls. Every Back
- * press added another set. Now both paths go through here.
+ * Everything that must stop when we leave a route, by a link (navigate()) or by Back and Forward
+ * (popstate → routeFromURL()), which used to leave the chat socket, coin heartbeat and polls running.
*/
*/
function teardownRoute(nextPath) {
function teardownRoute(nextPath) {
// A new route generation: anything the previous route registered through ov.scope() is
// A new route generation: anything the previous route registered through ov.scope() is
@@ -890,9 +884,6 @@ function teardownRoute(nextPath) {
@@ -890,9 +884,6 @@ function teardownRoute(nextPath) {
ovClearStrandedOverlays();
ovClearStrandedOverlays();
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyChat === 'function') destroyChat();
- /* destroyCanvasPage lived in js/canvas.js, which index.html has not loaded since canvas moved
- to OpenVibe.Games — so this guard has been permanently false. Removed rather than left as a
- line that looks like cleanup and never runs. */
if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();
if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);
if (typeof stopHomeRefresh === 'function') stopHomeRefresh();
if (typeof stopHomeRefresh === 'function') stopHomeRefresh();
@@ -931,11 +922,41 @@ function teardownRoute(nextPath) {
@@ -931,11 +922,41 @@ function teardownRoute(nextPath) {
}
}
}
}
+/**
+ * A tab open across a deploy runs old code on new API data (10-20: an old router put a VOD's openvibe.video link under
+ * Live's address, "Page not found"). Back in view or on a route change, at most every 5 min, it asks for the current
+ * client release (server/web/assets.js); when it differs, the next link is a full load, once per release, never mid
+ * broadcast or call.
+ */
+const _ovRelease = { loaded: null, stale: null, checkedAt: Date.now() };
+try { _ovRelease.loaded = JSON.parse(document.getElementById('ov-assets').textContent).release || null; } catch { /* */ }
+function ovCheckRelease() {
+ if (!_ovRelease.loaded || _ovRelease.stale || document.visibilityState === 'hidden' || Date.now() - _ovRelease.checkedAt < 3e5) return;
+ _ovRelease.checkedAt = Date.now();
+ fetch('/api/client-release', { cache: 'no-store' }).then((r) => r.json()).then(({ release }) => {
+ let tried = null;
+ try { tried = sessionStorage.getItem('ov-release-load'); } catch { /* */ }
+ if (release && release !== _ovRelease.loaded && release !== tried) _ovRelease.stale = release;
+ }).catch(() => {});
+}
+document.addEventListener('visibilitychange', ovCheckRelease);
+window.addEventListener('pageshow', (e) => { if (e.persisted) ovCheckRelease(); });
+function _ovHoldsMedia() {
+ try { if ([...broadcastState.streams.values()].some((s) => s.localStream || s.signalingWs || s.vodUploading)) return true; } catch { /* */ }
+ try { return !!(callState.joined || callState.connecting); } catch { return false; }
+}
+
/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */
/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */
const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);
const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);
function navigate(urlPath, replace = false) {
function navigate(urlPath, replace = false) {
if (isOffSite(urlPath)) { if (replace) location.replace(urlPath); else location.href = urlPath; return; }
if (isOffSite(urlPath)) { if (replace) location.replace(urlPath); else location.href = urlPath; return; }
+ if (_ovRelease.stale && !_ovHoldsMedia()) {
+ const to = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
+ try { sessionStorage.setItem('ov-release-load', _ovRelease.stale); } catch { /* */ }
+ if (replace) location.replace(to); else location.assign(to);
+ return;
+ }
teardownRoute(urlPath);
teardownRoute(urlPath);
// Normalize path
// Normalize path
@@ -949,6 +970,7 @@ function navigate(urlPath, replace = false) {
@@ -949,6 +970,7 @@ function navigate(urlPath, replace = false) {
}
}
routeFromURL();
routeFromURL();
+ ovCheckRelease();
}
}
const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';
const DEFAULT_PAGE_TITLE = 'OpenVibe.Live — Open-Source Live Streaming, Community Run';
@@ -1217,18 +1239,9 @@ const BRAND_AWAKE_MS = 5000;
@@ -1217,18 +1239,9 @@ const BRAND_AWAKE_MS = 5000;
let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;
let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;
/**
/**
- * Wake the OV mark for a moment, then let it go still.
- *
- * The mark ran six looping animations — a float, a glow pulse, a comet sweep, a dot pulse and two
- * SMIL orbit motions, plus a sweeping gradient — forever, on every page. That is a composited
- * repaint every frame for the entire time someone sits reading, which is most of the time they
- * are here. It now animates when there is a reason to: the route changed, so the mark spins once
- * and stays lively for five seconds, then settles with an overshoot and holds still until the
- * next change.
- *
- * CSS loops stop via animation-play-state, which freezes them on their 0% keyframe — every one of
- * those is a sensible rest pose. SMIL cannot be paused from CSS at all, so the SVG element's own
- * pauseAnimations()/unpauseAnimations() handles the orbit dots and the gradient sweep.
+ * Wake the OV mark for a moment, then let it go still: a route change spins it once and keeps it
+ * lively for five seconds (looping forever repainted every frame while people read). CSS loops stop
+ * via animation-play-state on their 0% rest pose; SMIL needs the SVG's pauseAnimations().
*/
*/
function ovWakeBrandMark() {
function ovWakeBrandMark() {
let marks;
let marks;
@@ -820,6 +820,13 @@ app.get('/api/admin/analytics/bots', requireAuth, permissions.requireAdmin, asyn
@@ -820,6 +820,13 @@ app.get('/api/admin/analytics/bots', requireAuth, permissions.requireAdmin, asyn
}
}
});
});
+// Which client code is current (server/web/assets.js clientRelease): a tab open across a deploy asks, and loads its next
+// page in full when it is running older code (public/js/app.js).
+app.get('/api/client-release', (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ res.json({ release: assets.clientRelease() });
+});
+
// ── Health Check ─────────────────────────────────────────────
// ── Health Check ─────────────────────────────────────────────
app.get('/api/health', (req, res) => {
app.get('/api/health', (req, res) => {
res.json({
res.json({
@@ -171,6 +171,16 @@ function manifest() {
@@ -171,6 +171,16 @@ function manifest() {
return out;
return out;
}
}
+/**
+ * One id for the client as a whole: the hash of every hash in the manifest, so it changes when any page script,
+ * stylesheet or fragment does. The shell carries the one it was rendered with (the ov-assets block) and
+ * GET /api/client-release answers the current one: a tab left open across a deploy compares the two (public/js/app.js).
+ */
+function clientRelease() {
+ const m = manifest();
+ return sha(Object.keys(m).sort().map((k) => `${k} ${m[k]}`).join('\n'));
+}
+
/** public/features.json — the feature/route registry shared with public/js/ov-loader.js. */
/** public/features.json — the feature/route registry shared with public/js/ov-loader.js. */
let _features = null, _featuresMtime = 0, _featuresAt = 0;
let _features = null, _featuresMtime = 0, _featuresAt = 0;
function features() {
function features() {
@@ -234,6 +244,7 @@ function rewriteHtml(html) {
@@ -234,6 +244,7 @@ function rewriteHtml(html) {
const reg = features();
const reg = features();
const payload = {
const payload = {
v: manifest(),
v: manifest(),
+ release: clientRelease(),
features: reg.features,
features: reg.features,
routes: reg.routes.map(({ path: p, features: f }) => ({ path: p, features: f })),
routes: reg.routes.map(({ path: p, features: f }) => ({ path: p, features: f })),
};
};
@@ -433,7 +444,7 @@ function staticHeaders(res) {
@@ -433,7 +444,7 @@ function staticHeaders(res) {
module.exports = {
module.exports = {
PUBLIC_DIR, MANIFEST_MARK,
PUBLIC_DIR, MANIFEST_MARK,
- setSharedDir, hashOf, versioned, manifest, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,
+ setSharedDir, hashOf, versioned, manifest, clientRelease, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,
versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,
versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,
_findPrevious: findPrevious,
_findPrevious: findPrevious,
};
};
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
*
*
* /vods and /clips stay on Live: they are the Content feed with a filter (public/js/content-feed.js), linking out.
* /vods and /clips stay on Live: they are the Content feed with a filter (public/js/content-feed.js), linking out.
*/
*/
-const { videoUrl } = require('./video-url');
+const { videoUrl, VIDEO_BASE } = require('./video-url');
+const { communityUrl } = require('./paste-handover');
const ID_RE = /^\d{1,15}$/;
const ID_RE = /^\d{1,15}$/;
/** `?t=` as seconds: 95, or 1:35, or 1:01:35. */
/** `?t=` as seconds: 95, or 1:35, or 1:01:35. */
@@ -22,8 +23,28 @@ function secondsOf(t) {
@@ -22,8 +23,28 @@ function secondsOf(t) {
return m ? Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) : null;
return m ? Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) : null;
}
}
-/** Mount the /vod/:id and /clip/:id redirects to OpenVibe.Video (anything not an id falls through to Live's 404). */
+// A whole OpenVibe.Video or OpenVibe.Community address glued onto Live's: /https://openvibe.video/v/12 (or
+// /https:/openvibe.video/v/12, once a proxy merges the slashes). A tab opened before the handover kept its old router,
+// which put any link it was handed under '/', and the API now hands it these absolute links.
+const GLUED_RE = /^\/https?:\/\/?([^/?#]+)(\/[^?#]*)?$/i;
+
+/** Where a glued path really points, on one of the two sites Live hands pages to; null for anything else. */
+function unglue(pathname, search = '') {
+ const m = GLUED_RE.exec(String(pathname || ''));
+ if (!m) return null;
+ const host = m[1].toLowerCase();
+ const origin = [VIDEO_BASE, communityUrl()].find((o) => { try { return new URL(o).host === host; } catch { return false; } });
+ return origin ? `${origin}${m[2] || '/'}${search}` : null;
+}
+
+/** Mount the /vod/:id and /clip/:id redirects to OpenVibe.Video (anything not an id falls through to Live's 404), and
+ * the glued addresses' redirects to the page they name. */
function register(app) {
function register(app) {
+ app.get(/^\/https?:\//i, (req, res, next) => {
+ const q = req.originalUrl.indexOf('?');
+ const to = unglue(req.path, q >= 0 ? req.originalUrl.slice(q) : '');
+ return to ? res.redirect(301, to) : next();
+ });
for (const [prefix, kind] of [['vod', 'vod'], ['clip', 'clip']]) {
for (const [prefix, kind] of [['vod', 'vod'], ['clip', 'clip']]) {
app.get([`/${prefix}/:id`, `/${prefix}/:id/`], (req, res, next) => {
app.get([`/${prefix}/:id`, `/${prefix}/:id/`], (req, res, next) => {
const to = ID_RE.test(req.params.id) ? videoUrl(kind, req.params.id, secondsOf(req.query && req.query.t)) : null;
const to = ID_RE.test(req.params.id) ? videoUrl(kind, req.params.id, secondsOf(req.query && req.query.t)) : null;
@@ -32,4 +53,4 @@ function register(app) {
@@ -32,4 +53,4 @@ function register(app) {
}
}
}
}
-module.exports = { register, secondsOf };
+module.exports = { register, secondsOf, unglue };
@@ -69,6 +69,8 @@ async function check(name, fn) {
@@ -69,6 +69,8 @@ async function check(name, fn) {
const json = JSON.parse(m[1]);
const json = JSON.parse(m[1]);
assert.strictEqual(json.v['/js/app.js'], NEW);
assert.strictEqual(json.v['/js/app.js'], NEW);
assert.strictEqual(json.v['/css/style.css'], CSS);
assert.strictEqual(json.v['/css/style.css'], CSS);
+ assert.match(json.release, /^[0-9a-f]{12}$/, 'the client release the shell was rendered with');
+ assert.strictEqual(json.release, assets.clientRelease());
});
});
await check('a path cannot escape public/', () => {
await check('a path cannot escape public/', () => {
@@ -78,11 +80,14 @@ async function check(name, fn) {
@@ -78,11 +80,14 @@ async function check(name, fn) {
await check('document version changes when a referenced asset changes', async () => {
await check('document version changes when a referenced asset changes', async () => {
const before = assets.document('index.html').version;
const before = assets.document('index.html').version;
+ const releaseBefore = assets.clientRelease();
fs.writeFileSync(path.join(rel('new'), 'css', 'style.css'), 'body{color:blue}');
fs.writeFileSync(path.join(rel('new'), 'css', 'style.css'), 'body{color:blue}');
await new Promise((r) => setTimeout(r, 2100));
await new Promise((r) => setTimeout(r, 2100));
const after = assets.document('index.html');
const after = assets.document('index.html');
assert.notStrictEqual(after.version, before);
assert.notStrictEqual(after.version, before);
assert.ok(after.html.includes(`/css/style.css?v=${sha('body{color:blue}')}`));
assert.ok(after.html.includes(`/css/style.css?v=${sha('body{color:blue}')}`));
+ assert.notStrictEqual(assets.clientRelease(), releaseBefore, 'any changed asset is a new client release');
+ assert.ok(after.html.includes(`"release":"${assets.clientRelease()}"`), 'and the shell carries it');
});
});
// A real express app, because the cache policy lives in response headers.
// A real express app, because the cache policy lives in response headers.
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+/**
+ * A tab left open across a deploy loads its next page in full (public/js/app.js, _ovRelease and navigate()).
+ *
+ * On 2026-10-20 a phone tab opened before recordings moved to openvibe.video got https://openvibe.video/v/5857 as a VOD's
+ * link from the new API, and its old navigate() put that under Live's address: openvibe.live/https://openvibe.video/v/…,
+ * "Page not found". The shell now carries its client release (the ov-assets block, server/web/assets.js) and the tab
+ * compares it with GET /api/client-release when it comes back into view or changes route. Runs app.js's release block
+ * and navigate() in a sandbox with a fake document, history, location and fetch.
+ *
+ * node test/stale-tab-release.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
+const start = src.indexOf('const _ovRelease = ');
+const end = src.indexOf('const DEFAULT_PAGE_TITLE');
+assert.ok(start > 0 && end > start, 'the release block and navigate() are in app.js, in that order');
+const block = src.slice(start, end);
+
+function sandbox({ loaded = 'aaaaaaaaaaaa', current = 'bbbbbbbbbbbb', stored = null } = {}) {
+ const calls = [];
+ const listeners = {};
+ const store = new Map(stored ? [['ov-release-load', stored]] : []);
+ const ctx = {
+ calls,
+ console,
+ Date,
+ document: {
+ visibilityState: 'visible',
+ getElementById: (id) => (id === 'ov-assets' && loaded ? { textContent: JSON.stringify({ v: {}, release: loaded }) } : null),
+ addEventListener: (type, fn) => { listeners[type] = fn; },
+ },
+ window: { addEventListener: (type, fn) => { listeners[`window:${type}`] = fn; } },
+ location: {
+ origin: 'https://openvibe.live',
+ assign: (u) => calls.push(['assign', u]),
+ replace: (u) => calls.push(['replace', u]),
+ set href(u) { calls.push(['href', u]); },
+ },
+ history: {
+ pushState: (s, t, u) => calls.push(['push', u]),
+ replaceState: (s, t, u) => calls.push(['replaceState', u]),
+ },
+ sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
+ fetch: async (url) => { calls.push(['fetch', url]); return { ok: true, json: async () => ({ release: current }) }; },
+ teardownRoute: () => calls.push(['teardown']),
+ routeFromURL: () => calls.push(['route']),
+ };
+ vm.createContext(ctx);
+ vm.runInContext(block, ctx);
+ return {
+ ctx, calls, listeners, store,
+ run: (code) => vm.runInContext(code, ctx),
+ // As if five minutes had gone by since the last check.
+ later: () => vm.runInContext('_ovRelease.checkedAt = 0', ctx),
+ settle: () => new Promise((r) => setImmediate(r)),
+ };
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
+}
+
+(async () => {
+ await check('an up-to-date tab switches routes in place, and off-site links leave', async () => {
+ const t = sandbox({ current: 'aaaaaaaaaaaa' });
+ t.later();
+ t.listeners.visibilitychange();
+ await t.settle();
+ t.run("navigate('/content?type=vods')");
+ t.run("navigate('https://openvibe.video/v/5857')");
+ assert.deepStrictEqual(t.calls, [['fetch', '/api/client-release'], ['teardown'], ['push', '/content?type=vods'], ['route'], ['href', 'https://openvibe.video/v/5857']]);
+ });
+
+ await check('a tab that comes back into view running an older client loads its next page in full, once', async () => {
+ const t = sandbox();
+ t.later();
+ t.listeners.visibilitychange();
+ await t.settle();
+ t.run("navigate('/@goosely')");
+ assert.deepStrictEqual(t.calls.slice(1), [['assign', '/@goosely']], 'no route switch on old code');
+ assert.strictEqual(t.store.get('ov-release-load'), 'bbbbbbbbbbbb');
+ t.calls.length = 0;
+ t.run("navigate('/vods', true)");
+ assert.deepStrictEqual(t.calls, [['replace', '/vods']]);
+ });
+
+ await check('it asks at most every five minutes, and not while hidden', async () => {
+ const t = sandbox();
+ t.listeners.visibilitychange();
+ t.run("navigate('/a')");
+ await t.settle();
+ assert.ok(!t.calls.some((c) => c[0] === 'fetch'), 'just loaded: no check');
+ t.later();
+ t.ctx.document.visibilityState = 'hidden';
+ t.listeners.visibilitychange();
+ await t.settle();
+ assert.ok(!t.calls.some((c) => c[0] === 'fetch'), 'hidden: no check');
+ t.ctx.document.visibilityState = 'visible';
+ t.run("navigate('/b')");
+ await t.settle();
+ assert.strictEqual(t.calls.filter((c) => c[0] === 'fetch').length, 1, 'a route change checks too');
+ t.calls.length = 0;
+ t.run("navigate('/c')");
+ assert.deepStrictEqual(t.calls, [['assign', '/c']]);
+ });
+
+ await check('never while the tab holds a broadcast or a call', async () => {
+ for (const holder of [
+ 'var broadcastState = { streams: new Map([[1, { localStream: {} }]]) };',
+ 'var callState = { joined: true, connecting: false };',
+ ]) {
+ const t = sandbox();
+ t.run(holder);
+ t.later();
+ t.listeners.visibilitychange();
+ await t.settle();
+ t.calls.length = 0;
+ t.run("navigate('/chat')");
+ assert.deepStrictEqual(t.calls, [['teardown'], ['push', '/chat'], ['route']], holder);
+ }
+ });
+
+ await check('a shell that still disagrees after the full load is not reloaded again', async () => {
+ const t = sandbox({ stored: 'bbbbbbbbbbbb' });
+ t.later();
+ t.listeners.visibilitychange();
+ await t.settle();
+ t.calls.length = 0;
+ t.run("navigate('/moments')");
+ assert.deepStrictEqual(t.calls, [['teardown'], ['push', '/moments'], ['route']]);
+ });
+
+ await check('a page without the manifest never asks', async () => {
+ const t = sandbox({ loaded: null });
+ t.later();
+ t.listeners.visibilitychange();
+ t.listeners['window:pageshow']({ persisted: true });
+ await t.settle();
+ assert.deepStrictEqual(t.calls, []);
+ });
+
+ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
+ console.log('\nstale tab release: all checks passed');
+})();
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
/**
/**
* Recordings and clips are watched on openvibe.video (server/web/video-handover.js): /vod/<id> and /clip/<id> answer a
* Recordings and clips are watched on openvibe.video (server/web/video-handover.js): /vod/<id> and /clip/<id> answer a
* 301 to the OpenVibe.Video page, with a ?t= start time as seconds (95, 1:35 or 0:01:35); anything that is not an id
* 301 to the OpenVibe.Video page, with a ?t= start time as seconds (95, 1:35 or 0:01:35); anything that is not an id
- * falls through to Live's own 404; the SPA shell names the origin in its ov-video-base meta (public/js/app.js videoHref),
+ * falls through to Live's own 404; an OpenVibe.Video or Community address glued onto Live's (/https://openvibe.video/v/12,
+ * what a tab opened before the handover made of the new links) 301s to the page it names; the SPA shell names the origin in its ov-video-base meta (public/js/app.js videoHref),
* and no client code links a /vod/ or /clip/ page any more.
* and no client code links a /vod/ or /clip/ page any more.
*
*
* node test/video-handover.test.js
* node test/video-handover.test.js
@@ -36,6 +37,16 @@ process.env.NODE_ENV = 'test';
@@ -36,6 +37,16 @@ process.env.NODE_ENV = 'test';
assert.deepStrictEqual(await get('/vod/12?t=soon'), { status: 301, location: 'https://openvibe.video/v/12' }, 'a junk start time is dropped');
assert.deepStrictEqual(await get('/vod/12?t=soon'), { status: 301, location: 'https://openvibe.video/v/12' }, 'a junk start time is dropped');
});
});
+ await check('a whole OpenVibe.Video or Community address glued onto Live\'s (an old tab\'s router did that) goes to the page it names', async () => {
+ assert.deepStrictEqual(await get('/https://openvibe.video/v/5857'), { status: 301, location: 'https://openvibe.video/v/5857' });
+ assert.deepStrictEqual(await get('/https:/openvibe.video/v/5857?t=95'), { status: 301, location: 'https://openvibe.video/v/5857?t=95' }, 'merged slashes, start time kept');
+ assert.deepStrictEqual(await get('/https://openvibe.video/c/7'), { status: 301, location: 'https://openvibe.video/c/7' });
+ assert.deepStrictEqual(await get('/https://openvibe.community/p/abc'), { status: 301, location: 'https://openvibe.community/p/abc' });
+ assert.strictEqual((await get('/https://evil.example/v/1')).status, 404, 'any other host is no redirect');
+ assert.strictEqual((await get('/https://openvibe.video.evil.example/v/1')).status, 404);
+ assert.strictEqual((await get('/https:openvibe.video/v/1')).status, 404);
+ });
+
await check('what is not an id stays Live\'s 404', async () => {
await check('what is not an id stays Live\'s 404', async () => {
assert.strictEqual((await get('/vod/abc')).status, 404);
assert.strictEqual((await get('/vod/abc')).status, 404);
assert.strictEqual((await get('/vod/0')).status, 404);
assert.strictEqual((await get('/vod/0')).status, 404);