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',
},
},