@schalkneethling/miyagi-core 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import config from "../default-config.js";
8
8
  import render from "../render/index.js";
9
9
  import { getVariationData } from "../mocks/index.js";
10
10
  import log from "../logger.js";
11
+ import { runPerformance } from "../performance/index.js";
11
12
 
12
13
  /**
13
14
  * @param {object} component
@@ -102,6 +103,32 @@ export default function Router() {
102
103
  });
103
104
  });
104
105
 
106
+ // Performance budget — dev-mode only.
107
+ if (!global.config.isBuild && global.config.performance?.enabled) {
108
+ global.app.get("/performance", async (req, res) => {
109
+ return render.renderMainPerformance({
110
+ res,
111
+ cookies: req.cookies,
112
+ });
113
+ });
114
+
115
+ global.app.get("/iframe/performance", async (req, res) => {
116
+ return render.iframe.performance({
117
+ res,
118
+ cookies: req.cookies,
119
+ });
120
+ });
121
+
122
+ global.app.get("/api/performance", (req, res) => {
123
+ try {
124
+ const result = runPerformance({ config: global.config });
125
+ res.json(result);
126
+ } catch (error) {
127
+ res.status(500).json({ error: error.message });
128
+ }
129
+ });
130
+ }
131
+
105
132
  global.app.get("/show", async (req, res) => {
106
133
  const { file, variation } = req.query;
107
134
 
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Performance-budget orchestrator.
3
+ * Turns the Miyagi config + filesystem state into a set of measurements and
4
+ * evaluations against the configured budgets. The output shape is shared by the
5
+ * CLI, the markdown report, and the dev-UI panel so each consumer renders the
6
+ * same data differently rather than re-deriving it.
7
+ * @module performance
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { measure } from "./measure.js";
13
+ import parseSize from "./parse-size.js";
14
+
15
+ /**
16
+ * @typedef {object} EvaluationRow
17
+ * @property {string} category - "global-css" | "global-js" | "folder:<name>" | "html"
18
+ * @property {string} key - stable identifier inside the category ("total", "perPage", or a file path)
19
+ * @property {string} label - human-readable label
20
+ * @property {number} actual - measured bytes (at the configured compression)
21
+ * @property {number|null} budget - configured budget in bytes, or null if unset
22
+ * @property {"ok"|"warn"|"exceed"|"unbudgeted"} status - classification against budget
23
+ * @property {number|null} ratio - actual / budget when budget is set
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} PerformanceResult
28
+ * @property {import("./measure.js").Measurement} measurement - raw per-file and per-category bytes
29
+ * @property {EvaluationRow[]} evaluations - budget comparison rows
30
+ * @property {"raw"|"gzip"|"brotli"} compression - which metric evaluations use
31
+ * @property {{ok: number, warn: number, exceed: number, unbudgeted: number}} summary - counts per status
32
+ */
33
+
34
+ const WARN_RATIO = 0.8;
35
+
36
+ /**
37
+ * Expand a css-string or js-object asset entry to a filesystem path.
38
+ * @param {string|{src: string}} entry
39
+ * @param {string} root
40
+ * @returns {string|null} absolute path, or null if the entry points at a remote URL
41
+ */
42
+ function resolveAssetPath(entry, root) {
43
+ const rel = typeof entry === "string" ? entry : entry?.src;
44
+
45
+ if (!rel || URL.canParse(rel)) {
46
+ return null;
47
+ }
48
+
49
+ return path.resolve(root || "", rel);
50
+ }
51
+
52
+ /**
53
+ * Walk a directory, collecting absolute paths of all non-directory entries.
54
+ * Hidden files (starting with a dot) are skipped — they are almost never shipped.
55
+ * @param {string} dir
56
+ * @returns {string[]}
57
+ */
58
+ function walkFolder(dir) {
59
+ if (!fs.existsSync(dir)) {
60
+ return [];
61
+ }
62
+
63
+ const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
64
+ const files = [];
65
+
66
+ for (const entry of entries) {
67
+ if (!entry.isFile()) {
68
+ continue;
69
+ }
70
+
71
+ if (entry.name.startsWith(".")) {
72
+ continue;
73
+ }
74
+
75
+ // entry.parentPath on Node 20+ gives the containing directory as an absolute
76
+ // path when `dir` was absolute — which it always is at our call sites.
77
+ files.push(path.join(entry.parentPath, entry.name));
78
+ }
79
+
80
+ return files;
81
+ }
82
+
83
+ /**
84
+ * Build the list of categorised file sets for the "always-on" (dev + CLI) scope:
85
+ * global CSS, global JS, and each configured asset folder.
86
+ * @param {object} config - global.config (or a spy equivalent in tests)
87
+ * @returns {{category: string, label: string, files: string[]}[]}
88
+ */
89
+ export function collectGlobalCategories(config) {
90
+ const assets = config?.assets ?? {};
91
+ const root = assets.root || "";
92
+
93
+ const globalCss = [
94
+ ...(assets.css || []),
95
+ ...(assets.shared?.css || []),
96
+ ]
97
+ .map((entry) => resolveAssetPath(entry, root))
98
+ .filter(Boolean);
99
+
100
+ const globalJs = [...(assets.js || []), ...(assets.shared?.js || [])]
101
+ .map((entry) => resolveAssetPath(entry, root))
102
+ .filter(Boolean);
103
+
104
+ const categories = [
105
+ { category: "global-css", label: "Global CSS", files: globalCss },
106
+ { category: "global-js", label: "Global JS", files: globalJs },
107
+ ];
108
+
109
+ for (const folder of assets.folder || []) {
110
+ const abs = path.resolve(root, folder);
111
+ categories.push({
112
+ category: `folder:${folder}`,
113
+ label: `Folder — ${folder}`,
114
+ files: walkFolder(abs),
115
+ });
116
+ }
117
+
118
+ return categories;
119
+ }
120
+
121
+ /**
122
+ * Build the list of categorised file sets for post-build HTML pages.
123
+ * @param {string} buildFolder
124
+ * @returns {{category: string, label: string, files: string[]}[]}
125
+ */
126
+ export function collectHtmlCategory(buildFolder) {
127
+ const manifestPath = path.resolve(buildFolder, "output.json");
128
+
129
+ if (!fs.existsSync(manifestPath)) {
130
+ return [];
131
+ }
132
+
133
+ /** @type {Array<string|{path: string}>} */
134
+ let manifest;
135
+ try {
136
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
137
+ } catch {
138
+ return [];
139
+ }
140
+
141
+ const files = manifest
142
+ .map((entry) => (typeof entry === "string" ? entry : entry?.path))
143
+ .filter((rel) => typeof rel === "string" && rel.endsWith(".html"))
144
+ .map((rel) => path.resolve(buildFolder, rel));
145
+
146
+ return [{ category: "html", label: "Rendered HTML pages", files }];
147
+ }
148
+
149
+ /**
150
+ * @param {number|null} actual
151
+ * @param {number|null} budget
152
+ * @returns {{status: "ok"|"warn"|"exceed"|"unbudgeted", ratio: number|null}}
153
+ */
154
+ function classify(actual, budget) {
155
+ if (budget == null) {
156
+ return { status: "unbudgeted", ratio: null };
157
+ }
158
+
159
+ if (budget <= 0) {
160
+ return { status: "unbudgeted", ratio: null };
161
+ }
162
+
163
+ const ratio = actual / budget;
164
+
165
+ if (actual > budget) {
166
+ return { status: "exceed", ratio };
167
+ }
168
+
169
+ if (ratio >= WARN_RATIO) {
170
+ return { status: "warn", ratio };
171
+ }
172
+
173
+ return { status: "ok", ratio };
174
+ }
175
+
176
+ /**
177
+ * Produce evaluation rows given a measurement and a budgets object.
178
+ * The shape of `budgets` is the `performance.budgets` config key; see
179
+ * lib/default-config.js for the canonical definition.
180
+ * @param {import("./measure.js").Measurement} measurement
181
+ * @param {object} budgets - performance.budgets config
182
+ * @param {"raw"|"gzip"|"brotli"} compression
183
+ * @param {{label: Record<string,string>}} meta - map category key -> human label
184
+ * @param {object} options
185
+ * @param {boolean} [options.listAllPages] - include every HTML page, not just those that exceed or warn
186
+ * @returns {EvaluationRow[]}
187
+ */
188
+ export function evaluate(measurement, budgets = {}, compression = "gzip", meta = { label: {} }, options = {}) {
189
+ const rows = [];
190
+ const labelFor = (category) => meta.label[category] || category;
191
+
192
+ const globalBudgets = budgets.global || {};
193
+ const htmlBudgets = budgets.html || {};
194
+ const folderBudgets = budgets.folders || {};
195
+
196
+ // Global CSS / JS
197
+ for (const [category, budgetKey] of [
198
+ ["global-css", "css"],
199
+ ["global-js", "js"],
200
+ ]) {
201
+ const categoryMeasurement = measurement.categories[category];
202
+ if (!categoryMeasurement) {
203
+ continue;
204
+ }
205
+
206
+ const budget = parseSize(globalBudgets[budgetKey] ?? null);
207
+ const actual = categoryMeasurement.totals[compression];
208
+ const { status, ratio } = classify(actual, budget);
209
+
210
+ rows.push({
211
+ category,
212
+ key: "total",
213
+ label: labelFor(category),
214
+ actual,
215
+ budget,
216
+ status,
217
+ ratio,
218
+ });
219
+ }
220
+
221
+ // Global umbrella (CSS + JS) if set
222
+ if (globalBudgets.total != null) {
223
+ const cssTotal = measurement.categories["global-css"]?.totals[compression] ?? 0;
224
+ const jsTotal = measurement.categories["global-js"]?.totals[compression] ?? 0;
225
+ const actual = cssTotal + jsTotal;
226
+ const budget = parseSize(globalBudgets.total);
227
+ const { status, ratio } = classify(actual, budget);
228
+ rows.push({
229
+ category: "global",
230
+ key: "total",
231
+ label: "Global total (CSS + JS)",
232
+ actual,
233
+ budget,
234
+ status,
235
+ ratio,
236
+ });
237
+ }
238
+
239
+ // Folders
240
+ for (const [category, categoryMeasurement] of Object.entries(measurement.categories)) {
241
+ if (!category.startsWith("folder:")) {
242
+ continue;
243
+ }
244
+ const name = category.slice("folder:".length);
245
+ const folderBudget = folderBudgets[name];
246
+ const budget = parseSize(folderBudget?.total ?? null);
247
+ const actual = categoryMeasurement.totals[compression];
248
+ const { status, ratio } = classify(actual, budget);
249
+ rows.push({
250
+ category,
251
+ key: "total",
252
+ label: labelFor(category),
253
+ actual,
254
+ budget,
255
+ status,
256
+ ratio,
257
+ });
258
+ }
259
+
260
+ if (folderBudgets.total != null) {
261
+ let actual = 0;
262
+ for (const [category, categoryMeasurement] of Object.entries(measurement.categories)) {
263
+ if (category.startsWith("folder:")) {
264
+ actual += categoryMeasurement.totals[compression];
265
+ }
266
+ }
267
+ const budget = parseSize(folderBudgets.total);
268
+ const { status, ratio } = classify(actual, budget);
269
+ rows.push({
270
+ category: "folders",
271
+ key: "total",
272
+ label: "All folders total",
273
+ actual,
274
+ budget,
275
+ status,
276
+ ratio,
277
+ });
278
+ }
279
+
280
+ // HTML: one row per page for perPage, one row for the total.
281
+ // By default only pages that exceed or warn are listed — a project with many
282
+ // component variants can produce hundreds of pages and the table becomes noise.
283
+ // Pass options.listAllPages to include every page.
284
+ const htmlMeasurement = measurement.categories.html;
285
+ if (htmlMeasurement) {
286
+ const perPageBudget = parseSize(htmlBudgets.perPage ?? null);
287
+
288
+ if (perPageBudget != null) {
289
+ let pagesChecked = 0;
290
+ let pagesPassed = 0;
291
+
292
+ for (const file of htmlMeasurement.files) {
293
+ const actual = file[compression];
294
+ const { status, ratio } = classify(actual, perPageBudget);
295
+ pagesChecked++;
296
+
297
+ if (status === "ok") {
298
+ pagesPassed++;
299
+ if (!options.listAllPages) {
300
+ continue;
301
+ }
302
+ }
303
+
304
+ rows.push({
305
+ category: "html",
306
+ key: file.path,
307
+ label: path.basename(file.path),
308
+ actual,
309
+ budget: perPageBudget,
310
+ status,
311
+ ratio,
312
+ });
313
+ }
314
+
315
+ // When filtering, add a summary row so the reader knows pages were checked
316
+ if (!options.listAllPages && pagesPassed > 0) {
317
+ rows.push({
318
+ category: "html",
319
+ key: "pages-ok",
320
+ label: `${pagesPassed} of ${pagesChecked} pages within budget`,
321
+ actual: 0,
322
+ budget: null,
323
+ status: "ok",
324
+ ratio: null,
325
+ });
326
+ }
327
+ }
328
+
329
+ if (htmlBudgets.total != null) {
330
+ const actual = htmlMeasurement.totals[compression];
331
+ const budget = parseSize(htmlBudgets.total);
332
+ const { status, ratio } = classify(actual, budget);
333
+ rows.push({
334
+ category: "html",
335
+ key: "total",
336
+ label: "All pages total",
337
+ actual,
338
+ budget,
339
+ status,
340
+ ratio,
341
+ });
342
+ }
343
+ }
344
+
345
+ return rows;
346
+ }
347
+
348
+ /**
349
+ * Summarise evaluation rows into bucket counts.
350
+ * @param {EvaluationRow[]} rows
351
+ * @returns {{ok: number, warn: number, exceed: number, unbudgeted: number}}
352
+ */
353
+ export function summarise(rows) {
354
+ const summary = { ok: 0, warn: 0, exceed: 0, unbudgeted: 0 };
355
+
356
+ for (const row of rows) {
357
+ summary[row.status] += 1;
358
+ }
359
+
360
+ return summary;
361
+ }
362
+
363
+ /**
364
+ * Run the full pipeline: collect → measure → evaluate → summarise.
365
+ * @param {object} [options]
366
+ * @param {object} [options.config] - override global.config, for tests
367
+ * @param {boolean} [options.html] - include post-build HTML measurement
368
+ * @param {string} [options.buildFolder] - required when html is true
369
+ * @param {boolean} [options.listAllPages] - list every HTML page, not just those that exceed or warn
370
+ * @returns {PerformanceResult}
371
+ */
372
+ export function runPerformance(options = {}) {
373
+ const config = options.config ?? global.config;
374
+ const perfConfig = config?.performance ?? {};
375
+ const compression = perfConfig.compression || "gzip";
376
+ const budgets = perfConfig.budgets || {};
377
+
378
+ const categories = collectGlobalCategories(config);
379
+
380
+ if (options.html && options.buildFolder) {
381
+ categories.push(...collectHtmlCategory(options.buildFolder));
382
+ }
383
+
384
+ /** @type {Record<string, string>} */
385
+ const labelMap = {};
386
+ for (const category of categories) {
387
+ labelMap[category.category] = category.label;
388
+ }
389
+
390
+ const measurement = measure(
391
+ categories.map(({ category, files }) => ({ category, files })),
392
+ );
393
+
394
+ const evaluations = evaluate(measurement, budgets, compression, {
395
+ label: labelMap,
396
+ }, { listAllPages: options.listAllPages });
397
+ const summary = summarise(evaluations);
398
+
399
+ return { measurement, evaluations, compression, summary };
400
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Measure on-disk, gzip-compressed, and brotli-compressed sizes of files.
3
+ * Transfer size is what a user actually downloads, which is almost never the raw
4
+ * on-disk size — gzip and brotli are reported alongside raw so users can see all
5
+ * three and pick the metric their hosting actually serves.
6
+ * We measure whichever files the user has configured in their assets — source
7
+ * files, bundled output from esbuild/rollup/webpack, or anything else. Point
8
+ * `config.assets` at the files you actually ship and the budget reflects reality.
9
+ * Results are cached by absolute path + mtime. In dev the file watcher changes
10
+ * the mtime on every save, naturally invalidating the cache.
11
+ * @module performance/measure
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import zlib from "node:zlib";
16
+
17
+ const cache = new Map();
18
+
19
+ /**
20
+ * @typedef {object} FileMeasurement
21
+ * @property {string} path - absolute path
22
+ * @property {number} raw - on-disk byte length
23
+ * @property {number} gzip - gzip-compressed byte length
24
+ * @property {number} brotli - brotli-compressed byte length
25
+ * @property {boolean} [missing] - true when the file could not be read
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} CategoryMeasurement
30
+ * @property {FileMeasurement[]} files - per-file measurements in this category
31
+ * @property {{raw: number, gzip: number, brotli: number}} totals - sum across files
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} Measurement
36
+ * @property {Record<string, CategoryMeasurement>} categories - keyed by category name
37
+ * @property {{raw: number, gzip: number, brotli: number}} totals - sum across categories
38
+ */
39
+
40
+ /**
41
+ * Clear the cached results. Intended for tests and for watcher-driven
42
+ * whole-state refreshes.
43
+ * @returns {void}
44
+ */
45
+ export function clearMeasureCache() {
46
+ cache.clear();
47
+ }
48
+
49
+ /**
50
+ * @param {string} absPath
51
+ * @returns {FileMeasurement}
52
+ */
53
+ function measureFile(absPath) {
54
+ // Open a file descriptor first, then stat and read through it. This avoids
55
+ // a race where the file is modified between stat and read — both operations
56
+ // now refer to the same version of the file.
57
+ let fd;
58
+ try {
59
+ fd = fs.openSync(absPath, "r");
60
+ } catch {
61
+ return { path: absPath, raw: 0, gzip: 0, brotli: 0, missing: true };
62
+ }
63
+
64
+ let stat;
65
+ let buffer;
66
+ try {
67
+ stat = fs.fstatSync(fd);
68
+ buffer = Buffer.alloc(stat.size);
69
+ fs.readSync(fd, buffer, 0, stat.size, 0);
70
+ } catch {
71
+ fs.closeSync(fd);
72
+ return { path: absPath, raw: 0, gzip: 0, brotli: 0, missing: true };
73
+ }
74
+ fs.closeSync(fd);
75
+
76
+ const cacheKey = `${absPath}:${stat.mtimeMs}:${stat.size}`;
77
+ const cached = cache.get(cacheKey);
78
+ if (cached) {
79
+ return cached;
80
+ }
81
+ const measurement = {
82
+ path: absPath,
83
+ raw: buffer.byteLength,
84
+ gzip: zlib.gzipSync(buffer).byteLength,
85
+ // Brotli default is text mode with maximum quality which is _very_ slow on
86
+ // large files; use quality 6 to approximate what a CDN typically serves.
87
+ brotli: zlib.brotliCompressSync(buffer, {
88
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 6 },
89
+ }).byteLength,
90
+ };
91
+
92
+ cache.set(cacheKey, measurement);
93
+ return measurement;
94
+ }
95
+
96
+ /**
97
+ * Measure a set of categorised file lists.
98
+ * @param {{category: string, files: string[]}[]} categorised
99
+ * @returns {Measurement}
100
+ */
101
+ export function measure(categorised) {
102
+ /** @type {Record<string, CategoryMeasurement>} */
103
+ const categories = {};
104
+ const grand = { raw: 0, gzip: 0, brotli: 0 };
105
+
106
+ for (const { category, files } of categorised) {
107
+ const measured = files.map((absPath) => measureFile(absPath));
108
+ const totals = measured.reduce(
109
+ (acc, file) => ({
110
+ raw: acc.raw + file.raw,
111
+ gzip: acc.gzip + file.gzip,
112
+ brotli: acc.brotli + file.brotli,
113
+ }),
114
+ { raw: 0, gzip: 0, brotli: 0 },
115
+ );
116
+
117
+ categories[category] = { files: measured, totals };
118
+ grand.raw += totals.raw;
119
+ grand.gzip += totals.gzip;
120
+ grand.brotli += totals.brotli;
121
+ }
122
+
123
+ return { categories, totals: grand };
124
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Human-friendly size parser for performance budgets.
3
+ * Accepts either a bare number (bytes) or a string with a SI-ish unit suffix.
4
+ * Recognised units (case-insensitive): B, kB, KB, MB, GB. The number may be
5
+ * a decimal (e.g. "1.5 MB") and whitespace between number and unit is optional.
6
+ * Uses the decimal convention (1 kB = 1000 B) because that is what web performance
7
+ * budgets are traditionally expressed in — it matches the web.dev budget guidance
8
+ * and the way transfer sizes are reported in browser devtools.
9
+ * @module performance/parse-size
10
+ */
11
+
12
+ const ONE_KILOBYTE = 1000;
13
+ const ONE_MEGABYTE = 1000 * 1000;
14
+ const ONE_GIGABYTE = 1000 * 1000 * 1000;
15
+
16
+ const UNIT_MULTIPLIERS = {
17
+ b: 1,
18
+ kb: ONE_KILOBYTE,
19
+ mb: ONE_MEGABYTE,
20
+ gb: ONE_GIGABYTE,
21
+ };
22
+
23
+ /**
24
+ * Parse a size value into bytes.
25
+ * @param {string|number|null|undefined} value
26
+ * @returns {number|null} bytes, or null when value is null/undefined
27
+ * @throws {TypeError} if value cannot be parsed
28
+ */
29
+ export default function parseSize(value) {
30
+ if (value == null) {
31
+ return null;
32
+ }
33
+
34
+ if (typeof value === "number") {
35
+ if (!Number.isFinite(value) || value < 0) {
36
+ throw new TypeError(`Invalid size value: ${value}`);
37
+ }
38
+
39
+ return Math.round(value);
40
+ }
41
+
42
+ if (typeof value !== "string") {
43
+ throw new TypeError(`Invalid size value: ${value}`);
44
+ }
45
+
46
+ const trimmed = value.trim();
47
+ // Match a number (integer or decimal) optionally followed by a unit suffix.
48
+ // Examples: "50", "1.5 kB", "200MB", "2048 B"
49
+ // Group 1: the numeric part (e.g. "1.5")
50
+ // Group 2: the unit part, may be empty (e.g. "kB", "MB", or "")
51
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/);
52
+
53
+ if (!match) {
54
+ throw new TypeError(`Invalid size value: ${value}`);
55
+ }
56
+
57
+ const amount = Number.parseFloat(match[1]);
58
+ const unit = (match[2] || "b").toLowerCase();
59
+
60
+ if (!(unit in UNIT_MULTIPLIERS)) {
61
+ throw new TypeError(`Unknown size unit "${match[2]}" in "${value}"`);
62
+ }
63
+
64
+ return Math.round(amount * UNIT_MULTIPLIERS[unit]);
65
+ }
66
+
67
+ /**
68
+ * Format a number of bytes as a human-friendly string (for display / reports).
69
+ * @param {number|null} bytes
70
+ * @returns {string}
71
+ */
72
+ export function formatSize(bytes) {
73
+ if (bytes == null) {
74
+ return "—";
75
+ }
76
+
77
+ if (bytes < ONE_KILOBYTE) {
78
+ return `${bytes} B`;
79
+ }
80
+
81
+ if (bytes < ONE_MEGABYTE) {
82
+ return `${(bytes / ONE_KILOBYTE).toFixed(2)} kB`;
83
+ }
84
+
85
+ return `${(bytes / ONE_MEGABYTE).toFixed(2)} MB`;
86
+ }