diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da27c08ecc..d287ea7176 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -205,3 +205,162 @@ jobs: with: dep-cache-key: ${{ needs.build.outputs.dep-cache-key }} submodule-cache-key: ${{ needs.checkout-submodules.outputs.submodule-cache-key }} + + bundle-size: + needs: [build] + runs-on: ubuntu-latest + continue-on-error: true + if: github.event_name == 'pull_request' || github.event_name == 'push' + # This job runs as a side task to monitor bundle sizes without blocking the workflow + + steps: + - name: Checkout current branch + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/cache/restore@v4 + id: dep-cache + with: + path: ${{github.workspace}} + key: ${{ needs.build.outputs.dep-cache-key }} + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install Dependencies (if not restored from cache) + if: steps.dep-cache.outputs.cache-hit != 'true' + run: npm ci + working-directory: ${{ github.workspace }} + + - name: Checkout baseline branch (for PR comparison) + if: github.event_name == 'pull_request' + uses: actions/checkout@v5 + with: + ref: ${{ github.base_ref }} + path: baseline-workspace + + - name: Use Node.js 24 (baseline) + if: github.event_name == 'pull_request' + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies in baseline + if: github.event_name == 'pull_request' + working-directory: baseline-workspace + run: npm ci || true + continue-on-error: true + + - name: Build baseline packages + if: github.event_name == 'pull_request' + working-directory: baseline-workspace + run: npm run build --workspaces --if-present || true + continue-on-error: true + + - name: Analyze baseline bundle sizes + if: github.event_name == 'pull_request' + run: | + # Copy the analysis script to baseline workspace if it doesn't exist there + # (since this is a new script, it might not be in the base branch yet) + if [ ! -f baseline-workspace/scripts/analyze-bundle-size.js ]; then + mkdir -p baseline-workspace/scripts + cp scripts/analyze-bundle-size.js baseline-workspace/scripts/ || true + fi + # Run analysis in baseline workspace + cd baseline-workspace + node scripts/analyze-bundle-size.js --save --output=../baseline-results.json || true + continue-on-error: true + + - name: Build all packages for bundle analysis + run: npm run build --workspaces --if-present + working-directory: ${{ github.workspace }} + + - name: Analyze Bundle Sizes (with comparison if PR) + if: github.event_name == 'pull_request' + id: bundle-analysis + run: | + # Ensure script exists + if [ ! -f scripts/analyze-bundle-size.js ]; then + echo "āŒ Error: scripts/analyze-bundle-size.js not found" + exit 1 + fi + if [ -f baseline-results.json ]; then + echo "šŸ“Š Comparing bundle sizes with baseline (${{ github.base_ref }})..." + node scripts/analyze-bundle-size.js --compare --baseline=baseline-results.json --summary --baseline-ref=${{ github.base_ref }} > bundle-output.txt 2>&1 || true + # Extract summary if present + if grep -q "---SUMMARY_START---" bundle-output.txt; then + sed -n '/---SUMMARY_START---/,/---SUMMARY_END---/p' bundle-output.txt | sed '1d;$d' > bundle-summary.md + fi + # Show full output + cat bundle-output.txt + else + echo "šŸ“Š Analyzing bundle sizes (baseline comparison unavailable)..." + node scripts/analyze-bundle-size.js + fi + working-directory: ${{ github.workspace }} + continue-on-error: true + + - name: Post Bundle Size Summary to PR + if: github.event_name == 'pull_request' && (steps.bundle-analysis.outcome == 'success' || steps.bundle-analysis.outcome == 'failure') + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + const summaryPath = path.join(process.env.GITHUB_WORKSPACE, 'bundle-summary.md'); + + if (fs.existsSync(summaryPath)) { + const summary = fs.readFileSync(summaryPath, 'utf-8'); + + // Find existing comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existingComment = comments.data.find( + c => c.user.type === 'Bot' && c.body.includes('šŸ“¦ Bundle Size Analysis') + ); + + const commentBody = summary + '\n\nGenerated by [bundle-size](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow'; + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: commentBody, + }); + console.log('āœ… Updated PR comment'); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody, + }); + console.log('āœ… Posted PR comment'); + } + } else { + console.log('No bundle summary found, skipping PR comment'); + } + + - name: Analyze Bundle Sizes (push to master) + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + run: | + if [ ! -f scripts/analyze-bundle-size.js ]; then + echo "āŒ Error: scripts/analyze-bundle-size.js not found" + exit 1 + fi + node scripts/analyze-bundle-size.js + working-directory: ${{ github.workspace }} diff --git a/.gitignore b/.gitignore index f6a429995e..0a944fefb6 100644 --- a/.gitignore +++ b/.gitignore @@ -106,15 +106,16 @@ Temporary Items # End of https://www.toptal.com/developers/gitignore/api/node -# ethereumjs +# ethereumjs datadir* ## Vite stats.html *bundle*.js +!scripts/analyze-bundle-size.js ## Vitest __snapshots__ # CSpell -.cspellcache \ No newline at end of file +.cspellcache diff --git a/package.json b/package.json index ce899901f4..27703bfd08 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "spellcheck": "npm run spellcheck:ts && npm run spellcheck:md", "spellcheck:ts": "npx cspell --gitignore -e \"./packages/ethereum-tests\" -e \"./packages/wallet/test\" -e \"./packages/client/archive\" -c ./config/cspell-ts.json \"./packages/**/*.ts\" --cache --show-suggestions --show-context", "spellcheck:md": "npx cspell --gitignore -e \"./packages/ethereum-tests\" -e \"./packages/client/withdrawals-testnet/**\" -e \"./packages/**/docs\" -c ./config/cspell-md.json \"**.md\" --cache --show-suggestions --show-context", + "bundle:analyze": "node scripts/analyze-bundle-size.js", + "bundle:save": "node scripts/analyze-bundle-size.js --save", + "bundle:compare": "node scripts/analyze-bundle-size.js --compare --baseline=bundle-size-baseline.json", "tsc": "npm run tsc --workspaces --if-present" }, "devDependencies": { diff --git a/scripts/BUNDLE_SIZE_ANALYSIS.md b/scripts/BUNDLE_SIZE_ANALYSIS.md new file mode 100644 index 0000000000..76c876b9fd --- /dev/null +++ b/scripts/BUNDLE_SIZE_ANALYSIS.md @@ -0,0 +1,102 @@ +# Bundle Size Analysis + +This document describes the bundle size analysis tooling for the EthereumJS monorepo. + +## Overview + +The bundle size analysis script (`scripts/analyze-bundle-size.js`) analyzes the compiled bundle sizes for all packages in the monorepo. It calculates both raw and gzipped sizes for ESM and CJS builds. + +## Usage + +### Basic Analysis + +Analyze current bundle sizes: + +```bash +npm run bundle:analyze +``` + +### Save Baseline + +Save current bundle sizes as a baseline for comparison: + +```bash +npm run bundle:save +# Or with custom output file: +node scripts/analyze-bundle-size.js --save --output=my-baseline.json +``` + +### Compare with Baseline + +Compare current bundle sizes with a saved baseline: + +```bash +npm run bundle:compare +# Or with custom baseline file: +node scripts/analyze-bundle-size.js --compare --baseline=my-baseline.json +``` + +## CI Integration + +The bundle size analysis runs automatically in CI on: +- **Pull Requests**: Compares bundle sizes against the base branch (e.g., master) +- **Pushes to master**: Analyzes bundle sizes without comparison + +The CI job: +- Runs as a non-blocking side task (`continue-on-error: true`) +- Shows comparison results highlighting significant changes (>5% or >10KB) +- Does not fail the workflow if bundle sizes increase + +## Output Format + +### Standard Analysis + +``` +šŸ“Š Bundle Size Analysis Results + +Package ESM Raw ESM Gzip CJS Raw CJS Gzip Total Raw Total Gzip +genesis 723.09 KB 259.54 KB 723.68 KB 259.98 KB 1.41 MB 519.53 KB +... +``` + +### Comparison Mode + +``` +šŸ“Š Bundle Size Comparison (Current vs Baseline) + +Package Baseline Raw Current Raw Diff Raw Baseline Gzip Current Gzip Diff Gzip +genesis 1.41 MB 1.42 MB +10.5 KB (+0.7%) 519.53 KB 521.2 KB +1.67 KB (+0.3%) +``` + +**Indicators:** +- āš ļø Significant increase (>5% or >10KB) +- āœ… Significant decrease (>5% or >10KB) +- (no indicator) No significant change + +## For Breaking Releases + +As mentioned by the PM, for breaking releases you should: +1. Run bundle analysis before the release +2. Review the results to ensure bundle sizes are acceptable +3. Use the comparison feature to track changes from the previous release + +## Manual Comparison Workflow + +To manually compare your changes with master: + +```bash +# 1. Checkout master and build +git checkout master +npm ci +npm run build --workspaces --if-present + +# 2. Save baseline +npm run bundle:save -- --output=master-baseline.json + +# 3. Checkout your branch and build +git checkout your-branch +npm run build --workspaces --if-present + +# 4. Compare +npm run bundle:analyze -- --compare --baseline=master-baseline.json +``` diff --git a/scripts/analyze-bundle-size.js b/scripts/analyze-bundle-size.js new file mode 100755 index 0000000000..c318ba93e6 --- /dev/null +++ b/scripts/analyze-bundle-size.js @@ -0,0 +1,442 @@ +#!/usr/bin/env node +/** + * Bundle size analysis script for EthereumJS monorepo + * Analyzes the dist folder sizes for all packages and reports bundle sizes + * Supports comparison mode to compare against a baseline + */ + +import { readdir, stat, writeFile, readFile, mkdir } from 'fs/promises' +import { createGzip } from 'zlib' +import { createReadStream } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const rootDir = join(__dirname, '..') +const packagesDir = join(rootDir, 'packages') + +/** + * Get gzipped size of a file + */ +async function getGzipSize(filePath) { + return new Promise((resolve, reject) => { + const chunks = [] + const gzip = createGzip() + const readStream = createReadStream(filePath) + + readStream + .pipe(gzip) + .on('data', (chunk) => chunks.push(chunk)) + .on('end', () => { + const size = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + resolve(size) + }) + .on('error', reject) + }) +} + +/** + * Recursively get all files in a directory + */ +async function getAllFiles(dir, fileList = []) { + const files = await readdir(dir, { withFileTypes: true }) + + for (const file of files) { + const filePath = join(dir, file.name) + + if (file.isDirectory()) { + await getAllFiles(filePath, fileList) + } else if (file.isFile() && (file.name.endsWith('.js') || file.name.endsWith('.mjs'))) { + fileList.push(filePath) + } + } + + return fileList +} + +/** + * Calculate total size of a directory + */ +async function calculateDirSize(dir) { + try { + const files = await getAllFiles(dir) + let totalSize = 0 + let totalGzipSize = 0 + + for (const file of files) { + const stats = await stat(file) + totalSize += stats.size + totalGzipSize += await getGzipSize(file) + } + + return { raw: totalSize, gzip: totalGzipSize, fileCount: files.length } + } catch (error) { + // Directory doesn't exist or is empty + return { raw: 0, gzip: 0, fileCount: 0 } + } +} + +/** + * Format bytes to human readable format + */ +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} + +/** + * Format difference with sign and color indicators + */ +function formatDiff(current, baseline) { + if (baseline === 0) return current > 0 ? '+NEW' : '0 B' + const diff = current - baseline + const percent = ((diff / baseline) * 100).toFixed(1) + const sign = diff >= 0 ? '+' : '' + return `${sign}${formatBytes(diff)} (${sign}${percent}%)` +} + +/** + * Analyze bundle sizes for all packages + */ +async function analyzeBundleSizes(packagesDirPath = packagesDir) { + const packages = await readdir(packagesDirPath, { withFileTypes: true }) + const results = [] + + for (const pkg of packages) { + if (!pkg.isDirectory()) continue + + const packagePath = join(packagesDirPath, pkg.name) + const distPath = join(packagePath, 'dist') + + // Check if dist folder exists + try { + await stat(distPath) + } catch { + // No dist folder, skip + continue + } + + // Calculate sizes for both ESM and CJS builds + const esmPath = join(distPath, 'esm') + const cjsPath = join(distPath, 'cjs') + + const esmSize = await calculateDirSize(esmPath) + const cjsSize = await calculateDirSize(cjsPath) + + const totalRaw = esmSize.raw + cjsSize.raw + const totalGzip = esmSize.gzip + cjsSize.gzip + + results.push({ + package: pkg.name, + esm: esmSize, + cjs: cjsSize, + total: { raw: totalRaw, gzip: totalGzip }, + }) + } + + // Sort by total size (descending) + results.sort((a, b) => b.total.raw - a.total.raw) + + return results +} + +/** + * Print analysis results + */ +function printResults(results, title = 'šŸ“Š Bundle Size Analysis Results') { + console.log(`\n${title}\n`) + console.log('Package'.padEnd(20) + 'ESM Raw'.padEnd(12) + 'ESM Gzip'.padEnd(12) + 'CJS Raw'.padEnd(12) + 'CJS Gzip'.padEnd(12) + 'Total Raw'.padEnd(12) + 'Total Gzip') + console.log('-'.repeat(100)) + + for (const result of results) { + if (result.total.raw === 0) continue + + const esmRaw = formatBytes(result.esm.raw) + const esmGzip = formatBytes(result.esm.gzip) + const cjsRaw = formatBytes(result.cjs.raw) + const cjsGzip = formatBytes(result.cjs.gzip) + const totalRaw = formatBytes(result.total.raw) + const totalGzip = formatBytes(result.total.gzip) + + console.log( + result.package.padEnd(20) + + esmRaw.padEnd(12) + + esmGzip.padEnd(12) + + cjsRaw.padEnd(12) + + cjsGzip.padEnd(12) + + totalRaw.padEnd(12) + + totalGzip + ) + } +} + +/** + * Compare current results with baseline + */ +function compareResults(current, baseline) { + const baselineMap = new Map(baseline.map(r => [r.package, r])) + const comparison = [] + + // Get all unique packages + const allPackages = new Set([...current.map(r => r.package), ...baseline.map(r => r.package)]) + + for (const pkg of allPackages) { + const currentResult = current.find(r => r.package === pkg) + const baselineResult = baselineMap.get(pkg) + + if (!currentResult && !baselineResult) continue + + const currentTotal = currentResult?.total || { raw: 0, gzip: 0 } + const baselineTotal = baselineResult?.total || { raw: 0, gzip: 0 } + + comparison.push({ + package: pkg, + current: currentTotal, + baseline: baselineTotal, + diff: { + raw: currentTotal.raw - baselineTotal.raw, + gzip: currentTotal.gzip - baselineTotal.gzip, + }, + }) + } + + // Sort by absolute difference (descending) + comparison.sort((a, b) => Math.abs(b.diff.raw) - Math.abs(a.diff.raw)) + + return comparison +} + +/** + * Print comparison results + */ +function printComparison(comparison) { + console.log('\nšŸ“Š Bundle Size Comparison (Current vs Baseline)\n') + console.log( + 'Package'.padEnd(20) + + 'Baseline Raw'.padEnd(15) + + 'Current Raw'.padEnd(15) + + 'Diff Raw'.padEnd(20) + + 'Baseline Gzip'.padEnd(15) + + 'Current Gzip'.padEnd(15) + + 'Diff Gzip' + ) + console.log('-'.repeat(120)) + + let hasChanges = false + for (const comp of comparison) { + if (comp.current.raw === 0 && comp.baseline.raw === 0) continue + + const baselineRaw = formatBytes(comp.baseline.raw) + const currentRaw = formatBytes(comp.current.raw) + const diffRaw = formatDiff(comp.current.raw, comp.baseline.raw) + const baselineGzip = formatBytes(comp.baseline.gzip) + const currentGzip = formatBytes(comp.current.gzip) + const diffGzip = formatDiff(comp.current.gzip, comp.baseline.gzip) + + // Highlight significant changes (>5% or >10KB) + const rawChangePercent = comp.baseline.raw > 0 ? (comp.diff.raw / comp.baseline.raw) * 100 : 0 + const significantChange = Math.abs(rawChangePercent) > 5 || Math.abs(comp.diff.raw) > 10240 + + if (significantChange && comp.diff.raw !== 0) { + hasChanges = true + const indicator = comp.diff.raw > 0 ? 'āš ļø ' : 'āœ… ' + console.log( + indicator + comp.package.padEnd(18) + + baselineRaw.padEnd(15) + + currentRaw.padEnd(15) + + diffRaw.padEnd(20) + + baselineGzip.padEnd(15) + + currentGzip.padEnd(15) + + diffGzip + ) + } else { + console.log( + ' ' + comp.package.padEnd(18) + + baselineRaw.padEnd(15) + + currentRaw.padEnd(15) + + diffRaw.padEnd(20) + + baselineGzip.padEnd(15) + + currentGzip.padEnd(15) + + diffGzip + ) + } + } + + if (hasChanges) { + console.log('\nāš ļø Significant changes detected (>5% or >10KB)') + } +} + +/** + * Save results to JSON file + */ +/** + * Generate markdown summary for PR comment + */ +function generateSummary(comparison, baselineRef = 'baseline') { + if (!comparison || comparison.length === 0) { + return '## šŸ“¦ Bundle Size Analysis\n\nNo bundle size data available.' + } + + // Calculate totals + let totalBaselineRaw = 0 + let totalCurrentRaw = 0 + let totalBaselineGzip = 0 + let totalCurrentGzip = 0 + + for (const comp of comparison) { + totalBaselineRaw += comp.baseline.raw + totalCurrentRaw += comp.current.raw + totalBaselineGzip += comp.baseline.gzip + totalCurrentGzip += comp.current.gzip + } + + const totalDiffRaw = totalCurrentRaw - totalBaselineRaw + const totalDiffGzip = totalCurrentGzip - totalBaselineGzip + const totalDiffPercentRaw = totalBaselineRaw > 0 ? ((totalDiffRaw / totalBaselineRaw) * 100).toFixed(2) : '0.00' + const totalDiffPercentGzip = totalBaselineGzip > 0 ? ((totalDiffGzip / totalBaselineGzip) * 100).toFixed(2) : '0.00' + + const signRaw = totalDiffRaw >= 0 ? '+' : '' + const signGzip = totalDiffGzip >= 0 ? '+' : '' + + let summary = `## šŸ“¦ Bundle Size Analysis\n\n` + summary += `**Total Bundle Size:** ${formatBytes(totalCurrentRaw)} (${formatBytes(totalCurrentGzip)} gzipped)\n` + summary += `**Change:** ${signRaw}${formatBytes(totalDiffRaw)} (${signRaw}${totalDiffPercentRaw}%) / ${signGzip}${formatBytes(totalDiffGzip)} (${signGzip}${totalDiffPercentGzip}%) gzipped\n\n` + summary += `Compared to ${baselineRef}\n\n` + + // Table header + summary += `| Package | Baseline | Current | Change |\n` + summary += `|---------|----------|---------|--------|\n` + + // Add rows for packages with changes + let hasSignificantChanges = false + for (const comp of comparison) { + if (comp.current.raw === 0 && comp.baseline.raw === 0) continue + + const rawChangePercent = comp.baseline.raw > 0 ? (comp.diff.raw / comp.baseline.raw) * 100 : 0 + const significantChange = Math.abs(rawChangePercent) > 5 || Math.abs(comp.diff.raw) > 10240 + + if (Math.abs(comp.diff.raw) > 0 || Math.abs(comp.diff.gzip) > 0) { + const baselineGzip = formatBytes(comp.baseline.gzip) + const currentGzip = formatBytes(comp.current.gzip) + const diffGzip = formatDiff(comp.current.gzip, comp.baseline.gzip) + + const indicator = significantChange ? (comp.diff.raw > 0 ? 'āš ļø ' : 'āœ… ') : '' + const changeText = `${formatDiff(comp.current.raw, comp.baseline.raw)} / ${diffGzip}` + + summary += `| ${indicator}**${comp.package}** | ${baselineGzip} | ${currentGzip} | ${changeText} |\n` + + if (significantChange) { + hasSignificantChanges = true + } + } + } + + if (hasSignificantChanges) { + summary += `\nāš ļø **Significant changes detected** (>5% or >10KB)\n` + } + + return summary +} + +async function saveResults(results, filePath) { + await writeFile(filePath, JSON.stringify(results, null, 2)) +} + +/** + * Load results from JSON file + */ +async function loadResults(filePath) { + try { + const data = await readFile(filePath, 'utf-8') + return JSON.parse(data) + } catch { + return null + } +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2) + const compareMode = args.includes('--compare') || args.includes('-c') + const baselineFile = args.find(arg => arg.startsWith('--baseline='))?.split('=')[1] || + args.find(arg => arg.startsWith('-b='))?.split('=')[1] + + if (compareMode && baselineFile) { + // Comparison mode + console.log('šŸ“¦ Analyzing current bundle sizes...') + const currentResults = await analyzeBundleSizes() + + console.log('šŸ“¦ Loading baseline results...') + const baselineResults = await loadResults(baselineFile) + + if (!baselineResults) { + console.error(`āŒ Baseline file not found: ${baselineFile}`) + process.exit(1) + } + + printResults(baselineResults, 'šŸ“Š Baseline Bundle Sizes') + printResults(currentResults, 'šŸ“Š Current Bundle Sizes') + + const comparison = compareResults(currentResults, baselineResults) + printComparison(comparison) + + // Generate summary if requested + if (args.includes('--summary') || args.includes('--summary-file')) { + const summaryFile = args.find(arg => arg.startsWith('--summary-file='))?.split('=')[1] + const baselineRef = args.find(arg => arg.startsWith('--baseline-ref='))?.split('=')[1] || 'baseline' + const summary = generateSummary(comparison, baselineRef) + + if (summaryFile) { + await writeFile(summaryFile, summary) + console.log(` +šŸ“ Summary saved to ${summaryFile}`) + } else { + // Output to stdout for CI to capture + console.log(' +---SUMMARY_START---') + console.log(summary) + console.log('---SUMMARY_END---') + } + } + + console.log(' +āœ… Bundle size comparison complete') + } else if (args.includes('--save') || args.includes('-s')) { + // Save mode - save current results to file + const outputFile = args.find(arg => arg.startsWith('--output='))?.split('=')[1] || + args.find(arg => arg.startsWith('-o='))?.split('=')[1] || + 'bundle-size-baseline.json' + + console.log('šŸ“¦ Analyzing bundle sizes...') + const results = await analyzeBundleSizes() + + await saveResults(results, outputFile) + console.log(`\nāœ… Results saved to ${outputFile}`) + printResults(results) + } else { + // Normal mode - just analyze and print + console.log('šŸ“¦ Analyzing bundle sizes...\n') + const results = await analyzeBundleSizes() + printResults(results) + console.log('\nāœ… Bundle size analysis complete') + console.log('\nšŸ’” Tip: Use --save to save baseline, --compare --baseline= to compare') + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error('āŒ Error:', error) + process.exit(1) + }) +} + +export { analyzeBundleSizes, compareResults, saveResults, loadResults } diff --git a/scripts/analyze-bundle-size.js.bak b/scripts/analyze-bundle-size.js.bak new file mode 100755 index 0000000000..3bf4df3b94 --- /dev/null +++ b/scripts/analyze-bundle-size.js.bak @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/** + * Bundle size analysis script for EthereumJS monorepo + * Analyzes the dist folder sizes for all packages and reports bundle sizes + * Supports comparison mode to compare against a baseline + */ + +import { readdir, stat, writeFile, readFile, mkdir } from 'fs/promises' +import { createGzip } from 'zlib' +import { createReadStream } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const rootDir = join(__dirname, '..') +const packagesDir = join(rootDir, 'packages') + +/** + * Get gzipped size of a file + */ +async function getGzipSize(filePath) { + return new Promise((resolve, reject) => { + const chunks = [] + const gzip = createGzip() + const readStream = createReadStream(filePath) + + readStream + .pipe(gzip) + .on('data', (chunk) => chunks.push(chunk)) + .on('end', () => { + const size = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + resolve(size) + }) + .on('error', reject) + }) +} + +/** + * Recursively get all files in a directory + */ +async function getAllFiles(dir, fileList = []) { + const files = await readdir(dir, { withFileTypes: true }) + + for (const file of files) { + const filePath = join(dir, file.name) + + if (file.isDirectory()) { + await getAllFiles(filePath, fileList) + } else if (file.isFile() && (file.name.endsWith('.js') || file.name.endsWith('.mjs'))) { + fileList.push(filePath) + } + } + + return fileList +} + +/** + * Calculate total size of a directory + */ +async function calculateDirSize(dir) { + try { + const files = await getAllFiles(dir) + let totalSize = 0 + let totalGzipSize = 0 + + for (const file of files) { + const stats = await stat(file) + totalSize += stats.size + totalGzipSize += await getGzipSize(file) + } + + return { raw: totalSize, gzip: totalGzipSize, fileCount: files.length } + } catch (error) { + // Directory doesn't exist or is empty + return { raw: 0, gzip: 0, fileCount: 0 } + } +} + +/** + * Format bytes to human readable format + */ +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} + +/** + * Format difference with sign and color indicators + */ +function formatDiff(current, baseline) { + if (baseline === 0) return current > 0 ? '+NEW' : '0 B' + const diff = current - baseline + const percent = ((diff / baseline) * 100).toFixed(1) + const sign = diff >= 0 ? '+' : '' + return `${sign}${formatBytes(diff)} (${sign}${percent}%)` +} + +/** + * Analyze bundle sizes for all packages + */ +async function analyzeBundleSizes(packagesDirPath = packagesDir) { + const packages = await readdir(packagesDirPath, { withFileTypes: true }) + const results = [] + + for (const pkg of packages) { + if (!pkg.isDirectory()) continue + + const packagePath = join(packagesDirPath, pkg.name) + const distPath = join(packagePath, 'dist') + + // Check if dist folder exists + try { + await stat(distPath) + } catch { + // No dist folder, skip + continue + } + + // Calculate sizes for both ESM and CJS builds + const esmPath = join(distPath, 'esm') + const cjsPath = join(distPath, 'cjs') + + const esmSize = await calculateDirSize(esmPath) + const cjsSize = await calculateDirSize(cjsPath) + + const totalRaw = esmSize.raw + cjsSize.raw + const totalGzip = esmSize.gzip + cjsSize.gzip + + results.push({ + package: pkg.name, + esm: esmSize, + cjs: cjsSize, + total: { raw: totalRaw, gzip: totalGzip }, + }) + } + + // Sort by total size (descending) + results.sort((a, b) => b.total.raw - a.total.raw) + + return results +} + +/** + * Print analysis results + */ +function printResults(results, title = 'šŸ“Š Bundle Size Analysis Results') { + console.log(`\n${title}\n`) + console.log('Package'.padEnd(20) + 'ESM Raw'.padEnd(12) + 'ESM Gzip'.padEnd(12) + 'CJS Raw'.padEnd(12) + 'CJS Gzip'.padEnd(12) + 'Total Raw'.padEnd(12) + 'Total Gzip') + console.log('-'.repeat(100)) + + for (const result of results) { + if (result.total.raw === 0) continue + + const esmRaw = formatBytes(result.esm.raw) + const esmGzip = formatBytes(result.esm.gzip) + const cjsRaw = formatBytes(result.cjs.raw) + const cjsGzip = formatBytes(result.cjs.gzip) + const totalRaw = formatBytes(result.total.raw) + const totalGzip = formatBytes(result.total.gzip) + + console.log( + result.package.padEnd(20) + + esmRaw.padEnd(12) + + esmGzip.padEnd(12) + + cjsRaw.padEnd(12) + + cjsGzip.padEnd(12) + + totalRaw.padEnd(12) + + totalGzip + ) + } +} + +/** + * Compare current results with baseline + */ +function compareResults(current, baseline) { + const baselineMap = new Map(baseline.map(r => [r.package, r])) + const comparison = [] + + // Get all unique packages + const allPackages = new Set([...current.map(r => r.package), ...baseline.map(r => r.package)]) + + for (const pkg of allPackages) { + const currentResult = current.find(r => r.package === pkg) + const baselineResult = baselineMap.get(pkg) + + if (!currentResult && !baselineResult) continue + + const currentTotal = currentResult?.total || { raw: 0, gzip: 0 } + const baselineTotal = baselineResult?.total || { raw: 0, gzip: 0 } + + comparison.push({ + package: pkg, + current: currentTotal, + baseline: baselineTotal, + diff: { + raw: currentTotal.raw - baselineTotal.raw, + gzip: currentTotal.gzip - baselineTotal.gzip, + }, + }) + } + + // Sort by absolute difference (descending) + comparison.sort((a, b) => Math.abs(b.diff.raw) - Math.abs(a.diff.raw)) + + return comparison +} + +/** + * Print comparison results + */ +function printComparison(comparison) { + console.log('\nšŸ“Š Bundle Size Comparison (Current vs Baseline)\n') + console.log( + 'Package'.padEnd(20) + + 'Baseline Raw'.padEnd(15) + + 'Current Raw'.padEnd(15) + + 'Diff Raw'.padEnd(20) + + 'Baseline Gzip'.padEnd(15) + + 'Current Gzip'.padEnd(15) + + 'Diff Gzip' + ) + console.log('-'.repeat(120)) + + let hasChanges = false + for (const comp of comparison) { + if (comp.current.raw === 0 && comp.baseline.raw === 0) continue + + const baselineRaw = formatBytes(comp.baseline.raw) + const currentRaw = formatBytes(comp.current.raw) + const diffRaw = formatDiff(comp.current.raw, comp.baseline.raw) + const baselineGzip = formatBytes(comp.baseline.gzip) + const currentGzip = formatBytes(comp.current.gzip) + const diffGzip = formatDiff(comp.current.gzip, comp.baseline.gzip) + + // Highlight significant changes (>5% or >10KB) + const rawChangePercent = comp.baseline.raw > 0 ? (comp.diff.raw / comp.baseline.raw) * 100 : 0 + const significantChange = Math.abs(rawChangePercent) > 5 || Math.abs(comp.diff.raw) > 10240 + + if (significantChange && comp.diff.raw !== 0) { + hasChanges = true + const indicator = comp.diff.raw > 0 ? 'āš ļø ' : 'āœ… ' + console.log( + indicator + comp.package.padEnd(18) + + baselineRaw.padEnd(15) + + currentRaw.padEnd(15) + + diffRaw.padEnd(20) + + baselineGzip.padEnd(15) + + currentGzip.padEnd(15) + + diffGzip + ) + } else { + console.log( + ' ' + comp.package.padEnd(18) + + baselineRaw.padEnd(15) + + currentRaw.padEnd(15) + + diffRaw.padEnd(20) + + baselineGzip.padEnd(15) + + currentGzip.padEnd(15) + + diffGzip + ) + } + } + + if (hasChanges) { + console.log('\nāš ļø Significant changes detected (>5% or >10KB)') + } +} + +/** + * Save results to JSON file + */ +async function saveResults(results, filePath) { + await writeFile(filePath, JSON.stringify(results, null, 2)) +} + +/** + * Load results from JSON file + */ +async function loadResults(filePath) { + try { + const data = await readFile(filePath, 'utf-8') + return JSON.parse(data) + } catch { + return null + } +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2) + const compareMode = args.includes('--compare') || args.includes('-c') + const baselineFile = args.find(arg => arg.startsWith('--baseline='))?.split('=')[1] || + args.find(arg => arg.startsWith('-b='))?.split('=')[1] + + if (compareMode && baselineFile) { + // Comparison mode + console.log('šŸ“¦ Analyzing current bundle sizes...') + const currentResults = await analyzeBundleSizes() + + console.log('šŸ“¦ Loading baseline results...') + const baselineResults = await loadResults(baselineFile) + + if (!baselineResults) { + console.error(`āŒ Baseline file not found: ${baselineFile}`) + process.exit(1) + } + + printResults(baselineResults, 'šŸ“Š Baseline Bundle Sizes') + printResults(currentResults, 'šŸ“Š Current Bundle Sizes') + + const comparison = compareResults(currentResults, baselineResults) + printComparison(comparison) + + console.log('\nāœ… Bundle size comparison complete') + } else if (args.includes('--save') || args.includes('-s')) { + // Save mode - save current results to file + const outputFile = args.find(arg => arg.startsWith('--output='))?.split('=')[1] || + args.find(arg => arg.startsWith('-o='))?.split('=')[1] || + 'bundle-size-baseline.json' + + console.log('šŸ“¦ Analyzing bundle sizes...') + const results = await analyzeBundleSizes() + + await saveResults(results, outputFile) + console.log(`\nāœ… Results saved to ${outputFile}`) + printResults(results) + } else { + // Normal mode - just analyze and print + console.log('šŸ“¦ Analyzing bundle sizes...\n') + const results = await analyzeBundleSizes() + printResults(results) + console.log('\nāœ… Bundle size analysis complete') + console.log('\nšŸ’” Tip: Use --save to save baseline, --compare --baseline= to compare') + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error('āŒ Error:', error) + process.exit(1) + }) +} + +export { analyzeBundleSizes, compareResults, saveResults, loadResults }