485 lines
15 KiB
JavaScript
485 lines
15 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 { 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<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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 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 });
|
|
}
|
|
});
|
|
|
|
// Bridge WS 이벤트 → 프론트엔드 포워딩
|
|
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);
|
|
});
|
|
});
|