// Spatial Office server — static files + WebSocket + OpenClaw bridge // Run: node server.js // Serves built frontend from src/frontend/dist on port 18790 const http = require('http'); const fs = require('fs'); const path = require('path'); const { WebSocketServer, WebSocket } = require('ws'); const PORT = process.env.PORT || 18790; const DIST_DIR = path.join(__dirname, 'src', 'frontend', 'dist'); // ── MIME types ────────────────────────────────────────────── const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.map': 'application/json', }; function serveFile(res, filePath) { const ext = path.extname(filePath); const mime = MIME[ext] || 'application/octet-stream'; try { const content = fs.readFileSync(filePath); res.writeHead(200, { 'Content-Type': mime, 'Content-Length': content.length }); res.end(content); } catch (e) { res.writeHead(404); res.end('Not found'); } } // ── HTTP server (static file serving) ────────────────────── const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } // Strip /office prefix if present (nginx passes it through, but Vite builds with base=/office/) let cleanPath = url.pathname; // Vite-built files have /office/ prefix baked in, serve relative to dist if (cleanPath.startsWith('/office/')) { cleanPath = cleanPath.slice('/office'.length); } let filePath = path.join(DIST_DIR, cleanPath === '/' ? 'index.html' : cleanPath); // Try exact path first const ext = path.extname(filePath); if (ext) { serveFile(res, filePath); } else { // No extension — try as directory or fallback to index.html (SPA) try { fs.statSync(path.join(filePath, 'index.html')); serveFile(res, path.join(filePath, 'index.html')); } catch { serveFile(res, path.join(DIST_DIR, 'index.html')); } } }); // ── WebSocket server (agent state from OpenClaw bridge) ──── const wss = new WebSocketServer({ noServer: true }); let mockInterval = null; // Agent definitions for mock mode (when bridge isn't available) const AGENT_DEFS = { cos: { id: 'cos', name: 'COS', department: 'cos', color: '#FF6B35' }, engineering: { id: 'engineering', name: 'Engineering', department: 'engineering', color: '#4ECDC4' }, 'senior-eng': { id: 'senior-eng', name: 'Senior Eng', department: 'senior-eng', color: '#7B2D8E' }, trust: { id: 'trust', name: 'Trust', department: 'trust', color: '#45B7D1' }, market: { id: 'market', name: 'Market', department: 'market', color: '#96CEB4' }, health: { id: 'health', name: 'Health', department: 'health', color: '#FFEAA7' }, content: { id: 'content', name: 'Content', department: 'content', color: '#DDA0DD' }, presentations: { id: 'presentations', name: 'Presentations', department: 'presentations', color: '#FF8A80' }, vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' }, }; const DEFAULT_POSITIONS = { cos: { row: 3, col: 2 }, engineering: { row: 4, col: 10 }, 'senior-eng': { row: 5, col: 10 }, trust: { row: 7, col: 2 }, market: { row: 4, col: 16 }, health: { row: 3, col: 24 }, content: { row: 5, col: 16 }, presentations: { row: 6, col: 16 }, vault: { row: 7, col: 24 }, }; const ACTIVITIES = ['idle', 'thinking', 'working', 'idle', 'idle']; const TASKS = [ 'Memory maintenance', 'Reviewing PRs', 'Running web search', 'Pairing with vault', 'Indexing documents', 'Processing heartbeat', 'Analyzing market data', 'Writing content draft', ]; function buildFullState() { const agents = {}; for (const [id, def] of Object.entries(AGENT_DEFS)) { const pos = DEFAULT_POSITIONS[id]; agents[id] = { ...def, position: { ...pos }, targetPosition: { ...pos }, activity: 'idle', currentTask: null, lastHeartbeat: Date.now(), pulse: 0, }; } return { agents }; } function startMockMode() { console.log('[server] Starting mock agent activity...'); const agentIds = Object.keys(AGENT_DEFS); let cycle = 0; mockInterval = setInterval(() => { cycle++; if (cycle % 3 === 0) { const a1 = agentIds[Math.floor(Math.random() * agentIds.length)]; let a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; while (a2 === a1 && agentIds.length > 1) { a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; } const activity1 = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)]; const activity2 = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)]; const task1 = TASKS[Math.floor(Math.random() * TASKS.length)]; const task2 = TASKS[Math.floor(Math.random() * TASKS.length)]; broadcast({ type: 'state.diff', changes: [ { op: 'agent.activity', path: `agents.${a1}`, value: { activity: activity1, currentTask: task1 } }, { op: 'agent.activity', path: `agents.${a2}`, value: { activity: activity2, currentTask: task2 } }, ], }); } if (cycle % 10 === 0) { const agentId = agentIds[Math.floor(Math.random() * agentIds.length)]; const pos = DEFAULT_POSITIONS[agentId]; const newPos = { row: Math.max(1, Math.min(18, pos.row + Math.floor((Math.random() - 0.5) * 6))), col: Math.max(1, Math.min(26, pos.col + Math.floor((Math.random() - 0.5) * 8))), }; broadcast({ type: 'state.diff', changes: [ { op: 'agent.position', path: `agents.${agentId}`, value: newPos }, ], }); } }, 1000); } function broadcast(msg) { const data = JSON.stringify(msg); for (const ws of wss.clients) { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } } } wss.on('connection', (ws) => { console.log('[server] WS client connected'); // Send full state const state = buildFullState(); ws.send(JSON.stringify({ type: 'state.full', payload: state })); console.log(`[server] Sent state.full: ${Object.keys(state.agents).length} agents`); ws.on('close', () => { console.log('[server] WS client disconnected'); }); }); // Handle WS upgrades on any path (nginx handles routing) server.on('upgrade', (req, socket, head) => { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); }); // ── Heartbeat to all WS clients ──────────────────────────── setInterval(() => { broadcast({ type: 'heartbeat', timestamp: Date.now() }); }, 15000); // ── Start ────────────────────────────────────────────────── server.listen(PORT, () => { console.log(`[server] Spatial Office on http://localhost:${PORT}`); startMockMode(); });