diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 271e8cc3d9d..e66324d200e 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -179,6 +179,9 @@ jobs: - 'pnpm-workspace.yaml' - 'knip.config.ts' - 'scripts/check-web-production-unused-after-knip-fix.mjs' + - '.npmrc' + - '.github/workflows/main-ci.yml' + - '.github/workflows/web-bundle-size.yml' - '.github/workflows/style.yml' - '.github/actions/setup-web/**' ts-common-style: diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 0ebe9774b90..1a44afe3fb0 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -107,8 +107,18 @@ jobs: if: inputs.run-python-style run: uv run --project api dotenv-linter ./api/.env.example ./web/.env.example - web-style: - name: Web Style + web-build: + name: Web Build and Bundle Analysis + if: ${{ !inputs.skip-checks && inputs.classification-succeeded && inputs.run-web-style }} + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/web-bundle-size.yml + with: + base-rev: ${{ inputs.base-rev }} + + web-style-checks: + name: Web Static Checks if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }} runs-on: depot-ubuntu-24.04 defaults: @@ -146,11 +156,6 @@ jobs: working-directory: . run: vp run knip - - name: Web build and unused translations check - env: - NODE_OPTIONS: --max-old-space-size=8192 - run: vp run build:vinext - - name: Web dead code check production working-directory: . run: vp run knip:production @@ -159,6 +164,24 @@ jobs: working-directory: . run: vp run knip:production-unused-check + # Preserve the existing required check while build and static checks run in parallel. + web-style: + name: Web Style + if: ${{ always() && !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-web-style) }} + needs: [web-build, web-style-checks] + runs-on: ubuntu-24.04 + permissions: {} + steps: + - name: Verify web checks + env: + BUILD_RESULT: ${{ needs.web-build.result }} + STYLE_RESULT: ${{ needs.web-style-checks.result }} + run: | + if [[ "$BUILD_RESULT" != 'success' || "$STYLE_RESULT" != 'success' ]]; then + echo "Web checks failed: build=$BUILD_RESULT, static checks=$STYLE_RESULT" >&2 + exit 1 + fi + ts-common-style: name: TS Common if: ${{ !inputs.skip-checks && (!inputs.classification-succeeded || inputs.run-ts-common-style) }} diff --git a/.github/workflows/web-bundle-size.yml b/.github/workflows/web-bundle-size.yml new file mode 100644 index 00000000000..888a520f929 --- /dev/null +++ b/.github/workflows/web-bundle-size.yml @@ -0,0 +1,152 @@ +name: Web Bundle Analysis + +on: + workflow_call: + inputs: + base-rev: + description: Exact base revision selected by the main CI event. + required: true + type: string + +permissions: + contents: read + pull-requests: write + +jobs: + measure: + name: Measure ${{ matrix.revision }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - revision: base + sha: ${{ inputs.base-rev }} + - revision: merge + sha: ${{ github.sha }} + env: + NEXT_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: --max-old-space-size=6144 + steps: + # Keep one collector for both revisions, including when it does not exist on base yet. + - name: Checkout reporting tools + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Preserve collector + run: cp web/scripts/bundle-analysis.ts "$RUNNER_TEMP/bundle-analysis.ts" + + - name: Checkout measured revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ matrix.sha }} + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Instrument selected revision + run: node "$RUNNER_TEMP/bundle-analysis.ts" instrument web + + - name: Full Vinext build with official Rolldown analysis + working-directory: web + shell: bash + run: | + set -o pipefail + started=$SECONDS + pnpm exec vinext build 2>&1 | tee "$RUNNER_TEMP/vinext-build.log" + printf 'Full Vinext build: **%s seconds**.\n' "$((SECONDS - started))" >> "$GITHUB_STEP_SUMMARY" + + - name: Collect sizes + env: + COMMIT_SHA: ${{ matrix.sha }} + run: node "$RUNNER_TEMP/bundle-analysis.ts" collect web "$COMMIT_SHA" "$RUNNER_TEMP/bundle-size/summary.json" + + - name: Upload size snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-bundle-summary-${{ matrix.revision }} + path: ${{ runner.temp }}/bundle-size/summary.json + if-no-files-found: error + retention-days: 14 + + - name: Upload official Rolldown analysis + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-bundle-analyzer-${{ matrix.revision }} + path: | + web/dist/client/bundle-analysis.json + web/dist/client/bundle-analysis.md + ${{ runner.temp }}/vinext-build.log + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + + report: + name: Compare bundle sizes + needs: measure + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout reporting tools + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 + with: + install: false + + - name: Download size snapshots + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: web-bundle-summary-* + path: bundle-results + + - name: Compare and summarize + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + node web/scripts/bundle-analysis.ts compare \ + bundle-results/web-bundle-summary-base/summary.json \ + bundle-results/web-bundle-summary-merge/summary.json \ + bundle-report.md + printf '\n[Download base/merge snapshots and official Rolldown analysis reports](%s#artifacts).\n' "$RUN_URL" >> bundle-report.md + printf '\nComparison: event base SHA vs the tested merge commit at GITHUB_SHA (PR or merge queue).\n' >> bundle-report.md + cat bundle-report.md >> "$GITHUB_STEP_SUMMARY" + + # Forks still receive the job summary and artifacts, without a privileged PR build. + - name: Update PR report + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('node:fs'); + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); + if (pr.state !== 'open' || pr.head.sha !== context.payload.pull_request.head.sha || + pr.base.sha !== context.payload.pull_request.base.sha) { + core.info('Skipping a superseded bundle report.'); + return; + } + const body = fs.readFileSync('bundle-report.md', 'utf8'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pull_number, per_page: 100, + }); + const existing = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && + comment.body?.startsWith('')); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + } diff --git a/web/docs/bundle-size.md b/web/docs/bundle-size.md new file mode 100644 index 00000000000..6083abe6dd6 --- /dev/null +++ b/web/docs/bundle-size.md @@ -0,0 +1,65 @@ +# Bundle analysis in pull requests + +The main CI calls **Web Bundle Analysis** when its Web style checks are required, +for both pull requests and merge queue entries. It builds the event's exact base +commit and the tested merge commit from `github.sha` in parallel. Both run the +complete existing `vinext build` pipeline, including the unused translations check. +No SSR, reference-analysis, or standalone stage is skipped. + +This replaces the separate Vinext build in Web Style. Static checks run alongside +the analysis workflow, and the existing **Web Style** required check succeeds only +when both complete successfully. Main CI owns change detection and duplicate-run +skipping. Target-branch updates alone do not rerun PRs. + +The official Rolldown `bundleAnalyzerPlugin` is added only to the client environment +through a temporary Vite config wrapper. Each revision keeps its own application +config and lockfile. The plugin is already bundled with the repository's Vite+ +runtime through `vite/rolldown/experimental`; no separate analyzer dependency is +installed. The experimental schema is validated and unsupported data fails the +job instead of appearing as savings. + +## Reading the report + +The comparison appears directly in the Actions summary and in an updated comment +for same-repository PRs. Merge queue runs receive the summary and artifacts without +a PR comment. Fork PRs receive the summary and artifacts without a +privileged build or comment token. Reports are informational: growth does not fail +the check; build, analysis, and reporting errors do. + +- **All client JS:** actual bytes and per-file gzip of the chunks listed in the + official analyzer. Includes dynamically imported chunks. +- **Client CSS:** emitted CSS under `dist/client/_next/static`, excluding copied + public assets. +- **Entry static dependencies:** recursively follows static chunk edges, with + shared chunks counted once within an entry. Application source paths identify + entries independently of output hashes. Dynamic entries are measured when + loaded; these are not full-page first-load measurements. Entries overlap and + cannot be summed. The comment shows the 20 largest absolute gzip changes. + Added/removed entry boundaries appear separately and are not treated as size + changes from zero. +- **Package attribution:** groups module sizes from the official analyzer by npm + package. This is attribution, not compressed output size; the comment shows the + 15 largest absolute changes. + +This measures Vinext/Rolldown output, not Next.js/Turbopack production output or +browser network timings. Compiler versions are recorded in both snapshots. + +Both revisions upload the official JSON and Markdown reports, build logs, and +size snapshots for 14 days. The summary links to these artifacts; no local viewer +is required. We do not rely on Vite DevTools' incomplete static Session Compare. + +See the [Rolldown analyzer documentation]. + +## Local collection + +The `instrument` command renames `vite.config.ts` and writes a temporary wrapper. +Use it only in a disposable checkout, as CI does: + +```sh +node web/scripts/bundle-analysis.ts instrument web +pnpm --dir web exec vinext build +node web/scripts/bundle-analysis.ts collect web "$(git rev-parse HEAD)" /tmp/current.json +node web/scripts/bundle-analysis.ts compare /tmp/base.json /tmp/current.json /tmp/report.md +``` + +[Rolldown analyzer documentation]: https://rolldown.rs/builtin-plugins/bundle-analyzer diff --git a/web/scripts/__tests__/bundle-analysis.spec.ts b/web/scripts/__tests__/bundle-analysis.spec.ts new file mode 100644 index 00000000000..fc0b5c887b0 --- /dev/null +++ b/web/scripts/__tests__/bundle-analysis.spec.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { collect, compare, parseAnalysis, parseSnapshot, staticClosure } from '../bundle-analysis' + +const dirs: string[] = [] +const sha = 'a'.repeat(40) +const graph = () => ({ + meta: { bundler: 'rolldown', version: '1.2.9' }, + chunks: [ + { + name: 'entry.js', + size: 5, + type: 'static-entry', + entryModule: 0, + imports: [ + { type: 'static', targetChunkIndex: 1 }, + { type: 'dynamic', targetChunkIndex: 2 }, + ], + }, + { + name: 'shared.js', + size: 6, + type: 'common', + imports: [{ type: 'static', targetChunkIndex: 0 }], + }, + { + name: 'lazy.js', + size: 4, + type: 'dynamic-entry', + entryModule: 1, + imports: [{ type: 'static', targetChunkIndex: 1 }], + }, + ], + modules: [ + { path: 'app/view.tsx', size: 5 }, + { path: 'app/lazy.tsx', size: 4 }, + { path: '../node_modules/.pnpm/@scope+pkg@1/node_modules/@scope/pkg/index.js', size: 6 }, + ], +}) +async function fixture(analysis = graph()) { + const root = await mkdtemp(join(tmpdir(), 'vinext-analysis-test-')) + dirs.push(root) + const client = join(root, 'dist/client') + await mkdir(join(client, '_next/static/css'), { recursive: true }) + await mkdir(join(root, 'node_modules/vinext'), { recursive: true }) + await writeFile( + join(root, 'node_modules/vinext/package.json'), + JSON.stringify({ version: '1.0.0' }), + ) + await writeFile(join(client, 'bundle-analysis.json'), JSON.stringify(analysis)) + for (const [name, data] of Object.entries({ + 'entry.js': 'entry', + 'shared.js': 'shared', + 'lazy.js': 'lazy', + '_next/static/css/app.css': 'css', + })) + await writeFile(join(client, name), data) + return root +} +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +it('deduplicates cycles and shared dependencies without following dynamic edges', () => { + expect([...staticClosure(parseAnalysis(JSON.stringify(graph())), 0)]).toEqual([0, 1]) + expect([...staticClosure(parseAnalysis(JSON.stringify(graph())), 2)]).toEqual([2, 1, 0]) +}) +it('measures emitted files independently of attribution and groups scoped packages', async () => { + const result = await collect(await fixture(), sha) + expect(result.totals.js).toEqual({ + raw: 15, + gzip: gzipSync('entry').length + gzipSync('shared').length + gzipSync('lazy').length, + }) + expect(result.entries['static-entry: app/view.tsx']?.raw).toBe(11) + expect(result.totals.css.raw).toBe(3) + expect(result.packages).toEqual({ '@scope/pkg': 6 }) + expect(parseSnapshot(JSON.stringify(result))).toEqual(result) +}) +it('fails on missing emitted files instead of reporting false savings', async () => { + const root = await fixture() + await rm(join(root, 'dist/client/lazy.js')) + await expect(collect(root, sha)).rejects.toThrow('ENOENT') +}) +it('rejects incompatible graph data and dangling references', () => { + const invalid = graph() + invalid.chunks[0]!.imports![0]!.targetChunkIndex = 99 + expect(() => parseAnalysis(JSON.stringify(invalid))).toThrow('Invalid') + expect(() => parseAnalysis('{}')).toThrow('Invalid') +}) +it('rejects out-of-output chunk paths', async () => { + const invalid = graph() + invalid.chunks[0]!.name = '../outside.js' + await expect(collect(await fixture(invalid), sha)).rejects.toThrow('outside') +}) +it('reports additions, removals, gzip differences and package attribution without calling entries pages', async () => { + const base = await collect(await fixture(), sha) + const merged = { + ...base, + commit: 'b'.repeat(40), + entries: { 'dynamic-entry: app/new|view.tsx': { raw: 4096, gzip: 2048 } }, + packages: { '@scope/pkg': 1030 }, + } + const report = compare(base, merged) + expect(report).toContain('New') + expect(report).toContain('Removed') + expect(report).toContain('new|view.tsx') + expect(report).toContain('New entry') + expect(report).not.toContain('+2.00 KiB') + expect(report).toContain('+1.00 KiB') + expect(report).toContain('not page first-load metrics') + expect(() => parseSnapshot(JSON.stringify({ ...base, totals: {} }))).toThrow('Invalid') +}) diff --git a/web/scripts/bundle-analysis.ts b/web/scripts/bundle-analysis.ts new file mode 100644 index 00000000000..67037bc364a --- /dev/null +++ b/web/scripts/bundle-analysis.ts @@ -0,0 +1,306 @@ +import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { gzipSync } from 'node:zlib' + +type Size = { raw: number; gzip: number } +type Analysis = { + meta: { bundler: string; version: string } + chunks: { + name: string + size: number + type: string + entryModule?: number + imports?: { targetChunkIndex: number; type: string }[] + }[] + modules: { path: string; size: number }[] +} +type Snapshot = { + version: 1 + commit: string + vinext: string + rolldown: string + totals: { js: Size; css: Size } + entries: Record + packages: Record +} +const record = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) +const bytes = (v: unknown): v is number => Number.isSafeInteger(v) && Number(v) >= 0 +const size = (v: unknown): v is Size => record(v) && bytes(v.raw) && bytes(v.gzip) +const empty = (): Size => ({ raw: 0, gzip: 0 }) +const add = (a: Size, b: Size): Size => ({ raw: a.raw + b.raw, gzip: a.gzip + b.gzip }) + +export function parseAnalysis(text: string): Analysis { + const v: unknown = JSON.parse(text) + if ( + !record(v) || + !record(v.meta) || + v.meta.bundler !== 'rolldown' || + typeof v.meta.version !== 'string' || + !Array.isArray(v.modules) || + !v.modules.every((m) => record(m) && typeof m.path === 'string' && bytes(m.size)) || + !Array.isArray(v.chunks) || + !v.chunks.length + ) + throw new Error('Invalid Rolldown analysis.') + const moduleCount = v.modules.length + const chunkCount = v.chunks.length + for (const c of v.chunks as unknown[]) { + if ( + !record(c) || + typeof c.name !== 'string' || + !bytes(c.size) || + !['static-entry', 'dynamic-entry', 'common'].includes(String(c.type)) || + (c.entryModule !== undefined && (!bytes(c.entryModule) || c.entryModule >= moduleCount)) || + (c.imports !== undefined && + (!Array.isArray(c.imports) || + !c.imports.every( + (i) => + record(i) && + bytes(i.targetChunkIndex) && + i.targetChunkIndex < chunkCount && + ['static', 'dynamic'].includes(String(i.type)), + ))) + ) + throw new Error('Invalid Rolldown chunk graph.') + } + return v as Analysis +} + +export function staticClosure(analysis: Analysis, index: number): Set { + const seen = new Set() + const visit = (i: number) => { + if (seen.has(i)) return + seen.add(i) + const chunk = analysis.chunks[i] + if (!chunk) throw new Error(`Missing chunk ${i}`) + for (const edge of chunk.imports ?? []) if (edge.type === 'static') visit(edge.targetChunkIndex) + } + visit(index) + return seen +} + +export async function collect(appDirectory: string, commit: string): Promise { + if (!/^[\da-f]{40}$/.test(commit)) throw new Error('Expected a commit SHA.') + const app = resolve(appDirectory) + const client = join(app, 'dist/client') + const analysis = parseAnalysis(await readFile(join(client, 'bundle-analysis.json'), 'utf8')) + const cache = new Map() + const measure = async (file: string) => { + const absolute = resolve(client, file) + const local = relative(client, absolute) + if (local.startsWith('..') || isAbsolute(local)) throw new Error('Asset outside client output.') + const cached = cache.get(absolute) + if (cached) return cached + const data = await readFile(absolute) + const result = { raw: data.length, gzip: gzipSync(data).length } + cache.set(absolute, result) + return result + } + const sumFiles = async (files: Iterable) => { + let result = empty() + for (const file of new Set(files)) result = add(result, await measure(file)) + return result + } + const css: string[] = [] + const visit = async (directory: string): Promise => { + for (const item of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, item.name) + if (item.isDirectory()) await visit(path) + else if (item.isFile() && item.name.endsWith('.css')) css.push(path) + } + } + await visit(join(client, '_next/static')) + const entries: Record = Object.create(null) + for (const [index, chunk] of analysis.chunks.entries()) { + if (chunk.entryModule === undefined || chunk.type === 'common') continue + const source = analysis.modules[chunk.entryModule]!.path + // Application and virtual browser entries are stable across chunk hash changes. + if (!source.startsWith('app/') && !source.includes('virtual:vinext-app-browser-entry')) continue + const key = `${chunk.type}: ${source}` + if (Object.hasOwn(entries, key)) throw new Error(`Duplicate entry: ${key}`) + entries[key] = await sumFiles( + [...staticClosure(analysis, index)].map((i) => analysis.chunks[i]!.name), + ) + } + if (!Object.keys(entries).length) throw new Error('No application entries in Rolldown analysis.') + const packages: Record = Object.create(null) + for (const module of analysis.modules) { + const tail = module.path.split('node_modules/').at(-1)! + if (tail === module.path) continue + const parts = tail.split('/') + const name = tail.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]! + packages[name] = (packages[name] ?? 0) + module.size + } + const pkg: unknown = JSON.parse( + await readFile(join(app, 'node_modules/vinext/package.json'), 'utf8'), + ) + if (!record(pkg) || typeof pkg.version !== 'string') throw new Error('Missing Vinext version.') + return { + version: 1, + commit, + vinext: pkg.version, + rolldown: analysis.meta.version, + totals: { js: await sumFiles(analysis.chunks.map((c) => c.name)), css: await sumFiles(css) }, + entries, + packages, + } +} + +export function parseSnapshot(text: string): Snapshot { + const v: unknown = JSON.parse(text) + if ( + !record(v) || + v.version !== 1 || + typeof v.commit !== 'string' || + !/^[\da-f]{40}$/.test(v.commit) || + typeof v.vinext !== 'string' || + typeof v.rolldown !== 'string' || + !record(v.totals) || + !size(v.totals.js) || + !size(v.totals.css) || + !record(v.entries) || + !Object.keys(v.entries).length || + !Object.values(v.entries).every(size) || + !record(v.packages) || + !Object.values(v.packages).every(bytes) + ) + throw new Error('Invalid bundle snapshot.') + return v as Snapshot +} +const kib = (n: number) => `${(n / 1024).toFixed(2)} KiB` +const delta = (a: number, b: number) => `${b > a ? '+' : b < a ? '−' : ''}${kib(Math.abs(b - a))}` +const label = (s: string) => + `${s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('|', '|').replaceAll('\n', ' ')}` +export function compare(a: Snapshot, b: Snapshot): string { + const lines = [ + '', + '## Vinext bundle analysis', + '', + `Base: \`${a.commit}\` · Merge: \`${b.commit}\``, + `Vinext: ${a.vinext} → ${b.vinext}; Rolldown: ${a.rolldown} → ${b.rolldown}`, + '', + '| All client output | Base gzip | Merge gzip | Gzip Δ | Raw Δ |', + '| --- | ---: | ---: | ---: | ---: |', + ] + for (const kind of ['js', 'css'] as const) + lines.push( + `| ${kind.toUpperCase()} | ${kib(a.totals[kind].gzip)} | ${kib(b.totals[kind].gzip)} | ${delta(a.totals[kind].gzip, b.totals[kind].gzip)} | ${delta(a.totals[kind].raw, b.totals[kind].raw)} |`, + ) + const changed = [...new Set([...Object.keys(a.entries), ...Object.keys(b.entries)])] + .filter( + (k) => + a.entries[k] && + b.entries[k] && + (a.entries[k].gzip !== b.entries[k].gzip || a.entries[k].raw !== b.entries[k].raw), + ) + .sort( + (x, y) => + Math.abs((b.entries[y]?.gzip ?? 0) - (a.entries[y]?.gzip ?? 0)) - + Math.abs((b.entries[x]?.gzip ?? 0) - (a.entries[x]?.gzip ?? 0)) || x.localeCompare(y), + ) + lines.push( + '', + '### Entry static dependencies', + '', + `Showing ${Math.min(20, changed.length)} of ${changed.length} changed entries, ordered by absolute gzip delta.`, + '', + '| Source entry | Base gzip | Merge gzip | Gzip Δ |', + '| --- | ---: | ---: | ---: |', + ) + if (!changed.length) lines.push('| No entry size changes | — | — | — |') + for (const k of changed.slice(0, 20)) + lines.push( + `| ${label(k)} | ${a.entries[k] ? kib(a.entries[k].gzip) : 'New'} | ${b.entries[k] ? kib(b.entries[k].gzip) : 'Removed'} | ${delta(a.entries[k]?.gzip ?? 0, b.entries[k]?.gzip ?? 0)} |`, + ) + const topology = [...new Set([...Object.keys(a.entries), ...Object.keys(b.entries)])] + .filter((k) => !a.entries[k] || !b.entries[k]) + .sort() + if (topology.length) { + lines.push( + '', + '### Added or removed entries', + '', + `Showing ${Math.min(20, topology.length)} of ${topology.length} entry-boundary changes. These are not size deltas from zero.`, + '', + '| Source entry | Change | Static dependencies (gzip) |', + '| --- | --- | ---: |', + ) + for (const k of topology.slice(0, 20)) + lines.push( + `| ${label(k)} | ${b.entries[k] ? 'New entry' : 'Removed entry'} | ${kib((b.entries[k] ?? a.entries[k])!.gzip)} |`, + ) + } + const packages = [...new Set([...Object.keys(a.packages), ...Object.keys(b.packages)])] + .filter((k) => a.packages[k] !== b.packages[k]) + .sort( + (x, y) => + Math.abs((b.packages[y] ?? 0) - (a.packages[y] ?? 0)) - + Math.abs((b.packages[x] ?? 0) - (a.packages[x] ?? 0)), + ) + lines.push( + '', + '### Package module attribution', + '', + '| Package | Base | Merge | Δ |', + '| --- | ---: | ---: | ---: |', + ) + if (!packages.length) lines.push('| No package attribution changes | — | — | — |') + for (const k of packages.slice(0, 15)) + lines.push( + `| ${label(k)} | ${kib(a.packages[k] ?? 0)} | ${kib(b.packages[k] ?? 0)} | ${delta(a.packages[k] ?? 0, b.packages[k] ?? 0)} |`, + ) + lines.push( + '', + 'Entries follow only static edges in the official Rolldown chunk graph, deduplicating shared files. Dynamic entries are measured when loaded; they are not page first-load metrics. Entry totals overlap and must not be summed.', + 'Package attribution sums module sizes reported by Rolldown, not compressed emitted bytes. Gzip totals are measured per emitted file. This is Vinext output, not Next.js/Turbopack output.', + 'Report only: increases do not fail this check. Missing or incompatible analysis data does.', + ) + if (a.vinext !== b.vinext || a.rolldown !== b.rolldown) + lines.push('', 'Compiler versions differ; changes may include compiler effects.') + return `${lines.join('\n')}\n` +} + +async function instrument(app: string) { + await rename(join(app, 'vite.config.ts'), join(app, 'vite.config.bundle-original.ts')) + await writeFile( + join(app, 'vite.config.ts'), + `import { defineConfig, mergeConfig } from 'vite-plus' +import { bundleAnalyzerPlugin } from 'vite/rolldown/experimental' +import original from './vite.config.bundle-original.ts' +export default defineConfig(async env => mergeConfig(await (typeof original === 'function' ? original(env) : original), { + plugins: [{ name: 'dify:bundle-analysis', applyToEnvironment(environment) { + return environment.name === 'client' ? [ + bundleAnalyzerPlugin({ fileName: 'bundle-analysis.json' }), + bundleAnalyzerPlugin({ fileName: 'bundle-analysis.md', format: 'md' }), + ] : false + } }], +})) +`, + ) +} +async function main() { + const [command, first, second, output] = process.argv.slice(2) + if (command === 'instrument' && first) return instrument(first) + if (!first || !second || !output) + throw new Error( + 'Usage: instrument | collect | compare ', + ) + const result = + command === 'collect' + ? `${JSON.stringify(await collect(first, second), null, 2)}\n` + : command === 'compare' + ? compare( + parseSnapshot(await readFile(first, 'utf8')), + parseSnapshot(await readFile(second, 'utf8')), + ) + : null + if (result === null) throw new Error('Unknown command.') + await mkdir(dirname(resolve(output)), { recursive: true }) + await writeFile(output, result) +} +if (import.meta.main) + main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 + })