#!/usr/bin/env node /** * Generates a combined bundle stats comment for GitHub Actions. * Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT). */ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; const REQUIRED_ARGS = new Map([ ['base', 'Mapping of bundle names to base stats JSON'], ['head', 'Mapping of bundle names to head stats JSON'], ]); function parseRawArgs(argv) { const args = new Map(); for (let index = 2; index < argv.length; index += 1) { const key = argv[index]; if (!key?.startsWith('--')) { throw new Error( `Unexpected argument "${key ?? ''}". Use --key value pairs.`, ); } const values = []; while (index + 1 < argv.length && !argv[index + 1].startsWith('--')) { values.push(argv[index + 1]); index += 1; } if (values.length === 0) { throw new Error(`Missing value for argument "${key}".`); } const keyName = key.slice(2); // Accumulate values if the key already exists if (args.has(keyName)) { args.set(keyName, [...args.get(keyName), ...values]); } else { args.set(keyName, values); } } return args; } function getSingleValue(args, key) { const values = args.get(key); if (!values) { return undefined; } if (values.length !== 1) { throw new Error(`Argument "--${key}" must have exactly one value.`); } return values[0]; } function parseMapping(values, key, description) { if (!values || values.length === 0) { throw new Error(`Missing required argument "--${key}" (${description}).`); } if (values.length === 1) { const [rawValue] = values; const trimmed = rawValue.trim(); if (trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('Value must be a JSON object.'); } return new Map( Object.entries(parsed).map(([name, pathValue]) => { if (typeof pathValue !== 'string') { throw new Error( `Value for "${name}" in "--${key}" must be a string path.`, ); } return [name, pathValue]; }), ); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown parsing error'; throw new Error( `Failed to parse "--${key}" value as JSON object: ${message}`, ); } } } const entries = new Map(); for (const value of values) { const [rawName, ...rawPathParts] = value.split('='); if (!rawName || rawPathParts.length === 0) { throw new Error( `Argument "--${key}" must be provided as name=path pairs or a JSON object.`, ); } const name = rawName.trim(); const pathValue = rawPathParts.join('=').trim(); if (!name) { throw new Error(`Argument "--${key}" contains an empty bundle name.`); } if (!pathValue) { throw new Error( `Argument "--${key}" for bundle "${name}" must include a non-empty path.`, ); } entries.set(name, pathValue); } if (entries.size === 0) { throw new Error(`Argument "--${key}" must define at least one bundle.`); } return entries; } function parseArgs(argv) { const args = parseRawArgs(argv); const baseMap = parseMapping( args.get('base'), 'base', REQUIRED_ARGS.get('base'), ); const headMap = parseMapping( args.get('head'), 'head', REQUIRED_ARGS.get('head'), ); const sections = []; for (const [name, basePath] of baseMap.entries()) { const headPath = headMap.get(name); if (!headPath) { throw new Error( `Bundle "${name}" is missing a corresponding "--head" entry.`, ); } sections.push({ name, basePath, headPath, }); } for (const name of headMap.keys()) { if (!baseMap.has(name)) { throw new Error( `Bundle "${name}" is missing a corresponding "--base" entry.`, ); } } return { sections, identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats', }; } async function loadStats(filePath) { try { const absolutePath = path.resolve(process.cwd(), filePath); const fileContents = await readFile(absolutePath, 'utf8'); const parsed = JSON.parse(fileContents); // Validate that we got a meaningful stats object if (!parsed || typeof parsed !== 'object') { throw new Error('Stats file does not contain a valid JSON object'); } return parsed; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error while parsing stats file'; console.error(`[bundle-stats] Failed to parse "${filePath}": ${message}`); throw new Error(`Failed to load stats file "${filePath}": ${message}`); } } function findAllChildren(node = {}) { if (Array.isArray(node.children)) { return node.children.flatMap(findAllChildren); } return [node]; } function trimPath(input) { if (!input) { return ''; } return input.replace(/.*node_modules/, '/node_modules'); } function assetNameToSizeMap(statAssets = {}) { const children = statAssets?.tree?.children; if (!Array.isArray(children) || children.length === 0) { return new Map(); } return new Map( children.map(asset => { const descendants = findAllChildren(asset); let size = 0; let gzipSize = statAssets?.options?.gzip ? 0 : null; for (const mod of descendants) { const nodePart = statAssets?.nodeParts?.[mod.uid]; if (!nodePart) { continue; } size += nodePart.renderedLength ?? 0; if (gzipSize !== null) { gzipSize += nodePart.gzipLength ?? 0; } } return [trimPath(asset.name), { size, gzipSize }]; }), ); } function chunkModuleNameToSizeMap(statChunks = {}) { if (!statChunks?.tree) { return new Map(); } return new Map( findAllChildren(statChunks.tree).map(mod => { const modInfo = statChunks?.nodeParts?.[mod.uid] ?? {}; const meta = statChunks?.nodeMetas?.[modInfo.metaUid] ?? {}; const id = trimPath(meta.id ?? ''); return [ id, { size: modInfo.renderedLength ?? 0, gzipSize: statChunks?.options?.gzip ? (modInfo.gzipLength ?? 0) : null, }, ]; }), ); } function sortDiffDescending(items) { return items.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); } function normaliseGzip(value) { if (value == null || Number.isNaN(value)) { return NaN; } return value; } function getAssetDiff(name, oldSize, newSize) { const diff = newSize.size - oldSize.size; const percent = oldSize.size === 0 ? newSize.size === 0 ? 0 : Infinity : +((1 - newSize.size / oldSize.size) * -100).toFixed(5) || 0; return { name, new: { size: newSize.size, gzipSize: normaliseGzip(newSize.gzipSize), }, old: { size: oldSize.size, gzipSize: normaliseGzip(oldSize.gzipSize), }, diff, diffPercentage: percent, }; } function webpackStatsDiff(oldAssets, newAssets) { const added = []; const removed = []; const bigger = []; const smaller = []; const unchanged = []; let newSizeTotal = 0; let oldSizeTotal = 0; let newGzipSizeTotal = 0; let oldGzipSizeTotal = 0; for (const [name, oldAssetSizes] of oldAssets) { oldSizeTotal += oldAssetSizes.size; oldGzipSizeTotal += oldAssetSizes.gzipSize ?? NaN; const newAsset = newAssets.get(name); if (!newAsset) { removed.push(getAssetDiff(name, oldAssetSizes, { size: 0, gzipSize: 0 })); continue; } const diff = getAssetDiff(name, oldAssetSizes, newAsset); if (diff.diffPercentage > 0) { bigger.push(diff); } else if (diff.diffPercentage < 0) { smaller.push(diff); } else { unchanged.push(diff); } } for (const [name, newAssetSizes] of newAssets) { newSizeTotal += newAssetSizes.size; newGzipSizeTotal += newAssetSizes.gzipSize ?? NaN; if (!oldAssets.has(name)) { added.push(getAssetDiff(name, { size: 0, gzipSize: 0 }, newAssetSizes)); } } const oldFilesCount = oldAssets.size; const newFilesCount = newAssets.size; return { added: sortDiffDescending(added), removed: sortDiffDescending(removed), bigger: sortDiffDescending(bigger), smaller: sortDiffDescending(smaller), unchanged, total: getAssetDiff( oldFilesCount === newFilesCount ? `${newFilesCount}` : `${oldFilesCount} → ${newFilesCount}`, { size: oldSizeTotal, gzipSize: oldGzipSizeTotal }, { size: newSizeTotal, gzipSize: newGzipSizeTotal }, ), }; } function getStatsDiff(oldStats, newStats) { return webpackStatsDiff( assetNameToSizeMap(oldStats), assetNameToSizeMap(newStats), ); } function getChunkModuleDiff(oldStats, newStats) { const diff = webpackStatsDiff( chunkModuleNameToSizeMap(oldStats), chunkModuleNameToSizeMap(newStats), ); if ( diff.added.length === 0 && diff.removed.length === 0 && diff.bigger.length === 0 && diff.smaller.length === 0 ) { return null; } return diff; } const BYTES_PER_KILOBYTE = 1024; const FILE_SIZE_DENOMINATIONS = [ 'B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB', ]; function formatFileSizeIEC(bytes, precision = 2) { if (bytes == null || Number.isNaN(bytes)) { return 'N/A'; } if (bytes === 0) { return `0 ${FILE_SIZE_DENOMINATIONS[0]}`; } const absBytes = Math.abs(bytes); const denominationIndex = Math.floor( Math.log(absBytes) / Math.log(BYTES_PER_KILOBYTE), ); const value = absBytes / Math.pow(BYTES_PER_KILOBYTE, denominationIndex); const stripped = parseFloat(value.toFixed(precision)); return `${stripped} ${FILE_SIZE_DENOMINATIONS[denominationIndex]}`; } function conditionalPercentage(number) { if (number === Infinity || number === -Infinity) { return '-'; } const absValue = Math.abs(number); if (absValue === 0 || absValue === 100) { return `${number}%`; } const value = Number.isFinite(absValue) ? absValue.toFixed(2) : absValue; return `${signFor(number)}${value}%`; } function capitalize(text) { if (!text) return ''; return `${text[0].toUpperCase()}${text.slice(1)}`; } function makeHeader(columns) { const header = columns.join(' | '); const separator = columns .map(column => Array.from({ length: column.length }) .map(() => '-') .join(''), ) .join(' | '); return `${header}\n${separator}`; } const TOTAL_HEADERS = makeHeader([ 'Files count', 'Total bundle size', '% Changed', ]); const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']); const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']); function signFor(num) { if (num === 0) return ''; return num > 0 ? '+' : '-'; } function toFileSizeDiff(oldSize, newSize, diff) { const diffLine = [ `${formatFileSizeIEC(oldSize)} → ${formatFileSizeIEC(newSize)}`, ]; if (typeof diff !== 'undefined') { diffLine.push(`(${signFor(diff)}${formatFileSizeIEC(diff)})`); } return diffLine.join(' '); } function toFileSizeDiffCell(asset) { const lines = []; if (asset.diff === 0) { lines.push(formatFileSizeIEC(asset.new.size)); if (asset.new.gzipSize) { lines.push(formatFileSizeIEC(asset.new.gzipSize)); } } else { lines.push(toFileSizeDiff(asset.old.size, asset.new.size, asset.diff)); if (asset.old.gzipSize || asset.new.gzipSize) { lines.push( `${toFileSizeDiff(asset.old.gzipSize, asset.new.gzipSize)} (gzip)`, ); } } return lines.join('
'); } function printAssetTableRow(asset) { return [ asset.name, toFileSizeDiffCell(asset), conditionalPercentage(asset.diffPercentage), ].join(' | '); } function printAssetTablesByGroup(statsDiff) { const statsFields = ['added', 'removed', 'bigger', 'smaller', 'unchanged']; return statsFields .map(field => { const assets = statsDiff[field] ?? []; if (assets.length === 0) { return `**${capitalize(field)}**\nNo assets were ${field}`; } return `**${capitalize(field)}**\n${TABLE_HEADERS}\n${assets .map(asset => printAssetTableRow(asset)) .join('\n')}`; }) .join('\n\n'); } function getDiffEmoji(diff) { if (diff.diffPercentage === Infinity) return '🆕'; if (diff.diffPercentage <= -100) return '🔥'; if (diff.diffPercentage > 0) return '📈'; if (diff.diffPercentage < 0) return '📉'; return ' '; } function getTrimmedChunkName(chunkModule) { const chunkName = chunkModule.name ?? ''; if (chunkName.startsWith('./')) { return chunkName.substring(2); } if (chunkName.startsWith('/')) { return chunkName.substring(1); } return chunkName; } function printChunkModuleRow(chunkModule) { const emoji = getDiffEmoji(chunkModule); const chunkName = getTrimmedChunkName(chunkModule); const diffPart = `${chunkModule.diff >= 0 ? '+' : '-'}${formatFileSizeIEC(chunkModule.diff)}`; const percentPart = Number.isFinite(chunkModule.diffPercentage) ? ` (${conditionalPercentage(chunkModule.diffPercentage)})` : ''; return [ `\`${chunkName}\``, `${emoji} ${diffPart}${percentPart}`, `${formatFileSizeIEC(chunkModule.old.size)} → ${formatFileSizeIEC(chunkModule.new.size)}`, ].join(' | '); } function printChunkModulesTable(statsDiff) { if (!statsDiff) { return ''; } const changedModules = [ ...(statsDiff.added ?? []), ...(statsDiff.removed ?? []), ...(statsDiff.bigger ?? []), ...(statsDiff.smaller ?? []), ].sort((a, b) => b.diffPercentage - a.diffPercentage); if (changedModules.length === 0) { return `
\nChangeset\nNo files were changed\n
`; } const rows = changedModules .slice(0, 100) .map(chunkModule => printChunkModuleRow(chunkModule)) .join('\n'); const summarySuffix = changedModules.length > 100 ? ' (largest 100 files by percent change)' : ''; return `
\nChangeset${summarySuffix}\n\n${CHUNK_TABLE_HEADERS}\n${rows}\n
`; } function printTotalAssetTable(statsDiff) { return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`; } function renderSection(title, statsDiff, chunkModuleDiff) { const { total, ...groups } = statsDiff; const parts = [`#### ${title}`, '', printTotalAssetTable({ total })]; const chunkTable = printChunkModulesTable(chunkModuleDiff); if (chunkTable) { parts.push('', chunkTable); } parts.push( '', `
\nView detailed bundle breakdown\n
\n\n${printAssetTablesByGroup( groups, )}\n
\n
`, ); return parts.join('\n'); } async function main() { const args = parseArgs(process.argv); console.error( `[bundle-stats] Found ${args.sections.length} sections to process`, ); args.sections.forEach((section, index) => { console.error( `[bundle-stats] Section ${index + 1}: ${section.name} (base: ${section.basePath}, head: ${section.headPath})`, ); }); const sections = []; for (const section of args.sections) { console.error(`[bundle-stats] Processing section: ${section.name}`); console.error( `[bundle-stats] Loading base stats from: ${section.basePath}`, ); const baseStats = await loadStats(section.basePath); console.error( `[bundle-stats] Loading head stats from: ${section.headPath}`, ); const headStats = await loadStats(section.headPath); const statsDiff = getStatsDiff(baseStats, headStats); const chunkDiff = getChunkModuleDiff(baseStats, headStats); console.error( `[bundle-stats] Section ${section.name}: ${statsDiff.total.name} files, total size ${statsDiff.total.old.size} → ${statsDiff.total.new.size}`, ); sections.push({ name: section.name, statsDiff, chunkDiff, }); } const identifier = ``; const comment = [ '### Bundle Stats', '', sections .map(section => renderSection(section.name, section.statsDiff, section.chunkDiff), ) .join('\n\n---\n\n'), '', identifier, '', ].join('\n'); process.stdout.write(comment); } main().catch(error => { console.error(error); process.exitCode = 1; });