From c98f3e2b99dff1620c0f04590bbbe1a0e43f3b15 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Sat, 14 Feb 2026 16:40:40 -0600 Subject: [PATCH] refactor: Use dedicated WebSocket server for torch sync - Add torch WebSocket server on port 4106 - Remove HTTP polling, use WebSocket for instant sync - Torch state changes broadcast immediately to all clients - Auto-reconnect on disconnect - Add port 4106 to kill-ports script --- frontend/src/config/endpoints.ts | 3 + frontend/src/services/torch.ts | 276 ++++++++++++------------------- package.json | 2 +- server/config.ts | 1 + server/routes/index.ts | 7 - server/routes/torch.ts | 159 ------------------ server/services/torch-server.ts | 192 +++++++++++++++++++++ server/terminal.ts | 3 + 8 files changed, 309 insertions(+), 334 deletions(-) delete mode 100644 server/routes/torch.ts create mode 100644 server/services/torch-server.ts diff --git a/frontend/src/config/endpoints.ts b/frontend/src/config/endpoints.ts index 6afcf58..aff28e9 100644 --- a/frontend/src/config/endpoints.ts +++ b/frontend/src/config/endpoints.ts @@ -50,6 +50,9 @@ export const endpoints = { // WebMCP HTTP API (for token requests) webmcpHttp: buildHttpUrl('/mcp', 4102), + // Torch WebSocket (multi-browser sync) + torch: buildWsUrl('/ws/torch', 4106), + // API base URL (Vite proxy handles /api in dev) api: '/api' } diff --git a/frontend/src/services/torch.ts b/frontend/src/services/torch.ts index 47cef9e..546fad8 100644 --- a/frontend/src/services/torch.ts +++ b/frontend/src/services/torch.ts @@ -2,205 +2,145 @@ import { useTorchStore } from '../stores/torch' import { getWebMCP, autoConnect } from './webmcp' import { endpoints } from '../config/endpoints' -const API_BASE = endpoints.api - -let pollingInterval: number | null = null +let torchWs: WebSocket | null = null let clientId: string | null = null +let reconnectTimeout: number | null = null /** - * Register this browser as a torch client + * Connect to torch WebSocket server */ -export async function registerClient(): Promise { - try { - const res = await fetch(`${API_BASE}/torch/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ +function connectToTorchServer(): Promise { + return new Promise((resolve, reject) => { + if (torchWs?.readyState === WebSocket.OPEN) { + resolve() + return + } + + console.log('[Torch] Connecting to server...') + torchWs = new WebSocket(endpoints.torch) + + torchWs.onopen = () => { + console.log('[Torch] Connected to server') + // Register this client + torchWs?.send(JSON.stringify({ + type: 'register', userAgent: navigator.userAgent, hostname: window.location.hostname - }) - }) - - if (!res.ok) { - console.error('[Torch] Failed to register:', res.status) - return null + })) + resolve() } - const data = await res.json() - clientId = data.id - - const torchStore = useTorchStore() - torchStore.setClientId(data.id) - - console.log(`[Torch] Registered as ${data.id}, hasTorch: ${data.hasTorch}`) - - // If we have the torch, connect to MCP - if (data.hasTorch) { - torchStore.setTorchState(data.id) - await connectToMCP() + torchWs.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + handleMessage(data) + } catch (e) { + console.error('[Torch] Invalid message:', e) + } } - return data.id - } catch (e) { - console.error('[Torch] Error registering:', e) - return null - } + torchWs.onclose = () => { + console.log('[Torch] Disconnected from server') + torchWs = null + + // Reconnect after delay + if (!reconnectTimeout) { + reconnectTimeout = window.setTimeout(() => { + reconnectTimeout = null + connectToTorchServer() + }, 2000) + } + } + + torchWs.onerror = (e) => { + console.error('[Torch] WebSocket error:', e) + reject(e) + } + }) } /** - * Unregister this browser + * Handle messages from torch server */ -export async function unregisterClient(): Promise { - if (!clientId) return +async function handleMessage(data: any) { + const torchStore = useTorchStore() - try { - await fetch(`${API_BASE}/torch/client/${clientId}`, { - method: 'DELETE' - }) - console.log('[Torch] Unregistered') - } catch (e) { - console.error('[Torch] Error unregistering:', e) + switch (data.type) { + case 'registered': { + clientId = data.id + torchStore.setClientId(data.id) + console.log(`[Torch] Registered as ${data.id}, hasTorch: ${data.hasTorch}`) + + if (data.hasTorch) { + torchStore.setTorchState(data.id) + await connectToMCP() + } + break + } + + case 'torch-update': { + const hadTorch = torchStore.hasTorch + const hasTorchNow = data.holderId === clientId + + torchStore.setClients(data.clients) + torchStore.setTorchState(data.holderId) + + if (hadTorch && !hasTorchNow) { + console.log('[Torch] Lost torch, disconnecting from MCP') + disconnectFromMCP() + } else if (!hadTorch && hasTorchNow) { + console.log('[Torch] Got torch, connecting to MCP') + await connectToMCP() + } + break + } + + case 'granted': { + console.log('[Torch] Torch granted!') + torchStore.setRequesting(false) + break + } + + case 'released': { + console.log('[Torch] Torch released') + break + } } - - clientId = null } /** * Request the torch */ export async function requestTorch(): Promise { - if (!clientId) { - console.error('[Torch] Not registered') + if (!torchWs || torchWs.readyState !== WebSocket.OPEN) { + console.error('[Torch] Not connected to server') return false } const torchStore = useTorchStore() torchStore.setRequesting(true) - try { - const res = await fetch(`${API_BASE}/torch/request`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId }) - }) - - if (!res.ok) { - console.error('[Torch] Failed to request:', res.status) - torchStore.setRequesting(false) - return false - } - - const data = await res.json() - console.log('[Torch] Torch granted!') - - // Update state and connect to MCP - torchStore.setTorchState(clientId) - torchStore.setRequesting(false) - await connectToMCP() - - return true - } catch (e) { - console.error('[Torch] Error requesting:', e) - torchStore.setRequesting(false) - return false - } + torchWs.send(JSON.stringify({ type: 'request' })) + return true } /** * Release the torch */ export async function releaseTorch(): Promise { - if (!clientId) { - console.error('[Torch] Not registered') + if (!torchWs || torchWs.readyState !== WebSocket.OPEN) { + console.error('[Torch] Not connected to server') return false } const torchStore = useTorchStore() - if (!torchStore.hasTorch) { console.warn('[Torch] Cannot release - do not have torch') return false } - try { - const res = await fetch(`${API_BASE}/torch/release`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId }) - }) - - if (!res.ok) { - console.error('[Torch] Failed to release:', res.status) - return false - } - - console.log('[Torch] Torch released') - - // Update state and disconnect from MCP - torchStore.setTorchState(null) - disconnectFromMCP() - - return true - } catch (e) { - console.error('[Torch] Error releasing:', e) - return false - } -} - -/** - * Fetch current torch state from server - */ -export async function fetchTorchState(): Promise { - try { - const res = await fetch(`${API_BASE}/torch`) - if (!res.ok) return - - const data = await res.json() - const torchStore = useTorchStore() - - torchStore.setClients(data.clients) - - // Check if torch holder changed - const hadTorch = torchStore.hasTorch - const hasTorchNow = data.holderId === clientId - - if (hadTorch && !hasTorchNow) { - // We lost the torch - disconnect - console.log('[Torch] Lost torch, disconnecting from MCP') - torchStore.setTorchState(data.holderId) - disconnectFromMCP() - } else if (!hadTorch && hasTorchNow) { - // We got the torch - connect - console.log('[Torch] Got torch, connecting to MCP') - torchStore.setTorchState(data.holderId) - await connectToMCP() - } else { - torchStore.setTorchState(data.holderId) - } - } catch (e) { - // Silently ignore polling errors - } -} - -/** - * Start polling for torch state changes - */ -export function startTorchPolling(intervalMs: number = 2000) { - if (pollingInterval) return - - console.log('[Torch] Starting polling...') - pollingInterval = window.setInterval(fetchTorchState, intervalMs) -} - -/** - * Stop polling - */ -export function stopTorchPolling() { - if (pollingInterval) { - window.clearInterval(pollingInterval) - pollingInterval = null - console.log('[Torch] Polling stopped') - } + torchWs.send(JSON.stringify({ type: 'release' })) + return true } /** @@ -244,26 +184,28 @@ function disconnectFromMCP(): void { * Initialize torch system */ export async function initTorch(): Promise { - // Register this client - await registerClient() - - // Start polling for state changes - startTorchPolling() + await connectToTorchServer() // Cleanup on page unload window.addEventListener('beforeunload', () => { - stopTorchPolling() - // Note: unregisterClient is async, may not complete before unload - // Server should handle stale clients via timeout + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + } + torchWs?.close() }) } /** * Cleanup torch system */ -export async function destroyTorch(): Promise { - stopTorchPolling() - await unregisterClient() +export function destroyTorch(): void { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + torchWs?.close() + torchWs = null + clientId = null const torchStore = useTorchStore() torchStore.reset() diff --git a/package.json b/package.json index 8f20cef..f556911 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "Dynamic canvas for Claude Code interaction", "scripts": { - "kill-ports": "node -e \"const {execSync} = require('child_process'); [4101,4102,4103,4105].forEach(p => { try { const pid = execSync('netstat -ano | findstr :' + p + ' | findstr LISTENING', {encoding:'utf8'}).split(/\\s+/).pop().trim(); if(pid) execSync('taskkill /PID ' + pid + ' /F', {stdio:'ignore'}); } catch(e){} }); console.log('Ports cleared');\"", + "kill-ports": "node -e \"const {execSync} = require('child_process'); [4101,4102,4103,4105,4106].forEach(p => { try { const pid = execSync('netstat -ano | findstr :' + p + ' | findstr LISTENING', {encoding:'utf8'}).split(/\\s+/).pop().trim(); if(pid) execSync('taskkill /PID ' + pid + ' /F', {stdio:'ignore'}); } catch(e){} }); console.log('Ports cleared');\"", "start": "bun run kill-ports && concurrently -n api,terminal,frontend -c blue,yellow,green \"cd server && bun --watch run index.ts\" \"cd server && bun run terminal.ts\" \"cd frontend && bun run dev --host\"", "start:api": "cd server && bun --watch run index.ts", "start:terminal": "cd server && bun run terminal.ts", diff --git a/server/config.ts b/server/config.ts index 1abd7bb..964b17d 100644 --- a/server/config.ts +++ b/server/config.ts @@ -2,6 +2,7 @@ export const PORT_HTTP = 4101 export const PORT_TERMINAL = 4103 export const PORT_GIT = 4105 +export const PORT_TORCH = 4106 // Terminal configuration export const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '') diff --git a/server/routes/index.ts b/server/routes/index.ts index bb8f32b..5fec066 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -11,7 +11,6 @@ import { handleWhisperRoutes } from './whisper' import { handleRecordingsRoutes } from './recordings' import { handleClaudeStatus } from './claude-status' import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git' -import { handleTorch } from './torch' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -51,12 +50,6 @@ export async function handleRequest(req: Request): Promise { if (res) return res } - // Torch (multi-browser MCP control) - if (path.startsWith('/api/torch')) { - const res = await handleTorch(req, url) - if (res) return res - } - // Claude Code status (thinking/idle) if (path === '/api/claude-status') { const res = await handleClaudeStatus(req) diff --git a/server/routes/torch.ts b/server/routes/torch.ts deleted file mode 100644 index 6fa7e60..0000000 --- a/server/routes/torch.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { jsonResponse, errorResponse } from '../utils/cors' - -// Torch state - which client has control -interface TorchClient { - id: string - userAgent: string - hostname: string - connectedAt: Date -} - -let torchState: { - holderId: string | null - clients: Map -} = { - holderId: null, - clients: new Map() -} - -// Generate unique client ID -let clientIdCounter = 1 -function generateClientId(): string { - return `client_${clientIdCounter++}_${Date.now().toString(36)}` -} - -// GET /api/torch - Get current torch state -export async function handleTorchGet() { - const clients = Array.from(torchState.clients.values()).map(c => ({ - ...c, - connectedAt: c.connectedAt.toISOString(), - hasTorch: c.id === torchState.holderId - })) - - return jsonResponse({ - holderId: torchState.holderId, - clients - }) -} - -// POST /api/torch/register - Register a new client -export async function handleTorchRegister(req: Request) { - const body = await req.json() - const { userAgent, hostname } = body - - const id = generateClientId() - const client: TorchClient = { - id, - userAgent: userAgent || 'Unknown', - hostname: hostname || 'Unknown', - connectedAt: new Date() - } - - torchState.clients.set(id, client) - - // Auto-assign torch if no one has it - const shouldHaveTorch = !torchState.holderId - if (shouldHaveTorch) { - torchState.holderId = id - } - - console.log(`[Torch] Client registered: ${id} (torch: ${shouldHaveTorch})`) - - return jsonResponse({ - id, - hasTorch: shouldHaveTorch - }) -} - -// POST /api/torch/request - Request the torch -export async function handleTorchRequest(req: Request) { - const body = await req.json() - const { clientId } = body - - if (!clientId || !torchState.clients.has(clientId)) { - return errorResponse('Invalid client ID', 400) - } - - const previousHolder = torchState.holderId - torchState.holderId = clientId - - console.log(`[Torch] Torch transferred: ${previousHolder} -> ${clientId}`) - - return jsonResponse({ - success: true, - previousHolder - }) -} - -// POST /api/torch/release - Release the torch -export async function handleTorchRelease(req: Request) { - const body = await req.json() - const { clientId } = body - - if (torchState.holderId !== clientId) { - return errorResponse('You do not have the torch', 400) - } - - torchState.holderId = null - console.log(`[Torch] Torch released by: ${clientId}`) - - return jsonResponse({ success: true }) -} - -// DELETE /api/torch/client/:id - Unregister a client -export async function handleTorchUnregister(clientId: string) { - if (!torchState.clients.has(clientId)) { - return errorResponse('Client not found', 404) - } - - torchState.clients.delete(clientId) - - // If this client had the torch, release it - if (torchState.holderId === clientId) { - torchState.holderId = null - - // Auto-assign to next client if any - const nextClient = torchState.clients.keys().next().value - if (nextClient) { - torchState.holderId = nextClient - console.log(`[Torch] Auto-assigned to: ${nextClient}`) - } - } - - console.log(`[Torch] Client unregistered: ${clientId}`) - - return jsonResponse({ success: true }) -} - -// Main handler -export async function handleTorch(req: Request, url: URL) { - const path = url.pathname - - // GET /api/torch - Get state - if (req.method === 'GET' && path === '/api/torch') { - return handleTorchGet() - } - - // POST /api/torch/register - Register client - if (req.method === 'POST' && path === '/api/torch/register') { - return handleTorchRegister(req) - } - - // POST /api/torch/request - Request torch - if (req.method === 'POST' && path === '/api/torch/request') { - return handleTorchRequest(req) - } - - // POST /api/torch/release - Release torch - if (req.method === 'POST' && path === '/api/torch/release') { - return handleTorchRelease(req) - } - - // DELETE /api/torch/client/:id - Unregister - const unregisterMatch = path.match(/^\/api\/torch\/client\/(.+)$/) - if (req.method === 'DELETE' && unregisterMatch) { - return handleTorchUnregister(unregisterMatch[1]) - } - - return null -} diff --git a/server/services/torch-server.ts b/server/services/torch-server.ts new file mode 100644 index 0000000..cf98370 --- /dev/null +++ b/server/services/torch-server.ts @@ -0,0 +1,192 @@ +/** + * Torch Server + * WebSocket server for multi-browser MCP control synchronization. + * Only one browser can have the "torch" and connect to MCP at a time. + */ + +import { PORT_TORCH } from '../config' + +// Client metadata +interface TorchClient { + ws: any + id: string + userAgent: string + hostname: string + connectedAt: Date +} + +// Connected clients +const clients = new Map() +let clientIdCounter = 1 + +// Torch state - who has control +let torchHolderId: string | null = null + +function generateClientId(): string { + return `client_${clientIdCounter++}_${Date.now().toString(36)}` +} + +function broadcast(message: string) { + for (const [ws] of clients) { + try { + ws.send(message) + } catch { + clients.delete(ws) + } + } +} + +function broadcastTorchState() { + const clientList = Array.from(clients.values()).map(c => ({ + id: c.id, + userAgent: c.userAgent, + hostname: c.hostname, + connectedAt: c.connectedAt.toISOString(), + hasTorch: c.id === torchHolderId + })) + + const message = JSON.stringify({ + type: 'torch-update', + holderId: torchHolderId, + clients: clientList + }) + + broadcast(message) +} + +function handleMessage(ws: any, data: any) { + const client = clients.get(ws) + if (!client) return + + switch (data.type) { + case 'register': { + client.userAgent = data.userAgent || 'Unknown' + client.hostname = data.hostname || 'Unknown' + + // Auto-assign torch if no one has it + const shouldHaveTorch = !torchHolderId + if (shouldHaveTorch) { + torchHolderId = client.id + } + + ws.send(JSON.stringify({ + type: 'registered', + id: client.id, + hasTorch: shouldHaveTorch + })) + + console.log(`[Torch] Registered: ${client.id} (torch: ${shouldHaveTorch})`) + broadcastTorchState() + break + } + + case 'request': { + const previousHolder = torchHolderId + torchHolderId = client.id + + ws.send(JSON.stringify({ type: 'granted' })) + console.log(`[Torch] Transferred: ${previousHolder} → ${client.id}`) + broadcastTorchState() + break + } + + case 'release': { + if (torchHolderId === client.id) { + torchHolderId = null + ws.send(JSON.stringify({ type: 'released' })) + console.log(`[Torch] Released by: ${client.id}`) + broadcastTorchState() + } + break + } + } +} + +export function startTorchServer() { + const server = Bun.serve({ + port: PORT_TORCH, + fetch(req, server) { + const url = new URL(req.url) + + // CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + } + + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }) + } + + // Health check + if (url.pathname === '/health') { + return Response.json({ + status: 'ok', + clients: clients.size, + torchHolder: torchHolderId + }, { headers: corsHeaders }) + } + + // WebSocket upgrade + const upgrade = req.headers.get('upgrade') + if (upgrade?.toLowerCase() === 'websocket') { + const success = server.upgrade(req) + if (success) return undefined + return new Response('WebSocket upgrade failed', { status: 400 }) + } + + return new Response('Torch WebSocket Server', { status: 200 }) + }, + websocket: { + open(ws) { + const id = generateClientId() + clients.set(ws, { + ws, + id, + userAgent: 'Unknown', + hostname: 'Unknown', + connectedAt: new Date() + }) + console.log(`[Torch] Client connected: ${id} (${clients.size} total)`) + }, + message(ws, message) { + try { + const data = JSON.parse(message.toString()) + handleMessage(ws, data) + } catch (e) { + console.error('[Torch] Invalid message:', e) + } + }, + close(ws) { + const client = clients.get(ws) + if (client) { + console.log(`[Torch] Client disconnected: ${client.id}`) + + // If this client had the torch, release it + if (torchHolderId === client.id) { + torchHolderId = null + + // Auto-assign to next client + const nextClient = clients.values().next().value + if (nextClient && nextClient.ws !== ws) { + torchHolderId = nextClient.id + console.log(`[Torch] Auto-assigned to: ${nextClient.id}`) + } + } + + clients.delete(ws) + broadcastTorchState() + } + } + } + }) + + console.log(`[Torch] WebSocket server on port ${PORT_TORCH}`) + return server +} + +export function stopTorchServer() { + clients.clear() + torchHolderId = null +} diff --git a/server/terminal.ts b/server/terminal.ts index 1c87d26..2bad03c 100644 --- a/server/terminal.ts +++ b/server/terminal.ts @@ -7,6 +7,7 @@ import { startTerminalServer } from './services/terminal' import { startGitServer } from './services/git-watcher' +import { startTorchServer } from './services/torch-server' import { WORKING_DIR } from './config' console.log('') @@ -14,6 +15,7 @@ console.log('='.repeat(50)) console.log('Terminal Server (Independent Process)') console.log(` Terminal WebSocket: ws://localhost:4103`) console.log(` Git WebSocket: ws://localhost:4105`) +console.log(` Torch WebSocket: ws://localhost:4106`) console.log(` Working Dir: ${WORKING_DIR}`) console.log('') console.log('This process is stable and won\'t restart') @@ -23,3 +25,4 @@ console.log('') startTerminalServer() startGitServer() +startTorchServer()