Rentenversicherer/server/index.ts
Claude a3b36d5c74
fix: Job-Polling statt Langläufer-Request gegen "Failed to fetch"
POST /api/process hielt die HTTP-Verbindung 30–120s+ offen, während
claude -p lief. Jeder Reverse-Proxy (und kurze Netz-Hänger) kappt so
eine Verbindung, der Browser sieht nur "Failed to fetch" — ununterscheidbar
von einem echten Claude-Fehler.

- Server: POST registriert einen Job und antwortet sofort mit 202 {jobId};
  claude läuft im Hintergrund, Ergebnis/Fehler landen im Job-Store
  (TTL 15min, periodische Bereinigung). Neuer GET /api/process/:jobId
  liefert pending/done/error in kurzen, proxy-sicheren Requests.
- Frontend: pollt den Job alle 2s; ein transienter Netzfehler beim Pollen
  wird erneut versucht statt die ganze Analyse abzubrechen. Echte
  Claude-Fehler werden jetzt mit Klartext angezeigt statt "Failed to fetch".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7fRh8N5kQsicT7q4gSnua
2026-06-19 10:15:36 +00:00

227 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 30120s+. 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<string, Job>();
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<string, Express.Multer.File[]> | 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);
});