/** * Gravity Web Server — Express REST API + WebSocket * * Antigravity IDE 여러 인스턴스를 CDP로 연결하고 * 웹 브라우저에서 세션을 전환하며 채팅할 수 있게 합니다. */ const express = require('express'); const http = require('http'); const path = require('path'); const { WebSocketServer, WebSocket } = require('ws'); const SessionManager = require('./session-manager'); const { AutoDiscovery } = require('./auto-discover'); const BridgeClient = require('./bridge-client'); const PORT = process.env.PORT || 3300; const app = express(); const server = http.createServer(app); app.use(express.json()); app.use(express.static(path.join(__dirname, '..', 'public'))); const sessionManager = new SessionManager(); // ─── WebSocket Setup ──────────────────────────────────── const wss = new WebSocketServer({ server, path: '/ws' }); /** @type {Map} */ const wsClients = new Map(); wss.on('connection', (ws) => { console.log('[WS] 클라이언트 연결'); wsClients.set(ws, { activeSessionId: null }); ws.on('message', async (raw) => { try { const msg = JSON.parse(raw.toString()); await handleWsMessage(ws, msg); } catch (err) { ws.send(JSON.stringify({ type: 'error', message: err.message })); } }); ws.on('close', () => { const state = wsClients.get(ws); if (state?.activeSessionId) { stopSessionPolling(state.activeSessionId); // 스크린캠스트도 중지 const session = sessionManager.getSession(state.activeSessionId); if (session) session.client.stopScreencast(); } wsClients.delete(ws); console.log('[WS] 클라이언트 연결 해제'); }); // 초기 세션 목록 전송 ws.send(JSON.stringify({ type: 'sessions_list', sessions: sessionManager.listSessions(), })); }); /** * WebSocket 메시지 핸들러 */ async function handleWsMessage(ws, msg) { const state = wsClients.get(ws); switch (msg.type) { case 'switch_session': { // 기존 세션 폴링 중지 if (state.activeSessionId) { stopSessionPolling(state.activeSessionId); } const session = sessionManager.getSession(msg.sessionId); if (!session) { ws.send(JSON.stringify({ type: 'error', message: '세션을 찾을 수 없습니다' })); return; } state.activeSessionId = msg.sessionId; // 이 세션의 CDP 폴링 시작 startSessionPolling(session, ws); ws.send(JSON.stringify({ type: 'session_switched', sessionId: msg.sessionId, session: { id: session.id, name: session.name, status: session.status, title: session.title, }, })); break; } case 'send_message': { const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (!session) { ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' })); return; } const result = await session.client.sendMessage(msg.text); ws.send(JSON.stringify({ type: 'message_sent', sessionId: session.id, ...result, })); break; } case 'get_screenshot': { const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (!session) { ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' })); return; } const data = await session.client.captureScreenshot(); if (data) { ws.send(JSON.stringify({ type: 'screenshot', sessionId: session.id, data: data, })); } break; } // ─── Phase 2: Screencast & Remote Input ──────────── case 'start_screencast': { const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (!session) { ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' })); return; } // 스크린캠스트 프레임 콜백 등록 session.client.onScreencastFrame = (frame) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'screencast_frame', sessionId: session.id, data: frame.data, metadata: frame.metadata, })); } }; const ok = await session.client.startScreencast(msg.options || {}); ws.send(JSON.stringify({ type: 'screencast_started', sessionId: session.id, success: ok, })); break; } case 'stop_screencast': { const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (session) { await session.client.stopScreencast(); } ws.send(JSON.stringify({ type: 'screencast_stopped', sessionId: msg.sessionId || state.activeSessionId, })); break; } case 'input_event': { const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (session) { await session.client.dispatchInput(msg.event); } break; } case 'click_action': { // 채팅 탭에서 Proceed/Cancel 등 버튼 클릭 const session = sessionManager.getSession(msg.sessionId || state.activeSessionId); if (!session) { ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' })); return; } const { x, y } = msg; try { // mousePressed + mouseReleased = 클릭 await session.client.dispatchInput({ type: 'mouse', action: 'mousePressed', x, y, button: 'left', clickCount: 1, }); await session.client.dispatchInput({ type: 'mouse', action: 'mouseReleased', x, y, button: 'left', clickCount: 1, }); ws.send(JSON.stringify({ type: 'action_clicked', success: true, label: msg.label })); } catch (err) { ws.send(JSON.stringify({ type: 'error', message: `버튼 클릭 실패: ${err.message}` })); } break; } default: ws.send(JSON.stringify({ type: 'error', message: `알 수 없는 메시지 타입: ${msg.type}` })); } } /** * 특정 세션의 CDP 채팅 폴링을 시작하고 결과를 ws 클라이언트에 전송 */ function startSessionPolling(session, ws) { session.client.onChatUpdate = (messages) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'chat_update', sessionId: session.id, messages: messages, timestamp: Date.now(), })); } }; session.client.startPolling(1000); } /** * 세션 폴링 중지 */ function stopSessionPolling(sessionId) { const session = sessionManager.getSession(sessionId); if (session) { session.client.stopPolling(); session.client.onChatUpdate = null; } } /** * 세션 목록 변경을 모든 WS 클라이언트에 브로드캐스트 */ function broadcastSessions() { const payload = JSON.stringify({ type: 'sessions_list', sessions: sessionManager.listSessions(), }); for (const [ws] of wsClients) { if (ws.readyState === WebSocket.OPEN) { ws.send(payload); } } } // ─── Bridge API 프록시 ────────────────────────────────── const bridge = new BridgeClient(); app.get('/api/bridge/health', async (req, res) => { try { const health = await bridge.checkHealth(); res.json(health); } catch (e) { res.json({ available: false, error: e.message }); } }); app.get('/api/bridge/sessions', async (req, res) => { try { const data = await bridge.getSessions(); res.json(data); } catch (e) { res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message }); } }); app.get('/api/bridge/cascades', async (req, res) => { try { const data = await bridge.getCascades(); res.json(data); } catch (e) { res.status(502).json({ error: 'Bridge 연결 실패: ' + e.message }); } }); app.post('/api/bridge/send', async (req, res) => { try { const { message, sessionId } = req.body; const result = await bridge.sendMessage(message, sessionId); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); } }); app.post('/api/bridge/accept', async (req, res) => { try { const result = await bridge.acceptStep(); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); } }); app.post('/api/bridge/reject', async (req, res) => { try { const result = await bridge.rejectStep(); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); } }); app.post('/api/bridge/accept-terminal', async (req, res) => { try { const result = await bridge.acceptTerminal(); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/bridge/trajectory/:id', async (req, res) => { try { const data = await bridge.getTrajectory(req.params.id); res.json(data); } catch (e) { res.status(502).json({ error: e.message }); } }); app.post('/api/bridge/action', async (req, res) => { try { const { action } = req.body; const result = await bridge.sendAction(action); res.json(result); } catch (e) { res.status(502).json({ error: e.message }); } }); bridge.connectWs((msg) => { broadcastToAll({ type: 'bridge_event', ...msg }); }); // ─── CDP REST API (레거시) ────────────────────────────── app.get('/api/sessions', (req, res) => { res.json(sessionManager.listSessions()); }); app.post('/api/sessions', async (req, res) => { const { name, host = 'localhost', cdpPort = 9000 } = req.body; if (!name) { return res.status(400).json({ error: 'name is required' }); } const session = await sessionManager.addSession(name, host, cdpPort); broadcastSessions(); res.status(201).json({ id: session.id, name: session.name, host: session.host, cdpPort: session.cdpPort, status: session.status, title: session.title, error: session.error || null, }); }); app.delete('/api/sessions/:id', async (req, res) => { const removed = await sessionManager.removeSession(req.params.id); if (!removed) { return res.status(404).json({ error: 'Session not found' }); } broadcastSessions(); res.json({ success: true }); }); app.post('/api/sessions/:id/reconnect', async (req, res) => { const result = await sessionManager.reconnectSession(req.params.id); broadcastSessions(); res.json(result); }); app.get('/api/sessions/:id/status', (req, res) => { const session = sessionManager.getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session not found' }); } res.json({ id: session.id, status: session.status, title: session.title, }); }); // Health app.get('/api/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), sessions: sessionManager.listSessions().length, }); }); // SPA 폴백 app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); // ─── Startup ──────────────────────────────────────────── server.listen(PORT, () => { console.log('═'.repeat(50)); console.log(` Gravity Web 서버 시작`); console.log(` http://localhost:${PORT}`); console.log('═'.repeat(50)); // Auto Discovery 시작 const discovery = new AutoDiscovery({ host: 'localhost', intervalMs: 15000, onDiscovered: async (instance) => { // 이미 같은 포트의 세션이 있는지 확인 const existing = sessionManager.listSessions().find( s => s.host === 'localhost' && s.cdpPort === instance.port ); if (existing) return; // 자동 세션 생성 try { const session = await sessionManager.addSession( `Antigravity :${instance.port}`, 'localhost', instance.port ); console.log(`[AutoDiscovery] 자동 세션 생성: ${session.name} (${session.id})`); // 모든 WS 클라이언트에 세션 목록 업데이트 broadcastToAll({ type: 'sessions_list', sessions: sessionManager.listSessions(), }); // 자동 발견 알림 broadcastToAll({ type: 'auto_discovered', session: { id: session.id, name: session.name, host: 'localhost', port: instance.port, status: session.status, }, }); } catch (err) { console.error(`[AutoDiscovery] 세션 생성 실패:`, err.message); } }, onLost: (port) => { // 해당 포트의 세션 상태 업데이트 const sessions = sessionManager.listSessions(); const lost = sessions.find(s => s.cdpPort === port && s.host === 'localhost'); if (lost) { console.log(`[AutoDiscovery] 세션 소실: ${lost.name}`); broadcastToAll({ type: 'session_lost', sessionId: lost.id, port, }); } }, }); discovery.start(); }); /** * 모든 WS 클라이언트에 메시지 브로드캐스트 */ function broadcastToAll(data) { const json = JSON.stringify(data); for (const [ws] of wsClients) { if (ws.readyState === WebSocket.OPEN) { ws.send(json); } } } // Graceful shutdown process.on('SIGINT', async () => { console.log('\n[Server] 종료 중...'); await sessionManager.cleanup(); server.close(() => { console.log('[Server] 종료 완료'); process.exit(0); }); });