diff --git a/.github/workflows/bundle-size-comment.yml b/.github/workflows/bundle-size-comment.yml new file mode 100644 index 000000000..5b809000f --- /dev/null +++ b/.github/workflows/bundle-size-comment.yml @@ -0,0 +1,230 @@ +name: bundle-size-comment + +on: + workflow_run: + workflows: + - bundle-size + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: read + +jobs: + comment: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Validate triggering workflow + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run + const shaPattern = /^[0-9a-f]{40}$/ + + if (run.name !== 'bundle-size') { + core.setFailed('The triggering workflow name is invalid.') + return + } + + if (!Number.isSafeInteger(run.id) || run.id <= 0 || !shaPattern.test(run.head_sha) || !Number.isSafeInteger(run.head_repository?.id) || run.head_repository.id <= 0) { + core.setFailed('The workflow payload contains invalid head repository metadata.') + return + } + - name: Resolve bundle artifact + id: artifact + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const run = context.payload.workflow_run + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id, + per_page: 100 + }) + const matches = artifacts.filter(artifact => artifact.name === 'bundle-size-report' && !artifact.expired) + + if (matches.length !== 1) { + core.setFailed('Expected exactly one bundle report artifact from the triggering run.') + return + } + + const artifact = matches[0] + if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0 || !Number.isSafeInteger(artifact.size_in_bytes) || artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 12 * 1024 * 1024) { + core.setFailed('The bundle report artifact metadata is invalid or too large.') + return + } + + core.setOutput('id', String(artifact.id)) + - name: Download untrusted bundle snapshots + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.artifact.outputs.id }} + path: ${{ runner.temp }}/bundle-size-report + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Resolve triggering pull request + id: pull + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BUNDLE_CONTEXT_PATH: ${{ runner.temp }}/bundle-size-report/context.json + with: + script: | + const fs = require('node:fs') + const path = require('node:path') + const run = context.payload.workflow_run + const repository = context.payload.repository + const candidates = run.pull_requests || [] + const contextPath = process.env.BUNDLE_CONTEXT_PATH + const shaPattern = /^[0-9a-f]{40}$/ + + core.setOutput('should-comment', 'false') + + const artifactDirectory = path.dirname(contextPath) + const files = fs.readdirSync(artifactDirectory).sort() + if (files.length !== 3 || files[0] !== 'base.json' || files[1] !== 'context.json' || files[2] !== 'pr.json') { + core.setFailed('The bundle artifact contains unexpected files.') + return + } + + const contextStats = fs.lstatSync(contextPath) + const baseStats = fs.lstatSync(path.join(artifactDirectory, 'base.json')) + const headStats = fs.lstatSync(path.join(artifactDirectory, 'pr.json')) + if (!contextStats.isFile() || contextStats.isSymbolicLink() || contextStats.size > 1_024 || + !baseStats.isFile() || baseStats.isSymbolicLink() || baseStats.size > 5 * 1024 * 1024 || + !headStats.isFile() || headStats.isSymbolicLink() || headStats.size > 5 * 1024 * 1024) { + core.setFailed('The bundle artifact does not contain bounded regular files.') + return + } + + const bundleContext = JSON.parse(fs.readFileSync(contextPath, 'utf8')) + const contextKeys = Object.keys(bundleContext) + if (contextKeys.length !== 1 || contextKeys[0] !== 'prNumber' || !Number.isSafeInteger(bundleContext.prNumber) || bundleContext.prNumber <= 0) { + core.setFailed('The bundle context contains an invalid pull request number.') + return + } + + if (candidates.length > 1 || (candidates.length === 1 && candidates[0].number !== bundleContext.prNumber)) { + core.setFailed('The bundle context does not match the workflow payload.') + return + } + + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: bundleContext.prNumber + }) + + if (pull.state !== 'open' || pull.head.sha !== run.head_sha) { + core.info(`Skipping stale or closed PR #${bundleContext.prNumber}.`) + return + } + + if (pull.head.repo?.id !== run.head_repository.id) { + core.setFailed('The pull request head repository does not match the triggering workflow.') + return + } + + if (pull.base.repo.id !== repository.id || pull.base.ref !== repository.default_branch || !shaPattern.test(pull.base.sha)) { + core.setFailed('The pull request does not target the expected default branch.') + return + } + + core.setOutput('should-comment', 'true') + core.setOutput('number', String(bundleContext.prNumber)) + core.setOutput('base-sha', pull.base.sha) + core.setOutput('head-sha', run.head_sha) + - name: Check out the trusted reporter + if: steps.pull.outputs.should-comment == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + ref: ${{ github.sha }} + - if: steps.pull.outputs.should-comment == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: lts/-1 + - name: Validate snapshots and render report + if: steps.pull.outputs.should-comment == 'true' + env: + BASE_SHA: ${{ steps.pull.outputs.base-sha }} + BASE_SNAPSHOT: ${{ runner.temp }}/bundle-size-report/base.json + HEAD_SHA: ${{ steps.pull.outputs.head-sha }} + HEAD_SNAPSHOT: ${{ runner.temp }}/bundle-size-report/pr.json + REPORT_PATH: ${{ runner.temp }}/bundle-size-comment.md + run: | + node scripts/bundle-size/report.mjs compare \ + --base "$BASE_SNAPSHOT" \ + --head "$HEAD_SNAPSHOT" \ + --base-sha "$BASE_SHA" \ + --head-sha "$HEAD_SHA" \ + --output "$REPORT_PATH" + - name: Mint bundle report app token + if: steps.pull.outputs.should-comment == 'true' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.BUNDLE_REPORT_APP_ID }} + private-key: ${{ secrets.BUNDLE_REPORT_APP_PRIVATE_KEY }} + - name: Update PR comment + if: steps.pull.outputs.should-comment == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BUNDLE_REPORT_PATH: ${{ runner.temp }}/bundle-size-comment.md + PR_NUMBER: ${{ steps.pull.outputs.number }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const fs = require('node:fs') + const marker = '' + const run = context.payload.workflow_run + const prNumber = Number(process.env.PR_NUMBER) + + if (!Number.isSafeInteger(prNumber) || prNumber <= 0) { + core.setFailed('The validated pull request number is invalid.') + return + } + + const report = fs.readFileSync(process.env.BUNDLE_REPORT_PATH, 'utf8') + if (Buffer.byteLength(report, 'utf8') > 60_000) { + core.setFailed('The bundle report is too large to post safely.') + return + } + + const body = `${marker}\n${report}\n[Workflow run](${run.html_url})` + + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }) + const previous = comments.find(comment => comment.user?.login?.endsWith('[bot]') && comment.body?.includes(marker)) + + if (previous) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: previous.id, + body + }) + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body + }) + } + } catch (error) { + core.warning(`Unable to post the bundle report comment: ${error.message}`) + await core.summary.addRaw(report).write() + core.info('The bundle report was written to the workflow run summary instead.') + } diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 000000000..592e1ec66 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,149 @@ +name: bundle-size + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: bundle-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + analyze-base: + name: analyze (base) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out the PR merge + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Check out the base revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: .bundle-base + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + - uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 + env: + npm_config_manage_package_manager_versions: "false" + with: + cache: true + cache-dependency-path: .bundle-base/pnpm-lock.yaml + working-directory: .bundle-base + runtime: node@24 + # The CLI keeps only the last `--extends`, so both layers go through `~/.nuxtrc`. + # `projectRoot` keeps `layer/` module ids identical between the base and PR checkouts. + - name: Configure the analyzer + run: | + { + echo "extends[]=../layer" + echo "extends[]=$GITHUB_WORKSPACE/scripts/bundle-size/nuxt-layer" + echo "build.analyze.projectRoot=$GITHUB_WORKSPACE/.bundle-base/" + } >> ~/.nuxtrc + - name: Analyze the base bundle + working-directory: .bundle-base/docs + run: >- + pnpm --package=@nuxt/cli-nightly@3.38.0-20260726-220626-c3c9e90 + dlx nuxt analyze + --no-serve + --logLevel=info + env: + NODE_OPTIONS: --max-old-space-size=8192 + NUXT_TELEMETRY_DISABLED: 1 + - name: Snapshot the base bundle + run: >- + node scripts/bundle-size/report.mjs snapshot + --root .bundle-base/docs + --analyze .bundle-base/docs/.nuxt/analyze/client.json + --label base + --sha ${{ github.event.pull_request.base.sha }} + --output ${{ runner.temp }}/bundle-size/base.json + - name: Upload base bundle snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bundle-size-base + path: ${{ runner.temp }}/bundle-size/base.json + if-no-files-found: error + retention-days: 1 + + analyze-pr: + name: analyze (PR) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out the PR merge + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 + with: + cache: true + cache-dependency-path: pnpm-lock.yaml + runtime: node@24 + - name: Configure the analyzer + run: | + { + echo "extends[]=../layer" + echo "extends[]=$GITHUB_WORKSPACE/scripts/bundle-size/nuxt-layer" + echo "build.analyze.projectRoot=$GITHUB_WORKSPACE/" + } >> ~/.nuxtrc + - name: Analyze the PR bundle + working-directory: docs + run: >- + pnpm --package=@nuxt/cli-nightly@3.38.0-20260726-220626-c3c9e90 + dlx nuxt analyze + --no-serve + --logLevel=info + env: + NODE_OPTIONS: --max-old-space-size=8192 + NUXT_TELEMETRY_DISABLED: 1 + - name: Snapshot the PR bundle + run: >- + node scripts/bundle-size/report.mjs snapshot + --root docs + --analyze docs/.nuxt/analyze/client.json + --label pr + --sha ${{ github.event.pull_request.head.sha }} + --output ${{ runner.temp }}/bundle-size/pr.json + - name: Upload PR bundle snapshot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bundle-size-pr + path: ${{ runner.temp }}/bundle-size/pr.json + if-no-files-found: error + retention-days: 1 + + report: + name: analyze + needs: + - analyze-base + - analyze-pr + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download base bundle snapshot + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bundle-size-base + path: ${{ runner.temp }}/bundle-size + - name: Download PR bundle snapshot + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bundle-size-pr + path: ${{ runner.temp }}/bundle-size + - name: Write bundle context + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: printf '{"prNumber":%s}\n' "$PR_NUMBER" > "$RUNNER_TEMP/bundle-size/context.json" + - name: Upload bundle snapshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bundle-size-report + path: ${{ runner.temp }}/bundle-size + if-no-files-found: error + retention-days: 7 diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fa5b6510..aed77d622 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,9 @@ export default createConfigForNuxt({ 'cli/**/*.ts', ], }).append( + { + ignores: ['scripts/bundle-size/**'], + }, { rules: { 'vue/multi-word-component-names': 'off', diff --git a/scripts/bundle-size/nuxt-layer/nuxt.config.ts b/scripts/bundle-size/nuxt-layer/nuxt.config.ts new file mode 100644 index 000000000..860a53e1b --- /dev/null +++ b/scripts/bundle-size/nuxt-layer/nuxt.config.ts @@ -0,0 +1,8 @@ +export default defineNuxtConfig({ + build: { + analyze: { + filename: '.nuxt/analyze/{name}.json', + template: 'raw-data' + } + } +}) diff --git a/scripts/bundle-size/report.mjs b/scripts/bundle-size/report.mjs new file mode 100644 index 000000000..61ba28362 --- /dev/null +++ b/scripts/bundle-size/report.mjs @@ -0,0 +1,394 @@ +import { constants, brotliCompressSync, gzipSync } from 'node:zlib' +import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, extname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const EMPTY_SIZE = Object.freeze({ raw: 0, gzip: 0, brotli: 0 }) +const SNAPSHOT_SCHEMA_VERSION = 2 +const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024 +const MAX_MODULES = 25_000 +const MAX_MODULE_ID_LENGTH = 2_048 + +function addSizes(target, sizes) { + target.raw += sizes.raw + target.gzip += sizes.gzip + target.brotli += sizes.brotli + return target +} + +function compressedSizes(contents) { + return { + raw: contents.byteLength, + gzip: gzipSync(contents, { level: 9 }).byteLength, + brotli: brotliCompressSync(contents, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11 + } + }).byteLength + } +} + +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) { + files.push(...await listFiles(path)) + } else if (entry.isFile()) { + files.push(path) + } + } + + return files +} + +function assetKind(file) { + const extension = extname(file) + if (extension === '.js' || extension === '.mjs') { + return 'javascript' + } + if (extension === '.css') { + return 'css' + } + return 'other' +} + +function normalizePath(path) { + return path.replaceAll('\\', '/') +} + +function normalizeModuleId(id, root) { + let normalized = normalizePath(id).replace(/^\0/, 'virtual:') + const normalizedRoot = `${normalizePath(resolve(root))}/` + + if (normalized.startsWith(normalizedRoot)) { + normalized = normalized.slice(normalizedRoot.length) + } + + const nodeModulesIndex = normalized.lastIndexOf('/node_modules/') + if (nodeModulesIndex !== -1) { + normalized = `node_modules/${normalized.slice(nodeModulesIndex + '/node_modules/'.length)}` + } + + const pnpmMatch = normalized.match(/^node_modules\/\.pnpm\/[^/]+\/node_modules\/(.+)$/) + return pnpmMatch ? `node_modules/${pnpmMatch[1]}` : normalized +} + +async function readAnalyzerModules(analyzePath, root) { + const analyzer = JSON.parse(await readFile(analyzePath, 'utf8')) + const modules = new Map() + + for (const meta of Object.values(analyzer.nodeMetas || {})) { + if (typeof meta.id !== 'string') { + continue + } + + const sizes = { ...EMPTY_SIZE } + for (const partId of Object.values(meta.moduleParts || {})) { + const part = analyzer.nodeParts?.[partId] + if (part) { + addSizes(sizes, { + raw: part.renderedLength || 0, + gzip: part.gzipLength || 0, + brotli: part.brotliLength || 0 + }) + } + } + + if (sizes.raw === 0 && sizes.gzip === 0 && sizes.brotli === 0) { + continue + } + + const id = normalizeModuleId(meta.id, root) + const existing = modules.get(id) || { ...EMPTY_SIZE } + modules.set(id, addSizes(existing, sizes)) + } + + return Object.fromEntries([...modules.entries()].sort(([a], [b]) => a.localeCompare(b))) +} + +export async function buildSnapshot({ root, analyzePath, label, sha }) { + const absoluteRoot = resolve(root) + const publicDir = resolve(absoluteRoot, '.output/public') + const assetDir = resolve(publicDir, '_nuxt') + const assets = {} + + for (const path of await listFiles(assetDir)) { + if (path.endsWith('.map') || path.endsWith('.br') || path.endsWith('.gz')) { + continue + } + + const file = normalizePath(relative(publicDir, path)) + assets[file] = { + kind: assetKind(file), + sizes: compressedSizes(await readFile(path)) + } + } + + const totals = { + javascript: { ...EMPTY_SIZE }, + css: { ...EMPTY_SIZE }, + other: { ...EMPTY_SIZE }, + all: { ...EMPTY_SIZE } + } + + for (const asset of Object.values(assets)) { + addSizes(totals[asset.kind], asset.sizes) + addSizes(totals.all, asset.sizes) + } + + return { + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + label, + sha, + totals, + modules: await readAnalyzerModules(resolve(analyzePath), absoluteRoot) + } +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function validateKeys(value, expected, path) { + const keys = Object.keys(value).sort() + const sortedExpected = [...expected].sort() + if (keys.length !== sortedExpected.length || keys.some((key, index) => key !== sortedExpected[index])) { + throw new Error(`${path} contains unexpected fields`) + } +} + +function validateSize(value, path) { + if (!isRecord(value)) { + throw new Error(`${path} must be an object`) + } + validateKeys(value, ['raw', 'gzip', 'brotli'], path) + for (const key of ['raw', 'gzip', 'brotli']) { + if (!Number.isSafeInteger(value[key]) || value[key] < 0) { + throw new Error(`${path}.${key} must be a non-negative safe integer`) + } + } +} + +export function validateSnapshot(snapshot, { label, sha } = {}) { + if (!isRecord(snapshot)) { + throw new Error('Snapshot must be an object') + } + validateKeys(snapshot, ['schemaVersion', 'label', 'sha', 'totals', 'modules'], 'Snapshot') + + if (snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) { + throw new Error(`Unsupported snapshot schema version: ${snapshot.schemaVersion}`) + } + if (snapshot.label !== label) { + throw new Error(`Expected a ${label} snapshot`) + } + if (!/^[0-9a-f]{40}$/.test(snapshot.sha)) { + throw new Error('Snapshot SHA must be a lowercase 40-character hexadecimal string') + } + if (sha && snapshot.sha !== sha) { + throw new Error(`${label} snapshot SHA does not match the triggering workflow`) + } + + if (!isRecord(snapshot.totals)) { + throw new Error('Snapshot totals must be an object') + } + validateKeys(snapshot.totals, ['javascript', 'css', 'other', 'all'], 'Snapshot totals') + for (const key of ['javascript', 'css', 'other', 'all']) { + validateSize(snapshot.totals[key], `Snapshot totals.${key}`) + } + for (const key of ['raw', 'gzip', 'brotli']) { + const sum = snapshot.totals.javascript[key] + snapshot.totals.css[key] + snapshot.totals.other[key] + if (snapshot.totals.all[key] !== sum) { + throw new Error(`Snapshot totals.all.${key} is inconsistent`) + } + } + + if (!isRecord(snapshot.modules)) { + throw new Error('Snapshot modules must be an object') + } + const modules = Object.entries(snapshot.modules) + if (modules.length > MAX_MODULES) { + throw new Error(`Snapshot contains more than ${MAX_MODULES} modules`) + } + for (const [id, sizes] of modules) { + if (id.length === 0 || id.length > MAX_MODULE_ID_LENGTH || /[\p{Cc}\p{Cf}\p{Cs}]/u.test(id)) { + throw new Error('Snapshot contains an invalid module identifier') + } + validateSize(sizes, `Snapshot module ${JSON.stringify(id)}`) + } + + return snapshot +} + +async function readSnapshot(path, expected) { + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('Snapshot path must be a regular file') + } + const contents = await readFile(path) + if (contents.byteLength > MAX_SNAPSHOT_BYTES) { + throw new Error(`Snapshot exceeds the ${MAX_SNAPSHOT_BYTES}-byte limit`) + } + return validateSnapshot(JSON.parse(contents.toString('utf8')), expected) +} + +function formatBytes(bytes) { + const absolute = Math.abs(bytes) + if (absolute < 1024) { + return `${bytes} B` + } + if (absolute < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KiB` + } + return `${(bytes / 1024 / 1024).toFixed(2)} MiB` +} + +function formatDelta(base, head) { + const delta = head - base + if (delta === 0) { + return '—' + } + + const sign = delta > 0 ? '+' : '' + const percentage = base === 0 ? '' : ` (${sign}${((delta / base) * 100).toFixed(1)}%)` + return `${sign}${formatBytes(delta)}${percentage}` +} + +function inlineCode(value) { + const escaped = value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('@', '@') + .replaceAll('|', '|') + .replaceAll('`', '`') + .replaceAll('[', '[') + .replaceAll(']', ']') + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + return `${escaped}` +} + +function metricRow(label, base, head) { + return `| ${label} | ${formatBytes(base.brotli)} | ${formatBytes(head.brotli)} | ${formatDelta(base.brotli, head.brotli)} | ${formatDelta(base.gzip, head.gzip)} |` +} + +function moduleRegressions(base, head) { + const modules = new Set([...Object.keys(base.modules), ...Object.keys(head.modules)]) + return [...modules].map((id) => { + const baseSize = Object.hasOwn(base.modules, id) ? base.modules[id] : EMPTY_SIZE + const headSize = Object.hasOwn(head.modules, id) ? head.modules[id] : EMPTY_SIZE + return { + id, + base: baseSize, + head: headSize, + delta: headSize.brotli - baseSize.brotli + } + }).filter(module => module.delta > 0) + .sort((a, b) => b.delta - a.delta) + .slice(0, 10) +} + +export function compareSnapshots(base, head, expected = {}) { + validateSnapshot(base, { label: 'base', sha: expected.baseSha }) + validateSnapshot(head, { label: 'pr', sha: expected.headSha }) + + const lines = [ + '## Production bundle', + '', + `Comparing \`${base.sha.slice(0, 8)}\` with \`${head.sha.slice(0, 8)}\`. Compressed sizes are calculated from the emitted production assets.`, + '', + '| Metric | Base (Brotli) | PR (Brotli) | Δ Brotli | Δ gzip |', + '| --- | ---: | ---: | ---: | ---: |', + metricRow('Client JavaScript', base.totals.javascript, head.totals.javascript), + metricRow('Client CSS', base.totals.css, head.totals.css), + metricRow('Other client assets', base.totals.other, head.totals.other), + metricRow('Total client assets', base.totals.all, head.totals.all) + ] + + const regressions = moduleRegressions(base, head) + if (regressions.length > 0) { + lines.push( + '', + '### Largest module increases', + '', + '| Module | Base (Brotli) | PR (Brotli) | Δ Brotli |', + '| --- | ---: | ---: | ---: |', + ...regressions.map(module => `| ${inlineCode(module.id)} | ${formatBytes(module.base.brotli)} | ${formatBytes(module.head.brotli)} | ${formatDelta(module.base.brotli, module.head.brotli)} |`) + ) + } + + lines.push('', '> Module values come from Nuxt’s analyzer and are attribution estimates. This workflow is currently report-only.') + + return `${lines.join('\n')}\n` +} + +function parseArguments(argv) { + const [command, ...args] = argv + const options = {} + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (!argument.startsWith('--')) { + throw new Error(`Unexpected argument: ${argument}`) + } + + const key = argument.slice(2) + const value = args[++index] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${key}`) + } + options[key] = value + } + + return { command, options } +} + +function required(options, key) { + if (!options[key]) { + throw new Error(`Missing required option --${key}`) + } + return options[key] +} + +async function writeJson(path, value) { + await mkdir(dirname(resolve(path)), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) +} + +async function main() { + const { command, options } = parseArguments(process.argv.slice(2)) + + if (command === 'snapshot') { + const snapshot = await buildSnapshot({ + root: required(options, 'root'), + analyzePath: required(options, 'analyze'), + label: required(options, 'label'), + sha: required(options, 'sha') + }) + await writeJson(required(options, 'output'), snapshot) + return + } + + if (command === 'compare') { + const baseSha = required(options, 'base-sha') + const headSha = required(options, 'head-sha') + const base = await readSnapshot(required(options, 'base'), { label: 'base', sha: baseSha }) + const head = await readSnapshot(required(options, 'head'), { label: 'pr', sha: headSha }) + const output = required(options, 'output') + await mkdir(dirname(resolve(output)), { recursive: true }) + await writeFile(output, compareSnapshots(base, head, { baseSha, headSha })) + return + } + + throw new Error('Expected the snapshot or compare command') +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/scripts/bundle-size/report.test.mjs b/scripts/bundle-size/report.test.mjs new file mode 100644 index 000000000..f472af0a0 --- /dev/null +++ b/scripts/bundle-size/report.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterAll, test } from 'vitest' +import { buildSnapshot, compareSnapshots, validateSnapshot } from './report.mjs' + +const temporaryDirectories = [] + +afterAll(async () => { + await Promise.all(temporaryDirectories.map(directory => rm(directory, { force: true, recursive: true }))) +}) + +test('builds and compares production bundle snapshots', async () => { + const root = await mkdtemp(join(tmpdir(), 'nuxt-bundle-size-')) + temporaryDirectories.push(root) + + const publicDir = join(root, '.output/public') + const assetDir = join(publicDir, '_nuxt') + const analyzePath = join(root, '.nuxt/analyze/client.json') + await mkdir(assetDir, { recursive: true }) + await mkdir(join(root, '.nuxt/analyze'), { recursive: true }) + await writeFile(join(assetDir, 'entry.js'), 'console.log("entry")') + await writeFile(join(assetDir, 'entry.css'), 'body { color: green }') + await writeFile(join(assetDir, 'entry.js.map'), '{}') + await writeFile(analyzePath, JSON.stringify({ + nodeParts: { + part: { + renderedLength: 100, + gzipLength: 80, + brotliLength: 60 + } + }, + nodeMetas: { + module: { + id: join(root, 'node_modules/example/index.js'), + moduleParts: { 'entry.js': 'part' } + } + } + })) + + const base = await buildSnapshot({ root, analyzePath, label: 'base', sha: 'a'.repeat(40) }) + const head = structuredClone(base) + head.label = 'pr' + head.sha = 'b'.repeat(40) + head.totals.javascript.brotli += 10 + head.totals.javascript.gzip += 12 + head.totals.all.brotli += 10 + head.totals.all.gzip += 12 + head.modules['node_modules/example/index.js'].brotli += 10 + + assert.equal(base.schemaVersion, 2) + assert.equal(base.assets, undefined) + assert.equal(base.modules['node_modules/example/index.js'].brotli, 60) + + const report = compareSnapshots(base, head) + assert.match(report, /Client JavaScript/) + assert.match(report, /Largest module increases/) + assert.match(report, /report-only/) +}) + +test('validates snapshots and safely renders module identifiers', () => { + const size = { raw: 1, gzip: 1, brotli: 1 } + const totals = { + javascript: { ...size }, + css: { raw: 0, gzip: 0, brotli: 0 }, + other: { raw: 0, gzip: 0, brotli: 0 }, + all: { ...size } + } + const base = { + schemaVersion: 2, + label: 'base', + sha: 'a'.repeat(40), + totals, + modules: {} + } + const head = { + schemaVersion: 2, + label: 'pr', + sha: 'b'.repeat(40), + totals, + modules: { + '`|@nuxt`': size, + '[click me](https://evil.example/)': size + } + } + + const report = compareSnapshots(base, head, { baseSha: base.sha, headSha: head.sha }) + assert.doesNotMatch(report, /@nuxt| validateSnapshot({ ...base, schemaVersion: 1 }, { label: 'base' }), + /Unsupported snapshot schema version/ + ) + assert.throws( + () => compareSnapshots(base, head, { baseSha: base.sha, headSha: 'c'.repeat(40) }), + /snapshot SHA does not match/ + ) +}) + +test('reports PR-only module identifiers inherited by ordinary objects', () => { + const emptyTotals = { + javascript: { raw: 0, gzip: 0, brotli: 0 }, + css: { raw: 0, gzip: 0, brotli: 0 }, + other: { raw: 0, gzip: 0, brotli: 0 }, + all: { raw: 0, gzip: 0, brotli: 0 } + } + const base = { + schemaVersion: 2, + label: 'base', + sha: 'a'.repeat(40), + totals: emptyTotals, + modules: {} + } + const head = { + schemaVersion: 2, + label: 'pr', + sha: 'b'.repeat(40), + totals: emptyTotals, + modules: { + constructor: { raw: 3, gzip: 2, brotli: 1 } + } + } + + const report = compareSnapshots(base, head) + assert.match(report, /constructor<\/code> \| 0 B \| 1 B \| \+1 B/) +}) diff --git a/vitest.config.mts b/vitest.config.mts index ec9defc88..a4043c0c3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,7 +6,7 @@ export default defineConfig({ { test: { name: 'unit', - include: ['test/unit/**/*.test.ts'], + include: ['test/unit/**/*.test.ts', 'scripts/**/*.test.mjs'], environment: 'node', }, },