/** * 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 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); } } } // ─── 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)); }); // Graceful shutdown process.on('SIGINT', async () => { console.log('\n[Server] 종료 중...'); await sessionManager.cleanup(); server.close(() => { console.log('[Server] 종료 완료'); process.exit(0); }); });