diff --git a/.github/workflows/bundle-size-comment.yml b/.github/workflows/bundle-size-comment.yml
new file mode 100644
index 0000000..f77e443
--- /dev/null
+++ b/.github/workflows/bundle-size-comment.yml
@@ -0,0 +1,230 @@
+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
+ }
+
+ // `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)
+ 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..e04a959
--- /dev/null
+++ b/scripts/bundle-size/report.mjs
@@ -0,0 +1,531 @@
+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
+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'])
+
+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')
+ }
+ 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)) {
+ 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) {
+ 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'
+ 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..991f2a5
--- /dev/null
+++ b/scripts/bundle-size/report.test.mjs
@@ -0,0 +1,219 @@
+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 },
+ // Not in ROUTES: a PR measuring a new page still gets its row.
+ '/blog': { 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.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/
+ )
+ 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',