mirror of
https://github.com/langgenius/dify.git
synced 2026-09-29 17:07:38 +08:00
ci(web): report Vinext bundles using Rolldown analysis (#42849)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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|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')
|
||||
})
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('|', '|').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
|
||||
})
|
||||
Reference in New Issue
Block a user