chore(ci): add turbo caching and replace size-limit (#524)

This commit is contained in:
rahim
2026-02-13 21:15:34 +11:00
committed by GitHub
parent 1c916efed4
commit 7b41930eed
9 changed files with 929 additions and 341 deletions
+376
View File
@@ -0,0 +1,376 @@
/**
* Generates a markdown bundle size report from measurement JSON data.
*
* Usage:
* node bundle-size-report.js --pr pr-size.json [--base base-size.json]
*
* When --base is omitted, generates a report showing current sizes only (no diff).
* When --base is provided, generates a comparison report with diffs and status icons.
*
* Reads JSON arrays of { name, size, type } entries produced by bundle-size.js.
*/
import { readFileSync } from 'node:fs';
const ESC = '\x1b[';
const ansi = {
bold: (s) => `${ESC}1m${s}${ESC}22m`,
dim: (s) => `${ESC}2m${s}${ESC}22m`,
cyan: (s) => `${ESC}36m${s}${ESC}39m`,
yellow: (s) => `${ESC}33m${s}${ESC}39m`,
white: (s) => `${ESC}37m${s}${ESC}39m`,
green: (s) => `${ESC}32m${s}${ESC}39m`,
};
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(2)} kB`;
}
function formatDelta(current, previous) {
if (previous === undefined) return { bytes: '—', pct: '' };
const diff = current - previous;
if (diff === 0) return { bytes: '0 B', pct: '0%' };
const sign = diff > 0 ? '+' : '-';
const pct = Math.abs((diff / previous) * 100).toFixed(1);
return {
bytes: `${sign}${formatBytes(Math.abs(diff))}`,
pct: `${sign}${pct}%`,
};
}
function statusIcon(current, previous) {
if (previous === undefined) return '🆕';
const diff = current - previous;
if (diff === 0) return '✅';
if (diff < 0) return '🔽';
const pct = (diff / previous) * 100;
return pct > 10 ? '🔴' : '🔺';
}
function deltaBar(current, previous, maxAbsPct) {
const width = 8;
if (previous === undefined || maxAbsPct === 0) return '░'.repeat(width);
const pct = Math.abs(((current - previous) / previous) * 100);
const filled = Math.round((pct / maxAbsPct) * width);
return '█'.repeat(filled) + '░'.repeat(width - filled);
}
/** Group entries by package: @videojs/utils/* -> utils, @videojs/store/* -> store */
function groupByPackage(entries) {
const groups = new Map();
for (const entry of entries) {
const match = entry.name.match(/^@videojs\/([^/]+)/);
const pkg = match ? match[1] : 'other';
if (!groups.has(pkg)) groups.set(pkg, []);
groups.get(pkg).push(entry);
}
return groups;
}
function computePackageData(groups, baseMap) {
const pkgData = [];
let grandTotalCurrent = 0;
let grandTotalBase = 0;
for (const [pkg, entries] of groups) {
const rootEntries = entries.filter((e) => e.type === 'root');
const subEntries = entries.filter((e) => e.type === 'subpath');
const pkgTotalCurrent =
rootEntries.reduce((s, e) => s + e.size, 0) +
subEntries.reduce((s, e) => s + e.size, 0);
const pkgTotalBase =
rootEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0) +
subEntries.reduce((s, e) => s + (baseMap[e.name] ?? 0), 0);
const hasBase = entries.some((e) => baseMap[e.name] !== undefined);
grandTotalCurrent += pkgTotalCurrent;
grandTotalBase += pkgTotalBase;
pkgData.push({
pkg,
entries,
rootEntries,
subEntries,
pkgTotalCurrent,
pkgTotalBase,
hasBase,
});
}
return { pkgData, grandTotalCurrent, grandTotalBase };
}
/** Generate a comparison report (PR vs base). */
function generateComparisonReport(current, base) {
const baseMap = Object.fromEntries(base.map((e) => [e.name, e.size]));
const groups = groupByPackage(current);
const { pkgData, grandTotalCurrent, grandTotalBase } = computePackageData(
groups,
baseMap,
);
const maxAbsPct = Math.max(
...pkgData.map((p) => {
if (!p.hasBase || p.pkgTotalBase === 0) return 0;
return Math.abs(
((p.pkgTotalCurrent - p.pkgTotalBase) / p.pkgTotalBase) * 100,
);
}),
);
// Overview table
const overview = [];
overview.push('| Package | Size | Diff | | % | |');
overview.push('|---|--:|--:|---|--:|:-:|');
for (const p of pkgData) {
const d = formatDelta(
p.pkgTotalCurrent,
p.hasBase ? p.pkgTotalBase : undefined,
);
const icon = p.hasBase
? statusIcon(p.pkgTotalCurrent, p.pkgTotalBase)
: '';
const bar = `\`${deltaBar(p.pkgTotalCurrent, p.hasBase ? p.pkgTotalBase : undefined, maxAbsPct)}\``;
overview.push(
`| **@videojs/${p.pkg}** | **${formatBytes(p.pkgTotalCurrent)}** | ${p.hasBase ? d.bytes : '—'} | ${bar} | ${p.hasBase ? d.pct : ''} | ${icon} |`,
);
}
// Detail sections — only for packages with multiple entries
const details = [];
const pkgsWithSubs = pkgData.filter((p) => p.entries.length > 1);
if (pkgsWithSubs.length > 0) {
details.push('#### Entry Breakdown');
details.push('');
details.push(
'Subpath sizes are the additional bytes on top of the root entry point, measured by bundling root + subpath together and subtracting the root-only size.',
);
details.push('');
for (const p of pkgsWithSubs) {
const {
pkg,
rootEntries,
subEntries,
pkgTotalCurrent,
pkgTotalBase,
hasBase,
} = p;
details.push('<details>');
details.push(`<summary><code>@videojs/${pkg}</code></summary>`);
details.push('');
details.push('| Entry | Base | PR | Diff | % | |');
details.push('|---|--:|--:|--:|--:|:-:|');
for (const entry of [...rootEntries, ...subEntries]) {
const displayName =
entry.name.replace(`@videojs/${pkg}`, '') || '.';
const label = displayName === '.' ? displayName : `.${displayName}`;
const prev = baseMap[entry.name];
const d = formatDelta(entry.size, prev);
details.push(
`| \`${label}\` | ${prev !== undefined ? formatBytes(prev) : '—'} | **${formatBytes(entry.size)}** | ${d.bytes} | ${d.pct} | ${statusIcon(entry.size, prev)} |`,
);
}
if (rootEntries.length + subEntries.length > 1) {
const d = formatDelta(
pkgTotalCurrent,
hasBase ? pkgTotalBase : undefined,
);
details.push(
`| **total** | **${hasBase ? formatBytes(pkgTotalBase) : '—'}** | **${formatBytes(pkgTotalCurrent)}** | **${hasBase ? d.bytes : '—'}** | **${hasBase ? d.pct : ''}** | |`,
);
}
details.push('');
details.push('</details>');
details.push('');
}
}
const grandDelta = formatDelta(grandTotalCurrent, grandTotalBase);
const marker = '<!-- bundle-size-report -->';
return [
marker,
'### 📦 Bundle Size Report',
'',
...overview,
'',
`**Total: ${formatBytes(grandTotalCurrent)}**${grandTotalBase ? ` · ${grandDelta.bytes} · ${grandDelta.pct}` : ''}`,
'',
'---',
'',
...details,
'---',
'',
'<details>',
'<summary>️ How to interpret</summary>',
'',
'Sizes are minified + brotli, measured with esbuild.',
'Package totals are computed as root size + marginal subpath costs.',
'Subpath marginal cost = (root + subpath bundled together) root alone.',
'',
'| Icon | Meaning |',
'|---|---|',
'| ✅ | No change |',
'| 🔺 | Increased ≤ 10% |',
'| 🔴 | Increased > 10% |',
'| 🔽 | Decreased |',
'| 🆕 | New (no baseline) |',
'',
'Run `pnpm size` locally to check current sizes.',
'</details>',
].join('\n');
}
/**
* Render rows as a padded, aligned table for terminal output.
*
* Each row is an array of cell values. Cells can be plain strings or
* `{ text, style }` objects where `style` is a ansi chain (e.g. ansi.bold).
* Alignment and padding use the plain text width; ANSI codes are ignored.
*
* The first row is treated as a dim header.
*/
function printTable(rows) {
if (rows.length === 0) return '';
const text = (cell) => (typeof cell === 'string' ? cell : cell.text);
const style = (cell) =>
typeof cell === 'string' ? (s) => s : cell.style ?? ((s) => s);
const cols = rows[0].length;
const widths = Array.from({ length: cols }, () => 0);
for (const row of rows) {
for (let i = 0; i < cols; i++) {
widths[i] = Math.max(widths[i], text(row[i]).length);
}
}
const sep = ansi.dim(
`${widths.map((w) => '─'.repeat(w)).join('─┼─')}`,
);
const lines = [];
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].map((cell, i) => {
const t = text(cell);
const padded =
i === cols - 1 ? t.padStart(widths[i]) : t.padEnd(widths[i]);
// Header row is dim, data rows use their own style
return r === 0 ? ansi.dim(padded) : style(cell)(padded);
});
lines.push(` ${cells.join(ansi.dim(' │ '))} `);
if (r === 0) lines.push(sep);
}
return lines.join('\n');
}
/** Color a size value based on magnitude. */
function colorSize(bytes) {
const text = formatBytes(bytes);
if (bytes >= 5 * 1024) return { text, style: ansi.yellow };
if (bytes >= 1024) return { text, style: ansi.white };
return { text, style: ansi.green };
}
/** Generate a local-only report (no base comparison). */
function generateLocalReport(current) {
const groups = groupByPackage(current);
const overviewRows = [['Package', 'Size']];
let grandTotal = 0;
const pkgsWithSubs = [];
for (const [pkg, entries] of groups) {
const rootEntries = entries.filter((e) => e.type === 'root');
const subEntries = entries.filter((e) => e.type === 'subpath');
const pkgTotal =
rootEntries.reduce((s, e) => s + e.size, 0) +
subEntries.reduce((s, e) => s + e.size, 0);
grandTotal += pkgTotal;
overviewRows.push([
{ text: `@videojs/${pkg}`, style: ansi.bold },
colorSize(pkgTotal),
]);
if (entries.length > 1) {
pkgsWithSubs.push({ pkg, rootEntries, subEntries, pkgTotal });
}
}
const lines = [];
lines.push('');
lines.push(printTable(overviewRows));
lines.push('');
lines.push(ansi.bold(`Total: ${formatBytes(grandTotal)}`));
if (pkgsWithSubs.length > 0) {
for (const { pkg, rootEntries, subEntries, pkgTotal } of pkgsWithSubs) {
lines.push('');
lines.push(ansi.bold(`@videojs/${pkg}`));
const rows = [['Entry', 'Size']];
for (const entry of [...rootEntries, ...subEntries]) {
const displayName =
entry.name.replace(`@videojs/${pkg}`, '') || '.';
const label = displayName === '.' ? displayName : `.${displayName}`;
rows.push([
{ text: label, style: ansi.cyan },
colorSize(entry.size),
]);
}
rows.push([
{ text: 'total', style: ansi.bold },
{ text: formatBytes(pkgTotal), style: ansi.bold },
]);
lines.push(printTable(rows));
}
}
lines.push('');
lines.push(ansi.dim('Sizes are minified + brotli.'));
lines.push('');
return lines.join('\n');
}
function main() {
const args = process.argv.slice(2);
const prIndex = args.indexOf('--pr');
const baseIndex = args.indexOf('--base');
if (prIndex === -1) {
// Read from stdin (piped from bundle-size.js)
const input = readFileSync('/dev/stdin', 'utf8');
const current = JSON.parse(input);
console.log(generateLocalReport(current));
return;
}
const prPath = args[prIndex + 1];
const current = JSON.parse(readFileSync(prPath, 'utf8'));
if (baseIndex !== -1) {
const basePath = args[baseIndex + 1];
const base = JSON.parse(readFileSync(basePath, 'utf8'));
console.log(generateComparisonReport(current, base));
} else {
console.log(generateLocalReport(current));
}
}
main();
+177
View File
@@ -0,0 +1,177 @@
/**
* Measures bundle sizes for all packages, computing marginal subpath costs.
*
* Auto-discovers packages from `packages/`, reads their `exports` field to find
* entry points, and externalizes `peerDependencies`.
*
* For packages with subpaths, each subpath is bundled together with the root
* entry point. The marginal cost is: (root + subpath) - root. This captures
* shared minification and compression, avoiding the inflated totals you get
* from summing independently-measured subpaths.
*
* Usage: node .github/scripts/bundle-size.js [--json output.json]
*/
import { build } from 'esbuild';
import { brotliCompressSync, constants } from 'node:zlib';
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '../..');
const PACKAGES_DIR = join(ROOT, 'packages');
const SKIP_PACKAGES = new Set(['sandbox', '__tech-preview__', 'react-native']);
/**
* @typedef {object} SizeEntry
* @property {string} name
* @property {number} size - Root: brotli size. Subpath: marginal cost over root.
* @property {'root' | 'subpath'} type
*/
/** Bundle entry points with esbuild and return the minified + brotli size. */
async function measure(entryPoints, external = []) {
const result = await build({
entryPoints,
bundle: true,
minify: true,
treeShaking: true,
format: 'esm',
write: false,
metafile: true,
outdir: '/tmp/bundle-size-out',
external,
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,
},
});
return compressed.length;
}
/**
* Resolve the `default` condition from an export value.
* Handles both `{ default: "./dist/..." }` objects and plain string values.
*/
function resolveDefault(exportValue) {
if (typeof exportValue === 'string') return exportValue;
if (typeof exportValue === 'object' && exportValue !== null) {
return exportValue.default ?? null;
}
return null;
}
/** Discover packages and their entry points from the filesystem. */
function discoverPackages() {
const packages = [];
for (const dirName of readdirSync(PACKAGES_DIR).sort()) {
if (SKIP_PACKAGES.has(dirName)) continue;
const pkgJsonPath = join(PACKAGES_DIR, dirName, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
if (!pkgJson.exports) continue;
const pkgName = pkgJson.name;
const pkgDir = join(PACKAGES_DIR, dirName);
const external = pkgJson.peerDependencies
? Object.keys(pkgJson.peerDependencies)
: [];
let rootPath = null;
const subpaths = [];
for (const [key, value] of Object.entries(pkgJson.exports)) {
// Skip wildcard exports (side-effect registration files)
if (key.includes('*')) continue;
const defaultPath = resolveDefault(value);
if (!defaultPath) continue;
// Skip types-only exports (no runtime code)
if (defaultPath.endsWith('.d.ts')) continue;
const absolutePath = resolve(pkgDir, defaultPath);
if (!existsSync(absolutePath)) continue;
// Skip empty files (types-only exports with no runtime code)
if (readFileSync(absolutePath, 'utf8').trim().length === 0) continue;
if (key === '.') {
rootPath = absolutePath;
} else {
subpaths.push({
name: `${pkgName}${key.slice(1)}`,
path: absolutePath,
});
}
}
if (rootPath) {
// Package with a root export (and optional subpaths)
packages.push({ name: pkgName, rootPath, external, subpaths });
} else if (subpaths.length > 0) {
// Package with only subpath exports (e.g., @videojs/utils)
// Each subpath is measured as an independent root
for (const sub of subpaths) {
packages.push({
name: sub.name,
rootPath: sub.path,
external,
subpaths: [],
});
}
}
}
return packages;
}
async function main() {
const packages = discoverPackages();
/** @type {SizeEntry[]} */
const results = [];
for (const pkg of packages) {
const rootSize = await measure([pkg.rootPath], pkg.external);
results.push({ name: pkg.name, size: rootSize, type: 'root' });
for (const sub of pkg.subpaths) {
const combinedSize = await measure(
[pkg.rootPath, sub.path],
pkg.external,
);
const marginal = combinedSize - rootSize;
results.push({ name: sub.name, size: marginal, type: 'subpath' });
}
}
// Parse --json flag
const jsonIndex = process.argv.indexOf('--json');
const outputPath = jsonIndex !== -1 ? process.argv[jsonIndex + 1] : null;
const output = JSON.stringify(results, null, 2);
if (outputPath) {
writeFileSync(outputPath, output);
console.log(`Written to ${outputPath}`);
} else {
console.log(output);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});