diff --git a/ApiCore-Frontend/Dockerfile b/ApiCore-Frontend/Dockerfile index a1058ff..4ec9a5c 100644 --- a/ApiCore-Frontend/Dockerfile +++ b/ApiCore-Frontend/Dockerfile @@ -11,6 +11,8 @@ RUN npx next typegen # Unit-Tests gruen sind. RUN npm run lint && npx tsc --noEmit && npm test 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 ENV NODE_ENV=production diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index 73e755c..67f9075 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -9,7 +9,8 @@ "lint": "eslint", "typecheck": "next typegen && tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "check:3d-budget": "node scripts/check-3d-budget.mjs" }, "dependencies": { "@base-ui/react": "^1.7.0", diff --git a/ApiCore-Frontend/scripts/check-3d-budget.mjs b/ApiCore-Frontend/scripts/check-3d-budget.mjs new file mode 100644 index 0000000..271ba61 --- /dev/null +++ b/ApiCore-Frontend/scripts/check-3d-budget.mjs @@ -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); +} diff --git a/ApiCore-Frontend/scripts/check-3d-budget.test.ts b/ApiCore-Frontend/scripts/check-3d-budget.test.ts new file mode 100644 index 0000000..d7033ff --- /dev/null +++ b/ApiCore-Frontend/scripts/check-3d-budget.test.ts @@ -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); + }); +});