fix(ci): report lazy bundle chunks separately (#1710)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sam Potts
2026-06-19 13:42:08 +10:00
committed by GitHub
co-authored by Cursor Agent
parent edfec1c93e
commit 17d6ebfc30
3 changed files with 288 additions and 68 deletions
+154 -33
View File
@@ -4,7 +4,9 @@
* Auto-discovers packages from `packages/`, reads their `exports` field to find
* entry points, and externalizes `peerDependencies`.
*
* All sizes are standalone totals (minified + brotli).
* JS sizes are initial static graph totals (minified + brotli). Lazy dynamic
* chunks are measured separately so they stay visible without counting as
* eager entry cost.
*
* Wildcard exports (e.g., `./ui/*`, `./media/⁕/index.js`) are resolved to
* actual files on disk. Supports both file-level (`*.js`) and directory-level
@@ -15,7 +17,7 @@
* Each entry includes a `category` field for grouped reporting in html/react:
* preset, media, player, skin, ui, or feature.
*
* Usage: node .github/scripts/bundle-size.js [--json output.json]
* Usage: node .github/scripts/bundle-size.js [--root repo-root] [--json output.json]
*/
import { build, transform } from 'esbuild';
@@ -26,11 +28,15 @@ import {
writeFileSync,
existsSync,
} from 'node:fs';
import { resolve, dirname, join, basename } from 'node:path';
import { resolve, dirname, join, basename, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '../..');
const rootIndex = process.argv.indexOf('--root');
const ROOT =
rootIndex !== -1
? resolve(process.argv[rootIndex + 1])
: resolve(__dirname, '../..');
const PACKAGES_DIR = join(ROOT, 'packages');
const SKIP_PACKAGES = new Set([
@@ -150,9 +156,70 @@ function buildPresetEntry(pkgShortName, config, distDir) {
* @property {string} [category] - preset, media, player, skin, ui, feature (only for html/react)
* @property {'js' | 'css'} format
* @property {number} [standaloneSize] - For UI components: standalone size used for stable diff gating
* @property {number} [totalSize] - Initial + lazy dynamic chunk size
* @property {number} [lazySize] - Lazy dynamic chunk size
* @property {number} [chunkCount] - Number of dynamic chunks
* @property {number} [standaloneTotalSize] - Standalone initial + lazy dynamic chunk size
* @property {number} [standaloneLazySize] - Standalone lazy dynamic chunk size
*/
/** Bundle entry points with esbuild and return the minified + brotli size. */
function compressSize(code) {
return brotliCompressSync(Buffer.from(code), {
params: {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
},
}).length;
}
function outputPath(path) {
return resolve(ROOT, path);
}
function entryKey(path) {
return relative(ROOT, path).replaceAll('\\', '/');
}
function staticOutputs(metafile, entryPoints) {
const entryPointsSet = entryPoints
? new Set(entryPoints.map((entry) => entryKey(entry)))
: null;
const outputs = new Set();
const queue = Object.entries(metafile.outputs)
.filter(([, output]) =>
entryPointsSet
? entryPointsSet.has(output.entryPoint)
: output.entryPoint === '<stdin>',
)
.map(([path]) => path);
for (const path of queue) {
if (outputs.has(path)) continue;
outputs.add(path);
const output = metafile.outputs[path];
for (const link of output.imports ?? []) {
if (link.kind === 'dynamic-import') continue;
if (metafile.outputs[link.path]) queue.push(link.path);
}
}
return outputs;
}
function sizeFields(measurement) {
return {
size: measurement.size,
...(measurement.lazySize > 0
? {
totalSize: measurement.totalSize,
lazySize: measurement.lazySize,
chunkCount: measurement.chunkCount,
}
: {}),
};
}
/** Bundle entry points with esbuild and return initial and lazy sizes. */
async function measure(entryPoints, external = []) {
const result = await build({
entryPoints,
@@ -160,35 +227,44 @@ async function measure(entryPoints, external = []) {
minify: true,
treeShaking: true,
format: 'esm',
splitting: true,
absWorkingDir: ROOT,
write: false,
outdir: '/tmp/bundle-size-out',
external,
metafile: true,
logLevel: 'silent',
});
const code = result.outputFiles.map((f) => f.text).join('');
const compressed = brotliCompressSync(Buffer.from(code), {
params: {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
},
});
const sizeByPath = new Map(
result.outputFiles.map((file) => [file.path, compressSize(file.text)]),
);
const staticPaths = staticOutputs(result.metafile, entryPoints);
let size = 0;
let totalSize = 0;
return compressed.length;
for (const [path] of Object.entries(result.metafile.outputs)) {
const bytes = sizeByPath.get(outputPath(path)) ?? 0;
totalSize += bytes;
if (staticPaths.has(path)) size += bytes;
}
return {
size,
totalSize,
lazySize: Math.max(0, totalSize - size),
chunkCount: Math.max(0, result.outputFiles.length - staticPaths.size),
};
}
/** Minify a CSS file with esbuild then brotli-compress it. */
async function measureCSS(filePath) {
const content = readFileSync(filePath, 'utf8');
const result = await transform(content, { loader: 'css', minify: true });
const compressed = brotliCompressSync(Buffer.from(result.code), {
params: {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
},
});
return compressed.length;
return compressSize(result.code);
}
/** Bundle a virtual entry (source string) with esbuild and return the minified + brotli size. */
/** Bundle a virtual entry (source string) with esbuild and return initial and lazy sizes. */
async function measureVirtual(code, resolveDir, external = []) {
const result = await build({
stdin: { contents: code, resolveDir, loader: 'js' },
@@ -196,20 +272,34 @@ async function measureVirtual(code, resolveDir, external = []) {
minify: true,
treeShaking: true,
format: 'esm',
splitting: true,
absWorkingDir: ROOT,
write: false,
outdir: '/tmp/bundle-size-out',
external,
metafile: true,
logLevel: 'silent',
});
const output = result.outputFiles.map((f) => f.text).join('');
const compressed = brotliCompressSync(Buffer.from(output), {
params: {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
},
});
const sizeByPath = new Map(
result.outputFiles.map((file) => [file.path, compressSize(file.text)]),
);
const staticPaths = staticOutputs(result.metafile);
let size = 0;
let totalSize = 0;
return compressed.length;
for (const [path] of Object.entries(result.metafile.outputs)) {
const bytes = sizeByPath.get(outputPath(path)) ?? 0;
totalSize += bytes;
if (staticPaths.has(path)) size += bytes;
}
return {
size,
totalSize,
lazySize: Math.max(0, totalSize - size),
chunkCount: Math.max(0, result.outputFiles.length - staticPaths.size),
};
}
/**
@@ -447,12 +537,13 @@ async function main() {
// Always measure root — needed for UI marginal calculations even when
// the root itself is excluded from results (categorized packages skip
// root because presets are measured as virtual bundles instead).
const rootSize = await measure([pkg.rootPath], pkg.external);
const rootMeasurement = await measure([pkg.rootPath], pkg.external);
const rootSize = rootMeasurement.size;
if (rootCat !== '_skip') {
results.push({
name: pkg.name,
size: rootSize,
...sizeFields(rootMeasurement),
type: 'root',
...(rootCat ? { category: rootCat } : {}),
format: 'js',
@@ -480,12 +571,29 @@ async function main() {
// compression non-linearity, so diffs must gate on standalone.
let size;
let standaloneSize;
let measurement;
let standaloneMeasurement;
if (cat === 'ui') {
const combined = await measure([pkg.rootPath, sub.path], pkg.external);
size = Math.max(0, combined - rootSize);
standaloneSize = await measure([sub.path], pkg.external);
standaloneMeasurement = await measure([sub.path], pkg.external);
size = Math.max(0, combined.size - rootSize);
const lazySize = Math.max(
0,
combined.lazySize - rootMeasurement.lazySize,
);
measurement = {
size,
totalSize: size + lazySize,
lazySize,
chunkCount: Math.max(
0,
combined.chunkCount - rootMeasurement.chunkCount,
),
};
standaloneSize = standaloneMeasurement.size;
} else {
size = await measure([sub.path], pkg.external);
measurement = await measure([sub.path], pkg.external);
size = measurement.size;
}
results.push({
@@ -494,7 +602,20 @@ async function main() {
type: 'subpath',
...(cat ? { category: cat } : {}),
format: 'js',
...(measurement && measurement.lazySize > 0
? {
totalSize: measurement.totalSize,
lazySize: measurement.lazySize,
chunkCount: measurement.chunkCount,
}
: {}),
...(standaloneSize !== undefined ? { standaloneSize } : {}),
...(standaloneMeasurement && standaloneMeasurement.lazySize > 0
? {
standaloneTotalSize: standaloneMeasurement.totalSize,
standaloneLazySize: standaloneMeasurement.lazySize,
}
: {}),
});
}
@@ -507,10 +628,10 @@ async function main() {
const code = buildPresetEntry(pkgShortName, config, distDir);
if (!code) continue;
const size = await measureVirtual(code, distDir, pkg.external);
const measurement = await measureVirtual(code, distDir, pkg.external);
results.push({
name: `${pkg.name}${config.label}`,
size,
...sizeFields(measurement),
type: 'subpath',
category: 'preset',
format: 'js',