// Claude Chat API — Express + WebSocket Server // // Exponiert die Claude Bridge als HTTP-REST-API und WebSocket fuer Streaming. // Port: 3100 (ENV: PORT) import express from 'express'; import cors from 'cors'; import { createServer } from 'node:http'; import { WebSocketServer } from 'ws'; import { authMiddleware, verifyWsToken } from './auth.js'; import { sendMessage, stopAll, getStatus, listModels, setModel, setMode, createSession, listSessions, getSession, } from './bridge.js'; const PORT = parseInt(process.env.PORT || '3100', 10); const app = express(); const server = createServer(app); // ============ Middleware ============ app.use(cors()); app.use(express.json()); // ============ REST Endpoints ============ // Status — oeffentlich fuer Health-Checks (optional: mit Auth schuetzen) app.get('/api/status', authMiddleware, (req, res) => { const status = getStatus(); res.json(status); }); // Modelle auflisten app.get('/api/models', authMiddleware, (req, res) => { res.json(listModels()); }); // Modell setzen app.post('/api/model', authMiddleware, (req, res) => { const { model } = req.body; if (!model) { return res.status(400).json({ error: 'Feld "model" fehlt' }); } try { const result = setModel(model); res.json(result); } catch (err) { res.status(400).json({ error: err.message }); } }); // Modus setzen app.post('/api/mode', authMiddleware, (req, res) => { const { mode } = req.body; if (!mode) { return res.status(400).json({ error: 'Feld "mode" fehlt' }); } try { const result = setMode(mode); res.json(result); } catch (err) { res.status(400).json({ error: err.message }); } }); // Sessions auflisten app.get('/api/sessions', authMiddleware, (req, res) => { res.json(listSessions()); }); // Neue Session erstellen app.post('/api/sessions', authMiddleware, (req, res) => { const { title } = req.body || {}; const session = createSession(title); res.status(201).json(session); }); // Einzelne Session abrufen app.get('/api/sessions/:id', authMiddleware, (req, res) => { const session = getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } res.json(session); }); // Stopp-Endpoint (auch ueber REST erreichbar) app.post('/api/stop', authMiddleware, (req, res) => { const stopped = stopAll(); res.json({ stopped }); }); // ============ WebSocket ============ const wss = new WebSocketServer({ noServer: true }); // Upgrade-Handler: Token aus Query-Parameter pruefen server.on('upgrade', (request, socket, head) => { // Nur /ws akzeptieren const url = new URL(request.url, `http://${request.headers.host}`); if (url.pathname !== '/ws') { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } // Token pruefen if (!verifyWsToken(request.url)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); }); wss.on('connection', (ws) => { console.log('[ws] Client verbunden'); ws.on('message', (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch { ws.send(JSON.stringify({ type: 'error', message: 'Ungueltiges JSON' })); return; } switch (msg.type) { case 'message': { if (!msg.content) { ws.send(JSON.stringify({ type: 'error', message: 'Feld "content" fehlt' })); return; } const emitter = sendMessage(msg.content, msg.model, msg.mode, msg.sessionId); // Alle Events an den WebSocket-Client weiterleiten emitter.on('text', (payload) => { safeSend(ws, { type: 'text', content: payload.content }); }); emitter.on('tool_call', (payload) => { safeSend(ws, { type: 'tool_call', name: payload.name, args: payload.args, id: payload.id, summary: payload.summary, }); }); emitter.on('tool_result', (payload) => { safeSend(ws, { type: 'tool_result', id: payload.id, success: payload.success, }); }); emitter.on('agent_started', (payload) => { safeSend(ws, { type: 'agent_started', id: payload.id, name: payload.name || payload.type, }); }); emitter.on('agent_stopped', (payload) => { safeSend(ws, { type: 'agent_stopped', id: payload.id, success: payload.success, }); }); emitter.on('result', (payload) => { safeSend(ws, { type: 'result', content: payload.content, cost: payload.cost, tokens: payload.tokens, session_id: payload.session_id, duration_ms: payload.duration_ms, model: payload.model, }); }); emitter.on('error', (payload) => { safeSend(ws, { type: 'error', message: payload.message }); }); break; } case 'stop': { const stopped = stopAll(); safeSend(ws, { type: 'stopped', success: stopped }); break; } default: safeSend(ws, { type: 'error', message: `Unbekannter Nachrichtentyp: ${msg.type}` }); } }); ws.on('close', () => { console.log('[ws] Client getrennt'); }); ws.on('error', (err) => { console.error('[ws] Fehler:', err.message); }); // Willkommens-Nachricht safeSend(ws, { type: 'connected', status: getStatus(), models: listModels(), }); }); /** Sicher an WebSocket senden (nur wenn offen) */ function safeSend(ws, data) { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(data)); } } // ============ Server starten ============ server.listen(PORT, () => { console.log(`[claude-chat-api] Server laeuft auf Port ${PORT}`); console.log(` REST: http://localhost:${PORT}/api/status`); console.log(` WebSocket: ws://localhost:${PORT}/ws?token=`); }); // Graceful Shutdown process.on('SIGTERM', () => { console.log('[server] SIGTERM empfangen, fahre herunter...'); stopAll(); wss.close(); server.close(() => process.exit(0)); }); process.on('SIGINT', () => { console.log('[server] SIGINT empfangen, fahre herunter...'); stopAll(); wss.close(); server.close(() => process.exit(0)); });