From d66d664cf5e49ba83c45c70c3dcb05f9a91cba6d Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Fri, 23 Oct 2026 10:53:21 +0200 Subject: [PATCH 1/2] ci: report bundle size changes on pull requests --- .github/workflows/bundle-size-comment.yml | 223 +++++++++ .github/workflows/bundle-size.yml | 98 ++++ scripts/bundle-size/report.mjs | 522 ++++++++++++++++++++++ scripts/bundle-size/report.test.mjs | 213 +++++++++ vitest.config.ts | 2 +- 5 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/bundle-size-comment.yml create mode 100644 .github/workflows/bundle-size.yml create mode 100644 scripts/bundle-size/report.mjs create mode 100644 scripts/bundle-size/report.test.mjs diff --git a/.github/workflows/bundle-size-comment.yml b/.github/workflows/bundle-size-comment.yml new file mode 100644 index 0000000..44e368c --- /dev/null +++ b/.github/workflows/bundle-size-comment.yml @@ -0,0 +1,223 @@ +name: bundle-size-comment + +# Runs from the default branch with a write token, even for fork PRs: the artifact it reads is validated, never executed. +# GitHub only triggers `workflow_run` from the default branch, so the first comment appears once this file is on `main`. +on: + workflow_run: + workflows: + - bundle-size + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: write + +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: 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: + 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 0000000..9ecb16a --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,98 @@ +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: + # Builds and measures `main` and the PR side by side, posted by `bundle-size-comment.yml`. + analyze: + name: analyze (${{ matrix.target }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + matrix: + target: [base, pr] + + steps: + - name: Check out the PR merge + uses: actions/checkout@v6 + with: + persist-credentials: false + # Both snapshots come from the PR's script, so they share one format even when it changes. + - name: Keep the PR's report script + run: cp scripts/bundle-size/report.mjs "$RUNNER_TEMP/report.mjs" + - name: Check out the base revision + if: matrix.target == 'base' + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + - uses: ./.github/actions/setup + - name: Build with the analyzer + run: | + { + echo "build.analyze.enabled=true" + echo "build.analyze.template=raw-data" + echo "build.analyze.filename=$GITHUB_WORKSPACE/playground/.nuxt/analyze/{name}.json" + echo "build.analyze.projectRoot=$GITHUB_WORKSPACE/" + } > playground/.nuxtrc + pnpm build + - name: Snapshot the bundle + env: + GITHUB_TOKEN: ${{ github.token }} + # Both sides render the same content, so the comparison only reflects code. + NUXT_DOCS_GITHUB_BRANCH: ${{ github.event.pull_request.base.sha }} + SNAPSHOT_SHA: ${{ matrix.target == 'base' && github.event.pull_request.base.sha || github.event.pull_request.head.sha }} + run: | + # nuxt-og-image signs its image URLs with it. + NUXT_APP_SECRET="$(openssl rand -base64 32)" PORT=3000 node playground/.output/server/index.mjs & + timeout 60 bash -c 'until curl -sf -o /dev/null http://localhost:3000/robots.txt; do sleep 1; done' + node "$RUNNER_TEMP/report.mjs" snapshot \ + --root playground \ + --analyze playground/.nuxt/analyze/client.json \ + --url http://localhost:3000 \ + --label ${{ matrix.target }} \ + --sha "$SNAPSHOT_SHA" \ + --output "$RUNNER_TEMP/bundle-size/${{ matrix.target }}.json" + - name: Upload the snapshot + uses: actions/upload-artifact@v7 + with: + name: bundle-size-${{ matrix.target }} + path: ${{ runner.temp }}/bundle-size/${{ matrix.target }}.json + if-no-files-found: error + retention-days: 1 + + # One artifact with both snapshots and the PR number, for the comment workflow to validate. + report: + needs: analyze + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Download the snapshots + uses: actions/download-artifact@v8 + with: + pattern: bundle-size-* + path: ${{ runner.temp }}/bundle-size + merge-multiple: true + - name: Write the 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 the report + uses: actions/upload-artifact@v7 + with: + name: bundle-size-report + path: ${{ runner.temp }}/bundle-size + if-no-files-found: error + retention-days: 7 diff --git a/scripts/bundle-size/report.mjs b/scripts/bundle-size/report.mjs new file mode 100644 index 0000000..77d1535 --- /dev/null +++ b/scripts/bundle-size/report.mjs @@ -0,0 +1,522 @@ +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 = 3 +const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024 +const MAX_MODULES = 25_000 +const MAX_MODULE_ID_LENGTH = 2_048 +const MAX_ROUTE_FILES = 1_000 + +// Pages whose upfront assets are reported: landing, a light docs page, a component-heavy docs page. +const ROUTES = Object.freeze(['/', '/getting-started/introduction', '/writing/components']) + +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))) +} + +function parseAttributes(source) { + const attributes = {} + for (const [, name, doubleQuoted, singleQuoted, unquoted] of source.matchAll(/([^\s"'=<>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) { + attributes[name.toLowerCase()] = doubleQuoted ?? singleQuoted ?? unquoted ?? '' + } + return attributes +} + +// Prefetched chunks are left out: the browser only fetches them when idle. +export function initialAssetPaths(html) { + const paths = new Set() + + for (const [, tag, source] of html.matchAll(/<(link|script)\b([^>]*)>/gi)) { + const attributes = parseAttributes(source) + const rel = (attributes.rel || '').toLowerCase().split(/\s+/) + const url = tag.toLowerCase() === 'script' + ? attributes.type === 'module' && attributes.src + : (rel.includes('modulepreload') || rel.includes('stylesheet')) && attributes.href + const index = url ? url.indexOf('_nuxt/') : -1 + if (index !== -1) { + paths.add(url.slice(index).split(/[?#]/)[0]) + } + } + + return paths +} + +// Pages render at request time, so their HTML comes from the running build, not from prerendered files. +async function fetchRouteHtml(url, route) { + const response = await fetch(new URL(route, url), { headers: { accept: 'text/html' } }) + // A page the build no longer has is reported as missing, any other failure invalidates the snapshot. + if (response.status === 404) { + return undefined + } + if (!response.ok) { + throw new Error(`${route} returned ${response.status}`) + } + return response.text() +} + +async function measureRoutes(url, assets) { + const routes = {} + + for (const route of ROUTES) { + const html = await fetchRouteHtml(url, route) + if (html === undefined) { + continue + } + + const sizes = { javascript: { ...EMPTY_SIZE }, css: { ...EMPTY_SIZE }, files: 0 } + for (const file of initialAssetPaths(html)) { + const asset = Object.hasOwn(assets, file) ? assets[file] : undefined + if (asset && asset.kind !== 'other') { + addSizes(sizes[asset.kind], asset.sizes) + sizes.files++ + } + } + routes[route] = sizes + } + + return routes +} + +export async function buildSnapshot({ root, analyzePath, url, 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, + routes: await measureRoutes(url, assets), + 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', 'routes', '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.routes)) { + throw new Error('Snapshot routes must be an object') + } + for (const [route, sizes] of Object.entries(snapshot.routes)) { + if (!ROUTES.includes(route)) { + throw new Error('Snapshot contains an unexpected route') + } + const path = `Snapshot route ${JSON.stringify(route)}` + if (!isRecord(sizes)) { + throw new Error(`${path} must be an object`) + } + validateKeys(sizes, ['javascript', 'css', 'files'], path) + validateSize(sizes.javascript, `${path}.javascript`) + validateSize(sizes.css, `${path}.css`) + if (!Number.isSafeInteger(sizes.files) || sizes.files < 0 || sizes.files > MAX_ROUTE_FILES) { + throw new Error(`${path}.files must be an integer between 0 and ${MAX_ROUTE_FILES}`) + } + } + + 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 formatCountDelta(base, head) { + const delta = head - base + if (delta === 0) { + return '—' + } + return delta > 0 ? `+${delta}` : String(delta) +} + +function routeRows(base, head) { + return ROUTES.filter(route => Object.hasOwn(base.routes, route) || Object.hasOwn(head.routes, route)).map((route) => { + const baseRoute = Object.hasOwn(base.routes, route) ? base.routes[route] : undefined + const headRoute = Object.hasOwn(head.routes, route) ? head.routes[route] : undefined + const baseJs = baseRoute ? formatBytes(baseRoute.javascript.brotli) : 'n/a' + const headJs = headRoute ? formatBytes(headRoute.javascript.brotli) : 'n/a' + if (!baseRoute || !headRoute) { + return `| ${inlineCode(route)} | ${baseJs} | ${headJs} | n/a | n/a | n/a |` + } + return `| ${inlineCode(route)} | ${baseJs} | ${headJs} | ${formatDelta(baseRoute.javascript.brotli, headRoute.javascript.brotli)} | ${formatDelta(baseRoute.css.brotli, headRoute.css.brotli)} | ${formatCountDelta(baseRoute.files, headRoute.files)} |` + }) +} + +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.` + ] + + const routes = routeRows(base, head) + if (routes.length > 0) { + lines.push( + '', + '### Initial page load', + '', + '| Page | Base JS (Brotli) | PR JS (Brotli) | Δ JS | Δ CSS | Δ files |', + '| --- | ---: | ---: | ---: | ---: | ---: |', + ...routes, + '', + '> Entry script, `modulepreload` chunks and stylesheets referenced by the server-rendered HTML. Lazy and prefetched chunks are excluded.' + ) + } + + lines.push( + '', + '### All client 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'), + url: required(options, 'url'), + 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 0000000..27638dc --- /dev/null +++ b/scripts/bundle-size/report.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +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, initialAssetPaths, validateSnapshot } from './report.mjs' + +const temporaryDirectories = [] +const servers = [] + +afterAll(async () => { + await Promise.all(temporaryDirectories.map(directory => rm(directory, { force: true, recursive: true }))) + await Promise.all(servers.map(server => new Promise(resolve => server.close(resolve)))) +}) + +/** Serves `pages` (route → status and HTML) like the running build, 404 for any other route. */ +async function servePages(pages) { + const server = createServer((request, response) => { + const page = pages[new URL(request.url, 'http://localhost').pathname] ?? { status: 404, html: 'Not found' } + response.writeHead(page.status, { 'content-type': 'text/html' }) + response.end(page.html) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + servers.push(server) + return `http://127.0.0.1:${server.address().port}` +} + +async function createBuild() { + const root = await mkdtemp(join(tmpdir(), 'nuxt-bundle-size-')) + temporaryDirectories.push(root) + + const assetDir = join(root, '.output/public/_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(join(assetDir, 'chunk.js'), 'export const chunk = 1') + await writeFile(join(assetDir, 'lazy.js'), 'export const lazy = 1') + 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' } + } + } + })) + return { root, analyzePath } +} + +test('builds and compares production bundle snapshots', async () => { + const { root, analyzePath } = await createBuild() + // `/writing/components` is left out: a page the build no longer has is skipped, not an error. + const url = await servePages({ + '/': { + status: 200, + html: [ + '', + '', + '', + '', + '', + '' + ].join('\n') + }, + '/getting-started/introduction': { status: 200, html: '' } + }) + + const base = await buildSnapshot({ root, analyzePath, url, 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 + head.routes['/'].javascript.brotli += 10 + head.routes['/'].files += 1 + + assert.equal(base.schemaVersion, 3) + assert.equal(base.assets, undefined) + assert.equal(base.modules['node_modules/example/index.js'].brotli, 60) + + // Entry is deduplicated, prefetched and unknown assets are skipped. + assert.deepEqual(Object.keys(base.routes), ['/', '/getting-started/introduction']) + assert.equal(base.routes['/'].files, 3) + assert.equal(base.routes['/'].javascript.raw, 'console.log("entry")'.length + 'export const chunk = 1'.length) + assert.equal(base.routes['/'].css.raw, 'body { color: green }'.length) + assert.equal(base.routes['/getting-started/introduction'].files, 1) + + const report = compareSnapshots(base, head) + assert.match(report, /Client JavaScript/) + assert.match(report, /Largest module increases/) + assert.match(report, /report-only/) + assert.match(report, /\| \/<\/code> \| .+ \| \+10 B \(.+\) \| — \| \+1 \|/) + assert.ok(report.indexOf('### Initial page load') < report.indexOf('### All client assets')) +}) + +test('fails the snapshot when a page errors', async () => { + const { root, analyzePath } = await createBuild() + const url = await servePages({ '/': { status: 500, html: 'Server error' } }) + + await assert.rejects( + buildSnapshot({ root, analyzePath, url, label: 'base', sha: 'a'.repeat(40) }), + /\/ returned 500/ + ) +}) + +test('extracts upfront assets from server-rendered HTML', () => { + const paths = initialAssetPaths([ + '', + '', + '', + '', + '' + ].join('')) + assert.deepEqual([...paths], ['_nuxt/a.css', '_nuxt/b.js', '_nuxt/c.js']) +}) + +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: 3, + label: 'base', + sha: 'a'.repeat(40), + totals, + routes: {}, + modules: {} + } + const head = { + schemaVersion: 3, + label: 'pr', + sha: 'b'.repeat(40), + totals, + routes: { + '/': { javascript: { ...size }, css: { ...size }, files: 1 } + }, + modules: { + '`|@nuxt`': size, + '[click me](https://evil.example/)': size + } + } + + const report = compareSnapshots(base, head, { baseSha: base.sha, headSha: head.sha }) + assert.doesNotMatch(report, /@nuxt|\/<\/code> \| n\/a \| 1 B \| n\/a \| n\/a \| n\/a \|/) + + assert.throws( + () => validateSnapshot({ ...head, routes: { '/': head.routes['/'] } }, { label: 'pr' }), + /unexpected route/ + ) + assert.throws( + () => validateSnapshot({ ...head, routes: { '/': { ...head.routes['/'], files: 1.5 } } }, { label: 'pr' }), + /files must be an integer/ + ) + assert.throws( + () => 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: 3, + label: 'base', + sha: 'a'.repeat(40), + totals: emptyTotals, + routes: {}, + modules: {} + } + const head = { + schemaVersion: 3, + label: 'pr', + sha: 'b'.repeat(40), + totals: emptyTotals, + routes: {}, + modules: { + constructor: { raw: 3, gzip: 2, brotli: 1 } + } + } + + const report = compareSnapshots(base, head) + assert.match(report, /constructor<\/code> \| 0 B \| 1 B \| \+1 B/) + assert.doesNotMatch(report, /Initial page load/) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 431711d..35bde04 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ { test: { name: 'unit', - include: ['test/**/*.test.ts', 'modules/**/*.test.ts'], + include: ['test/**/*.test.ts', 'modules/**/*.test.ts', 'scripts/**/*.test.mjs'], exclude: ['test/e2e/**', 'test/vercel/**', 'test/smoke/**'], setupFiles: ['test/setup.ts'], environment: 'node', From 78ded2dadb83714d2bd3728702125e97f6660cd9 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Fri, 23 Oct 2026 16:01:10 +0200 Subject: [PATCH 2/2] reviews --- .github/workflows/bundle-size-comment.yml | 7 +++++++ scripts/bundle-size/report.mjs | 17 +++++++++++++---- scripts/bundle-size/report.test.mjs | 18 ++++++++++++------ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/workflows/bundle-size-comment.yml b/.github/workflows/bundle-size-comment.yml index 44e368c..f77e443 100644 --- a/.github/workflows/bundle-size-comment.yml +++ b/.github/workflows/bundle-size-comment.yml @@ -137,6 +137,13 @@ jobs: return } + // `main` can move between the snapshot and this run: skip like a stale head, the next push reports again. + const baseSnapshot = JSON.parse(fs.readFileSync(path.join(artifactDirectory, 'base.json'), 'utf8')) + if (baseSnapshot?.sha !== pull.base.sha) { + core.info(`Skipping PR #${bundleContext.prNumber}: its base moved since the snapshot.`) + return + } + core.setOutput('should-comment', 'true') core.setOutput('number', String(bundleContext.prNumber)) core.setOutput('base-sha', pull.base.sha) diff --git a/scripts/bundle-size/report.mjs b/scripts/bundle-size/report.mjs index 77d1535..e04a959 100644 --- a/scripts/bundle-size/report.mjs +++ b/scripts/bundle-size/report.mjs @@ -9,6 +9,8 @@ const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024 const MAX_MODULES = 25_000 const MAX_MODULE_ID_LENGTH = 2_048 const MAX_ROUTE_FILES = 1_000 +const MAX_ROUTES = 50 +const ROUTE_PATTERN = /^\/[\w\-./]{0,255}$/ // Pages whose upfront assets are reported: landing, a light docs page, a component-heavy docs page. const ROUTES = Object.freeze(['/', '/getting-started/introduction', '/writing/components']) @@ -276,9 +278,13 @@ export function validateSnapshot(snapshot, { label, sha } = {}) { if (!isRecord(snapshot.routes)) { throw new Error('Snapshot routes must be an object') } - for (const [route, sizes] of Object.entries(snapshot.routes)) { - if (!ROUTES.includes(route)) { - throw new Error('Snapshot contains an unexpected route') + const routes = Object.entries(snapshot.routes) + if (routes.length > MAX_ROUTES) { + throw new Error(`Snapshot contains more than ${MAX_ROUTES} routes`) + } + for (const [route, sizes] of routes) { + if (!ROUTE_PATTERN.test(route)) { + throw new Error('Snapshot contains an invalid route') } const path = `Snapshot route ${JSON.stringify(route)}` if (!isRecord(sizes)) { @@ -371,7 +377,10 @@ function formatCountDelta(base, head) { } function routeRows(base, head) { - return ROUTES.filter(route => Object.hasOwn(base.routes, route) || Object.hasOwn(head.routes, route)).map((route) => { + const extraRoutes = [...new Set([...Object.keys(base.routes), ...Object.keys(head.routes)])] + .filter(route => !ROUTES.includes(route)) + .sort() + return [...ROUTES, ...extraRoutes].filter(route => Object.hasOwn(base.routes, route) || Object.hasOwn(head.routes, route)).map((route) => { const baseRoute = Object.hasOwn(base.routes, route) ? base.routes[route] : undefined const headRoute = Object.hasOwn(head.routes, route) ? head.routes[route] : undefined const baseJs = baseRoute ? formatBytes(baseRoute.javascript.brotli) : 'n/a' diff --git a/scripts/bundle-size/report.test.mjs b/scripts/bundle-size/report.test.mjs index 27638dc..991f2a5 100644 --- a/scripts/bundle-size/report.test.mjs +++ b/scripts/bundle-size/report.test.mjs @@ -149,7 +149,9 @@ test('validates snapshots and safely renders module identifiers', () => { sha: 'b'.repeat(40), totals, routes: { - '/': { javascript: { ...size }, css: { ...size }, files: 1 } + '/': { javascript: { ...size }, css: { ...size }, files: 1 }, + // Not in ROUTES: a PR measuring a new page still gets its row. + '/blog': { javascript: { ...size }, css: { ...size }, files: 1 } }, modules: { '`|@nuxt`': size, @@ -162,11 +164,15 @@ test('validates snapshots and safely renders module identifiers', () => { assert.match(report, /`<\/code>|@nuxt<img src=x>`/) assert.match(report, /[click me]\(https:\/\/evil\.example\/\)/) assert.match(report, /\| \/<\/code> \| n\/a \| 1 B \| n\/a \| n\/a \| n\/a \|/) - - assert.throws( - () => validateSnapshot({ ...head, routes: { '/': head.routes['/'] } }, { label: 'pr' }), - /unexpected route/ - ) + assert.match(report, /\| \/blog<\/code> \| n\/a \| 1 B \| n\/a \| n\/a \| n\/a \|/) + assert.ok(report.indexOf('/') < report.indexOf('/blog')) + + for (const route of ['/', '/a|b', '/[x](y)', 'blog', `/${'a'.repeat(256)}`, '/blog\n', '/blog\r\n', '/blog
']) { + assert.throws( + () => validateSnapshot({ ...head, routes: { [route]: head.routes['/'] } }, { label: 'pr' }), + /invalid route/ + ) + } assert.throws( () => validateSnapshot({ ...head, routes: { '/': { ...head.routes['/'], files: 1.5 } } }, { label: 'pr' }), /files must be an integer/