import express, { type Request, type Response, type NextFunction } from 'express'; import multer from 'multer'; import { randomUUID } from 'node:crypto'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { runClaude, type ClaudeFieldSpec, type ClaudeFormResponse, } from './claudeRunner.ts'; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? '127.0.0.1'; const IS_PROD = process.env.NODE_ENV === 'production'; const DIST_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', 'dist' ); const MAX_FILE_BYTES = 20 * 1024 * 1024; const MAX_SOURCES = 10; const ALLOWED_MIME = new Set([ 'application/pdf', 'image/png', 'image/jpeg', 'image/webp', ]); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_FILE_BYTES, files: MAX_SOURCES + 1 }, fileFilter: (_req, file, cb) => { if (!ALLOWED_MIME.has(file.mimetype)) { cb(new Error(`Unsupported mime: ${file.mimetype}`)); return; } cb(null, true); }, }); // Langläufer-Jobs: claude -p dauert 30–120s+. Würde die HTTP-Verbindung so // lange offen gehalten, kappt jeder Reverse-Proxy (oder ein kurzer Netz- // Hänger) sie und der Browser bekommt nur "Failed to fetch". Stattdessen // kehrt POST sofort mit einer Job-ID zurück, das Frontend pollt per GET. type Job = | { status: 'pending'; createdAt: number } | { status: 'done'; createdAt: number; result: ClaudeFormResponse } | { status: 'error'; createdAt: number; error: string }; const jobs = new Map(); const JOB_TTL_MS = 15 * 60 * 1000; // Abgelaufene Jobs regelmäßig aufräumen, damit die Map nicht wächst. setInterval(() => { const now = Date.now(); for (const [id, job] of jobs) { if (now - job.createdAt > JOB_TTL_MS) jobs.delete(id); } }, 60 * 1000).unref(); const app = express(); app.post( '/api/process', upload.fields([ { name: 'form', maxCount: 1 }, { name: 'sources', maxCount: MAX_SOURCES }, ]), async (req: Request, res: Response, next: NextFunction) => { const files = req.files as Record | undefined; const formFile = files?.form?.[0]; const sourceFiles = files?.sources ?? []; const sourceText = typeof req.body?.sourceText === 'string' ? req.body.sourceText.trim() : ''; if (!formFile) { res.status(400).json({ error: 'form wird benötigt.' }); return; } if (sourceFiles.length === 0 && sourceText.length === 0) { res .status(400) .json({ error: 'Mindestens eine Quelldatei oder sourceText nötig.' }); return; } let fieldSpecs: ClaudeFieldSpec[]; try { const raw = req.body?.fields; if (typeof raw !== 'string') throw new Error('fields fehlt'); fieldSpecs = JSON.parse(raw); if (!Array.isArray(fieldSpecs) || fieldSpecs.length === 0) { throw new Error('fields muss ein nicht-leeres Array sein'); } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); res.status(400).json({ error: `fields-Parameter ungültig: ${msg}` }); return; } const requestId = randomUUID(); const tempDir = path.join(os.tmpdir(), `rentenv-${requestId}`); const formName = filenameForMime('form', formFile.mimetype); // Quelldateien schreiben — Fehler hier sind echte 5xx (kein claude-Lauf). const sourceNames: string[] = []; try { await mkdir(tempDir, { recursive: true }); await writeFile(path.join(tempDir, formName), formFile.buffer); for (let i = 0; i < sourceFiles.length; i++) { const f = sourceFiles[i]; const name = filenameForMime(`source_${i + 1}`, f.mimetype); await writeFile(path.join(tempDir, name), f.buffer); sourceNames.push(name); } if (sourceText.length > 0) { await writeFile(path.join(tempDir, 'source_text.txt'), sourceText); } } catch (e: unknown) { if (existsSync(tempDir)) { rm(tempDir, { recursive: true, force: true }).catch(() => {}); } next(e); return; } // Job registrieren und SOFORT antworten — claude läuft im Hintergrund. jobs.set(requestId, { status: 'pending', createdAt: Date.now() }); res.status(202).json({ jobId: requestId }); runClaude(tempDir, { formFilename: formName, sourceFilenames: sourceNames, hasSourceText: sourceText.length > 0, fields: fieldSpecs, }) .then((result) => { jobs.set(requestId, { status: 'done', createdAt: Date.now(), result, }); }) .catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); console.error('[claude job error]', requestId, msg); jobs.set(requestId, { status: 'error', createdAt: Date.now(), error: msg, }); }) .finally(() => { if (existsSync(tempDir)) { rm(tempDir, { recursive: true, force: true }).catch(() => {}); } }); } ); // Status/Ergebnis eines Jobs. Bewusst kurze Requests -> proxy-sicher. app.get('/api/process/:jobId', (req: Request, res: Response) => { const job = jobs.get(req.params.jobId); if (!job) { res .status(404) .json({ error: 'Job nicht gefunden (abgelaufen oder Server neugestartet).' }); return; } if (job.status === 'pending') { res.json({ status: 'pending' }); return; } if (job.status === 'error') { res.json({ status: 'error', error: 'Claude CLI failed', details: job.error }); return; } res.json({ status: 'done', result: job.result }); }); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { const msg = err instanceof Error ? err.message : String(err); console.error('[server error]', msg); res.status(500).json({ error: 'Serverfehler beim Vorbereiten', details: msg }); }); app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); // Production: statische Assets + SPA-Fallback auf index.html. if (IS_PROD && existsSync(DIST_DIR)) { app.use(express.static(DIST_DIR)); app.get(/^\/(?!api\/).*/, (_req, res) => { res.sendFile(path.join(DIST_DIR, 'index.html')); }); } app.listen(PORT, HOST, () => { console.log(`[server] listening on http://${HOST}:${PORT}`); }); function filenameForMime(base: string, mime: string): string { switch (mime) { case 'application/pdf': return `${base}.pdf`; case 'image/png': return `${base}.png`; case 'image/jpeg': return `${base}.jpg`; case 'image/webp': return `${base}.webp`; default: return `${base}.bin`; } } process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason); });