openvibers/openvibe.live Commits

Performance: cache versioned assets, skeleton the feeds, cache prepared statements, index hot paths

OP OpenVibers committed 16 Sept 2026 · 2f325de603 · 11 files, +430 −49

Measured first, from a throttled phone against production. Baseline: first contentful paint
6.0s, largest contentful paint 6.6s, 1.81MB over 96 requests, and a cumulative layout shift of
0.83 — deep in the "poor" band, meaning the page visibly jumps around while it loads.

Browser side:

Every script and stylesheet is already referenced with ?v=N, which makes each version its own
URL, yet all of them were served no-cache with CDN-Cache-Control: no-store. Nothing was cached
by the browser or the edge, so a repeat visit revalidated fifty files and every request came
all the way to this box. Versioned requests are now immutable for a year; anything without a
?v= keeps the old revalidate-always behaviour, and HTML is still no-cache because it is the
document that names the current versions.

Feeds now have placeholders. The live grid, the digest, the star spotlight and the hero stat
board used to be empty boxes that popped into existence as each request landed, which is most
of where that 0.83 layout shift came from. The placeholders are in the HTML itself with their
styling inlined in the head — an injected skeleton is useless when it arrives behind a 481KB
script, which is exactly what the first attempt did.

Of the eleven render-blocking stylesheets, seven belong to other routes (the broadcast desk,
the messenger, the cosmetics modal, after-show reports, the guide overlay). They now load
without blocking the first paint, with a noscript fallback.

Also removed from every page load: the shared account switcher script (28.8KB, and Live has its
own switchAccount(), so nothing referenced it), an ONVIF camera query fired by home visitors,
and broadcast tab restoration that could issue broadcast API calls from the front page. All
three ran because every route's markup lives in one document, so "is the element present?"
guards never discriminated by route.

Server side:

Every query funnelled through run/get/all, each of which called prepare() again — SQLite
re-parsed and re-planned the same statement on every call, 200+ compiles for one cold home
load. Statements are now cached by SQL text, with a cap and eviction on connection close.

Seventeen new indexes, each replacing a recurring full table scan. The most important is on
bans(ip_address): the IP-ban middleware runs on every request including static assets, and was
scanning that table each time. The chat auto-delete sweep runs every 30 seconds against the
largest table on the site and compared datetime(auto_delete_at), which made its index unusable;
it compares the raw column now.

The newest-commit line in the hero shelled out with execSync on the request path, blocking the
whole event loop for up to four seconds, and wrote its cache only after returning — so a burst
of first loads after a deploy each spawned their own git process. It refreshes off the request
path now and resolves once at startup. The startup registry fetch also had no deadline, so an
unreachable openvibe.network could stall a boot indefinitely and keep the site down through a
deploy; it now gives up after three seconds and falls back to env defaults.

