diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e302c36 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# AfiS-Aktensystem — Vorlage für den .env-Reiter in Dockge. +# Diese Datei enthält nur Platzhalter. Die echten Werte gehören nach +# /opt/stacks/afis/.env und niemals ins Repository. Siehe docs/11-betrieb.md. + +# --- Zeitzone ----------------------------------------------------------------- +TZ=Europe/Berlin + +# --- Datenbank ---------------------------------------------------------------- +# Wird von db, pgbouncer, web, worker und den Backups gelesen. +POSTGRES_PASSWORD=hier-ein-langes-zufaelliges-passwort + +# Die Anwendung spricht über pgbouncer im Transaktionsmodus, deshalb pgbouncer=true. +DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@pgbouncer:6432/afis?pgbouncer=true&connection_limit=1 +# Migrationen und LISTEN/NOTIFY brauchen eine Verbindung an pgbouncer vorbei. +DIRECT_DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@db:5432/afis + +# --- Anwendung ---------------------------------------------------------------- +APP_URL=https://afis.example.de +# 32+ Zeichen. Erzeugen mit: openssl rand -base64 48 +AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen + +# --- Discord ------------------------------------------------------------------ +# Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein, +# sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +# Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen +# Voll-Abgleich meldet. Das ist die eigentliche Alarmierung. +DISCORD_ADMIN_CHANNEL_ID= + +# --- Notfallzugang ------------------------------------------------------------ +# Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5 +# und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim +# ersten Fehler in der Rollenverwaltung selbst aus. +BOOTSTRAP_ADMIN_IDS= + +# --- Dateien und Prüfkette ---------------------------------------------------- +FILES_DIR=/data/files +CLAMAV_SOCKET=/var/run/clamav/clamd.sock +PDF_SERVICE_URL=http://pdf:3000 + +# --- Cloudflare Tunnel -------------------------------------------------------- +CLOUDFLARE_TUNNEL_TOKEN= + +# --- Auslagerungs-Backup ------------------------------------------------------ +RESTIC_REPOSITORY= +RESTIC_PASSWORD= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..987584b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: Prüfung + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + pruefen: + name: Lint, Typen, Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: afis + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U afis -d afis" + --health-interval 10s --health-timeout 5s --health-retries 5 + + env: + DATABASE_URL: postgresql://afis:afis@localhost:5432/afis + DIRECT_DATABASE_URL: postgresql://afis:afis@localhost:5432/afis + APP_URL: http://127.0.0.1:3100 + AUTH_SECRET: nur-fuer-die-pruefung-mindestens-32-zeichen-lang + DISCORD_CLIENT_ID: pruefung + DISCORD_CLIENT_SECRET: pruefung + DISCORD_BOT_TOKEN: pruefung + DISCORD_GUILD_ID: '0' + NEXT_TELEMETRY_DISABLED: '1' + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Erzeugt den Prisma-Client nach packages/db/src/generated und übersetzt + # @afis/db. Muss vor lint, typecheck und test laufen. + - name: Datenbankpaket bauen + run: pnpm --filter @afis/db run build + + - name: Datenbank aufbauen + run: pnpm db:deploy + + - run: pnpm db:seed + + - run: pnpm lint + + - run: pnpm typecheck + + # Die Regeltests in packages/db laufen gegen die echte Postgres-Instanz. + - run: pnpm test + + - name: Playwright-Browser + run: pnpm --filter @afis/web exec playwright install --with-deps chromium + + # Baut die Anwendung und prüft sie über den Standalone-Server, also genau + # so, wie der Container sie startet. + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: web/playwright-report/ + retention-days: 7 + + # Budget aus docs/09-performance-und-ux.md: erste Route unter 120 kB + # JavaScript nach Kompression. + - name: Bündelgröße prüfen + run: pnpm --filter @afis/web exec node ../scripts/check-bundle-budget.mjs + + images: + name: Images bauen + runs-on: ubuntu-latest + needs: pruefen + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + dienst: [web, worker, pdf] + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + # Auf Pull Requests nur bauen, nicht schieben. Das Schieben macht + # .github/workflows/images.yml auf main. + - uses: docker/build-push-action@v6 + with: + context: . + file: docker/${{ matrix.dienst }}.Dockerfile + push: false + cache-from: type=gha,scope=${{ matrix.dienst }} + cache-to: type=gha,mode=max,scope=${{ matrix.dienst }} diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..53e5b86 --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,62 @@ +name: Images + +# Baut die drei Images und schiebt sie in die GitHub Container Registry. +# Dockge zieht sie beim Update, siehe docs/11-betrieb.md. + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + bauen: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + include: + - dienst: web + dockerfile: docker/web.Dockerfile + - dienst: worker + dockerfile: docker/worker.Dockerfile + - dienst: pdf + dockerfile: docker/pdf.Dockerfile + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/afis-${{ matrix.dienst }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.dienst }} + cache-to: type=gha,mode=max,scope=${{ matrix.dienst }} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7492cfc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,200 @@ +# AfiS-Aktensystem — ein einziger Dockge-Stack. +# Ablage: /opt/stacks/afis/compose.yaml, .env im eigenen Dockge-Reiter. +# Begründung der Entscheidungen in docs/11-betrieb.md: +# - keine Einmal- und keine Init-Container, Dockge zeigt beendete Container nicht +# - Migrationen laufen im Startkommando von `web` +# - jeder Dienst hat einen Healthcheck, das ist in Dockge die Statusanzeige +# - kein Reverse Proxy: TLS und Kompression macht der Cloudflare Tunnel +# +# DEIN-USER durch den eigenen GitHub-Namen ersetzen. + +x-logging: &logging + logging: + driver: json-file + options: { max-size: '10m', max-file: '3' } + +services: + web: + <<: *logging + image: ghcr.io/DEIN-USER/afis-web:latest + restart: unless-stopped + # Die Migration läuft im Startkommando, nicht in einem Init-Container. + # Die Prisma-CLI liegt im Image unter /app/migrator, es wird nichts nachgeladen. + command: sh -c "cd /app/migrator && node node_modules/prisma/build/index.js migrate deploy && cd /app && node web/server.js" + env_file: .env + environment: + NODE_ENV: production + PORT: '3000' + HOSTNAME: '0.0.0.0' + volumes: + - files:/data/files + - clamav_socket:/var/run/clamav + ports: + - '3000:3000' # nur im LAN, von außen kommt der Tunnel + depends_on: + pgbouncer: { condition: service_healthy } + clamav: { condition: service_healthy } + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + worker: + <<: *logging + image: ghcr.io/DEIN-USER/afis-worker:latest + restart: unless-stopped + env_file: .env + depends_on: + pgbouncer: { condition: service_healthy } + healthcheck: + test: ['CMD', 'node', 'healthcheck.js'] # prüft Gateway-Verbindung + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + db: + <<: *logging + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + TZ: ${TZ} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U afis -d afis'] + interval: 10s + timeout: 5s + retries: 5 + + pgbouncer: + <<: *logging + image: edoburu/pgbouncer:latest + restart: unless-stopped + environment: + DB_HOST: db + DB_USER: afis + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: afis + POOL_MODE: transaction + MAX_CLIENT_CONN: '100' + DEFAULT_POOL_SIZE: '20' + AUTH_TYPE: scram-sha-256 + depends_on: + db: { condition: service_healthy } + healthcheck: + test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -p 6432 -U afis'] + interval: 15s + timeout: 5s + retries: 5 + + clamav: + <<: *logging + image: clamav/clamav:stable + restart: unless-stopped + environment: + CLAMAV_NO_MILTERD: 'true' + FRESHCLAM_CHECKS: '2' + volumes: + - clamav_db:/var/lib/clamav + - clamav_socket:/var/run/clamav + - files:/data/files + healthcheck: + test: ['CMD-SHELL', 'clamdscan --ping 1 || exit 1'] + interval: 60s + timeout: 15s + retries: 3 + start_period: 300s # erster Signaturdownload dauert + + pdf: + <<: *logging + image: ghcr.io/DEIN-USER/afis-pdf:latest # Playwright-Basis + restart: unless-stopped + environment: + PORT: '3000' + APP_URL: ${APP_URL} + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + cloudflared: + <<: *logging + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + web: { condition: service_healthy } + healthcheck: + test: ['CMD', 'cloudflared', '--version'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 20s + + db-backup: + <<: *logging + image: prodrigestivill/postgres-backup-local:16 + restart: unless-stopped + environment: + POSTGRES_HOST: db + POSTGRES_DB: afis + POSTGRES_USER: afis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_EXTRA_OPTS: '-Fc' + SCHEDULE: '0 15 4 * * *' # täglich 04:15 + BACKUP_KEEP_DAYS: '7' + BACKUP_KEEP_WEEKS: '4' + BACKUP_KEEP_MONTHS: '6' + HEALTHCHECK_PORT: '8080' + TZ: ${TZ} + volumes: + - /mnt/backup/afis/db:/backups + depends_on: + db: { condition: service_healthy } + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost:8080/ || exit 1'] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + offsite-backup: + <<: *logging + image: mazzolino/restic:latest + restart: unless-stopped + environment: + RUN_ON_STARTUP: 'false' + BACKUP_CRON: '0 30 4 * * *' # 15 Minuten nach dem Dump + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY} + RESTIC_PASSWORD: ${RESTIC_PASSWORD} + RESTIC_BACKUP_SOURCES: /data/db /data/files + RESTIC_FORGET_ARGS: '--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune' + TZ: ${TZ} + volumes: + - /mnt/backup/afis/db:/data/db:ro + - files:/data/files:ro + # Kein HTTP-Endpunkt im Image. Geprüft wird, dass der Cron-Dauerläufer noch + # steht und restic aufrufbar ist. Ohne Healthcheck wäre der Dienst in Dockge + # nur "läuft", auch wenn er seit Wochen nichts mehr sichert. + healthcheck: + test: ['CMD-SHELL', 'pgrep -f go-crond >/dev/null && restic version >/dev/null || exit 1'] + interval: 300s + timeout: 15s + retries: 3 + start_period: 30s + +volumes: + pgdata: + files: + clamav_db: + clamav_socket: diff --git a/docker/pdf.Dockerfile b/docker/pdf.Dockerfile new file mode 100644 index 0000000..44ec98d --- /dev/null +++ b/docker/pdf.Dockerfile @@ -0,0 +1,39 @@ +# Image für den Dienst `pdf`. Playwright-Basis mit vorinstalliertem Chromium, +# rendert die Druckansicht der Akte. Nur im Stack-Netz erreichbar. + +ARG PLAYWRIGHT_VERSION=v1.62.1-noble +ARG PNPM_VERSION=9.15.4 + +FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS build +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc tsconfig.base.json ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/pdf... + +COPY pdf/ pdf/ +RUN pnpm --filter @afis/pdf build && \ + pnpm --filter @afis/pdf --prod --no-optional deploy /bundle && \ + cp -r pdf/dist /bundle/dist + +FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + PORT=3000 + +COPY --from=build --chown=pwuser:pwuser /bundle ./ + +USER pwuser +EXPOSE 3000 + +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD wget -qO- http://localhost:3000/health || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile new file mode 100644 index 0000000..cf076d4 --- /dev/null +++ b/docker/web.Dockerfile @@ -0,0 +1,81 @@ +# Image für den Dienst `web`. Standalone-Build, siehe web/next.config.ts. +# +# Die Migration läuft im Startkommando des Containers, nicht hier und nicht in +# einem Init-Container: Dockge zeigt beendete Container nicht dauerhaft an. +# Begründung in docs/11-betrieb.md. +# +# Dafür liegt eine eigenständige Prisma-CLI unter /app/migrator im Image. Sie +# beim Start aus dem Netz zu ziehen wäre der falsche Weg: der Container startet +# dann nur, wenn die Registry gerade erreichbar ist. + +ARG NODE_VERSION=22-alpine +ARG PNPM_VERSION=9.15.4 +ARG PRISMA_VERSION=7.10.0 + +# --- Abhängigkeiten ----------------------------------------------------------- +FROM node:${NODE_VERSION} AS deps +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/web... --filter @afis/db... + +# --- Bauen -------------------------------------------------------------------- +FROM node:${NODE_VERSION} AS build +ARG PRISMA_VERSION +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /repo/web/node_modules ./web/node_modules +COPY . . + +# Prisma-Client erzeugen und @afis/db nach dist übersetzen. +RUN pnpm --filter @afis/db build + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm --filter @afis/web build + +# Eigenständige Migrations-CLI mit heruntergeladener Schema-Engine. +RUN mkdir -p /migrator && cd /migrator && \ + npm install --no-package-lock --no-audit --no-fund "prisma@${PRISMA_VERSION}" && \ + cp -r /repo/packages/db/prisma /migrator/prisma && \ + cp /repo/packages/db/prisma.config.ts /migrator/prisma.config.ts + +# --- Laufzeit ----------------------------------------------------------------- +FROM node:${NODE_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN apk add --no-cache wget openssl && \ + addgroup -g 1001 -S afis && \ + adduser -u 1001 -S afis -G afis + +# Standalone-Ausgabe: server.js plus die tatsächlich benutzten node_modules. +COPY --from=build --chown=afis:afis /repo/web/.next/standalone/ ./ +COPY --from=build --chown=afis:afis /repo/web/.next/static ./web/.next/static +COPY --from=build --chown=afis:afis /repo/web/public ./web/public + +# Migrations-CLI samt Schema, Migrationsordner und Engine. +COPY --from=build --chown=afis:afis /migrator ./migrator + +USER afis +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -qO- http://localhost:3000/api/health || exit 1 + +# Das Startkommando setzt der Stack (Migration + Server), siehe compose.yaml. +CMD ["node", "web/server.js"] diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile new file mode 100644 index 0000000..d9000f2 --- /dev/null +++ b/docker/worker.Dockerfile @@ -0,0 +1,51 @@ +# Image für den Dienst `worker`. Hängt am Discord-Gateway und hält die +# Guild-Mitgliedschaft in Postgres aktuell, siehe docs/02-architektur.md. + +ARG NODE_VERSION=22-alpine +ARG PNPM_VERSION=9.15.4 + +FROM node:${NODE_VERSION} AS deps +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./ +COPY packages/db/package.json packages/db/ +COPY web/package.json web/ +COPY worker/package.json worker/ +COPY pdf/package.json pdf/ + +RUN pnpm install --frozen-lockfile --filter @afis/worker... --filter @afis/db... + +FROM node:${NODE_VERSION} AS build +ARG PNPM_VERSION +RUN npm install -g pnpm@${PNPM_VERSION} +WORKDIR /repo + +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules +COPY --from=deps /repo/worker/node_modules ./worker/node_modules +COPY . . + +RUN pnpm --filter @afis/db build && pnpm --filter @afis/worker build && \ + pnpm --filter @afis/worker --prod --no-optional deploy /bundle && \ + cp -r worker/dist /bundle/dist && \ + cp worker/healthcheck.js /bundle/healthcheck.js + +FROM node:${NODE_VERSION} AS runtime +WORKDIR /app + +ENV NODE_ENV=production + +RUN apk add --no-cache openssl && \ + addgroup -g 1001 -S afis && \ + adduser -u 1001 -S afis -G afis + +COPY --from=build --chown=afis:afis /bundle ./ + +USER afis + +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD node healthcheck.js || exit 1 + +CMD ["node", "dist/index.js"] diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 0000000..6dac165 --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,57 @@ +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' +import { gzipSync } from 'node:zlib' + +/** + * Bündel-Budget aus docs/09-performance-und-ux.md: die erste Route muss unter + * 120 kB JavaScript nach Kompression bleiben. + * + * Gemessen wird je Route die Vereinigung ihrer JavaScript-Chunks aus + * app-build-manifest.json, gzip-komprimiert. Das entspricht dem, was Next + * selbst als "First Load JS" ausweist. Cloudflare liefert Brotli aus, das ist + * noch etwas kleiner, gzip ist damit die vorsichtigere Schätzung. + */ +const BUDGET_BYTES = 120 * 1024 + +const appDir = process.cwd() +const manifestPath = path.join(appDir, '.next', 'app-build-manifest.json') + +if (!existsSync(manifestPath)) { + console.error('Kein Build gefunden. Zuerst `pnpm --filter @afis/web run build` laufen lassen.') + process.exit(1) +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const routes = Object.entries(manifest.pages ?? {}) + +if (routes.length === 0) { + console.error('Keine Routen im Manifest gefunden.') + process.exit(1) +} + +const measured = routes + .map(([route, files]) => { + let bytes = 0 + for (const file of new Set(files)) { + if (!file.endsWith('.js')) continue + const full = path.join(appDir, '.next', file) + if (!existsSync(full)) continue + bytes += gzipSync(readFileSync(full)).byteLength + } + return { route, bytes } + }) + .sort((a, b) => b.bytes - a.bytes) + +const kb = (n) => `${(n / 1024).toFixed(1)} kB` + +for (const { route, bytes } of measured) { + console.log(`${bytes > BUDGET_BYTES ? '✗' : '✓'} ${route.padEnd(28)} ${kb(bytes)}`) +} + +const worst = measured[0] +console.log(`\nGrößte Route: ${worst.route} mit ${kb(worst.bytes)}, Budget ${kb(BUDGET_BYTES)}`) + +if (worst.bytes > BUDGET_BYTES) { + console.error(`Budget überschritten um ${kb(worst.bytes - BUDGET_BYTES)}.`) + process.exit(1) +}