openvibers/openvibe.live Commits
Live: public routes block only on a first-paint subset of style.css (84 KB of 468); the full file follows in the same cascade place (#113)
Changes
@@@ -53,6 +53,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s## Key Pitfalls- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).++- **First-paint CSS:** public routes block only on `/css/style.core.css`, a subset of `style.css` generated from `public/css/critical-selectors.json` (`server/web/critical-css.js`); the full `style.css` follows without blocking. After changing how a public page looks, re-run `node scripts/perf/critical-css.js` (docs/performance-audit.md).- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.@@@ -130,6 +130,28 @@ Shared 1.17 to 1.20 changed `release-watch.js`, which every page loads after firThe release client adds one request (the EventSource, which excludes itself from idle-network checks) and no measurable paint or blocking cost. The size budgets in `npm test` (scripts/perf/check-budgets.js) still pass.++## Measurement 2026-10-12: the first-paint stylesheet++++A phone-profile sweep of every OpenVibe home page (4x CPU, ~1.6 Mbit/s) put openvibe.live last: first paint 4.9–5.3 s.++Every route blocked on all of `style.css` (468 KB raw, ~97 KB brotli as served), while CDP rule coverage over the public++routes (home, content, VODs, clips, moments, arena, chat, search, a channel, a VOD, a clip, pastes) at 412 and 1366 px++used 436 of its selectors, 17–18 % of the file.++++- `scripts/perf/critical-css.js` writes those selectors to `public/css/critical-selectors.json`; `server/web/critical-css.js`++ builds the subset from the `style.css` on disk (rules naming a listed selector, `:root`/`html`/`body`/`*`, each inside++ its `@media`/`@supports`/`@container`, and only the `@keyframes` they use): 84 KB raw, ~15 KB brotli.++- On those routes the shell links `/css/style.core.css?v=…` (generated, content-hashed, served before the `/css` static++ mount) as the blocking stylesheet, and the full `style.css` right after it with `media="print"` flipped on load, plus a++ `<noscript>` copy. Both sit where `style.css` sat, before the feature stylesheets, so once the full file has arrived the++ cascade is exactly the old one (the subset's rules come earlier and are repeated unchanged). Other routes (dashboard,++ settings, broadcast, admin, …) keep `style.css` blocking.++- A/B on production pages through a local same-origin proxy (brotli, phone profile): first paint home 2.48 → 1.47 s,++ channel 2.51 → 1.46 s, VOD 2.24 → 1.48 s (LCP 3.8 → 1.5 s); LCP and CLS elsewhere unchanged; the settled pages are++ pixel-identical apart from animations and timestamps.++- Re-run `node scripts/perf/critical-css.js` after changing a public page's look: a rule it does not list still arrives++ with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed++ selectors are no longer in `style.css`.++## Budgets`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerlynnew file mode 100644@@@ -0,0 +1,457 @@++{++ "_about": "Generated by scripts/perf/critical-css.js: the style.css selectors a public page's first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.",++ "base": "https://openvibe.live",++ "routes": [++ "/",++ "/content",++ "/vods",++ "/clips",++ "/moments",++ "/arena",++ "/chat",++ "/search?q=game",++ "/@iZoR_One",++ "/vod/5717",++ "/clip/441",++ "/pastes",++ "/docs"++ ],++ "selectors": [++ "#openvibe-bell-mount",++ "#page-home",++ "#page-home .hero",++ "#page-home.page",++ "#stream-grid-live .empty-state",++ "*",++ "*::after",++ "*::before",++ ".arena-beef-empty i",++ ".arena-beef-empty p",++ ".arena-beefs h2",++ ".arena-beefs h2 small",++ ".arena-board-head h2",++ ".arena-board-head h2 small",++ ".arena-board-head input",++ ".arena-container",++ ".arena-done summary",++ ".arena-done summary small",++ ".arena-hero h1",++ ".arena-ladder h2",++ ".arena-ladder h2 small",++ ".arena-live h2",++ ".arena-live h2 small",++ ".arena-live-dot",++ ".arena-loading i",++ ".arena-lvl",++ ".arena-mic-moment-body",++ ".arena-mic-moment-head small",++ ".arena-note",++ ".arena-portrait",++ ".arena-portrait img",++ ".arena-portrait-glow",++ ".arena-portrait-sm",++ ".arena-portrait-xs",++ ".arena-power b",++ ".arena-power small",++ ".arena-rank-1",++ ".arena-rank-2",++ ".arena-rank-3",++ ".arena-row .arena-chevron",++ ".arena-row-main",++ ".arena-row-main strong",++ ".arena-rules h3",++ ".arena-rules ul",++ ".arena-rules-list",++ ".badge",++ ".brand-mark",++ ".brand-mark .ov-comet",++ ".brand-mark .ov-dot",++ ".brand-mark .ov-glow",++ ".brand-mark .ov-spark",++ ".brand-mark .ov-v",++ ".brand-mark svg",++ ".brand-mark--hero",++ ".brand-mark--hero svg",++ ".brand-mark.is-awake .ov-comet",++ ".brand-mark.is-awake .ov-dot",++ ".brand-mark.is-awake .ov-glow",++ ".brand-mark.is-awake .ov-spark",++ ".brand-mark.is-awake .ov-v",++ ".brand-mark.is-awake svg",++ ".brand-mark.is-settling svg",++ ".brand-mark.is-spin svg",++ ".btn",++ ".btn-danger",++ ".btn-follow",++ ".btn-lg",++ ".btn-openvibe-bucks-mini",++ ".btn-openvibe-coins-mini",++ ".btn-outline",++ ".btn-primary",++ ".btn-small",++ ".card-ai-clamp",++ ".card-ai-clamp i.fa-wand-magic-sparkles",++ ".card-ai-overview",++ ".card-ai-static",++ ".card-ai-toggle",++ ".ch-goal-widget",++ ".ch-goal-widget:not(.cgw-multi)",++ ".ch-offline-area",++ ".ch-offline-area .ch-offline-screen",++ ".ch-offline-header-info",++ ".ch-offline-header-info h1",++ ".ch-offline-overlay",++ ".ch-offline-overlay > *",++ ".ch-offline-overlay-close",++ ".ch-offline-reopen",++ ".ch-offline-screen",++ ".ch-panel-bar > .ch-vods-filter-bar",++ ".ch-panel-bar > .clips-taken-filters",++ ".ch-tab",++ ".ch-tab i",++ ".ch-tab-badge",++ ".ch-tab-badge[hidden]",++ ".ch-tab-panel",++ ".ch-tab-panel.active",++ ".ch-tab.active",++ ".ch-tab.active .ch-tab-badge",++ ".ch-tab.active::after",++ ".ch-tabs",++ ".chat-avatar",++ ".chat-avatar-letter",++ ".chat-avatar-wrap",++ ".chat-badge",++ ".chat-badge i",++ ".chat-badge-admin",++ ".chat-badge-streamer",++ ".chat-badge[data-tip]::after",++ ".chat-badge[data-tip]::before",++ ".chat-emote",++ ".chat-emote-animated",++ ".chat-header",++ ".chat-header-actions",++ ".chat-input-area",++ ".chat-input-row",++ ".chat-input-row .chat-send-btn",++ ".chat-link",++ ".chat-messages",++ ".chat-mode-select",++ ".chat-mode-select option:disabled",++ ".chat-msg",++ ".chat-msg .chat-user",++ ".chat-msg-hoverable .chat-reply-btn",++ ".chat-msg-tts-off",++ ".chat-msg-tts-off .chat-user::after",++ ".chat-msg.system",++ ".chat-nickels-float .rewards-coin-balance",++ ".chat-replay-empty p",++ ".chat-replay-header h4",++ ".chat-replay-messages",++ ".chat-reply-header",++ ".chat-reply-header i",++ ".chat-reply-snippet",++ ".chat-reply-user",++ ".chat-send-btn",++ ".chat-settings-btn",++ ".chat-sidebar",++ ".chat-sidebar .chat-header",++ ".chat-sidebar .chat-header::before",++ ".chat-sidebar .mobile-chat-close",++ ".chat-slowmode-banner",++ ".chat-stream-badge",++ ".chat-textarea",++ ".chat-textarea::placeholder",++ ".chat-time-inline",++ ".chat-tools-row",++ ".chat-tools-row > button",++ ".chat-tools-row > button.btn-openvibe-bucks-mini",++ ".chat-tools-row > button.btn-openvibe-coins-mini",++ ".chat-tools-row > button.chat-nickels-float",++ ".chat-tools-row > button.chat-tts-toggle",++ ".chat-tools-row > button.chat-tts-toggle.tts-active",++ ".chat-tts-toggle",++ ".chat-tts-toggle.tts-active",++ ".chat-users-panel",++ ".clip-modal-overlay",++ ".comments-empty",++ ".comments-header h3",++ ".container",++ ".content-container",++ ".controls-dock",++ ".cta-ambient",++ ".cta-btn-glow",++ ".cta-embers",++ ".cta-embers span",++ ".cta-hero",++ ".cta-hero-grad",++ ".cta-lede strong",++ ".cta-method",++ ".cta-method span",++ ".cta-method span b",++ ".cta-method strong",++ ".cta-method-num",++ ".cta-section-label",++ ".cta-section-label i",++ ".digest-chip",++ ".digest-hot-line b",++ ".digest-hot-line i",++ ".digest-stat b",++ ".digest-stat i",++ ".digest-title i",++ ".dvr-progress-bar",++ ".dvr-progress-fill::after",++ ".dvr-progress-wrap",++ ".egg-key",++ ".egg-key--letter",++ ".emote-picker",++ ".emote-picker-btn",++ ".emote-picker-btn.gif-btn",++ ".emote-picker-grid",++ ".empty-state",++ ".empty-state i",++ ".fab-label",++ ".fcw-unread-badge",++ ".floating-chat-fab",++ ".floating-chat-widget",++ ".form-input",++ ".form-input-sm",++ ".gai-badge",++ ".gai-strip-text",++ ".global-chat-header",++ ".global-chat-header .muted",++ ".global-chat-header h2",++ ".global-chat-main",++ ".global-chat-main .chat-input-area",++ ".global-chat-main .chat-input-row",++ ".global-chat-main .chat-input-row .chat-textarea",++ ".global-chat-main .chat-input-row input",++ ".global-chat-messages",++ ".goal-bar",++ ".goal-fill",++ ".hero",++ ".hero .hero-tagline",++ ".hero h1",++ ".hero p",++ ".hero-aurora",++ ".hero-bg",++ ".hero-bg-layer",++ ".hero-bg-layer.active",++ ".hero-collage",++ ".hero-content",++ ".hero-egg-titles",++ ".hero-float",++ ".hero-float img",++ ".hero-float--clip .hero-float-badge",++ ".hero-float--paste .hero-float-badge",++ ".hero-float--vod .hero-float-badge",++ ".hero-float-body",++ ".hero-float-inner",++ ".hero-latest b",++ ".hero-philosophy-btn",++ ".hero-quip.visible",++ ".hero-rotate-wrap",++ ".hero-rotate.visible",++ ".hero-scrim",++ ".hero-slogan-timer b",++ ".hero-slogan-timer i",++ ".hero-stat",++ ".hero-stat > i",++ ".hero-stat > i::before",++ ".hero-stat-chart-hint",++ ".hero-stat-delta b",++ ".hero-stat-label",++ ".hero-stat-meta",++ ".hero-stat-more i",++ ".hero-stat-num",++ ".hero-stat-sub",++ ".hero-stats",++ ".hero::after",++ ".hero::before",++ ".home-about-body",++ ".home-about-toggle",++ ".home-changelog-fade",++ ".home-changelog-wrapper",++ ".home-changelog-wrapper.collapsed",++ ".home-cta-banner",++ ".home-cta-banner .btn-outline",++ ".home-cta-banner .cta-hero",++ ".home-cta-banner .cta-lede strong",++ ".home-cta-banner .cta-method span",++ ".home-cta-banner .cta-method span b",++ ".home-cta-banner .cta-method strong",++ ".home-cta-banner .home-cta-feature > span",++ ".home-cta-banner .home-cta-feature > strong",++ ".home-cta-feature",++ ".home-cta-feature > i",++ ".home-cta-feature > span",++ ".home-cta-feature > strong",++ ".home-cta-features .home-cta-feature:nth-child(1)",++ ".home-cta-features .home-cta-feature:nth-child(2)",++ ".home-cta-features .home-cta-feature:nth-child(3)",++ ".home-cta-features .home-cta-feature:nth-child(4)",++ ".home-cta-features .home-cta-feature:nth-child(5)",++ ".home-cta-features .home-cta-feature:nth-child(6)",++ ".home-cta-features .home-cta-feature:nth-child(7)",++ ".home-cta-features .home-cta-feature:nth-child(8)",++ ".home-cta-features .home-cta-feature:nth-child(9)",++ ".home-cta-inner",++ ".home-digest",++ ".home-load-more i",++ ".home-load-more small",++ ".home-paste-icon",++ ".home-paste-info",++ ".home-paste-media",++ ".home-paste-media img",++ ".home-paste-type",++ ".live-dot",++ ".live-stream-tabs",++ ".media-transcript-toggle > i:first-child",++ ".mobile-chat-badge",++ ".mobile-chat-close",++ ".mobile-chat-toggle",++ ".modal-overlay",++ ".muted",++ ".navbar",++ ".navbar > *",++ ".network-card-info",++ ".network-card-info span",++ ".network-card-info strong",++ ".network-hub-header > i",++ ".network-hub-header h3",++ ".network-hub-header p",++ ".off-cyc-title i",++ ".off-top-dur",++ ".off-top-rank",++ ".off-top-thumb",++ ".off-top-thumb img",++ ".offline-global-chat",++ ".offline-global-chat .chat-header",++ ".offline-global-chat .chat-input-area",++ ".offline-global-chat .chat-input-row",++ ".offline-global-chat .chat-input-row .chat-textarea",++ ".offline-global-chat .chat-input-row input",++ ".offline-global-chat .chat-messages",++ ".openvibe-navbar.navbar",++ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",++ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",++ ".openvibe-navbar.navbar .ovnav-launch",++ ".ov-comet",++ ".ov-dot",++ ".ov-glow",++ ".ov-orbit",++ ".ov-orbit--tail",++ ".ov-ring",++ ".ov-spark",++ ".ov-spark--2",++ ".ov-spark--3",++ ".ov-v",++ ".page",++ ".page.active",++ ".paste-modal-overlay",++ ".pastes-grid",++ ".pastes-pagination",++ ".pc-tools-row > button",++ ".pc-tools-row > button.btn-openvibe-bucks-mini",++ ".pc-tools-row > button.btn-openvibe-coins-mini",++ ".pc-tools-row > button.chat-nickels-float",++ ".pc-tools-row > button.chat-tts-toggle.tts-active",++ ".protocol-badge",++ ".protocol-rtmp",++ ".pulse-act i",++ ".pulse-grid",++ ".pulse-kicker i",++ ".pulse-rank b",++ ".pulse-sub b",++ ".rewards-panel",++ ".rs-lite .ov-orbit",++ ".rs-lite .ov-spark",++ ".section-header",++ ".section-header .badge",++ ".section-header .ovd",++ ".section-header > *",++ ".section-header > .badge",++ ".section-header > .muted",++ ".section-header > :not(h2)",++ ".section-header > p",++ ".section-header > span:not(.badge)",++ ".section-header h2",++ ".section-header h2 > i.live-dot",++ ".section-header h2 > i:first-child",++ ".section-header h2 > i:first-child::before",++ ".section-header::after",++ ".stream-card",++ ".stream-card-avatar",++ ".stream-card-avatar img",++ ".stream-card-duration",++ ".stream-card-info",++ ".stream-card-streamer",++ ".stream-card-thumb",++ ".stream-card-thumb i",++ ".stream-card-thumb img",++ ".stream-card-title",++ ".stream-card-viewers",++ ".stream-card-viewers b",++ ".stream-card-viewers i",++ ".stream-grid",++ ".stream-layout",++ ".streamer-group-card .card-ai-overview",++ ".streamer-group-name",++ ".streamer-group-stream-info",++ ".streamer-group-stream-thumb",++ ".toast-container",++ ".tour",++ ".ts-seg",++ ".ts-time",++ ".vc-btn",++ ".vc-channel-body",++ ".vc-mini-bar",++ ".vc-modal-overlay",++ ".vc-progress-bar",++ ".vc-progress-buffer",++ ".vc-progress-fill",++ ".vc-progress-fill::after",++ ".vc-progress-wrap",++ ".vc-sidebar-header h3",++ ".vc-sidebar-header h3 i",++ ".vc-spacer",++ ".vc-time-display",++ ".vc-volume-group",++ ".vc-volume-group .volume-slider",++ ".video-controls",++ ".video-overlay",++ ".vod-ai-memory-t",++ ".vod-ai-timeline-hint i",++ ".vod-ai-timeline-title i",++ ".vod-extra-details .detail-chip i",++ ".vod-player-container",++ ".vod-player-info h2",++ ".vod-player-main",++ ".vod-source-info i",++ ".volume-slider",++ ".volume-slider::-webkit-slider-runnable-track",++ ".volume-slider::-webkit-slider-thumb",++ ".vp-live-indicator",++ "::-webkit-scrollbar",++ "::-webkit-scrollbar-thumb",++ "::-webkit-scrollbar-track",++ ":root",++ "[data-fx-viewport]:not(.fx-live)",++ "[data-fx-viewport]:not(.fx-live) *",++ "[data-fx-viewport]:not(.fx-live) *::after",++ "[data-fx-viewport]:not(.fx-live) *::before",++ "a",++ "body",++ "body.ov-nav-overlay .navbar.ovnav-top",++ "body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top",++ "body::after",++ "body::before",++ "button",++ "html",++ "img",++ "input",++ "main#app",++ "select",++ "select.form-input",++ "textarea"++ ]++}@@@ -24,8 +24,8 @@ const BUDGETS = {jsFiles: 26, // measured 23 (incl. route scripts for "/")jsRawKB: 1150, // measured ~1000jsBrotliKB: 262, // measured 260.1 (2026-10-09: the shared navbar grew with My Profile and the OpenRestream brand)-- cssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)-- cssBrotliKB: 130,++ cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)++ cssBrotliKB: 55, // measured ~47.5 (was ~130)};// Code that must never be part of the home page's first load.const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',nnew file mode 100644@@@ -0,0 +1,75 @@++#!/usr/bin/env node++'use strict';++// Write public/css/critical-selectors.json: the style.css selectors a public page's first view uses.++//++// node scripts/perf/critical-css.js [--base https://openvibe.live] [--settle 9000]++//++// Opens each public route below at a phone and a desktop width in headless Chrome, records CSS rule coverage for++// style.css (CDP CSS.startRuleUsageTracking) after the page settles, maps every used rule back to its selectors with++// server/web/critical-css.js's parser (over the exact text the page loaded), and writes their union. The server builds++// the blocking first-paint subset from it (server/web/critical-css.js). Re-run it after a large change to a public++// page's look; a rule it does not list still arrives with the full style.css, just not before the first paint.++const fs = require('fs');++const path = require('path');++const { launch, sleep } = require('./cdp');++const { parse, splitSelectors, SELECTORS } = require('../../server/web/critical-css');++++const args = process.argv.slice(2);++const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def : args[i + 1]; };++const BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');++const SETTLE = Number(opt('--settle', 9000));++// The public routes people land on from search, links and the home page (signed out).++const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/arena', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];++const WIDTHS = [[412, 900, true], [1366, 900, false]];++++async function coverage(url, [width, height, mobile]) {++ const cdp = await launch({ width, height });++ try {++ await cdp.send('Page.enable');++ await cdp.send('DOM.enable');++ await cdp.send('CSS.enable');++ if (mobile) await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2.6, mobile: true });++ const sheets = new Map();++ cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, e.header));++ await cdp.send('CSS.startRuleUsageTracking');++ await cdp.send('Page.navigate', { url });++ await sleep(SETTLE);++ const r = await cdp.send('CSS.stopRuleUsageTracking');++ const out = new Set();++ const byId = new Map();++ for (const u of (r.result && r.result.ruleUsage) || []) {++ if (!u.used) continue;++ const h = sheets.get(u.styleSheetId);++ if (!h || !/\/css\/style\.css(\?|$)/.test(h.sourceURL || '')) continue;++ if (!byId.has(u.styleSheetId)) {++ const t = await cdp.send('CSS.getStyleSheetText', { styleSheetId: u.styleSheetId });++ const text = (t.result && t.result.text) || '';++ const rules = [];++ const walk = (list) => { for (const n of list) { if (n.type === 'rule') rules.push(n); else if (n.type === 'block') walk(n.children); } };++ walk(parse(text));++ byId.set(u.styleSheetId, rules);++ }++ // The rule whose text holds this range (coverage ranges are a rule's selector and body).++ const rule = byId.get(u.styleSheetId).find((n) => n.start <= u.startOffset && u.startOffset < n.end);++ if (rule) for (const s of splitSelectors(rule.selector)) out.add(s);++ }++ return out;++ } finally {++ cdp.close();++ }++}++++(async () => {++ const all = new Set();++ for (const route of ROUTES) {++ for (const w of WIDTHS) {++ const got = await coverage(BASE + route, w);++ for (const s of got) all.add(s);++ console.log(`${route.padEnd(18)} ${String(w[0]).padStart(4)}px ${got.size} selectors`);++ }++ }++ const selectors = [...all].sort();++ fs.writeFileSync(SELECTORS, `${JSON.stringify({ _about: 'Generated by scripts/perf/critical-css.js: the style.css selectors a public page\'s first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.', base: BASE, routes: ROUTES, selectors }, null, 1)}\n`);++ console.log(`${selectors.length} selectors → ${path.relative(process.cwd(), SELECTORS)}`);++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -619,6 +619,8 @@ app.use('/assets/sounds', express.static(soundsPath, {const noCacheHeaders = assets.setNoCache;const assetOpts = { etag: true, lastModified: true, setHeaders: assets.staticHeaders };app.use('/js', assets.versionedStatic('/js'), express.static(path.join(assets.PUBLIC_DIR, 'js'), assetOpts));++// The first-paint subset of style.css is generated (server/web/critical-css.js), so it is served before the static mount.++app.get(assets.CORE_CSS, assets.serveCoreCss);app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.PUBLIC_DIR, 'css'), assetOpts));// Page markup that is fetched when a route is first opened instead of shipping inside index.html.app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.join(assets.PUBLIC_DIR, 'fragments'), { ...assetOpts, fallthrough: false }));@@@ -33,6 +33,9 @@ const RECHECK_MS = 2000;const HASH_LEN = 12;// Directories whose files are versioned in documents and loadable through the manifest.const MANIFEST_DIRS = ['js', 'css', 'fragments'];++// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.++const critical = require('./critical-css');++const CORE_CSS = '/css/style.core.css';const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;const MANIFEST_MARK = '<!--ov:asset-manifest-->';const ROUTE_CSS_MARK = '<!--ov:route-css-->';@@@ -64,6 +67,7 @@ function resolveIn(root, urlPath) {* bytes belong to the old hash — and Cloudflare would cache that for a year.*/function hashOf(urlPath, { fresh = false } = {}) {++ if (urlPath === CORE_CSS) { const c = critical.core(); return c ? c.hash : null; }const now = Date.now();const entry = files.get(urlPath);if (entry && !fresh && now - entry.checkedAt < RECHECK_MS) return entry.hash;@@@ -255,6 +259,7 @@ function renderRoute(html, urlPath) {const jsTags = js.map((j) => `<script src="${versioned(j)}" defer data-ov-feature-asset></script>`).join('\n');const bootTag = `<script type="application/json" id="ov-route-features">${JSON.stringify(names)}</script>`;let out = html.replace(ROUTE_CSS_MARK, cssTags).replace(ROUTE_JS_MARK, `${bootTag}\n${jsTags}`);++ out = firstPaintCss(out, urlPath);if (lateTags) out = out.replace('</head>', ` ${lateTags}\n</head>`);// The route's page markup, inline: a direct visit renders without a second round trip, and// crawlers get the same content they did when every page shipped in index.html.@@@ -274,6 +279,34 @@ function renderRoute(html, urlPath) {return out;}++/**++ * The public routes whose first view critical-selectors.json was measured on (scripts/perf/critical-css.js): their shell++ * links the subset as the blocking stylesheet and the full style.css right after it, without blocking. Every other++ * route (the dashboard, settings, the broadcast desk, admin…) keeps style.css blocking, exactly as before.++ */++const FIRST_PAINT_ROUTES = /^\/(?:$|content|vods|clips|moments|arena|chat|pastes|search)(?:[/?#]|$)|^\/@[A-Za-z0-9_]+\/?$|^\/(?:vod|clip)\/\d+\/?$/;++const STYLE_LINK = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[A-Za-z0-9_.-]+)">/;++function firstPaintCss(html, urlPath) {++ const p = String(urlPath || '/').split(/[?#]/)[0] || '/';++ if (!FIRST_PAINT_ROUTES.test(p)) return html;++ const m = STYLE_LINK.exec(html);++ const core = m && hashOf(CORE_CSS);++ if (!core) return html;++ const full = m[1];++ return html.replace(m[0], `<link rel="stylesheet" href="${CORE_CSS}?v=${core}" data-ov-first-paint>++ <link rel="stylesheet" href="${full}" media="print" onload="this.media='all';this.onload=null">++ <noscript><link rel="stylesheet" href="${full}"></noscript>`);++}++++/** GET /css/style.core.css: the generated subset, immutable under its current ?v=, short-cached otherwise. */++function serveCoreCss(req, res, next) {++ const c = critical.core();++ if (!c) return next();++ const v = typeof req.query.v === 'string' ? req.query.v : '';++ if (v && v === c.hash) setImmutable(res); else setNoCache(res);++ res.type('text/css').send(c.css);++}++/** A page fragment's HTML (public/fragments/<name>.html), cached until the file changes. */const _fragments = new Map();function fragment(name) {@@@ -385,6 +418,6 @@ function staticHeaders(res) {module.exports = {PUBLIC_DIR, MANIFEST_MARK,setSharedDir, hashOf, versioned, manifest, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,-- versionedStatic, staticHeaders, setNoCache, setImmutable,++ versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,_findPrevious: findPrevious,};nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * The first-paint subset of public/css/style.css.++ *++ * style.css holds every page's rules (~440 KB raw, ~97 KB brotli) and blocked the first paint of every route, while a++ * public page's first view uses about a sixth of it (CDP rule coverage over the public routes at phone and desktop++ * widths). On a public route the shell now links this subset as the blocking stylesheet, and the full style.css right++ * after it without blocking (media="print", flipped to all on load; a <noscript> copy). Both links sit where style.css++ * always sat, so once the full file has arrived the cascade is exactly what it was: the subset's rules come earlier and++ * are repeated, unchanged, by the full file. Only the moment before it arrives differs, and that moment is painted with++ * the rules the first view uses.++ *++ * Which rules: those whose selector list names a selector in public/css/critical-selectors.json (written by++ * scripts/perf/critical-css.js from coverage), the document-level rules (`:root`, `html`, `body`, `*`), each inside the++ * @media/@supports/@container blocks it came from, and the @keyframes the kept rules animate with. Built from the file++ * on disk and rebuilt when it changes; a rule added to style.css later simply arrives with the full file until the++ * selector list is regenerated.++ *++ * parse(css) → nodes: { type: 'rule', selector, start, end, text } | { type: 'block', prelude, children, text }++ * | { type: 'raw', name, text } (@keyframes, @font-face, statements)++ * splitSelectors(selector) → the selector list, split on top-level commas (not inside :is()/:not()/[...])++ * extract(css, selectors) → the subset's CSS text++ * core() → { css, hash, bytes, from } for the current style.css (cached by mtime)++ */++const fs = require('fs');++const path = require('path');++++// The same public directory server/web/assets.js serves (OV_PUBLIC_DIR overrides it in tests).++const PUBLIC_DIR = path.resolve(process.env.OV_PUBLIC_DIR++ || (process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'public') : path.join(__dirname, '../../public')));++const STYLE = path.join(PUBLIC_DIR, 'css', 'style.css');++const SELECTORS = path.join(PUBLIC_DIR, 'css', 'critical-selectors.json');++const DOCUMENT_LEVEL = new Set([':root', 'html', 'body', '*', '*::before', '*::after', 'body::before', 'body::after']);++const NESTING = /^@(media|supports|container|layer)\b/i;++++/** Skip a comment or string starting at i; returns the index after it, or i when there is none. */++function skip(css, i) {++ if (css[i] === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); return e === -1 ? css.length : e + 2; }++ if (css[i] === '"' || css[i] === "'") {++ const q = css[i];++ let j = i + 1;++ while (j < css.length && css[j] !== q) j += css[j] === '\\' ? 2 : 1;++ return j + 1;++ }++ return i;++}++++/** The index just after the `}` that closes the block whose `{` is at open. */++function closeOf(css, open) {++ let depth = 0;++ for (let i = open; i < css.length;) {++ const n = skip(css, i);++ if (n !== i) { i = n; continue; }++ if (css[i] === '{') depth++;++ else if (css[i] === '}' && --depth === 0) return i + 1;++ i++;++ }++ return css.length;++}++++function parse(css, from = 0, to = css.length) {++ const nodes = [];++ let i = from;++ while (i < to) {++ const n = skip(css, i);++ if (n !== i) { i = n; continue; }++ if (/\s/.test(css[i]) || css[i] === ';' || css[i] === '}') { i++; continue; }++ // A prelude runs to the first `{` or `;` outside strings and comments.++ const start = i;++ let j = i;++ while (j < to && css[j] !== '{' && css[j] !== ';') { const k = skip(css, j); j = k !== j ? k : j + 1; }++ const prelude = css.slice(start, j).replace(/\/\*[\s\S]*?\*\//g, '').trim();++ if (j >= to || css[j] === ';') { nodes.push({ type: 'raw', name: prelude.split(/\s/)[0], text: css.slice(start, Math.min(j + 1, to)) }); i = j + 1; continue; }++ const end = closeOf(css, j);++ if (prelude.startsWith('@')) {++ if (NESTING.test(prelude)) nodes.push({ type: 'block', prelude, start, end, children: parse(css, j + 1, end - 1), text: css.slice(start, end) });++ else nodes.push({ type: 'raw', name: prelude.split(/\s/)[0].toLowerCase(), prelude, start, end, text: css.slice(start, end) });++ } else {++ nodes.push({ type: 'rule', selector: prelude, start, end, text: css.slice(start, end) });++ }++ i = end;++ }++ return nodes;++}++++/** "a, b:is(c, d), [x=','] e" → ['a', 'b:is(c, d)', "[x=','] e"], each with its whitespace collapsed. */++function splitSelectors(selector) {++ const out = [];++ let depth = 0, cur = '', q = null;++ for (const ch of String(selector)) {++ if (q) { cur += ch; if (ch === q) q = null; continue; }++ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }++ if (ch === '(' || ch === '[') depth++;++ else if (ch === ')' || ch === ']') depth--;++ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }++ cur += ch;++ }++ out.push(cur);++ return out.map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);++}++++function keepRule(node, wanted) {++ return splitSelectors(node.selector).some((s) => wanted.has(s) || DOCUMENT_LEVEL.has(s));++}++++function pick(nodes, wanted) {++ const out = [];++ for (const node of nodes) {++ if (node.type === 'rule') { if (keepRule(node, wanted)) out.push(node.text.trim()); continue; }++ if (node.type === 'block') {++ const inner = pick(node.children, wanted);++ if (inner.length) out.push(`${node.prelude} {\n${inner.join('\n')}\n}`);++ }++ }++ return out;++}++++function extract(css, selectors) {++ const wanted = selectors instanceof Set ? selectors : new Set(selectors);++ const nodes = parse(css);++ const kept = pick(nodes, wanted);++ const body = kept.join('\n');++ // The @keyframes (and @font-face) the kept rules use; nothing else that is raw.++ const raws = [];++ const walk = (list) => {++ for (const node of list) {++ if (node.type === 'block') walk(node.children);++ if (node.type !== 'raw') continue;++ if (node.name === '@font-face') raws.push(node.text.trim());++ if (/^@(-webkit-)?keyframes$/.test(node.name)) {++ const name = (node.prelude || '').split(/\s+/)[1];++ if (name && new RegExp(`(^|[\\s,:])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s,;!]|$)`, 'm').test(body)) raws.push(node.text.trim());++ }++ }++ };++ walk(nodes);++ return `/* Generated by server/web/critical-css.js from style.css: the rules a public page's first view uses. */\n${body}\n${raws.join('\n')}\n`;++}++++let _cache = null;++/** The subset for the style.css on disk, rebuilt when it or the selector list changes. */++function core() {++ let st, sel;++ try { st = fs.statSync(STYLE); sel = fs.statSync(SELECTORS); } catch { return null; }++ const key = `${st.mtimeMs}:${st.size}:${sel.mtimeMs}:${sel.size}`;++ if (_cache && _cache.key === key) return _cache;++ let selectors = [];++ try { selectors = JSON.parse(fs.readFileSync(SELECTORS, 'utf8')).selectors || []; } catch { selectors = []; }++ if (!selectors.length) return null;++ const css = extract(fs.readFileSync(STYLE, 'utf8'), selectors);++ _cache = { key, css, hash: require('crypto').createHash('sha256').update(css).digest('hex').slice(0, 12), bytes: Buffer.byteLength(css), from: st.size };++ return _cache;++}++++module.exports = { parse, splitSelectors, extract, core, STYLE, SELECTORS, DOCUMENT_LEVEL };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// The first-paint subset of style.css (server/web/critical-css.js) and the shell swap (server/web/assets.js): a public++// route's first paint blocks only on the subset, the full style.css follows without blocking in the same place (so the++// final cascade is unchanged), every other route keeps style.css blocking, and the subset is a fraction of the file.++const assert = require('assert');++const fs = require('fs');++const critical = require('../server/web/critical-css');++const assets = require('../server/web/assets');++++let pass = 0;++const ok = (m) => { pass++; console.log(` ✓ ${m}`); };++++// ── The parser and the extraction ──++{++ const css = `/* a comment { with braces } */++:root { --x: 1; }++html { color: red; }++.keep, .other:is(.a, .b) { color: blue; animation: spin 1s linear; }++.drop { color: green; animation: fade 1s; }++@media (max-width: 600px) { .keep { padding: 1px; } .drop { padding: 2px; } }++@media print { .drop { display: none; } }++@supports (display: grid) { .keep { display: grid; } }++@keyframes spin { from { transform: rotate(0); } to { transform: rotate(1turn); } }++@keyframes fade { from { opacity: 0; } to { opacity: 1; } }++.str::after { content: "}{"; }`;++ assert.deepStrictEqual(critical.splitSelectors('.keep, .other:is(.a, .b), [x=","] y'), ['.keep', '.other:is(.a, .b)', '[x=","] y']);++ const nodes = critical.parse(css);++ assert.strictEqual(nodes.filter((n) => n.type === 'rule').length, 5, 'top-level rules, a string with braces included');++ const out = critical.extract(css, ['.keep', '.str::after']);++ assert.ok(out.includes(':root { --x: 1; }') && out.includes('html { color: red; }'), 'document-level rules always');++ assert.ok(out.includes('.keep, .other:is(.a, .b) { color: blue;'), 'a rule naming a wanted selector, whole');++ assert.ok(!out.includes('.drop'), 'nothing else');++ assert.match(out, /@media \(max-width: 600px\) \{\n\.keep \{ padding: 1px; \}\n\}/, 'inside its @media, only the kept rule');++ assert.ok(!out.includes('@media print'), 'an @media left empty is dropped');++ assert.ok(out.includes('@supports (display: grid)'), '@supports kept with its rule');++ assert.ok(out.includes('@keyframes spin') && !out.includes('@keyframes fade'), 'only the keyframes kept rules animate with');++ assert.ok(out.includes('content: "}{"'), 'strings survive');++ ok('extract keeps wanted and document-level rules in their @media/@supports, and only their keyframes');++}++++// ── The real style.css ──++const core = critical.core();++assert.ok(core && core.css.length > 1000, 'a subset is built from the committed selector list');++assert.ok(core.bytes < core.from * 0.3, `the subset is a fraction of style.css (${core.bytes} of ${core.from})`);++assert.match(core.hash, /^[0-9a-f]{12}$/);++assert.ok(core.css.includes(':root'), 'the theme variables are in it');++{++ const listed = JSON.parse(fs.readFileSync(critical.SELECTORS, 'utf8')).selectors;++ const inFile = new Set();++ const walk = (list) => { for (const n of list) { if (n.type === 'rule') critical.splitSelectors(n.selector).forEach((s) => inFile.add(s)); else if (n.type === 'block') walk(n.children); } };++ walk(critical.parse(fs.readFileSync(critical.STYLE, 'utf8')));++ const stale = listed.filter((s) => !inFile.has(s));++ // A renamed or removed rule leaves its selector behind: harmless, but a list this stale should be regenerated.++ assert.ok(stale.length <= listed.length * 0.1, `critical-selectors.json is stale (${stale.length} of ${listed.length} no longer in style.css): run node scripts/perf/critical-css.js`);++}++ok(`the subset of the real style.css is ${(100 * core.bytes / core.from).toFixed(0)}% of it, from a current selector list`);++++// ── The shell ──++const doc = assets.document('index.html').html;++const blockingLinks = (html) => [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((h) => !/noscript/.test(h));++for (const url of ['/', '/content', '/vods', '/clips', '/chat', '/search?q=x', '/@someone', '/vod/12', '/clip/7', '/arena']) {++ const html = assets.renderRoute(doc, url);++ const head = html.slice(0, html.indexOf('</head>'));++ assert.ok(head.includes(`<link rel="stylesheet" href="/css/style.core.css?v=${core.hash}" data-ov-first-paint>`), `${url}: the subset blocks the first paint`);++ const full = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[^"]+)" media="print" onload="this\.media='all';this\.onload=null">/.exec(head);++ assert.ok(full, `${url}: the full style.css follows without blocking`);++ assert.ok(head.indexOf('style.core.css') < head.indexOf(full[1]) && head.indexOf(full[1]) < head.indexOf('<meta name="ov-css-anchor">'), `${url}: in style.css's place in the cascade, before the feature stylesheets`);++ assert.ok(head.includes(`<noscript><link rel="stylesheet" href="${full[1]}"></noscript>`), `${url}: a no-JS copy`);++ assert.ok(!blockingLinks(head.replace(/<noscript>[\s\S]*?<\/noscript>/g, '')).some((h) => h.startsWith('/css/style.css')), `${url}: style.css itself no longer blocks`);++}++for (const url of ['/dashboard', '/broadcast', '/settings', '/admin', '/documentation', '/vod/12/edit', '/@someone/videos']) {++ const html = assets.renderRoute(doc, url);++ assert.ok(!html.includes('style.core.css'), `${url}: no subset`);++ assert.match(html, /<link rel="stylesheet" href="\/css\/style\.css\?v=[^"]+">/, `${url}: style.css blocks as before`);++}++ok('public routes block on the subset and load style.css after it in the same place; every other route is unchanged');++++assert.strictEqual(assets.hashOf(assets.CORE_CSS), core.hash, 'the subset is versioned by its content');++ok('the subset is versioned by its content');++++console.log(`critical-css: ${pass} checks passed`);
@@ -53,6 +53,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -53,6 +53,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
## Key Pitfalls
## Key Pitfalls
- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).
- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).
+- **First-paint CSS:** public routes block only on `/css/style.core.css`, a subset of `style.css` generated from `public/css/critical-selectors.json` (`server/web/critical-css.js`); the full `style.css` follows without blocking. After changing how a public page looks, re-run `node scripts/perf/critical-css.js` (docs/performance-audit.md).
- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.
- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.
- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.
- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.
- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.
- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.
@@ -130,6 +130,28 @@ Shared 1.17 to 1.20 changed `release-watch.js`, which every page loads after fir
@@ -130,6 +130,28 @@ Shared 1.17 to 1.20 changed `release-watch.js`, which every page loads after fir
The release client adds one request (the EventSource, which excludes itself from idle-network checks) and no measurable paint or blocking cost. The size budgets in `npm test` (scripts/perf/check-budgets.js) still pass.
The release client adds one request (the EventSource, which excludes itself from idle-network checks) and no measurable paint or blocking cost. The size budgets in `npm test` (scripts/perf/check-budgets.js) still pass.
+## Measurement 2026-10-12: the first-paint stylesheet
+
+A phone-profile sweep of every OpenVibe home page (4x CPU, ~1.6 Mbit/s) put openvibe.live last: first paint 4.9–5.3 s.
+Every route blocked on all of `style.css` (468 KB raw, ~97 KB brotli as served), while CDP rule coverage over the public
+routes (home, content, VODs, clips, moments, arena, chat, search, a channel, a VOD, a clip, pastes) at 412 and 1366 px
+used 436 of its selectors, 17–18 % of the file.
+
+- `scripts/perf/critical-css.js` writes those selectors to `public/css/critical-selectors.json`; `server/web/critical-css.js`
+ builds the subset from the `style.css` on disk (rules naming a listed selector, `:root`/`html`/`body`/`*`, each inside
+ its `@media`/`@supports`/`@container`, and only the `@keyframes` they use): 84 KB raw, ~15 KB brotli.
+- On those routes the shell links `/css/style.core.css?v=…` (generated, content-hashed, served before the `/css` static
+ mount) as the blocking stylesheet, and the full `style.css` right after it with `media="print"` flipped on load, plus a
+ `<noscript>` copy. Both sit where `style.css` sat, before the feature stylesheets, so once the full file has arrived the
+ cascade is exactly the old one (the subset's rules come earlier and are repeated unchanged). Other routes (dashboard,
+ settings, broadcast, admin, …) keep `style.css` blocking.
+- A/B on production pages through a local same-origin proxy (brotli, phone profile): first paint home 2.48 → 1.47 s,
+ channel 2.51 → 1.46 s, VOD 2.24 → 1.48 s (LCP 3.8 → 1.5 s); LCP and CLS elsewhere unchanged; the settled pages are
+ pixel-identical apart from animations and timestamps.
+- Re-run `node scripts/perf/critical-css.js` after changing a public page's look: a rule it does not list still arrives
+ with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed
+ selectors are no longer in `style.css`.
+
## Budgets
## Budgets
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
new file mode 100644
new file mode 100644
@@ -0,0 +1,457 @@
@@ -0,0 +1,457 @@
+{
+ "_about": "Generated by scripts/perf/critical-css.js: the style.css selectors a public page's first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.",
+ "base": "https://openvibe.live",
+ "routes": [
+ "/",
+ "/content",
+ "/vods",
+ "/clips",
+ "/moments",
+ "/arena",
+ "/chat",
+ "/search?q=game",
+ "/@iZoR_One",
+ "/vod/5717",
+ "/clip/441",
+ "/pastes",
+ "/docs"
+ ],
+ "selectors": [
+ "#openvibe-bell-mount",
+ "#page-home",
+ "#page-home .hero",
+ "#page-home.page",
+ "#stream-grid-live .empty-state",
+ "*",
+ "*::after",
+ "*::before",
+ ".arena-beef-empty i",
+ ".arena-beef-empty p",
+ ".arena-beefs h2",
+ ".arena-beefs h2 small",
+ ".arena-board-head h2",
+ ".arena-board-head h2 small",
+ ".arena-board-head input",
+ ".arena-container",
+ ".arena-done summary",
+ ".arena-done summary small",
+ ".arena-hero h1",
+ ".arena-ladder h2",
+ ".arena-ladder h2 small",
+ ".arena-live h2",
+ ".arena-live h2 small",
+ ".arena-live-dot",
+ ".arena-loading i",
+ ".arena-lvl",
+ ".arena-mic-moment-body",
+ ".arena-mic-moment-head small",
+ ".arena-note",
+ ".arena-portrait",
+ ".arena-portrait img",
+ ".arena-portrait-glow",
+ ".arena-portrait-sm",
+ ".arena-portrait-xs",
+ ".arena-power b",
+ ".arena-power small",
+ ".arena-rank-1",
+ ".arena-rank-2",
+ ".arena-rank-3",
+ ".arena-row .arena-chevron",
+ ".arena-row-main",
+ ".arena-row-main strong",
+ ".arena-rules h3",
+ ".arena-rules ul",
+ ".arena-rules-list",
+ ".badge",
+ ".brand-mark",
+ ".brand-mark .ov-comet",
+ ".brand-mark .ov-dot",
+ ".brand-mark .ov-glow",
+ ".brand-mark .ov-spark",
+ ".brand-mark .ov-v",
+ ".brand-mark svg",
+ ".brand-mark--hero",
+ ".brand-mark--hero svg",
+ ".brand-mark.is-awake .ov-comet",
+ ".brand-mark.is-awake .ov-dot",
+ ".brand-mark.is-awake .ov-glow",
+ ".brand-mark.is-awake .ov-spark",
+ ".brand-mark.is-awake .ov-v",
+ ".brand-mark.is-awake svg",
+ ".brand-mark.is-settling svg",
+ ".brand-mark.is-spin svg",
+ ".btn",
+ ".btn-danger",
+ ".btn-follow",
+ ".btn-lg",
+ ".btn-openvibe-bucks-mini",
+ ".btn-openvibe-coins-mini",
+ ".btn-outline",
+ ".btn-primary",
+ ".btn-small",
+ ".card-ai-clamp",
+ ".card-ai-clamp i.fa-wand-magic-sparkles",
+ ".card-ai-overview",
+ ".card-ai-static",
+ ".card-ai-toggle",
+ ".ch-goal-widget",
+ ".ch-goal-widget:not(.cgw-multi)",
+ ".ch-offline-area",
+ ".ch-offline-area .ch-offline-screen",
+ ".ch-offline-header-info",
+ ".ch-offline-header-info h1",
+ ".ch-offline-overlay",
+ ".ch-offline-overlay > *",
+ ".ch-offline-overlay-close",
+ ".ch-offline-reopen",
+ ".ch-offline-screen",
+ ".ch-panel-bar > .ch-vods-filter-bar",
+ ".ch-panel-bar > .clips-taken-filters",
+ ".ch-tab",
+ ".ch-tab i",
+ ".ch-tab-badge",
+ ".ch-tab-badge[hidden]",
+ ".ch-tab-panel",
+ ".ch-tab-panel.active",
+ ".ch-tab.active",
+ ".ch-tab.active .ch-tab-badge",
+ ".ch-tab.active::after",
+ ".ch-tabs",
+ ".chat-avatar",
+ ".chat-avatar-letter",
+ ".chat-avatar-wrap",
+ ".chat-badge",
+ ".chat-badge i",
+ ".chat-badge-admin",
+ ".chat-badge-streamer",
+ ".chat-badge[data-tip]::after",
+ ".chat-badge[data-tip]::before",
+ ".chat-emote",
+ ".chat-emote-animated",
+ ".chat-header",
+ ".chat-header-actions",
+ ".chat-input-area",
+ ".chat-input-row",
+ ".chat-input-row .chat-send-btn",
+ ".chat-link",
+ ".chat-messages",
+ ".chat-mode-select",
+ ".chat-mode-select option:disabled",
+ ".chat-msg",
+ ".chat-msg .chat-user",
+ ".chat-msg-hoverable .chat-reply-btn",
+ ".chat-msg-tts-off",
+ ".chat-msg-tts-off .chat-user::after",
+ ".chat-msg.system",
+ ".chat-nickels-float .rewards-coin-balance",
+ ".chat-replay-empty p",
+ ".chat-replay-header h4",
+ ".chat-replay-messages",
+ ".chat-reply-header",
+ ".chat-reply-header i",
+ ".chat-reply-snippet",
+ ".chat-reply-user",
+ ".chat-send-btn",
+ ".chat-settings-btn",
+ ".chat-sidebar",
+ ".chat-sidebar .chat-header",
+ ".chat-sidebar .chat-header::before",
+ ".chat-sidebar .mobile-chat-close",
+ ".chat-slowmode-banner",
+ ".chat-stream-badge",
+ ".chat-textarea",
+ ".chat-textarea::placeholder",
+ ".chat-time-inline",
+ ".chat-tools-row",
+ ".chat-tools-row > button",
+ ".chat-tools-row > button.btn-openvibe-bucks-mini",
+ ".chat-tools-row > button.btn-openvibe-coins-mini",
+ ".chat-tools-row > button.chat-nickels-float",
+ ".chat-tools-row > button.chat-tts-toggle",
+ ".chat-tools-row > button.chat-tts-toggle.tts-active",
+ ".chat-tts-toggle",
+ ".chat-tts-toggle.tts-active",
+ ".chat-users-panel",
+ ".clip-modal-overlay",
+ ".comments-empty",
+ ".comments-header h3",
+ ".container",
+ ".content-container",
+ ".controls-dock",
+ ".cta-ambient",
+ ".cta-btn-glow",
+ ".cta-embers",
+ ".cta-embers span",
+ ".cta-hero",
+ ".cta-hero-grad",
+ ".cta-lede strong",
+ ".cta-method",
+ ".cta-method span",
+ ".cta-method span b",
+ ".cta-method strong",
+ ".cta-method-num",
+ ".cta-section-label",
+ ".cta-section-label i",
+ ".digest-chip",
+ ".digest-hot-line b",
+ ".digest-hot-line i",
+ ".digest-stat b",
+ ".digest-stat i",
+ ".digest-title i",
+ ".dvr-progress-bar",
+ ".dvr-progress-fill::after",
+ ".dvr-progress-wrap",
+ ".egg-key",
+ ".egg-key--letter",
+ ".emote-picker",
+ ".emote-picker-btn",
+ ".emote-picker-btn.gif-btn",
+ ".emote-picker-grid",
+ ".empty-state",
+ ".empty-state i",
+ ".fab-label",
+ ".fcw-unread-badge",
+ ".floating-chat-fab",
+ ".floating-chat-widget",
+ ".form-input",
+ ".form-input-sm",
+ ".gai-badge",
+ ".gai-strip-text",
+ ".global-chat-header",
+ ".global-chat-header .muted",
+ ".global-chat-header h2",
+ ".global-chat-main",
+ ".global-chat-main .chat-input-area",
+ ".global-chat-main .chat-input-row",
+ ".global-chat-main .chat-input-row .chat-textarea",
+ ".global-chat-main .chat-input-row input",
+ ".global-chat-messages",
+ ".goal-bar",
+ ".goal-fill",
+ ".hero",
+ ".hero .hero-tagline",
+ ".hero h1",
+ ".hero p",
+ ".hero-aurora",
+ ".hero-bg",
+ ".hero-bg-layer",
+ ".hero-bg-layer.active",
+ ".hero-collage",
+ ".hero-content",
+ ".hero-egg-titles",
+ ".hero-float",
+ ".hero-float img",
+ ".hero-float--clip .hero-float-badge",
+ ".hero-float--paste .hero-float-badge",
+ ".hero-float--vod .hero-float-badge",
+ ".hero-float-body",
+ ".hero-float-inner",
+ ".hero-latest b",
+ ".hero-philosophy-btn",
+ ".hero-quip.visible",
+ ".hero-rotate-wrap",
+ ".hero-rotate.visible",
+ ".hero-scrim",
+ ".hero-slogan-timer b",
+ ".hero-slogan-timer i",
+ ".hero-stat",
+ ".hero-stat > i",
+ ".hero-stat > i::before",
+ ".hero-stat-chart-hint",
+ ".hero-stat-delta b",
+ ".hero-stat-label",
+ ".hero-stat-meta",
+ ".hero-stat-more i",
+ ".hero-stat-num",
+ ".hero-stat-sub",
+ ".hero-stats",
+ ".hero::after",
+ ".hero::before",
+ ".home-about-body",
+ ".home-about-toggle",
+ ".home-changelog-fade",
+ ".home-changelog-wrapper",
+ ".home-changelog-wrapper.collapsed",
+ ".home-cta-banner",
+ ".home-cta-banner .btn-outline",
+ ".home-cta-banner .cta-hero",
+ ".home-cta-banner .cta-lede strong",
+ ".home-cta-banner .cta-method span",
+ ".home-cta-banner .cta-method span b",
+ ".home-cta-banner .cta-method strong",
+ ".home-cta-banner .home-cta-feature > span",
+ ".home-cta-banner .home-cta-feature > strong",
+ ".home-cta-feature",
+ ".home-cta-feature > i",
+ ".home-cta-feature > span",
+ ".home-cta-feature > strong",
+ ".home-cta-features .home-cta-feature:nth-child(1)",
+ ".home-cta-features .home-cta-feature:nth-child(2)",
+ ".home-cta-features .home-cta-feature:nth-child(3)",
+ ".home-cta-features .home-cta-feature:nth-child(4)",
+ ".home-cta-features .home-cta-feature:nth-child(5)",
+ ".home-cta-features .home-cta-feature:nth-child(6)",
+ ".home-cta-features .home-cta-feature:nth-child(7)",
+ ".home-cta-features .home-cta-feature:nth-child(8)",
+ ".home-cta-features .home-cta-feature:nth-child(9)",
+ ".home-cta-inner",
+ ".home-digest",
+ ".home-load-more i",
+ ".home-load-more small",
+ ".home-paste-icon",
+ ".home-paste-info",
+ ".home-paste-media",
+ ".home-paste-media img",
+ ".home-paste-type",
+ ".live-dot",
+ ".live-stream-tabs",
+ ".media-transcript-toggle > i:first-child",
+ ".mobile-chat-badge",
+ ".mobile-chat-close",
+ ".mobile-chat-toggle",
+ ".modal-overlay",
+ ".muted",
+ ".navbar",
+ ".navbar > *",
+ ".network-card-info",
+ ".network-card-info span",
+ ".network-card-info strong",
+ ".network-hub-header > i",
+ ".network-hub-header h3",
+ ".network-hub-header p",
+ ".off-cyc-title i",
+ ".off-top-dur",
+ ".off-top-rank",
+ ".off-top-thumb",
+ ".off-top-thumb img",
+ ".offline-global-chat",
+ ".offline-global-chat .chat-header",
+ ".offline-global-chat .chat-input-area",
+ ".offline-global-chat .chat-input-row",
+ ".offline-global-chat .chat-input-row .chat-textarea",
+ ".offline-global-chat .chat-input-row input",
+ ".offline-global-chat .chat-messages",
+ ".openvibe-navbar.navbar",
+ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",
+ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
+ ".openvibe-navbar.navbar .ovnav-launch",
+ ".ov-comet",
+ ".ov-dot",
+ ".ov-glow",
+ ".ov-orbit",
+ ".ov-orbit--tail",
+ ".ov-ring",
+ ".ov-spark",
+ ".ov-spark--2",
+ ".ov-spark--3",
+ ".ov-v",
+ ".page",
+ ".page.active",
+ ".paste-modal-overlay",
+ ".pastes-grid",
+ ".pastes-pagination",
+ ".pc-tools-row > button",
+ ".pc-tools-row > button.btn-openvibe-bucks-mini",
+ ".pc-tools-row > button.btn-openvibe-coins-mini",
+ ".pc-tools-row > button.chat-nickels-float",
+ ".pc-tools-row > button.chat-tts-toggle.tts-active",
+ ".protocol-badge",
+ ".protocol-rtmp",
+ ".pulse-act i",
+ ".pulse-grid",
+ ".pulse-kicker i",
+ ".pulse-rank b",
+ ".pulse-sub b",
+ ".rewards-panel",
+ ".rs-lite .ov-orbit",
+ ".rs-lite .ov-spark",
+ ".section-header",
+ ".section-header .badge",
+ ".section-header .ovd",
+ ".section-header > *",
+ ".section-header > .badge",
+ ".section-header > .muted",
+ ".section-header > :not(h2)",
+ ".section-header > p",
+ ".section-header > span:not(.badge)",
+ ".section-header h2",
+ ".section-header h2 > i.live-dot",
+ ".section-header h2 > i:first-child",
+ ".section-header h2 > i:first-child::before",
+ ".section-header::after",
+ ".stream-card",
+ ".stream-card-avatar",
+ ".stream-card-avatar img",
+ ".stream-card-duration",
+ ".stream-card-info",
+ ".stream-card-streamer",
+ ".stream-card-thumb",
+ ".stream-card-thumb i",
+ ".stream-card-thumb img",
+ ".stream-card-title",
+ ".stream-card-viewers",
+ ".stream-card-viewers b",
+ ".stream-card-viewers i",
+ ".stream-grid",
+ ".stream-layout",
+ ".streamer-group-card .card-ai-overview",
+ ".streamer-group-name",
+ ".streamer-group-stream-info",
+ ".streamer-group-stream-thumb",
+ ".toast-container",
+ ".tour",
+ ".ts-seg",
+ ".ts-time",
+ ".vc-btn",
+ ".vc-channel-body",
+ ".vc-mini-bar",
+ ".vc-modal-overlay",
+ ".vc-progress-bar",
+ ".vc-progress-buffer",
+ ".vc-progress-fill",
+ ".vc-progress-fill::after",
+ ".vc-progress-wrap",
+ ".vc-sidebar-header h3",
+ ".vc-sidebar-header h3 i",
+ ".vc-spacer",
+ ".vc-time-display",
+ ".vc-volume-group",
+ ".vc-volume-group .volume-slider",
+ ".video-controls",
+ ".video-overlay",
+ ".vod-ai-memory-t",
+ ".vod-ai-timeline-hint i",
+ ".vod-ai-timeline-title i",
+ ".vod-extra-details .detail-chip i",
+ ".vod-player-container",
+ ".vod-player-info h2",
+ ".vod-player-main",
+ ".vod-source-info i",
+ ".volume-slider",
+ ".volume-slider::-webkit-slider-runnable-track",
+ ".volume-slider::-webkit-slider-thumb",
+ ".vp-live-indicator",
+ "::-webkit-scrollbar",
+ "::-webkit-scrollbar-thumb",
+ "::-webkit-scrollbar-track",
+ ":root",
+ "[data-fx-viewport]:not(.fx-live)",
+ "[data-fx-viewport]:not(.fx-live) *",
+ "[data-fx-viewport]:not(.fx-live) *::after",
+ "[data-fx-viewport]:not(.fx-live) *::before",
+ "a",
+ "body",
+ "body.ov-nav-overlay .navbar.ovnav-top",
+ "body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top",
+ "body::after",
+ "body::before",
+ "button",
+ "html",
+ "img",
+ "input",
+ "main#app",
+ "select",
+ "select.form-input",
+ "textarea"
+ ]
+}
@@ -24,8 +24,8 @@ const BUDGETS = {
@@ -24,8 +24,8 @@ const BUDGETS = {
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1150, // measured ~1000
jsRawKB: 1150, // measured ~1000
jsBrotliKB: 262, // measured 260.1 (2026-10-09: the shared navbar grew with My Profile and the OpenRestream brand)
jsBrotliKB: 262, // measured 260.1 (2026-10-09: the shared navbar grew with My Profile and the OpenRestream brand)
- cssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)
- cssBrotliKB: 130,
+ cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
+ cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
// Code that must never be part of the home page's first load.
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+'use strict';
+// Write public/css/critical-selectors.json: the style.css selectors a public page's first view uses.
+//
+// node scripts/perf/critical-css.js [--base https://openvibe.live] [--settle 9000]
+//
+// Opens each public route below at a phone and a desktop width in headless Chrome, records CSS rule coverage for
+// style.css (CDP CSS.startRuleUsageTracking) after the page settles, maps every used rule back to its selectors with
+// server/web/critical-css.js's parser (over the exact text the page loaded), and writes their union. The server builds
+// the blocking first-paint subset from it (server/web/critical-css.js). Re-run it after a large change to a public
+// page's look; a rule it does not list still arrives with the full style.css, just not before the first paint.
+const fs = require('fs');
+const path = require('path');
+const { launch, sleep } = require('./cdp');
+const { parse, splitSelectors, SELECTORS } = require('../../server/web/critical-css');
+
+const args = process.argv.slice(2);
+const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def : args[i + 1]; };
+const BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');
+const SETTLE = Number(opt('--settle', 9000));
+// The public routes people land on from search, links and the home page (signed out).
+const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/arena', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];
+const WIDTHS = [[412, 900, true], [1366, 900, false]];
+
+async function coverage(url, [width, height, mobile]) {
+ const cdp = await launch({ width, height });
+ try {
+ await cdp.send('Page.enable');
+ await cdp.send('DOM.enable');
+ await cdp.send('CSS.enable');
+ if (mobile) await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2.6, mobile: true });
+ const sheets = new Map();
+ cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, e.header));
+ await cdp.send('CSS.startRuleUsageTracking');
+ await cdp.send('Page.navigate', { url });
+ await sleep(SETTLE);
+ const r = await cdp.send('CSS.stopRuleUsageTracking');
+ const out = new Set();
+ const byId = new Map();
+ for (const u of (r.result && r.result.ruleUsage) || []) {
+ if (!u.used) continue;
+ const h = sheets.get(u.styleSheetId);
+ if (!h || !/\/css\/style\.css(\?|$)/.test(h.sourceURL || '')) continue;
+ if (!byId.has(u.styleSheetId)) {
+ const t = await cdp.send('CSS.getStyleSheetText', { styleSheetId: u.styleSheetId });
+ const text = (t.result && t.result.text) || '';
+ const rules = [];
+ const walk = (list) => { for (const n of list) { if (n.type === 'rule') rules.push(n); else if (n.type === 'block') walk(n.children); } };
+ walk(parse(text));
+ byId.set(u.styleSheetId, rules);
+ }
+ // The rule whose text holds this range (coverage ranges are a rule's selector and body).
+ const rule = byId.get(u.styleSheetId).find((n) => n.start <= u.startOffset && u.startOffset < n.end);
+ if (rule) for (const s of splitSelectors(rule.selector)) out.add(s);
+ }
+ return out;
+ } finally {
+ cdp.close();
+ }
+}
+
+(async () => {
+ const all = new Set();
+ for (const route of ROUTES) {
+ for (const w of WIDTHS) {
+ const got = await coverage(BASE + route, w);
+ for (const s of got) all.add(s);
+ console.log(`${route.padEnd(18)} ${String(w[0]).padStart(4)}px ${got.size} selectors`);
+ }
+ }
+ const selectors = [...all].sort();
+ fs.writeFileSync(SELECTORS, `${JSON.stringify({ _about: 'Generated by scripts/perf/critical-css.js: the style.css selectors a public page\'s first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.', base: BASE, routes: ROUTES, selectors }, null, 1)}\n`);
+ console.log(`${selectors.length} selectors → ${path.relative(process.cwd(), SELECTORS)}`);
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -619,6 +619,8 @@ app.use('/assets/sounds', express.static(soundsPath, {
@@ -619,6 +619,8 @@ app.use('/assets/sounds', express.static(soundsPath, {
const noCacheHeaders = assets.setNoCache;
const noCacheHeaders = assets.setNoCache;
const assetOpts = { etag: true, lastModified: true, setHeaders: assets.staticHeaders };
const assetOpts = { etag: true, lastModified: true, setHeaders: assets.staticHeaders };
app.use('/js', assets.versionedStatic('/js'), express.static(path.join(assets.PUBLIC_DIR, 'js'), assetOpts));
app.use('/js', assets.versionedStatic('/js'), express.static(path.join(assets.PUBLIC_DIR, 'js'), assetOpts));
+// The first-paint subset of style.css is generated (server/web/critical-css.js), so it is served before the static mount.
+app.get(assets.CORE_CSS, assets.serveCoreCss);
app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.PUBLIC_DIR, 'css'), assetOpts));
app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.PUBLIC_DIR, 'css'), assetOpts));
// Page markup that is fetched when a route is first opened instead of shipping inside index.html.
// Page markup that is fetched when a route is first opened instead of shipping inside index.html.
app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.join(assets.PUBLIC_DIR, 'fragments'), { ...assetOpts, fallthrough: false }));
app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.join(assets.PUBLIC_DIR, 'fragments'), { ...assetOpts, fallthrough: false }));
@@ -33,6 +33,9 @@ const RECHECK_MS = 2000;
@@ -33,6 +33,9 @@ const RECHECK_MS = 2000;
const HASH_LEN = 12;
const HASH_LEN = 12;
// Directories whose files are versioned in documents and loadable through the manifest.
// Directories whose files are versioned in documents and loadable through the manifest.
const MANIFEST_DIRS = ['js', 'css', 'fragments'];
const MANIFEST_DIRS = ['js', 'css', 'fragments'];
+// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.
+const critical = require('./critical-css');
+const CORE_CSS = '/css/style.core.css';
const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;
const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;
const MANIFEST_MARK = '<!--ov:asset-manifest-->';
const MANIFEST_MARK = '<!--ov:asset-manifest-->';
const ROUTE_CSS_MARK = '<!--ov:route-css-->';
const ROUTE_CSS_MARK = '<!--ov:route-css-->';
@@ -64,6 +67,7 @@ function resolveIn(root, urlPath) {
@@ -64,6 +67,7 @@ function resolveIn(root, urlPath) {
* bytes belong to the old hash — and Cloudflare would cache that for a year.
* bytes belong to the old hash — and Cloudflare would cache that for a year.
*/
*/
function hashOf(urlPath, { fresh = false } = {}) {
function hashOf(urlPath, { fresh = false } = {}) {
+ if (urlPath === CORE_CSS) { const c = critical.core(); return c ? c.hash : null; }
const now = Date.now();
const now = Date.now();
const entry = files.get(urlPath);
const entry = files.get(urlPath);
if (entry && !fresh && now - entry.checkedAt < RECHECK_MS) return entry.hash;
if (entry && !fresh && now - entry.checkedAt < RECHECK_MS) return entry.hash;
@@ -255,6 +259,7 @@ function renderRoute(html, urlPath) {
@@ -255,6 +259,7 @@ function renderRoute(html, urlPath) {
const jsTags = js.map((j) => `<script src="${versioned(j)}" defer data-ov-feature-asset></script>`).join('\n');
const jsTags = js.map((j) => `<script src="${versioned(j)}" defer data-ov-feature-asset></script>`).join('\n');
const bootTag = `<script type="application/json" id="ov-route-features">${JSON.stringify(names)}</script>`;
const bootTag = `<script type="application/json" id="ov-route-features">${JSON.stringify(names)}</script>`;
let out = html.replace(ROUTE_CSS_MARK, cssTags).replace(ROUTE_JS_MARK, `${bootTag}\n${jsTags}`);
let out = html.replace(ROUTE_CSS_MARK, cssTags).replace(ROUTE_JS_MARK, `${bootTag}\n${jsTags}`);
+ out = firstPaintCss(out, urlPath);
if (lateTags) out = out.replace('</head>', ` ${lateTags}\n</head>`);
if (lateTags) out = out.replace('</head>', ` ${lateTags}\n</head>`);
// The route's page markup, inline: a direct visit renders without a second round trip, and
// The route's page markup, inline: a direct visit renders without a second round trip, and
// crawlers get the same content they did when every page shipped in index.html.
// crawlers get the same content they did when every page shipped in index.html.
@@ -274,6 +279,34 @@ function renderRoute(html, urlPath) {
@@ -274,6 +279,34 @@ function renderRoute(html, urlPath) {
return out;
return out;
}
}
+/**
+ * The public routes whose first view critical-selectors.json was measured on (scripts/perf/critical-css.js): their shell
+ * links the subset as the blocking stylesheet and the full style.css right after it, without blocking. Every other
+ * route (the dashboard, settings, the broadcast desk, admin…) keeps style.css blocking, exactly as before.
+ */
+const FIRST_PAINT_ROUTES = /^\/(?:$|content|vods|clips|moments|arena|chat|pastes|search)(?:[/?#]|$)|^\/@[A-Za-z0-9_]+\/?$|^\/(?:vod|clip)\/\d+\/?$/;
+const STYLE_LINK = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[A-Za-z0-9_.-]+)">/;
+function firstPaintCss(html, urlPath) {
+ const p = String(urlPath || '/').split(/[?#]/)[0] || '/';
+ if (!FIRST_PAINT_ROUTES.test(p)) return html;
+ const m = STYLE_LINK.exec(html);
+ const core = m && hashOf(CORE_CSS);
+ if (!core) return html;
+ const full = m[1];
+ return html.replace(m[0], `<link rel="stylesheet" href="${CORE_CSS}?v=${core}" data-ov-first-paint>
+ <link rel="stylesheet" href="${full}" media="print" onload="this.media='all';this.onload=null">
+ <noscript><link rel="stylesheet" href="${full}"></noscript>`);
+}
+
+/** GET /css/style.core.css: the generated subset, immutable under its current ?v=, short-cached otherwise. */
+function serveCoreCss(req, res, next) {
+ const c = critical.core();
+ if (!c) return next();
+ const v = typeof req.query.v === 'string' ? req.query.v : '';
+ if (v && v === c.hash) setImmutable(res); else setNoCache(res);
+ res.type('text/css').send(c.css);
+}
+
/** A page fragment's HTML (public/fragments/<name>.html), cached until the file changes. */
/** A page fragment's HTML (public/fragments/<name>.html), cached until the file changes. */
const _fragments = new Map();
const _fragments = new Map();
function fragment(name) {
function fragment(name) {
@@ -385,6 +418,6 @@ function staticHeaders(res) {
@@ -385,6 +418,6 @@ 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, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,
- versionedStatic, staticHeaders, setNoCache, setImmutable,
+ versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,
_findPrevious: findPrevious,
_findPrevious: findPrevious,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * The first-paint subset of public/css/style.css.
+ *
+ * style.css holds every page's rules (~440 KB raw, ~97 KB brotli) and blocked the first paint of every route, while a
+ * public page's first view uses about a sixth of it (CDP rule coverage over the public routes at phone and desktop
+ * widths). On a public route the shell now links this subset as the blocking stylesheet, and the full style.css right
+ * after it without blocking (media="print", flipped to all on load; a <noscript> copy). Both links sit where style.css
+ * always sat, so once the full file has arrived the cascade is exactly what it was: the subset's rules come earlier and
+ * are repeated, unchanged, by the full file. Only the moment before it arrives differs, and that moment is painted with
+ * the rules the first view uses.
+ *
+ * Which rules: those whose selector list names a selector in public/css/critical-selectors.json (written by
+ * scripts/perf/critical-css.js from coverage), the document-level rules (`:root`, `html`, `body`, `*`), each inside the
+ * @media/@supports/@container blocks it came from, and the @keyframes the kept rules animate with. Built from the file
+ * on disk and rebuilt when it changes; a rule added to style.css later simply arrives with the full file until the
+ * selector list is regenerated.
+ *
+ * parse(css) → nodes: { type: 'rule', selector, start, end, text } | { type: 'block', prelude, children, text }
+ * | { type: 'raw', name, text } (@keyframes, @font-face, statements)
+ * splitSelectors(selector) → the selector list, split on top-level commas (not inside :is()/:not()/[...])
+ * extract(css, selectors) → the subset's CSS text
+ * core() → { css, hash, bytes, from } for the current style.css (cached by mtime)
+ */
+const fs = require('fs');
+const path = require('path');
+
+// The same public directory server/web/assets.js serves (OV_PUBLIC_DIR overrides it in tests).
+const PUBLIC_DIR = path.resolve(process.env.OV_PUBLIC_DIR
+ || (process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'public') : path.join(__dirname, '../../public')));
+const STYLE = path.join(PUBLIC_DIR, 'css', 'style.css');
+const SELECTORS = path.join(PUBLIC_DIR, 'css', 'critical-selectors.json');
+const DOCUMENT_LEVEL = new Set([':root', 'html', 'body', '*', '*::before', '*::after', 'body::before', 'body::after']);
+const NESTING = /^@(media|supports|container|layer)\b/i;
+
+/** Skip a comment or string starting at i; returns the index after it, or i when there is none. */
+function skip(css, i) {
+ if (css[i] === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); return e === -1 ? css.length : e + 2; }
+ if (css[i] === '"' || css[i] === "'") {
+ const q = css[i];
+ let j = i + 1;
+ while (j < css.length && css[j] !== q) j += css[j] === '\\' ? 2 : 1;
+ return j + 1;
+ }
+ return i;
+}
+
+/** The index just after the `}` that closes the block whose `{` is at open. */
+function closeOf(css, open) {
+ let depth = 0;
+ for (let i = open; i < css.length;) {
+ const n = skip(css, i);
+ if (n !== i) { i = n; continue; }
+ if (css[i] === '{') depth++;
+ else if (css[i] === '}' && --depth === 0) return i + 1;
+ i++;
+ }
+ return css.length;
+}
+
+function parse(css, from = 0, to = css.length) {
+ const nodes = [];
+ let i = from;
+ while (i < to) {
+ const n = skip(css, i);
+ if (n !== i) { i = n; continue; }
+ if (/\s/.test(css[i]) || css[i] === ';' || css[i] === '}') { i++; continue; }
+ // A prelude runs to the first `{` or `;` outside strings and comments.
+ const start = i;
+ let j = i;
+ while (j < to && css[j] !== '{' && css[j] !== ';') { const k = skip(css, j); j = k !== j ? k : j + 1; }
+ const prelude = css.slice(start, j).replace(/\/\*[\s\S]*?\*\//g, '').trim();
+ if (j >= to || css[j] === ';') { nodes.push({ type: 'raw', name: prelude.split(/\s/)[0], text: css.slice(start, Math.min(j + 1, to)) }); i = j + 1; continue; }
+ const end = closeOf(css, j);
+ if (prelude.startsWith('@')) {
+ if (NESTING.test(prelude)) nodes.push({ type: 'block', prelude, start, end, children: parse(css, j + 1, end - 1), text: css.slice(start, end) });
+ else nodes.push({ type: 'raw', name: prelude.split(/\s/)[0].toLowerCase(), prelude, start, end, text: css.slice(start, end) });
+ } else {
+ nodes.push({ type: 'rule', selector: prelude, start, end, text: css.slice(start, end) });
+ }
+ i = end;
+ }
+ return nodes;
+}
+
+/** "a, b:is(c, d), [x=','] e" → ['a', 'b:is(c, d)', "[x=','] e"], each with its whitespace collapsed. */
+function splitSelectors(selector) {
+ const out = [];
+ let depth = 0, cur = '', q = null;
+ for (const ch of String(selector)) {
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
+ if (ch === '(' || ch === '[') depth++;
+ else if (ch === ')' || ch === ']') depth--;
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
+ cur += ch;
+ }
+ out.push(cur);
+ return out.map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
+}
+
+function keepRule(node, wanted) {
+ return splitSelectors(node.selector).some((s) => wanted.has(s) || DOCUMENT_LEVEL.has(s));
+}
+
+function pick(nodes, wanted) {
+ const out = [];
+ for (const node of nodes) {
+ if (node.type === 'rule') { if (keepRule(node, wanted)) out.push(node.text.trim()); continue; }
+ if (node.type === 'block') {
+ const inner = pick(node.children, wanted);
+ if (inner.length) out.push(`${node.prelude} {\n${inner.join('\n')}\n}`);
+ }
+ }
+ return out;
+}
+
+function extract(css, selectors) {
+ const wanted = selectors instanceof Set ? selectors : new Set(selectors);
+ const nodes = parse(css);
+ const kept = pick(nodes, wanted);
+ const body = kept.join('\n');
+ // The @keyframes (and @font-face) the kept rules use; nothing else that is raw.
+ const raws = [];
+ const walk = (list) => {
+ for (const node of list) {
+ if (node.type === 'block') walk(node.children);
+ if (node.type !== 'raw') continue;
+ if (node.name === '@font-face') raws.push(node.text.trim());
+ if (/^@(-webkit-)?keyframes$/.test(node.name)) {
+ const name = (node.prelude || '').split(/\s+/)[1];
+ if (name && new RegExp(`(^|[\\s,:])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s,;!]|$)`, 'm').test(body)) raws.push(node.text.trim());
+ }
+ }
+ };
+ walk(nodes);
+ return `/* Generated by server/web/critical-css.js from style.css: the rules a public page's first view uses. */\n${body}\n${raws.join('\n')}\n`;
+}
+
+let _cache = null;
+/** The subset for the style.css on disk, rebuilt when it or the selector list changes. */
+function core() {
+ let st, sel;
+ try { st = fs.statSync(STYLE); sel = fs.statSync(SELECTORS); } catch { return null; }
+ const key = `${st.mtimeMs}:${st.size}:${sel.mtimeMs}:${sel.size}`;
+ if (_cache && _cache.key === key) return _cache;
+ let selectors = [];
+ try { selectors = JSON.parse(fs.readFileSync(SELECTORS, 'utf8')).selectors || []; } catch { selectors = []; }
+ if (!selectors.length) return null;
+ const css = extract(fs.readFileSync(STYLE, 'utf8'), selectors);
+ _cache = { key, css, hash: require('crypto').createHash('sha256').update(css).digest('hex').slice(0, 12), bytes: Buffer.byteLength(css), from: st.size };
+ return _cache;
+}
+
+module.exports = { parse, splitSelectors, extract, core, STYLE, SELECTORS, DOCUMENT_LEVEL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// The first-paint subset of style.css (server/web/critical-css.js) and the shell swap (server/web/assets.js): a public
+// route's first paint blocks only on the subset, the full style.css follows without blocking in the same place (so the
+// final cascade is unchanged), every other route keeps style.css blocking, and the subset is a fraction of the file.
+const assert = require('assert');
+const fs = require('fs');
+const critical = require('../server/web/critical-css');
+const assets = require('../server/web/assets');
+
+let pass = 0;
+const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
+
+// ── The parser and the extraction ──
+{
+ const css = `/* a comment { with braces } */
+:root { --x: 1; }
+html { color: red; }
+.keep, .other:is(.a, .b) { color: blue; animation: spin 1s linear; }
+.drop { color: green; animation: fade 1s; }
+@media (max-width: 600px) { .keep { padding: 1px; } .drop { padding: 2px; } }
+@media print { .drop { display: none; } }
+@supports (display: grid) { .keep { display: grid; } }
+@keyframes spin { from { transform: rotate(0); } to { transform: rotate(1turn); } }
+@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
+.str::after { content: "}{"; }`;
+ assert.deepStrictEqual(critical.splitSelectors('.keep, .other:is(.a, .b), [x=","] y'), ['.keep', '.other:is(.a, .b)', '[x=","] y']);
+ const nodes = critical.parse(css);
+ assert.strictEqual(nodes.filter((n) => n.type === 'rule').length, 5, 'top-level rules, a string with braces included');
+ const out = critical.extract(css, ['.keep', '.str::after']);
+ assert.ok(out.includes(':root { --x: 1; }') && out.includes('html { color: red; }'), 'document-level rules always');
+ assert.ok(out.includes('.keep, .other:is(.a, .b) { color: blue;'), 'a rule naming a wanted selector, whole');
+ assert.ok(!out.includes('.drop'), 'nothing else');
+ assert.match(out, /@media \(max-width: 600px\) \{\n\.keep \{ padding: 1px; \}\n\}/, 'inside its @media, only the kept rule');
+ assert.ok(!out.includes('@media print'), 'an @media left empty is dropped');
+ assert.ok(out.includes('@supports (display: grid)'), '@supports kept with its rule');
+ assert.ok(out.includes('@keyframes spin') && !out.includes('@keyframes fade'), 'only the keyframes kept rules animate with');
+ assert.ok(out.includes('content: "}{"'), 'strings survive');
+ ok('extract keeps wanted and document-level rules in their @media/@supports, and only their keyframes');
+}
+
+// ── The real style.css ──
+const core = critical.core();
+assert.ok(core && core.css.length > 1000, 'a subset is built from the committed selector list');
+assert.ok(core.bytes < core.from * 0.3, `the subset is a fraction of style.css (${core.bytes} of ${core.from})`);
+assert.match(core.hash, /^[0-9a-f]{12}$/);
+assert.ok(core.css.includes(':root'), 'the theme variables are in it');
+{
+ const listed = JSON.parse(fs.readFileSync(critical.SELECTORS, 'utf8')).selectors;
+ const inFile = new Set();
+ const walk = (list) => { for (const n of list) { if (n.type === 'rule') critical.splitSelectors(n.selector).forEach((s) => inFile.add(s)); else if (n.type === 'block') walk(n.children); } };
+ walk(critical.parse(fs.readFileSync(critical.STYLE, 'utf8')));
+ const stale = listed.filter((s) => !inFile.has(s));
+ // A renamed or removed rule leaves its selector behind: harmless, but a list this stale should be regenerated.
+ assert.ok(stale.length <= listed.length * 0.1, `critical-selectors.json is stale (${stale.length} of ${listed.length} no longer in style.css): run node scripts/perf/critical-css.js`);
+}
+ok(`the subset of the real style.css is ${(100 * core.bytes / core.from).toFixed(0)}% of it, from a current selector list`);
+
+// ── The shell ──
+const doc = assets.document('index.html').html;
+const blockingLinks = (html) => [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((h) => !/noscript/.test(h));
+for (const url of ['/', '/content', '/vods', '/clips', '/chat', '/search?q=x', '/@someone', '/vod/12', '/clip/7', '/arena']) {
+ const html = assets.renderRoute(doc, url);
+ const head = html.slice(0, html.indexOf('</head>'));
+ assert.ok(head.includes(`<link rel="stylesheet" href="/css/style.core.css?v=${core.hash}" data-ov-first-paint>`), `${url}: the subset blocks the first paint`);
+ const full = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[^"]+)" media="print" onload="this\.media='all';this\.onload=null">/.exec(head);
+ assert.ok(full, `${url}: the full style.css follows without blocking`);
+ assert.ok(head.indexOf('style.core.css') < head.indexOf(full[1]) && head.indexOf(full[1]) < head.indexOf('<meta name="ov-css-anchor">'), `${url}: in style.css's place in the cascade, before the feature stylesheets`);
+ assert.ok(head.includes(`<noscript><link rel="stylesheet" href="${full[1]}"></noscript>`), `${url}: a no-JS copy`);
+ assert.ok(!blockingLinks(head.replace(/<noscript>[\s\S]*?<\/noscript>/g, '')).some((h) => h.startsWith('/css/style.css')), `${url}: style.css itself no longer blocks`);
+}
+for (const url of ['/dashboard', '/broadcast', '/settings', '/admin', '/documentation', '/vod/12/edit', '/@someone/videos']) {
+ const html = assets.renderRoute(doc, url);
+ assert.ok(!html.includes('style.core.css'), `${url}: no subset`);
+ assert.match(html, /<link rel="stylesheet" href="\/css\/style\.css\?v=[^"]+">/, `${url}: style.css blocks as before`);
+}
+ok('public routes block on the subset and load style.css after it in the same place; every other route is unchanged');
+
+assert.strictEqual(assets.hashOf(assets.CORE_CSS), core.hash, 'the subset is versioned by its content');
+ok('the subset is versioned by its content');
+
+console.log(`critical-css: ${pass} checks passed`);