diff --git a/.github/scripts/frontend-bundle-visualizer-report.mjs b/.github/scripts/frontend-bundle-visualizer-report.mjs new file mode 100644 index 0000000000..240062e9c4 --- /dev/null +++ b/.github/scripts/frontend-bundle-visualizer-report.mjs @@ -0,0 +1,159 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +const inputFile = process.argv[2]; +const outputFile = process.argv[3]; + +if (inputFile == null || outputFile == null) { + console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs '); + process.exit(1); +} + +const byteFormatter = new Intl.NumberFormat('en-US'); + +function formatBytes(value) { + if (!Number.isFinite(value) || value <= 0) return '0 B'; + + const units = ['B', 'KiB', 'MiB', 'GiB']; + let unitIndex = 0; + let size = value; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; +} + +function formatPercent(value) { + return `${value.toFixed(1)}%`; +} + +function sharePercent(value, total) { + if (total === 0) return '0.0%'; + return formatPercent((value / total) * 100); +} + +function tableCell(value) { + return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); +} + +function code(value) { + const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' '); + const backtickRuns = sanitized.match(/`+/g) ?? []; + const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1)); + const fence = '`'.repeat(fenceLength); + const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : ''; + + return `${fence}${padding}${sanitized}${padding}${fence}`; +} + +function tableCode(value) { + return tableCell(code(value)); +} + +const data = JSON.parse(await readFile(inputFile, 'utf8')); +const nodeParts = data.nodeParts ?? {}; +const nodeMetas = Object.values(data.nodeMetas ?? {}); +const moduleRows = []; +const bundleMap = new Map(); + +for (const meta of nodeMetas) { + const row = { + id: meta.id, + bundles: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + importedByCount: meta.importedBy?.length ?? 0, + importedCount: meta.imported?.length ?? 0, + parts: [], + }; + + for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { + const part = nodeParts[partUid]; + if (part == null) continue; + + row.bundles += 1; + row.renderedLength += part.renderedLength; + row.gzipLength += part.gzipLength; + row.brotliLength += part.brotliLength; + row.parts.push({ bundleId, ...part }); + + const bundle = bundleMap.get(bundleId) ?? { + id: bundleId, + modules: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + }; + bundle.modules += 1; + bundle.renderedLength += part.renderedLength; + bundle.gzipLength += part.gzipLength; + bundle.brotliLength += part.brotliLength; + bundleMap.set(bundleId, bundle); + } + + if (row.bundles > 0) { + moduleRows.push(row); + } +} + +const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); +const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + +let staticImports = 0; +let dynamicImports = 0; +for (const meta of nodeMetas) { + for (const imported of meta.imported ?? []) { + if (imported.dynamic) { + dynamicImports += 1; + } else { + staticImports += 1; + } + } +} + +const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); +const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); +const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); +const entries = nodeMetas.filter((meta) => meta.isEntry).length; +const externals = nodeMetas.filter((meta) => meta.isExternal).length; + +const lines = [ + '# Bundle Report', + '', + '| Bundles | Modules | Entries | Externals | Static Imports | Dynamic Imports |', + '|---:|---:|---:|---:|---:|---:|', + `| ${bundleRows.length} | ${moduleRows.length} | ${entries} | ${externals} | ${staticImports} | ${dynamicImports} |`, + '', + '| Metric | Total |', + '|---|---:|', + `| Rendered size | ${formatBytes(totalRendered)} |`, +]; + +if (data.options?.gzip) { + lines.push(`| Gzip size | ${formatBytes(totalGzip)} |`); +} +if (data.options?.brotli) { + lines.push(`| Brotli size | ${formatBytes(totalBrotli)} |`); +} + +lines.push('', '## Top 10', ''); +for (const row of hotModules.slice(0, 10)) { + lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, totalRendered)} (${formatBytes(row.renderedLength)})`); +} + +lines.push( + '', + '## Hot Modules (Self Size)', + '', + '| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |', + '|---|---:|---:|---:|---:|---:|---:|---:|', +); + +for (const row of hotModules.slice(0, 15)) { + lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, totalRendered)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`); +} + +await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/workflows/frontend-bundle-visualizer-comment.yml b/.github/workflows/frontend-bundle-visualizer-comment.yml index 884eb97673..44a8cef045 100644 --- a/.github/workflows/frontend-bundle-visualizer-comment.yml +++ b/.github/workflows/frontend-bundle-visualizer-comment.yml @@ -23,6 +23,11 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: + - name: Checkout report generator + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + - name: Download visualizer report uses: actions/download-artifact@v8 with: @@ -32,6 +37,9 @@ jobs: repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} + - name: Generate visualizer report + run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" + - name: Comment on pull request uses: actions/github-script@v9 with: diff --git a/.github/workflows/frontend-bundle-visualizer.yml b/.github/workflows/frontend-bundle-visualizer.yml index 86b6d960f4..c8d8d64c2e 100644 --- a/.github/workflows/frontend-bundle-visualizer.yml +++ b/.github/workflows/frontend-bundle-visualizer.yml @@ -20,6 +20,7 @@ on: - pnpm-lock.yaml - pnpm-workspace.yaml - .node-version + - .github/scripts/frontend-bundle-visualizer-report.mjs - .github/workflows/frontend-bundle-visualizer.yml - .github/workflows/frontend-bundle-visualizer-comment.yml @@ -64,12 +65,16 @@ jobs: - name: Build frontend visualizer env: FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: markdown - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/report.md + FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/stats.json run: pnpm --filter frontend run build + - name: Generate visualizer report + run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" + - name: Check visualizer report run: | + test -s "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" test -s "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" cat "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" >> "$GITHUB_STEP_SUMMARY" diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 6bacc563f5..b1413be0a4 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -27,10 +27,16 @@ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.s function getBundleVisualizerPlugin(): PluginOption[] { if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return []; - const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown' ? 'markdown' : 'treemap'; + const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown' + ? 'markdown' + : process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'raw-data' + ? 'raw-data' + : 'treemap'; const defaultFilename = template === 'markdown' ? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/report.md') - : path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html'); + : template === 'raw-data' + ? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.json') + : path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html'); return [ visualizer({