ci(web): report Vinext bundles using Rolldown analysis (#42849)

This commit is contained in:
Stephen Zhou
2026-09-23 14:06:57 +00:00
committed by GitHub
parent e9550e50d0
commit e43ea0986e
6 changed files with 670 additions and 7 deletions
+3
View File
@@ -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:
+30 -7
View File
@@ -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) }}
+152
View File
@@ -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('<!-- dify-vinext-bundle-size -->'));
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 });
}
+65
View File
@@ -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
@@ -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&#124;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')
})
+306
View File
@@ -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<string, Size>
packages: Record<string, number>
}
const record = (v: unknown): v is Record<string, unknown> =>
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<number> {
const seen = new Set<number>()
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<Snapshot> {
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<string, Size>()
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<string>) => {
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<void> => {
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<string, Size> = 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<string, number> = 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) =>
`<code>${s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('|', '&#124;').replaceAll('\n', ' ')}</code>`
export function compare(a: Snapshot, b: Snapshot): string {
const lines = [
'<!-- dify-vinext-bundle-size -->',
'## 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 <web> | collect <web> <sha> <json> | compare <base.json> <merge.json> <report.md>',
)
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
})