openvibers/openvibe.shared perf-budget.js

perf-budget.js

main 9.6 KB · 178 lines · javascript

1'use strict';
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 */
36const zlib = require('zlib');
37
38const kb = (n) => Math.round((n / 1024) * 10) / 10;
39const brotli = (buf) => zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
40const 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). */
46function 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.
64const 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})();`;
75const 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. */
78function 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. */
90async 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
121async 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
148const 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};
155function vital(m, name) {
156 if (!m.cwv) throw new Error(`perf-budget: ${name} needs measure({ browser })`);
157 return m.cwv[name];
158}
159
160function 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
170function 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
177module.exports = { measure, check, format, assetsOf };
178