diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbebe3dc..7fd02731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Build both targets run: pnpm build:both + - name: Bundle size gate + run: pnpm bundle-size + - name: Validate MF manifest run: | MANIFEST=$(find dist/remote -name "mf-manifest.json" | head -1) diff --git a/package.json b/package.json index 63b9ab40..9666130a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "bundle-size": "node scripts/ci/bundle-size-gate.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.0", diff --git a/scripts/ci/bundle-size-gate.mjs b/scripts/ci/bundle-size-gate.mjs new file mode 100644 index 00000000..fe2f40bb --- /dev/null +++ b/scripts/ci/bundle-size-gate.mjs @@ -0,0 +1,88 @@ +/** + * Bundle-size gate for CI. + * + * Finds all .js files in dist/remote/static/js/, measures their gzip size, + * reports per-file and total, and fails if total exceeds the budget. + * + * Budgets: + * - Total remote bundle: <= 2000 kB gzip + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +const BUDGET_TOTAL_KB = 2000; +const JS_DIR = "dist/remote/static/js"; + +function collectJsFiles(dir) { + const results = []; + let entries; + try { + entries = readdirSync(dir); + } catch { + return results; + } + for (const entry of entries) { + const full = join(dir, entry); + const stat = statSync(full, { throwIfNoEntry: false }); + if (!stat) continue; + if (stat.isDirectory()) { + results.push(...collectJsFiles(full)); + } else if (entry.endsWith(".js")) { + results.push(full); + } + } + return results; +} + +const files = collectJsFiles(JS_DIR); + +if (files.length === 0) { + console.error(`No .js files found in ${JS_DIR}. Did the build run?`); + process.exit(1); +} + +let totalGzip = 0; +const rows = []; + +for (const file of files) { + const raw = readFileSync(file); + const gzipped = gzipSync(raw); + const gzipKB = gzipped.length / 1024; + totalGzip += gzipKB; + rows.push({ file: file.replace("dist/remote/", ""), rawKB: (raw.length / 1024).toFixed(1), gzipKB: gzipKB.toFixed(1) }); +} + +rows.sort((a, b) => parseFloat(b.gzipKB) - parseFloat(a.gzipKB)); + +console.log("\n--- Bundle Size Report ---\n"); +console.log( + "File".padEnd(60) + + "Raw (kB)".padStart(12) + + "Gzip (kB)".padStart(12) +); +console.log("-".repeat(84)); + +for (const r of rows) { + console.log( + r.file.padEnd(60) + + r.rawKB.padStart(12) + + r.gzipKB.padStart(12) + ); +} + +console.log("-".repeat(84)); +console.log( + "TOTAL".padEnd(60) + + "".padStart(12) + + totalGzip.toFixed(1).padStart(12) +); +console.log(`\nBudget: ${BUDGET_TOTAL_KB} kB gzip`); + +if (totalGzip > BUDGET_TOTAL_KB) { + console.error(`\nFAILED: Total gzip size ${totalGzip.toFixed(1)} kB exceeds budget of ${BUDGET_TOTAL_KB} kB`); + process.exit(1); +} else { + console.log(`\nPASSED: ${totalGzip.toFixed(1)} kB / ${BUDGET_TOTAL_KB} kB budget`); +}