diff --git a/.bundle-budget.json b/.bundle-budget.json new file mode 100644 index 0000000000..8fec5eb893 --- /dev/null +++ b/.bundle-budget.json @@ -0,0 +1,6 @@ +{ + "_comment": "First Load JS budgets (brotli). Report-only during the soak: scripts/bundle-budget.mjs runs without --gate and the Dockerfile stage ends in `|| true`. Tightened to baseline + ~10-15% headroom from build pr-preview-2511-fzrjd (shared 425.9 kB, heaviest route /user/[username]/models 1.13 MB). To enforce: add --gate to the `size` script + drop the `|| true` in the Dockerfile. `routes` overrides routeMax for a specific page.", + "shared": "470 kB", + "routeMax": "1.3 MB", + "routes": {} +} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4394cbbd94..4c93014b9c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/Dockerfile b/Dockerfile index 124f12ee76..e0b8102be5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,21 @@ ARG NODE_BUILD_MEM=8192 RUN --mount=type=cache,target=/app/.next/cache \ SKIP_ENV_VALIDATION=1 IS_BUILD=true NODE_OPTIONS="--max_old_space_size=${NODE_BUILD_MEM}" pnpm run build +# Bundle-size budget (report-only during the soak). Next 16 (Turbopack) emits +# opaque hashed chunks and removed per-route build stats, so scripts/bundle-budget.mjs +# parses .next/build-manifest.json to reconstruct per-page First Load JS (brotli) +# + a shared-by-all-pages figure. Runs here because .next exists in this stage +# and the build already happened — no duplicate build. `|| true` keeps it +# report-only (numbers print to the build log); to GATE, add `--gate` to the +# node invocation and replace `|| true; cat ...` with `; rc=$?; cat ...; exit $rc` +# so a budget breach fails the image build. +# The report is also written to /app/bundle-budget.txt and COPYied into the +# runner image so the Tekton bundle-comment task can surface it on the PR +# (kubectl exec ... cat) without a duplicate build. +# Invoke node directly (not `pnpm run size`) so pnpm's lifecycle preamble +# (`> model-share@… size /app`) stays out of the report/comment. +RUN node scripts/bundle-budget.mjs > /app/bundle-budget.txt 2>&1 || true; cat /app/bundle-budget.txt + # Server source maps (.next/server/**/*.js.map) are emitted by the build # (productionBrowserSourceMaps -> turbopackSourceMaps) but @vercel/nft does NOT # trace sibling .map files into .next/standalone, so they never reach runtime. @@ -94,6 +109,9 @@ RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/next.config.mjs ./ COPY --from=builder /app/public ./public COPY --from=builder /app/package.json ./package.json +# Bundle-budget report (report-only) — surfaced on the PR by the Tekton +# bundle-comment task via `kubectl exec ... cat /app/bundle-budget.txt`. +COPY --from=builder /app/bundle-budget.txt ./bundle-budget.txt COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/package.json b/package.json index 12ae84cc61..4eb9072afa 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "build": "next build", "build:dev": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" next build", "build:analyze": "cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ANALYZE=true next build", + "size": "node scripts/bundle-budget.mjs", "deploy": "pnpm run build && pnpm run db:deploy", "postinstall": "pnpm run db:generate", "typecheck": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" tsc --noEmit", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs new file mode 100644 index 0000000000..66598b2ec0 --- /dev/null +++ b/scripts/bundle-budget.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * Bundle budget — per-page First Load JS, brotli-sized. + * + * Next 16 (Turbopack) emits opaque hashed chunks with no stable + * framework-/main-/_app- filenames, and removed the per-route build-stats + * table, so a glob tool (size-limit) can only see a coarse total. This script + * reads `.next/build-manifest.json` instead and reconstructs the real metric + * Next used to print: + * + * First Load JS for a route = brotli( union( pages[route], pages["/_app"], + * polyfillFiles ) ) + * Shared by all pages = brotli( pages["/_app"] + polyfillFiles ) + * + * It runs inside the Dockerfile builder stage (where `.next` exists), so there + * is no extra build. Report-only by default; pass `--gate` to exit non-zero on + * a budget breach (for when we promote this to a hard gate). + * + * Budgets live in `.bundle-budget.json`: + * { "shared": "350 kB", "routeMax": "1.5 MB", "routes": { "/x": "2 MB" } } + */ +import { readFileSync, existsSync } from 'node:fs'; +import { brotliCompressSync, constants as zc } from 'node:zlib'; +import { join } from 'node:path'; + +const NEXT_DIR = process.env.NEXT_DIR || '.next'; +const MANIFEST = join(NEXT_DIR, 'build-manifest.json'); +const BUDGET_FILE = process.env.BUNDLE_BUDGET_FILE || '.bundle-budget.json'; +const GATE = process.argv.includes('--gate'); +const TOP = Number(process.env.BUNDLE_TOP || 20); + +const isJs = (f) => typeof f === 'string' && f.endsWith('.js'); +const uniq = (arr) => [...new Set(arr)]; + +function parseSize(s) { + if (typeof s === 'number') return s; + const m = String(s).trim().match(/^([\d.]+)\s*(b|kb|mb|gb)?$/i); + if (!m) return NaN; + const n = parseFloat(m[1]); + const unit = (m[2] || 'b').toLowerCase(); + return n * { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[unit]; +} +function human(b) { + if (b >= 1024 ** 2) return (b / 1024 ** 2).toFixed(2) + ' MB'; + if (b >= 1024) return (b / 1024).toFixed(1) + ' kB'; + return b + ' B'; +} + +if (!existsSync(MANIFEST)) { + console.error(`bundle-budget: no manifest at ${MANIFEST} — was the build run?`); + process.exit(GATE ? 1 : 0); +} + +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')); +const budget = existsSync(BUDGET_FILE) ? JSON.parse(readFileSync(BUDGET_FILE, 'utf8')) : {}; +const sharedBudget = budget.shared != null ? parseSize(budget.shared) : Infinity; +const routeMax = budget.routeMax != null ? parseSize(budget.routeMax) : Infinity; +const routeOverrides = budget.routes || {}; + +// Brotli each file once (files are shared across many routes → cache). +const sizeCache = new Map(); +function brSize(file) { + if (sizeCache.has(file)) return sizeCache.get(file); + const abs = join(NEXT_DIR, file); + let size = 0; + if (existsSync(abs)) { + size = brotliCompressSync(readFileSync(abs), { + params: { [zc.BROTLI_PARAM_QUALITY]: 11 }, + }).length; + } else { + console.error(`bundle-budget: WARN missing chunk ${file}`); + } + sizeCache.set(file, size); + return size; +} +const sumBr = (files) => files.reduce((s, f) => s + brSize(f), 0); + +const pages = manifest.pages || {}; +const sharedFiles = uniq([...(pages['/_app'] || []), ...(manifest.polyfillFiles || [])]).filter(isJs); +const sharedSize = sumBr(sharedFiles); + +const SKIP = new Set(['/_app', '/_error', '/_document']); +const routes = Object.keys(pages) + .filter((r) => !SKIP.has(r)) + .map((route) => { + const files = uniq([...(pages[route] || []), ...sharedFiles]).filter(isJs); + return { route, files: files.length, size: sumBr(files) }; + }) + .sort((a, b) => b.size - a.size); + +// Coarse total = brotli of every referenced client .js (deduped via cache). +const allFiles = uniq(Object.values(pages).flat().filter(isJs).concat(sharedFiles)); +const totalSize = sumBr(allFiles); + +// ---- report ---- +const breaches = []; +const flag = (ok) => (ok ? 'OK' : 'OVER'); + +console.log('===== bundle budget — First Load JS (brotli) ====='); +{ + const ok = sharedSize <= sharedBudget; + if (!ok) breaches.push(`shared ${human(sharedSize)} > ${human(sharedBudget)}`); + const limit = sharedBudget === Infinity ? '—' : human(sharedBudget); + console.log( + `Shared by all pages : ${human(sharedSize).padStart(9)} (${sharedFiles.length} files) [budget ${limit}] ${flag(ok)}` + ); +} +console.log(`Total client JS : ${human(totalSize).padStart(9)} (${allFiles.length} files)`); +console.log(`Routes analysed : ${routes.length}`); +console.log(''); +console.log(`Heaviest ${Math.min(TOP, routes.length)} routes by First Load JS:`); +for (const r of routes.slice(0, TOP)) { + const limit = routeOverrides[r.route] != null ? parseSize(routeOverrides[r.route]) : routeMax; + const ok = r.size <= limit; + if (!ok) breaches.push(`${r.route} ${human(r.size)} > ${human(limit)}`); + const limTxt = limit === Infinity ? '—' : human(limit); + console.log(` ${human(r.size).padStart(9)} ${r.route.padEnd(42)} [budget ${limTxt}] ${flag(ok)}`); +} +// routes outside the top-N can still breach +for (const r of routes.slice(TOP)) { + const limit = routeOverrides[r.route] != null ? parseSize(routeOverrides[r.route]) : routeMax; + if (r.size > limit) breaches.push(`${r.route} ${human(r.size)} > ${human(limit)}`); +} +console.log(''); + +if (breaches.length) { + console.log(`${breaches.length} budget breach(es):`); + for (const b of breaches) console.log(` ✗ ${b}`); + console.log(GATE ? 'FAIL (gating)' : 'breaches found (report-only — not gating)'); + process.exit(GATE ? 1 : 0); +} +console.log(GATE ? 'PASS' : 'within budget (report-only)'); +process.exit(0);