mirror of
https://github.com/zoriya/v10.git
synced 2026-08-06 06:07:56 +00:00
fix(ci): report lazy bundle chunks separately (#1710)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
edfec1c93e
commit
17d6ebfc30
+154
-33
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user