Plus: the live grid kept its empty-state node across re-renders (replacing innerHTML deleted it
outright, so once anyone went live the "nobody is streaming" message could never come back),
and OpenCoins totals now sit beside channel points on the hero stat board, fed by a new cached
internal endpoint on OpenVibe.Network. The API Docs hero button is now "Build & Mod", because
the point is that the site is yours to extend, not that there is documentation.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 67d7304f..a3f3702f 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -156,3 +156,34 @@
Below-the-fold sections are skipped by layout and paint until they approach the viewport.
contain-intrinsic-size keeps the scrollbar honest so the page doesn't jump as they resolve. */
#page-home .home-section-lazy { content-visibility: auto; contain-intrinsic-size: auto 480px; }
++
++/* ── "Build & Mod": the one hero button that asks for something back ──
++ Everything else in this row sends you somewhere to watch. This one says the site is yours to
++ extend, so it is allowed to be the loudest: a drifting gradient wash, a sparkle that pops off
++ the wand, and a secondary "API docs" label that keeps the destination honest without the
++ button reading like documentation. All of it pauses with the rest of the row when off-screen. */
++.hb-label small { font-size: 0.68rem; font-weight: 600; opacity: 0.62; margin-left: 5px; letter-spacing: 0.01em; }
++.hb-vibe {
++ background-image: linear-gradient(115deg, color-mix(in srgb, var(--c) 16%, transparent), transparent 42%, color-mix(in srgb, #a78bfa 14%, transparent));
++ background-size: 220% 100%;
++ animation: hbVibeWash 11s ease-in-out infinite;
++}
++@keyframes hbVibeWash { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
++.hb-vibe .hb-ico { position: relative; overflow: visible; }
++/* A spark that leaves the wand every few seconds. */
++.hb-vibe .hb-ico::after {
++ content: ''; position: absolute; top: 2px; right: 1px; width: 5px; height: 5px; border-radius: 50%;
++ background: radial-gradient(circle, #fff, var(--c) 60%, transparent 70%);
++ opacity: 0; animation: hbSpark 4.4s ease-out infinite;
++}
++@keyframes hbSpark {
++ 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
++ 10% { opacity: 1; transform: translate(2px, -3px) scale(1); }
++ 38% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }
++ 100% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }
++}
++.hb-vibe:hover { box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--c) 55%, transparent); }
++#page-home .hero-cta .hb-vibe, #page-home .hero-cta .hb-vibe .hb-ico::after { animation-play-state: paused; }
++#page-home .hero-cta.fx-live .hb-vibe, #page-home .hero-cta.fx-live .hb-vibe .hb-ico::after { animation-play-state: running; }
++@media (max-width: 520px) { .hb-label small { display: none; } }
++@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }
ddiff --git a/public/index.html b/public/index.html
iindex 761e83de..67c63267 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -61,19 +61,54 @@
})();
</script>
-- <!-- Stylesheets -->
++ <!-- Placeholder styling, inline on purpose.
++ The skeletons below are in the HTML so the page reserves the space each feed will occupy
++ before a single script runs. That only works if their styling is here too — a stylesheet
++ request would land after the first paint, which is the moment it is needed. ~1KB. -->
++ <style id="ovsk-css">
++ .ovsk{position:relative;overflow:hidden;background:var(--bg-card,#171826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:14px}
++ .ovsk-bar{position:relative;overflow:hidden;background:color-mix(in srgb,var(--text-muted,#8b93ad) 14%,transparent);border-radius:6px}
++ .ovsk::after,.ovsk-bar::after{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,color-mix(in srgb,var(--text-primary,#f1f4fb) 7%,transparent) 50%,transparent 80%);transform:translateX(-100%);animation:ovskSweep 1.5s ease-in-out infinite}
++ @keyframes ovskSweep{to{transform:translateX(100%)}}
++ .ovsk-card{display:flex;flex-direction:column}
++ .ovsk-thumb{aspect-ratio:16/9;width:100%;background:color-mix(in srgb,var(--text-muted,#8b93ad) 10%,transparent)}
++ .ovsk-meta{display:flex;gap:10px;align-items:center;padding:10px 12px 12px}
++ .ovsk-av{width:32px;height:32px;border-radius:50%;flex:none}
++ .ovsk-lines{flex:1;min-width:0;display:grid;gap:6px}
++ .ovsk-panel{padding:14px;display:grid;gap:10px}
++ .ovsk-fade{animation:ovskFade .25s ease forwards}
++ @keyframes ovskFade{to{opacity:0}}
++ @media (max-width:760px){#stream-grid-live .ovsk-card:nth-of-type(n+3){display:none}}
++ @media (prefers-reduced-motion:reduce){.ovsk::after,.ovsk-bar::after{animation:none}}
++ </style>
++
++ <!-- Stylesheets.
++ Blocking set = what the home page paints with. Everything else belongs to another route
++ (the broadcast desk, the messenger, the cosmetics modal, after-show reports, the guide
++ overlay) and is fetched without blocking the first paint: media="print" makes the browser
++ treat it as non-critical, and the onload flips it back to all once it has arrived. -->
++ <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="/css/style.css?v=166">
-- <link rel="stylesheet" href="/css/chat-shared.css?v=1">
-- <link rel="stylesheet" href="/css/broadcast.css?v=11">
-- <link rel="stylesheet" href="/css/cosmetics.css?v=2">
-- <link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/home-fx.css?v=9">
++ <link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
-- <link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
-- <link rel="stylesheet" href="/css/recap.css?v=1">
-- <link rel="stylesheet" href="/css/context-cards.css?v=3">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++ <link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/context-cards.css?v=3" media="print" onload="this.media='all';this.onload=null">
++ <noscript>
++ <link rel="stylesheet" href="/css/chat-shared.css?v=1">
++ <link rel="stylesheet" href="/css/broadcast.css?v=11">
++ <link rel="stylesheet" href="/css/cosmetics.css?v=2">
++ <link rel="stylesheet" href="/css/messenger.css?v=1">
++ <link rel="stylesheet" href="/css/ov-guide.css?v=1">
++ <link rel="stylesheet" href="/css/recap.css?v=1">
++ <link rel="stylesheet" href="/css/context-cards.css?v=3">
++ </noscript>
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
@@@ -248,7 +283,7 @@
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate" id="hero-rotate"></span></span></p>
<p class="hero-quip" id="hero-quip" aria-live="polite"></p>
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
-- <div class="hero-stats" id="hero-stats" aria-label="Site stats"></div>
++ <div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
<div class="hero-egg" id="hero-egg" style="display:none">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<span class="hero-egg-icon">🥚</span>
@@@ -286,12 +321,12 @@
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"
-- data-ovtip-title="Built for vibe coders"
++ data-ovtip-title="Mod it, bot it, build on it"
data-ovtip-icon="fa-wand-magic-sparkles"
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
-- data-ovtip="Every feature here has an <b>open API</b> — chat, streams, clips, overlays, robots, the lot. <em>You don't need to know how to code.</em> Point an AI at these docs, describe the bot or overlay you want, and it writes it for you."
-- data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> Open here, good vibes — come build something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">API Docs</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
++ data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build &amp; Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
@@@ -312,10 +347,10 @@
<div class="container">
<!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
-- <div class="home-digest" id="home-digest" style="display:none"></div>
++ <div class="home-digest" id="home-digest"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
-- <div class="home-star-section" id="home-star-section" style="display:none"></div>
++ <div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
<!-- Live Streams -->
<div class="section-header">
@@@ -323,7 +358,11 @@
<span id="live-count" class="badge">0</span>
</div>
<div class="stream-grid" id="stream-grid-live">
-- <div class="empty-state" id="no-live-streams">
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="empty-state" id="no-live-streams" style="display:none">
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<p>No one is streaming right now</p>
<p class="muted">Be the first to go live!</p>
@@@ -5084,7 +5123,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=142" defer></script>
++<script src="/js/ov-skeleton.js?v=1" defer></script>
++<script src="/js/app.js?v=144" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@@ -5094,7 +5134,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
--<script src="/js/onvif-dashboard.js?v=5" defer></script>
++<script src="/js/onvif-dashboard.js?v=6" defer></script>
<script src="/js/dashboard.js?v=16" defer></script>
<script src="/js/admin.js?v=8" defer></script>
<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
@@@ -5149,7 +5189,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-workspace.js?v=16" defer></script>
--<script src="/js/broadcast.js?v=28" defer></script>
++<script src="/js/broadcast.js?v=29" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
@@@ -5164,8 +5204,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/ov-mark.js?v=1" defer></script>
--<script src="/shared/tooltip.js" defer></script>
--<script src="/shared/footer.js" defer></script>
++<script src="/shared/tooltip.js?v=1" defer></script>
++<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=6" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
@@@ -5173,9 +5213,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/guides-live.js?v=4" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
--<script src="/shared/theme-loader.js" defer></script>
++<script src="/shared/theme-loader.js?v=1" defer></script>
<script src="/shared/notification-ui.js?v=2" defer></script>
--<script src="/shared/account-switcher.js" defer></script>
<script>
// Initialize OpenVibe notifications and theme sync (navbar is handled natively by openvibelive).
// Runs on DOMContentLoaded so the now-deferred shared libs (OpenVibeThemeLoader / OpenVibeNotifications)
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 7bb4ea21..e8619dac 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1307,7 +1307,15 @@ function renderHeroStats(stats) {
{ icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },
{ icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
{ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
-- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },
++ // OpenCoins are network-wide; channel points below are per channel. Shown only when
++ // the wallet service answered — four zeroed chips would read as "nobody has any".
++ ...(stats.coinsEarned != null ? [
++ { icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins Earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools' },
++ { icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins Spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },
++ { icon: 'fa-vault', num: stats.coinsCirculating, label: 'Coins Held', title: 'OpenCoins sitting in wallets right now, across every OpenVibe site' },
++ { icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },
++ ] : []),
++ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', recent: R.points },
{ icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },
{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
],
@@@ -1866,6 +1874,17 @@ async function loadHome() {
void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans
void loadHeroEgg(); // daily AI easter egg widget
++ // Claim the space every feed is about to fill. Without this the page arrives as a short
++ // column that grows in jumps as each request lands, which reads as slow and shoves content
++ // around under the reader's thumb. Placeholders are cleared by whoever renders real content,
++ // with a backstop below so nothing shimmers forever after a failed request.
++ if (window.OVSkeleton) {
++ OVSkeleton.cards('stream-grid-live');
++ OVSkeleton.block('home-digest', { rows: 3 });
++ OVSkeleton.block('home-star-section', { rows: 4, height: 140 });
++ setTimeout(() => OVSkeleton.clearAll(), 9000);
++ }
++
// Reset homepage pagination on fresh load
_homeRecentOnlinePage = 1;
_homeRecentVodsPage = 1;
@@@ -1879,7 +1898,13 @@ async function loadHome() {
const noLiveEl = document.getElementById('no-live-streams');
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
-- } catch (e) { console.error('Failed to load live streams', e); }
++ } catch (e) {
++ console.error('Failed to load live streams', e);
++ // Never leave placeholders shimmering over a failed request.
++ if (window.OVSkeleton) OVSkeleton.clear('stream-grid-live');
++ const noLiveEl = document.getElementById('no-live-streams');
++ if (noLiveEl) noLiveEl.style.display = '';
++ }
loadHomeRecentOnline();
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
@@@ -1977,7 +2002,8 @@ async function loadHomeDigest() {
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
let d;
-- try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); } catch { return; }
++ try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }
++ catch { box.innerHTML = ''; box.style.display = 'none'; return; }
if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }
const away = !!(since && currentUser);
const n = (v) => Number(v || 0).toLocaleString();
@@@ -2305,11 +2331,20 @@ function streamCardHTML(s, isLive) {
function renderStreamGrid(containerId, streams, isLive) {
const c = document.getElementById(containerId);
++ if (!c) return;
++ // Placeholders have done their job the moment real data lands. Remove them by class rather
++ // than clearing the container, because the live grid keeps its empty-state child in there.
++ c.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } });
if (!streams.length) {
if (!isLive) c.innerHTML = '<div class="empty-state"><p class="muted">No recent streams</p></div>';
return;
}
++ // Keep the live grid's empty-state node across a re-render. Replacing innerHTML used to
++ // delete it outright, so once anyone went live the "nobody is streaming" message could never
++ // come back when they all stopped — the grid just sat there blank.
++ const empty = c.querySelector('.empty-state');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
++ if (empty) { empty.style.display = 'none'; c.appendChild(empty); }
}
/* ── Real-time home updates ─────────────────────────────────── */
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 256d8957..ba506a08 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -5753,7 +5753,19 @@ function _initPipDragResize() {
document.addEventListener('DOMContentLoaded', () => {
initBroadcastSettingsListeners();
-- initBroadcastTabs();
++ // Restoring the saved tab can issue broadcast API calls and force a layout on a hidden page.
++ // Only do it once the broadcast desk is the route the visitor is actually on.
++ const _bcPage = document.getElementById('page-broadcast');
++ const _bcTabsOnce = () => {
++ if (!_bcPage || !_bcPage.classList.contains('active')) return;
++ if (_bcPage.dataset.tabsInit) return;
++ _bcPage.dataset.tabsInit = '1';
++ initBroadcastTabs();
++ };
++ _bcTabsOnce();
++ if (_bcPage && 'MutationObserver' in window) {
++ new MutationObserver(_bcTabsOnce).observe(_bcPage, { attributes: true, attributeFilter: ['class'] });
++ }
// Update mobile chat FAB visibility on resize
window.addEventListener('resize', () => {
ddiff --git a/public/js/onvif-dashboard.js b/public/js/onvif-dashboard.js
iindex 0dceb129..4a36f7c3 100644
---- a/public/js/onvif-dashboard.js
++++ b/public/js/onvif-dashboard.js
@@@ -352,9 +352,20 @@ async function showEditCameraModal(cameraId) {
}
}
--// Initialize on page load
++// Every page's markup lives in one document, so #dash-cameras-list exists even on the home page —
++// this used to fire an ONVIF camera query for anyone landing on the front page. Load the cameras
++// when the dashboard is actually on screen, and again whenever it becomes so.
++function _onvifMaybeLoad() {
++ const page = document.getElementById('page-dashboard');
++ if (!page || !page.classList.contains('active')) return;
++ if (page.dataset.onvifLoaded) return;
++ page.dataset.onvifLoaded = '1';
++ if (document.getElementById('dash-cameras-list')) loadDashboardCameras();
++}
document.addEventListener('DOMContentLoaded', () => {
-- if (document.getElementById('dash-cameras-list')) {
-- loadDashboardCameras();
++ _onvifMaybeLoad();
++ const page = document.getElementById('page-dashboard');
++ if (page && 'MutationObserver' in window) {
++ new MutationObserver(_onvifMaybeLoad).observe(page, { attributes: true, attributeFilter: ['class'] });
}
});
ddiff --git a/public/js/ov-skeleton.js b/public/js/ov-skeleton.js
nnew file mode 100644
iindex 00000000..e7edcb23
---- /dev/null
++++ b/public/js/ov-skeleton.js
@@@ -0,0 +1,112 @@
++/**
++ * ov-skeleton.js — placeholder shapes while a section is loading.
++ *
++ * Every feed on this site used to be `display:none` until its fetch came back, so the page
++ * arrived as a short column that grew in jumps as each request landed. That reads as slow even
++ * when it isn't, and every late section shoves the page around under the reader's thumb.
++ *
++ * A skeleton fixes both: the space a section is about to occupy is claimed immediately with
++ * shimmering blocks in roughly the shape of the real thing, so the layout stops moving and the
++ * page looks like it is filling in rather than stalling.
++ *
++ * Deliberately small and dependency-free:
++ * OVSkeleton.cards('stream-grid-live') — a responsive row of stream-card placeholders
++ * OVSkeleton.block(el, { rows, height }) — a generic panel placeholder
++ * OVSkeleton.clear(el) — remove placeholders from a container
++ *
++ * Counts follow the container's real width, so a phone gets two placeholders and a wide desktop
++ * gets six. There is no point rendering eight shimmering cards into a 400px column.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OVSkeleton) return;
++
++ const CSS = `
++.ovsk { position: relative; overflow: hidden; background: var(--bg-card, #171826);
++ border: 1px solid var(--border, rgba(255,255,255,.08)); border-radius: 14px; }
++.ovsk-bar { background: color-mix(in srgb, var(--text-muted, #8b93ad) 14%, transparent); border-radius: 6px; }
++/* One sweep definition, shared by every placeholder, so a screen full of them moves as one. */
++.ovsk::after, .ovsk-bar::after { content: ''; position: absolute; inset: 0;
++ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--text-primary, #f1f4fb) 7%, transparent) 50%, transparent 80%);
++ transform: translateX(-100%); animation: ovskSweep 1.5s ease-in-out infinite; }
++.ovsk-bar { position: relative; overflow: hidden; }
++@keyframes ovskSweep { to { transform: translateX(100%); } }
++.ovsk-card { display: flex; flex-direction: column; }
++.ovsk-thumb { aspect-ratio: 16 / 9; width: 100%; background: color-mix(in srgb, var(--text-muted, #8b93ad) 10%, transparent); }
++.ovsk-meta { display: flex; gap: 10px; align-items: center; padding: 10px 12px 12px; }
++.ovsk-av { width: 32px; height: 32px; border-radius: 50%; flex: none; }
++.ovsk-lines { flex: 1; min-width: 0; display: grid; gap: 6px; }
++.ovsk-panel { padding: 14px; display: grid; gap: 10px; }
++.ovsk-fade { animation: ovskFade .25s ease forwards; }
++@keyframes ovskFade { to { opacity: 0; } }
++@media (prefers-reduced-motion: reduce) { .ovsk::after, .ovsk-bar::after { animation: none; } }`;
++
++ function ensure() {
++ if (document.getElementById('ovsk-css')) return;
++ const st = document.createElement('style'); st.id = 'ovsk-css'; st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ const el = (target) => typeof target === 'string' ? document.getElementById(target) : target;
++
++ /** How many placeholders actually fit — never more than the container can show. */
++ function fitCount(container, minWidth, max) {
++ const w = container.getBoundingClientRect().width || window.innerWidth;
++ const perRow = Math.max(1, Math.floor(w / minWidth));
++ // Two rows on a roomy screen, one on a phone, capped so we never paint a wall of grey.
++ return Math.min(max, perRow * (w >= 900 ? 2 : 1));
++ }
++
++ function cards(target, opts = {}) {
++ const c = el(target);
++ if (!c || c.querySelector('.ovsk-card')) return;
++ ensure();
++ const n = opts.count || fitCount(c, opts.minWidth || 300, opts.max || 6);
++ const frag = document.createDocumentFragment();
++ for (let i = 0; i < n; i++) {
++ const d = document.createElement('div');
++ d.className = 'ovsk ovsk-card';
++ d.setAttribute('aria-hidden', 'true');
++ d.innerHTML = '<div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div>'
++ + '<div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div>'
++ + '<div class="ovsk-bar" style="height:9px;width:45%"></div></div></div>';
++ frag.appendChild(d);
++ }
++ c.appendChild(frag);
++ }
++
++ /** A generic panel placeholder for sections that are not card grids. */
++ function block(target, opts = {}) {
++ const c = el(target);
++ if (!c || c.querySelector('.ovsk-panel')) return;
++ ensure();
++ const rows = opts.rows || 3;
++ const d = document.createElement('div');
++ d.className = 'ovsk ovsk-panel';
++ d.setAttribute('aria-hidden', 'true');
++ if (opts.height) d.style.minHeight = typeof opts.height === 'number' ? `${opts.height}px` : opts.height;
++ let html = '';
++ for (let i = 0; i < rows; i++) {
++ const w = [92, 68, 80, 55][i % 4];
++ html += `<div class="ovsk-bar" style="height:${i === 0 ? 14 : 10}px;width:${i === 0 ? 45 : w}%"></div>`;
++ }
++ d.innerHTML = html;
++ c.appendChild(d);
++ if (opts.reveal !== false) c.style.display = '';
++ }
++
++ /** Remove placeholders. Fades so a fast response does not flash a grey block. */
++ function clear(target) {
++ const c = el(target);
++ if (!c) return;
++ c.querySelectorAll('.ovsk').forEach(n => {
++ n.classList.add('ovsk-fade');
++ setTimeout(() => { try { n.remove(); } catch { /* */ } }, 240);
++ });
++ }
++
++ /** Placeholders that are still up after a failed load should just go away. */
++ function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }
++
++ window.OVSkeleton = { cards, block, clear, clearAll, ensure };
++})();
ddiff --git a/server/config.js b/server/config.js
iindex 09d35ee8..1ad6d103 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -301,6 +301,10 @@ async function refreshRegistry() {
'X-Internal-Key': config.internalApiKey,
'Accept': 'application/json',
},
++ // Without a deadline this call can hang, and it runs during startup — an unreachable
++ // or slow openvibe.network used to stall the whole boot, which during a deploy means
++ // the site stays down until the fetch gives up. Config falls back to env defaults.
++ signal: AbortSignal.timeout ? AbortSignal.timeout(3000) : undefined,
});
if (!res.ok) {
console.warn('[Config] Failed to refresh URL registry from openvibe.network:', res.status);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 004054dd..10affae1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -2180,6 +2180,38 @@ function initDb() {
database.exec('CREATE INDEX IF NOT EXISTS idx_clips_user_created ON clips(user_id, is_public, created_at)');
} catch (e) { console.warn('[DB] performance index migration:', e.message); }
++ // Second pass of hot-path indexes, found by tracing what the home page and the always-on
++ // middleware actually execute. Each one replaces a recurring full table scan. They are created
++ // individually so a table that does not exist in some deployment cannot stop the rest.
++ for (const ix of [
++ // The IP-ban check runs in middleware on EVERY request, including static assets — this was
++ // a full scan of `bans` per request, the most-executed avoidable query in the system.
++ 'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',
++ // Looked up once per live stream on /api/streams, which every open home tab polls.
++ 'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',
++ // The chat auto-delete sweep runs every 30s and scanned + sorted the largest table.
++ 'CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at)',
++ // Home digest, per signed-in load.
++ 'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)',
++ 'CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_streams_started ON streams(started_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_streams_live_ended ON streams(is_live, ended_at)',
++ // Hero stat board aggregates.
++ 'CREATE INDEX IF NOT EXISTS idx_tx_type_created ON transactions(type, created_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_coin_tx_created ON coin_transactions(created_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_users_created ON users(created_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)',
++ 'CREATE INDEX IF NOT EXISTS idx_anon_ip_created ON anon_ip_mappings(created_at)',
++ 'CREATE INDEX IF NOT EXISTS idx_goals_active ON donation_goals(is_active)',
++ 'CREATE INDEX IF NOT EXISTS idx_arena_moments_said ON arena_mic_moments(said_at)',
++ // Boot-time transcript backfill sweeps.
++ 'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',
++ 'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',
++ 'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
++ ]) {
++ try { database.exec(ix); } catch { /* table not present in this deployment */ }
++ }
++
// Migrate: add force_nsfw column to channels (admin-set, overrides user toggle)
try {
const cols = database.pragma('table_info(channels)').map(c => c.name);
@@@ -2653,17 +2685,45 @@ function initDb() {
}
// ── Generic helpers ──────────────────────────────────────────
++//
++// Every query in this file (and in every route module) funnels through run/get/all, and each one
++// used to call prepare() again — so SQLite re-parsed and re-planned the same statement on every
++// single call. A cold home page load alone compiled 200+ statements it had already compiled.
++//
++// better-sqlite3 statements are reusable, so we keep them in a Map keyed by SQL text. The only
++// shapes worth excluding are the ones built with a variable-length `IN (?,?,?)` list, which
++// produce a different SQL string every time and would fill the map with single-use entries; the
++// size cap below handles those without needing to detect them.
++const _stmtCache = new Map();
++const _STMT_CACHE_MAX = 600;
++
++function stmt(sql) {
++ let st = _stmtCache.get(sql);
++ if (st) return st;
++ st = getDb().prepare(sql);
++ // Plain FIFO eviction. Hot statements are re-added on their next call, and the cap only
++ // matters for the dynamic IN-list shapes, which are cheap to lose.
++ if (_stmtCache.size >= _STMT_CACHE_MAX) {
++ const oldest = _stmtCache.keys().next().value;
++ if (oldest !== undefined) _stmtCache.delete(oldest);
++ }
++ _stmtCache.set(sql, st);
++ return st;
++}
++
++/** Statements belong to a connection, so a reopened database must start with an empty cache. */
++function clearStatementCache() { _stmtCache.clear(); }
function run(sql, params = []) {
-- return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));
++ return stmt(sql).run(...(Array.isArray(params) ? params : [params]));
}
function get(sql, params = []) {
-- return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));
++ return stmt(sql).get(...(Array.isArray(params) ? params : [params]));
}
function all(sql, params = []) {
-- return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));
++ return stmt(sql).all(...(Array.isArray(params) ? params : [params]));
}
// ── User helpers ─────────────────────────────────────────────
@@@ -5976,6 +6036,8 @@ function forgiveBan(userId) {
function close() {
if (db) {
++ // Cached statements belong to this connection — they must not outlive it.
++ clearStatementCache();
db.close();
db = null;
}
@@@ -7370,7 +7432,11 @@ function deleteExpiredChatMessages(limit = 500) {
FROM chat_messages
WHERE is_deleted = 0
AND auto_delete_at IS NOT NULL
-- AND datetime(auto_delete_at) <= CURRENT_TIMESTAMP
++ -- Compared raw, not through datetime(): wrapping the column in a function makes the
++ -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
++ -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
++ -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.
++ AND auto_delete_at <= CURRENT_TIMESTAMP
ORDER BY auto_delete_at ASC
LIMIT ?`,
[Math.max(1, Number(limit) || 500)]
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 96392cc1..c036c422 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -190,6 +190,18 @@ async function heroStats() {
stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);
stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };
}
++ // Site-wide OpenCoins sit next to channel points on the board: one is the network-wide
++ // currency people earn everywhere, the other is per-channel. The ledger lives on
++ // OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
++ try {
++ const c = await require('../monetization/wallet-client').networkCoinStats();
++ if (c) {
++ stats.coinsEarned = c.earned;
++ stats.coinsSpent = c.spent;
++ stats.coinsCirculating = c.circulating;
++ stats.coinHolders = c.holders;
++ }
++ } catch { /* the board is better off missing four chips than failing */ }
return stats;
}
@@@ -281,18 +293,35 @@ router.get('/pulse', async (req, res) => {
});
// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).
--let _updateCache = { at: 0, data: null };
++// The newest shipped commit, shown as a one-liner in the hero.
++//
++// This used to shell out with execSync on the request path. That blocks the entire event loop —
++// every other request on the box waits — for up to the 4s timeout, and because the cache was only
++// written after the call returned, a burst of first-loads after a deploy all queued behind their
++// own separate git process. Now the refresh happens off the request path and readers only ever
++// get whatever was last resolved.
++let _updateCache = { at: 0, data: null, running: false };
++function _refreshLatestUpdate() {
++ if (_updateCache.running) return;
++ _updateCache.running = true;
++ require('child_process').execFile(
++ 'git', ['--no-pager', 'log', '--pretty=format:%h||%s||%aI', '-1'],
++ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 },
++ (err, stdout) => {
++ _updateCache.running = false;
++ _updateCache.at = Date.now();
++ if (err) return;
++ const [short, subject, date] = String(stdout).trim().split('||');
++ _updateCache.data = subject ? { short, subject, date } : null;
++ }
++ );
++}
function _latestUpdate() {
-- if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;
-- try {
-- const { execSync } = require('child_process');
-- const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,
-- { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });
-- const [short, subject, date] = String(raw).trim().split('||');
-- _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };
-- } catch { _updateCache = { at: Date.now(), data: null }; }
++ if (Date.now() - _updateCache.at >= 5 * 60_000) _refreshLatestUpdate(); // never awaited
return _updateCache.data;
}
++// Resolve it once at startup so the very first home load already has it.
++setTimeout(_refreshLatestUpdate, 1500).unref?.();
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
ddiff --git a/server/index.js b/server/index.js
iindex 8088c6ad..026c79e5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -466,7 +466,8 @@ if (sharedServePath) {
const filePath = path.join(sharedServePath, fileName);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-- res.setHeader('Cache-Control', 'public, max-age=300');
++ // Same deal as /js and /css: a ?v= request is a version-pinned URL and can be cached hard.
++ res.setHeader('Cache-Control', req.query.v ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) {
@@@ -489,11 +490,27 @@ app.use('/assets/sounds', express.static(soundsPath, {
},
}));
--// JS/CSS/HTML: no-cache + tell Cloudflare CDN not to cache at edge
--// Browsers revalidate with etag (304 Not Modified), CDN always fetches fresh from origin
++// JS/CSS/HTML caching.
++//
++// Every script and stylesheet in our HTML is referenced with ?v=N, which makes each version a
++// distinct URL — so a versioned request can be cached hard, by the browser and at the Cloudflare
++// edge, and a deploy that bumps ?v= is picked up instantly because it is a different URL. That
++// turns a repeat visit from "revalidate ~50 files" into "read them from disk cache", and it lets
++// the edge serve them without touching this box at all.
++//
++// A request with no ?v= is something we cannot version-track, so it keeps the old behaviour:
++// revalidate every time, never cache at the edge. HTML is always no-cache — it is the document
++// that names the current asset versions.
const noCacheHeaders = (res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); };
--app.use('/js', express.static(path.join(__dirname, '../public/js'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));
--app.use('/css', express.static(path.join(__dirname, '../public/css'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));
++const IMMUTABLE = 'public, max-age=31536000, immutable';
++const markVersioned = (req, res, next) => { res.locals.ovVersioned = !!req.query.v; next(); };
++const assetHeaders = (res) => {
++ if (res.locals && res.locals.ovVersioned) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }
++ else noCacheHeaders(res);
++};
++const assetOpts = { etag: true, lastModified: true, setHeaders: assetHeaders };
++app.use('/js', markVersioned, express.static(path.join(__dirname, '../public/js'), assetOpts));
++app.use('/css', markVersioned, express.static(path.join(__dirname, '../public/css'), assetOpts));
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
ddiff --git a/server/monetization/wallet-client.js b/server/monetization/wallet-client.js
iindex 677e6784..5e91e199 100644
---- a/server/monetization/wallet-client.js
++++ b/server/monetization/wallet-client.js
@@@ -128,4 +128,29 @@ async function historyForToken(userToken, limit = 50, offset = 0) {
}
}
--module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken };
++/**
++ * Site-wide OpenCoins totals for the home hero stat board.
++ *
++ * The coin ledger lives on OpenVibe.Network, so this is the only way Live can show what the
++ * network economy looks like. Held for five minutes and never allowed to fail loudly: the hero
++ * simply drops the coin chips if the network is unreachable, rather than losing the whole board.
++ */
++let _statsCache = { at: 0, data: null };
++async function networkCoinStats() {
++ if (_statsCache.data && Date.now() - _statsCache.at < 5 * 60 * 1000) return _statsCache.data;
++ try {
++ const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;
++ const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {
++ headers: { 'X-Internal-Key': INTERNAL_API_KEY, Accept: 'application/json' },
++ signal: ctl,
++ });
++ if (!res.ok) return _statsCache.data;
++ const data = await res.json();
++ _statsCache = { at: Date.now(), data };
++ return data;
++ } catch {
++ return _statsCache.data; // stale is fine; missing is fine
++ }
++}
++
++module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken, networkCoinStats };