Files
gravity_web/server/index.js

340 lines
11 KiB
JavaScript

/**
* 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<WebSocket, {activeSessionId: string|null}>} */
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);
});
});