From 8375d1cb03145a718d064470a36f7e25ea482225 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Tue, 1 Sep 2026 00:01:49 +0200 Subject: [PATCH] Worker- und PDF-Dienst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Worker hängt am Discord-Gateway und schreibt alle 30 Sekunden sein Lebenszeichen in die Datenbank, damit /api/health im Web-Container den Gateway-Zustand kennt, ohne selbst mit Discord zu sprechen. healthcheck.js prüft genau dieses Lebenszeichen. Der eigentliche Rollen-Abgleich kommt in Phase 1. Der PDF-Dienst rendert die Druckansicht über Playwright. Kein PDF-Layout von Hand, die Gestaltung kommt aus @media print der Seite selbst. Er kennt keine Rechte: wer darf, entscheidet die Web-App, bevor sie hier anfragt, und schickt das Sitzungs-Cookie des Abrufers mit, damit die gerenderte Seite dessen Freigabestufe sieht. --- pdf/eslint.config.mjs | 8 +++ pdf/package.json | 26 +++++++++ pdf/src/index.ts | 109 +++++++++++++++++++++++++++++++++++++ pdf/tsconfig.build.json | 4 ++ pdf/tsconfig.json | 10 ++++ worker/eslint.config.mjs | 8 +++ worker/healthcheck.js | 39 +++++++++++++ worker/package.json | 29 ++++++++++ worker/src/env.ts | 31 +++++++++++ worker/src/heartbeat.ts | 20 +++++++ worker/src/index.ts | 72 ++++++++++++++++++++++++ worker/tsconfig.build.json | 8 +++ worker/tsconfig.json | 10 ++++ 13 files changed, 374 insertions(+) create mode 100644 pdf/eslint.config.mjs create mode 100644 pdf/package.json create mode 100644 pdf/src/index.ts create mode 100644 pdf/tsconfig.build.json create mode 100644 pdf/tsconfig.json create mode 100644 worker/eslint.config.mjs create mode 100644 worker/healthcheck.js create mode 100644 worker/package.json create mode 100644 worker/src/env.ts create mode 100644 worker/src/heartbeat.ts create mode 100644 worker/src/index.ts create mode 100644 worker/tsconfig.build.json create mode 100644 worker/tsconfig.json diff --git a/pdf/eslint.config.mjs b/pdf/eslint.config.mjs new file mode 100644 index 0000000..ae78238 --- /dev/null +++ b/pdf/eslint.config.mjs @@ -0,0 +1,8 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, +) diff --git a/pdf/package.json b/pdf/package.json new file mode 100644 index 0000000..7b1c0f9 --- /dev/null +++ b/pdf/package.json @@ -0,0 +1,26 @@ +{ + "name": "@afis/pdf", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "tsc -p tsconfig.build.json", + "lint": "eslint src --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "playwright": "1.62.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.0", + "@types/node": "^22.10.0", + "eslint": "9.39.5", + "tsx": "4.20.6", + "typescript": "5.9.3", + "typescript-eslint": "^8.20.0", + "vitest": "4.1.11" + } +} diff --git a/pdf/src/index.ts b/pdf/src/index.ts new file mode 100644 index 0000000..4abe153 --- /dev/null +++ b/pdf/src/index.ts @@ -0,0 +1,109 @@ +import { createServer, type IncomingMessage } from 'node:http' +import { chromium, type Browser } from 'playwright' + +/** + * Rendert die Druckansicht einer Akte zu PDF. Kein PDF-Layout von Hand, die + * Gestaltung kommt aus `@media print` der Seite selbst. Siehe docs/08-suche-und-export.md. + * + * Der Dienst ist nur im Stack-Netz erreichbar und kennt keine Rechte. Wer darf, + * entscheidet die Web-App, bevor sie hier anfragt; sie schickt das Sitzungs-Cookie + * des Abrufers mit, damit die gerenderte Seite dessen Freigabestufe sieht. + */ +const PORT = Number(process.env.PORT ?? 3000) +const RENDER_TIMEOUT_MS = Number(process.env.RENDER_TIMEOUT_MS ?? 30_000) + +let browser: Browser | null = null + +async function getBrowser(): Promise { + if (browser?.isConnected()) return browser + browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] }) + return browser +} + +type RenderRequest = { + url: string + cookies?: { name: string; value: string; domain: string; path: string }[] + /** Wasserzeichen: Name des Abrufers, Zeitstempel, Freigabestufe. */ + headerHtml?: string + footerHtml?: string +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + request.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > 1_000_000) { + reject(new Error('Anfrage zu groß')) + request.destroy() + return + } + chunks.push(chunk) + }) + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + request.on('error', reject) + }) +} + +async function render(spec: RenderRequest): Promise { + const context = await (await getBrowser()).newContext({ locale: 'de-DE', timezoneId: 'Europe/Berlin' }) + try { + if (spec.cookies?.length) await context.addCookies(spec.cookies) + const page = await context.newPage() + await page.goto(spec.url, { waitUntil: 'networkidle', timeout: RENDER_TIMEOUT_MS }) + await page.emulateMedia({ media: 'print' }) + return await page.pdf({ + format: 'A4', + printBackground: true, + displayHeaderFooter: Boolean(spec.headerHtml || spec.footerHtml), + headerTemplate: spec.headerHtml ?? '', + footerTemplate: spec.footerHtml ?? '', + margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' }, + }) + } finally { + await context.close() + } +} + +const server = createServer((request, response) => { + void (async () => { + if (request.url === '/health') { + const healthy = browser === null || browser.isConnected() + response.writeHead(healthy ? 200 : 503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ status: healthy ? 'up' : 'down' })) + return + } + + if (request.method === 'POST' && request.url === '/render') { + try { + const spec = JSON.parse(await readBody(request)) as RenderRequest + if (typeof spec.url !== 'string') throw new Error('url fehlt') + const pdf = await render(spec) + response.writeHead(200, { + 'content-type': 'application/pdf', + 'content-length': String(pdf.byteLength), + }) + response.end(pdf) + } catch (error) { + console.error('[pdf] Rendern fehlgeschlagen', error) + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: error instanceof Error ? error.message : 'unbekannt' })) + } + return + } + + response.writeHead(404) + response.end() + })() +}) + +server.listen(PORT, () => console.log(`[pdf] hört auf Port ${PORT}`)) + +async function shutdown(): Promise { + server.close() + await browser?.close().catch(() => undefined) + process.exit(0) +} +process.on('SIGTERM', () => void shutdown()) +process.on('SIGINT', () => void shutdown()) diff --git a/pdf/tsconfig.build.json b/pdf/tsconfig.build.json new file mode 100644 index 0000000..872420e --- /dev/null +++ b/pdf/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": false, "sourceMap": true } +} diff --git a/pdf/tsconfig.json b/pdf/tsconfig.json new file mode 100644 index 0000000..8a81dd4 --- /dev/null +++ b/pdf/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/worker/eslint.config.mjs b/worker/eslint.config.mjs new file mode 100644 index 0000000..ae78238 --- /dev/null +++ b/worker/eslint.config.mjs @@ -0,0 +1,8 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, +) diff --git a/worker/healthcheck.js b/worker/healthcheck.js new file mode 100644 index 0000000..8984232 --- /dev/null +++ b/worker/healthcheck.js @@ -0,0 +1,39 @@ +// Healthcheck des Dienstes `worker`, wird vom Compose aufgerufen. +// Prueft das eigene Lebenszeichen in der Datenbank: ist es frisch und meldet +// `up`, gilt die Gateway-Verbindung als in Ordnung. Siehe docs/11-betrieb.md. +// `pg` wird als CommonJS ausgeliefert, deshalb der Default-Import. +import pg from 'pg' + +const { Client } = pg + +const MAX_AGE_MS = 120_000 + +const client = new Client({ connectionString: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL }) + +try { + await client.connect() + const { rows } = await client.query( + 'SELECT status, at FROM "ServiceHeartbeat" WHERE service = $1', + ['worker'], + ) + const beat = rows[0] + if (!beat) { + console.error('kein Lebenszeichen') + process.exit(1) + } + const ageMs = Date.now() - new Date(beat.at).getTime() + if (ageMs > MAX_AGE_MS) { + console.error(`Lebenszeichen ${Math.round(ageMs / 1000)} s alt`) + process.exit(1) + } + if (beat.status !== 'up') { + console.error(`Zustand ${beat.status}`) + process.exit(1) + } + process.exit(0) +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} finally { + await client.end().catch(() => undefined) +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..44ec399 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,29 @@ +{ + "name": "@afis/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "tsc -p tsconfig.build.json", + "lint": "eslint src --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@afis/db": "workspace:*", + "discord.js": "14.27.0", + "pg": "8.13.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.0", + "@types/node": "^22.10.0", + "@types/pg": "^8.11.10", + "eslint": "9.39.5", + "tsx": "4.20.6", + "typescript": "5.9.3", + "typescript-eslint": "^8.20.0", + "vitest": "4.1.11" + } +} diff --git a/worker/src/env.ts b/worker/src/env.ts new file mode 100644 index 0000000..8e0e91f --- /dev/null +++ b/worker/src/env.ts @@ -0,0 +1,31 @@ +/** + * Der Worker teilt sich die .env mit der Web-App, braucht aber nur einen Teil + * davon. Fehlt etwas, stirbt der Prozess sofort mit klarer Meldung, damit der + * Fehler in Dockge am roten Container sichtbar wird und nicht im Lograuschen. + */ +export type WorkerEnv = { + databaseUrl: string + discordBotToken: string + discordGuildId: string + /** Voll-Abgleich als Sicherheitsnetz, siehe docs/02-architektur.md. */ + fullSyncIntervalMs: number + heartbeatIntervalMs: number + adminChannelId?: string +} + +function required(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`Konfiguration unvollständig: ${name} fehlt`) + return value +} + +export function loadEnv(): WorkerEnv { + return { + databaseUrl: required('DATABASE_URL'), + discordBotToken: required('DISCORD_BOT_TOKEN'), + discordGuildId: required('DISCORD_GUILD_ID'), + fullSyncIntervalMs: Number(process.env.WORKER_FULL_SYNC_MS ?? 5 * 60_000), + heartbeatIntervalMs: Number(process.env.WORKER_HEARTBEAT_MS ?? 30_000), + adminChannelId: process.env.DISCORD_ADMIN_CHANNEL_ID, + } +} diff --git a/worker/src/heartbeat.ts b/worker/src/heartbeat.ts new file mode 100644 index 0000000..fda260c --- /dev/null +++ b/worker/src/heartbeat.ts @@ -0,0 +1,20 @@ +import type { PrismaClient } from '@afis/db' + +export type HeartbeatStatus = 'up' | 'degraded' | 'down' + +/** + * Der Worker schreibt sein Lebenszeichen in die Datenbank, damit /api/health im + * Web-Container den Gateway-Zustand kennt, ohne selbst mit Discord zu sprechen. + * Siehe docs/11-betrieb.md. + */ +export async function writeHeartbeat( + db: PrismaClient, + status: HeartbeatStatus, + detail?: string, +): Promise { + await db.serviceHeartbeat.upsert({ + where: { service: 'worker' }, + create: { service: 'worker', status, detail: detail ?? null }, + update: { status, detail: detail ?? null, at: new Date() }, + }) +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..720223b --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,72 @@ +import { Client, GatewayIntentBits, Events } from 'discord.js' +import { createPrismaClient } from '@afis/db' +import { loadEnv } from './env.js' +import { writeHeartbeat } from './heartbeat.js' + +/** + * Rollen-Abgleich-Worker. Haengt am Discord-Gateway und haelt die + * Guild-Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei + * Discord anfragen muss. Siehe docs/02-architektur.md. + * + * Phase 0 baut das Geruest: Verbindung, Lebenszeichen, sauberes Herunterfahren. + * Der eigentliche Abgleich (Ereignisse, Voll-Abgleich, pg_notify) kommt in Phase 1. + */ +async function main(): Promise { + const env = loadEnv() + const db = createPrismaClient(env.databaseUrl) + + const client = new Client({ + // Server Members Intent muss im Developer Portal aktiviert sein, sonst + // liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md. + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], + }) + + let gatewayReady = false + + client.once(Events.ClientReady, (ready) => { + gatewayReady = true + console.log(`[worker] am Gateway als ${ready.user.tag}`) + }) + + client.on(Events.ShardDisconnect, (_event, shardId) => { + gatewayReady = false + console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`) + }) + + client.on(Events.ShardResume, () => { + gatewayReady = true + console.log('[worker] Verbindung wiederhergestellt') + }) + + client.on(Events.Error, (error) => { + console.error('[worker] Gateway-Fehler', error) + }) + + const heartbeat = setInterval(() => { + void writeHeartbeat( + db, + gatewayReady ? 'up' : 'degraded', + gatewayReady ? undefined : 'Gateway nicht verbunden', + ).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error)) + }, env.heartbeatIntervalMs) + + async function shutdown(signal: string): Promise { + console.log(`[worker] ${signal}, fahre herunter`) + clearInterval(heartbeat) + await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined) + await client.destroy() + await db.$disconnect() + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) + + await writeHeartbeat(db, 'degraded', 'Start läuft') + await client.login(env.discordBotToken) +} + +main().catch((error: unknown) => { + console.error('[worker] Start fehlgeschlagen', error) + process.exit(1) +}) diff --git a/worker/tsconfig.build.json b/worker/tsconfig.build.json new file mode 100644 index 0000000..c4e7589 --- /dev/null +++ b/worker/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "sourceMap": true + } +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..8a81dd4 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}