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
227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
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<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);
|
||
});
|