openvibers/openvibe.shared perf-budget.js
perf-budget.js
| 1 | ; |
| 2 | /** |
| 3 | * openvibe-shared/perf-budget — size budgets for a page's first load (roadmap WS-T task 1), measured |
| 4 | * from what the running server actually answers: no browser, deterministic, cheap enough for `npm test`. Core Web Vitals are opt-in: `browser` adds a real-Chrome pass. |
| 5 | * |
| 6 | * const { measure, check, format } = require('openvibe-shared/perf-budget'); |
| 7 | * const m = await measure({ base: 'http://127.0.0.1:4000', path: '/' }); |
| 8 | * const over = check(m, { htmlBrotliKB: 30, jsFiles: 12, jsBrotliKB: 90, cssBrotliKB: 40 }); |
| 9 | * assert.deepStrictEqual(over, [], format(m, over)); |
| 10 | * |
| 11 | * measure({ base, path = '/', fetch, headers, browser }) fetches the page, then every same-origin script |
| 12 | * (<script src>) and stylesheet (<link rel="stylesheet">, not media="print") it names, and answers |
| 13 | * { html: { rawKB, brotliKB }, js: { files, rawKB, brotliKB }, css: { files, rawKB, brotliKB }, |
| 14 | * external: [url], urls: { js: [..], css: [..] } } |
| 15 | * sizes in KB (1 decimal), brotli at quality 11 as the edge serves it. Scripts and stylesheets on other |
| 16 | * origins (the OpenVibe Frame from openvibe.network, a CDN) are listed in `external` and counted in |
| 17 | * `files`, not in bytes: they are the other origin's budget. Inline <script>/<style> count in the HTML. |
| 18 | * |
| 19 | * With `browser` (true for launch() defaults, an options object for browser-harness launch(), or |
| 20 | * { running } for a browser already started with launch()), the page is then opened at 1280×900 in a fresh |
| 21 | * browser context, one mouse click lands at the viewport centre, and 500 ms later the result gains |
| 22 | * cwv: { lcpMs, inpMs, cls } |
| 23 | * lcpMs the last largest-contentful-paint entry's startTime (null when none); inpMs the longest 'event' |
| 24 | * entry with an interactionId (0 when none); cls the largest session window of layout shifts without recent |
| 25 | * input (shifts under 1 s apart, at most 5 s long; the web-vitals definition), 3 decimals. A browser this |
| 26 | * call launched is stopped again; a running one is left running. |
| 27 | * |
| 28 | * const m = await measure({ base, path: '/', browser: true }); |
| 29 | * check(m, { lcpMs: 2500, inpMs: 200, cls: 0.1 }); |
| 30 | * |
| 31 | * check(m, budgets) → [{ name, value, budget }] for every budget exceeded. Budget names: |
| 32 | * htmlRawKB htmlBrotliKB jsFiles jsRawKB jsBrotliKB cssFiles cssRawKB cssBrotliKB externalFiles |
| 33 | * lcpMs inpMs cls (these need measure({ browser }); without m.cwv check() throws) |
| 34 | * Set each a little above a measurement; raising one should be a decision said in the commit. |
| 35 | */ |
| 36 | const zlib = require('zlib'); |
| 37 | |
| 38 | const kb = (n) => Math.round((n / 1024) * 10) / 10; |
| 39 | const brotli = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length; |
| 40 | const attr = (tag, name) => { |
| 41 | const m = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag); |
| 42 | return m ? (m[2] ?? m[3] ?? m[4] ?? '') : null; |
| 43 | }; |
| 44 | |
| 45 | /** The script and stylesheet URLs a page's HTML names, in order (comments ignored). */ |
| 46 | function assetsOf(html) { |
| 47 | const body = String(html).replace(/<!--[\s\S]*?-->/g, ''); |
| 48 | const js = [], css = []; |
| 49 | for (const m of body.matchAll(/<script\b[^>]*>/gi)) { |
| 50 | const src = attr(m[0], 'src'); |
| 51 | const type = (attr(m[0], 'type') || '').toLowerCase(); |
| 52 | if (src && !['application/json', 'application/ld+json', 'text/template'].includes(type)) js.push(src); |
| 53 | } |
| 54 | for (const m of body.matchAll(/<link\b[^>]*>/gi)) { |
| 55 | const rel = (attr(m[0], 'rel') || '').toLowerCase().split(/\s+/); |
| 56 | const media = (attr(m[0], 'media') || '').toLowerCase(); |
| 57 | const disabled = /\sdisabled(\s|=|\/?>)/i.test(m[0]); |
| 58 | if (rel.includes('stylesheet') && media !== 'print' && !disabled) css.push(attr(m[0], 'href')); |
| 59 | } |
| 60 | return { js: js.filter(Boolean), css: css.filter(Boolean) }; |
| 61 | } |
| 62 | |
| 63 | // Installed before any page script: the Core Web Vitals entries, buffered so none before it is missed. |
| 64 | const CWV_PROBE = `(() => { |
| 65 | if (window.__ovCwv) return; |
| 66 | const s = { lcp: [], shifts: [], events: [] }; |
| 67 | const watch = (type, fn, extra) => { |
| 68 | try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe({ type, buffered: true, ...extra }); } catch { /* unsupported */ } |
| 69 | }; |
| 70 | watch('largest-contentful-paint', (e) => s.lcp.push(e.startTime)); |
| 71 | watch('layout-shift', (e) => { if (!e.hadRecentInput) s.shifts.push([e.startTime, e.value]); }); |
| 72 | watch('event', (e) => { if (e.interactionId > 0) s.events.push(e.duration); }, { durationThreshold: 16 }); |
| 73 | Object.defineProperty(window, '__ovCwv', { value: () => s }); |
| 74 | })();`; |
| 75 | const VIEWPORT = { width: 1280, height: 900 }; |
| 76 | |
| 77 | /** The largest session window of layout shifts ([startTime, value]): under 1 s apart, at most 5 s long. */ |
| 78 | function clsOf(shifts) { |
| 79 | let max = 0, sum = 0, first = 0, last = 0; |
| 80 | for (const [t, v] of [...shifts].sort((a, b) => a[0] - b[0])) { |
| 81 | if (sum && t - last < 1000 && t - first < 5000) sum += v; |
| 82 | else { sum = v; first = t; } |
| 83 | last = t; |
| 84 | max = Math.max(max, sum); |
| 85 | } |
| 86 | return Math.round(max * 1000) / 1000; |
| 87 | } |
| 88 | |
| 89 | /** Opens the page in Chrome with the probe, clicks once at the viewport centre, and reads the vitals. */ |
| 90 | async function vitals(url, browser) { |
| 91 | const harness = require('./browser-harness'); |
| 92 | const own = !browser.running; |
| 93 | const b = browser.running || await harness.launch(browser === true ? {} : browser); |
| 94 | let context = null, page = null; |
| 95 | try { |
| 96 | context = await b.newContext(); |
| 97 | page = await harness.openPage(b, context, VIEWPORT); |
| 98 | await page.send('Page.addScriptToEvaluateOnNewDocument', { source: CWV_PROBE }); |
| 99 | await page.goto(url); |
| 100 | // The click waits for the first paint: Chrome stops recording LCP at the first input, and a slower machine (the |
| 101 | // macOS CI runner, 2026-10-27) could click before anything was painted, so no LCP at all. |
| 102 | await page.evaluate(`new Promise((ok) => { const t0 = Date.now(); (function poll() { if (performance.getEntriesByType('paint').some((e) => e.name === 'first-contentful-paint') || Date.now() - t0 > 3000) return ok(true); requestAnimationFrame(() => setTimeout(poll, 16)); })(); })`); |
| 103 | const at = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2, button: 'left', clickCount: 1 }; |
| 104 | await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at }); |
| 105 | await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at }); |
| 106 | await new Promise((r) => setTimeout(r, 500)); |
| 107 | const raw = await page.evaluate('window.__ovCwv ? window.__ovCwv() : null'); |
| 108 | if (!raw) throw new Error(`perf-budget: the vitals probe did not run on ${url}`); |
| 109 | return { |
| 110 | lcpMs: raw.lcp.length ? Math.round(raw.lcp[raw.lcp.length - 1]) : null, |
| 111 | inpMs: Math.round(Math.max(0, ...raw.events)), |
| 112 | cls: clsOf(raw.shifts), |
| 113 | }; |
| 114 | } finally { |
| 115 | if (page) await page.close(); |
| 116 | if (context) await context.dispose(); |
| 117 | if (own) await b.close(); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers = {}, browser = null } = {}) { |
| 122 | const origin = new URL(base).origin; |
| 123 | const pageUrl = new URL(path, base).href; |
| 124 | const res = await f(pageUrl, { headers: { accept: 'text/html', ...headers } }); |
| 125 | if (!res.ok) throw new Error(`perf-budget: ${pageUrl} answered ${res.status}`); |
| 126 | const html = Buffer.from(await res.arrayBuffer()); |
| 127 | const { js, css } = assetsOf(html.toString('utf8')); |
| 128 | const external = []; |
| 129 | async function total(urls) { |
| 130 | let raw = 0, br = 0; |
| 131 | for (const u of urls) { |
| 132 | const abs = new URL(u, pageUrl); |
| 133 | if (abs.origin !== origin) { external.push(abs.href); continue; } |
| 134 | const r = await f(abs.href, { headers }); |
| 135 | if (!r.ok) throw new Error(`perf-budget: ${abs.pathname} (named by ${path}) answered ${r.status}`); |
| 136 | const b = Buffer.from(await r.arrayBuffer()); |
| 137 | raw += b.length; br += brotli(b); |
| 138 | } |
| 139 | return { files: urls.length, rawKB: kb(raw), brotliKB: kb(br) }; |
| 140 | } |
| 141 | const jsT = await total(js); |
| 142 | const cssT = await total(css); |
| 143 | const m = { url: pageUrl, html: { rawKB: kb(html.length), brotliKB: kb(brotli(html)) }, js: jsT, css: cssT, external, urls: { js, css } }; |
| 144 | if (browser) m.cwv = await vitals(pageUrl, browser); |
| 145 | return m; |
| 146 | } |
| 147 | |
| 148 | const READ = { |
| 149 | htmlRawKB: (m) => m.html.rawKB, htmlBrotliKB: (m) => m.html.brotliKB, |
| 150 | jsFiles: (m) => m.js.files, jsRawKB: (m) => m.js.rawKB, jsBrotliKB: (m) => m.js.brotliKB, |
| 151 | cssFiles: (m) => m.css.files, cssRawKB: (m) => m.css.rawKB, cssBrotliKB: (m) => m.css.brotliKB, |
| 152 | externalFiles: (m) => m.external.length, |
| 153 | lcpMs: (m) => vital(m, 'lcpMs'), inpMs: (m) => vital(m, 'inpMs'), cls: (m) => vital(m, 'cls'), |
| 154 | }; |
| 155 | function vital(m, name) { |
| 156 | if (!m.cwv) throw new Error(`perf-budget: ${name} needs measure({ browser })`); |
| 157 | return m.cwv[name]; |
| 158 | } |
| 159 | |
| 160 | function check(m, budgets = {}) { |
| 161 | const over = []; |
| 162 | for (const [name, budget] of Object.entries(budgets)) { |
| 163 | if (!READ[name]) throw new Error(`perf-budget: unknown budget ${name}`); |
| 164 | const value = READ[name](m); |
| 165 | if (value > budget) over.push({ name, value, budget }); |
| 166 | } |
| 167 | return over; |
| 168 | } |
| 169 | |
| 170 | function format(m, over = []) { |
| 171 | const lines = [`${m.url}: html ${m.html.rawKB} KB (${m.html.brotliKB} br), js ${m.js.files} files ${m.js.rawKB} KB (${m.js.brotliKB} br), css ${m.css.files} files ${m.css.rawKB} KB (${m.css.brotliKB} br), ${m.external.length} external`]; |
| 172 | if (m.cwv) lines.push(` cwv: lcp ${m.cwv.lcpMs ?? 'none'} ms, inp ${m.cwv.inpMs} ms, cls ${m.cwv.cls}`); |
| 173 | for (const o of over) lines.push(` over budget: ${o.name} ${o.value} > ${o.budget}`); |
| 174 | return lines.join('\n'); |
| 175 | } |
| 176 | |
| 177 | module.exports = { measure, check, format, assetsOf }; |
| 178 |