ci: Größenbudget des 3D-Chunks im Frontend-Build prüfen
scripts/check-3d-budget.mjs misst den three.js-Chunk (Marker WebGLRenderer und __THREE__) mit gzip -9 und bricht den Image-Build ab, wenn er 320 kB übersteigt oder nicht gefunden wird. Heute 299 kB. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
92c00b4453
commit
2207bd2e67
4 files changed
+118
-1
No files matched your search
@@ -11,6 +11,8 @@ RUN npx next typegen
|
|||||||
# Unit-Tests gruen sind.
|
# Unit-Tests gruen sind.
|
||||||
RUN npm run lint && npx tsc --noEmit && npm test
|
RUN npm run lint && npx tsc --noEmit && npm test
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
|
||||||
|
RUN npm run check:3d-budget
|
||||||
|
|
||||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "next typegen && tsc --noEmit",
|
"typecheck": "next typegen && tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"check:3d-budget": "node scripts/check-3d-budget.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Bundle-Budget fuer den lazy geladenen Beute-3D-Chunk.
|
||||||
|
// Spec: docs/superpowers/specs/2026-10-15-beute-3d-design.md (§9.9 / L3).
|
||||||
|
// Laeuft nach `next build`; Exit 1, wenn der three.js-Chunk das Budget
|
||||||
|
// ueberschreitet oder gar kein three.js-Chunk gefunden wird.
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
// Spec §9.9: hoechstens 320 kB gzip fuer den 3D-Chunk (1 kB = 1000 Byte).
|
||||||
|
export const BUDGET_BYTES = 320_000;
|
||||||
|
|
||||||
|
// Stabile Marker, die three.js auch minifiziert im Chunk behaelt (String-
|
||||||
|
// Literale): der Renderer-Name und die globale Mehrfach-Import-Markierung.
|
||||||
|
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
|
||||||
|
|
||||||
|
export function isThreeChunk(source) {
|
||||||
|
return THREE_MARKERS.every((marker) => source.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Misst den groessten three.js-Chunk (gzip level 9) unter chunksDir.
|
||||||
|
* @returns {{ ok: boolean, file: string | null, gzipBytes: number, budget: number, message: string }}
|
||||||
|
*/
|
||||||
|
export function checkBudget(chunksDir, budget = BUDGET_BYTES) {
|
||||||
|
let largest = null;
|
||||||
|
for (const name of readdirSync(chunksDir)) {
|
||||||
|
if (!name.endsWith(".js")) continue;
|
||||||
|
const source = readFileSync(path.join(chunksDir, name));
|
||||||
|
if (!isThreeChunk(source.toString("latin1"))) continue;
|
||||||
|
const gzipBytes = gzipSync(source, { level: 9 }).length;
|
||||||
|
if (!largest || gzipBytes > largest.gzipBytes) largest = { file: name, gzipBytes };
|
||||||
|
}
|
||||||
|
if (!largest) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
file: null,
|
||||||
|
gzipBytes: 0,
|
||||||
|
budget,
|
||||||
|
message: `Kein three.js-Chunk in ${chunksDir} gefunden (Marker: ${THREE_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ok = largest.gzipBytes <= budget;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
...largest,
|
||||||
|
budget,
|
||||||
|
message: `3D-Chunk ${largest.file}: ${largest.gzipBytes} Byte gzip, Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = checkBudget(chunksDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`check-3d-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
(result.ok ? console.log : console.error)(result.message);
|
||||||
|
process.exit(result.ok ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BUDGET_BYTES, checkBudget } from "./check-3d-budget.mjs";
|
||||||
|
|
||||||
|
const threeChunk = (filler: string) => `/*WebGLRenderer*/window.__THREE__="186";${filler}`;
|
||||||
|
// Hex of random bytes is barely compressible by gzip (about 50 %).
|
||||||
|
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
|
||||||
|
|
||||||
|
describe("check-3d-budget", () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), "budget-"));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("passes when the three.js chunk is under budget", () => {
|
||||||
|
writeFileSync(path.join(dir, "a.js"), threeChunk("x".repeat(1000)));
|
||||||
|
writeFileSync(path.join(dir, "other.js"), "console.log(1)");
|
||||||
|
const result = checkBudget(dir);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.file).toBe("a.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the three.js chunk is over budget", () => {
|
||||||
|
writeFileSync(path.join(dir, "big.js"), threeChunk(noise(1000)));
|
||||||
|
const result = checkBudget(dir, 100);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.gzipBytes).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures the largest of several three.js chunks", () => {
|
||||||
|
writeFileSync(path.join(dir, "small.js"), threeChunk("x"));
|
||||||
|
writeFileSync(path.join(dir, "large.js"), threeChunk(noise(2000)));
|
||||||
|
expect(checkBudget(dir).file).toBe("large.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when no three.js chunk exists", () => {
|
||||||
|
writeFileSync(path.join(dir, "app.js"), "export const a = 1; // WebGLRenderer only");
|
||||||
|
const result = checkBudget(dir);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.file).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the spec budget of 320 kB by default", () => {
|
||||||
|
expect(BUDGET_BYTES).toBe(320_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user