import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer } from './servers/hono'; import { verify } from './servers/jwt'; import { isTokenBlacklisted } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; import { piWebsocket } from './servers/api/pi/websocket'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import { cliampWebsocket } from './servers/api/cliamp/websocket'; import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws'; import { desktopWebsocket } from './servers/api/desktop/websocket'; import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.html'; import { startBrowserRelay } from './servers/api/browser/relay'; import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; const { PORT = '5000' } = process.env; type WSData = { userId: number; email: string; username: string; role: string; provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar'; sandboxed: boolean; sessionId?: string; cwd?: string; command?: string; cols?: number; rows?: number; files?: string; devServerPort?: number; devServerSlug?: string; wsProxyPath?: string; wsToken?: string; }; // Sidecar registration WebSocket handler const sidecarConnections = new Map, string>(); // ws → sidecar ID const sidecarWebsocket = { open(_ws: ServerWebSocket) { // Wait for registration message }, message(ws: ServerWebSocket, raw: string | Buffer) { try { const data = typeof raw === 'string' ? raw : raw.toString(); const msg = JSON.parse(data); if (msg.type === 'register') { const id = registerSidecar(ws, msg as SidecarRegistration); sidecarConnections.set(ws, id); ws.send(JSON.stringify({ type: 'registered', id })); return; } // Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs) if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) { handleSidecarQueueCommand(ws, msg); return; } const id = sidecarConnections.get(ws); if (id) { handleSidecarMessage(id, msg); } } catch { // skip malformed messages } }, close(ws: ServerWebSocket) { const id = sidecarConnections.get(ws); if (id) { unregisterSidecar(id); sidecarConnections.delete(ws); } }, drain() {}, }; // Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs) async function handleSidecarQueueCommand(ws: ServerWebSocket, msg: Record) { const id = msg.id as string; try { switch (msg.type) { case 'queue:enqueue': { const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams); ws.send(JSON.stringify({ type: 'queue:enqueued', id, job })); break; } case 'queue:cancel': { const job = await queueCancel(msg.jobId as string); ws.send(JSON.stringify({ type: 'queue:cancelled', id, job })); break; } case 'queue:list': { const jobs = await queueList(); ws.send(JSON.stringify({ type: 'queue:list', id, jobs })); break; } case 'queue:get': { const job = await queueGet(msg.jobId as string); ws.send(JSON.stringify({ type: 'queue:get', id, job })); break; } default: ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` })); } } catch (err) { ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) })); } } const handlers: Record = { terminal: terminalWebsocket, pi: piWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, cliamp: cliampWebsocket, 'cliamp-audio': cliampAudioWebsocket, desktop: desktopWebsocket, sidecar: sidecarWebsocket, }; // Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.) type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean }; const devServerUpstreams = new Map, UpstreamState>(); const devServerWebsocket = { async open(ws: ServerWebSocket) { const { devServerPort, wsProxyPath, wsToken } = ws.data; // Validate JWT (deferred from upgrade which must be synchronous in Bun) if (!wsToken) { ws.close(4001, 'Unauthorized'); return; } try { const payload = await verify(wsToken); if (!payload) { ws.close(4001, 'Unauthorized'); return; } if (payload.jti && await isTokenBlacklisted(payload.jti)) { ws.close(4001, 'Unauthorized'); return; } } catch { ws.close(4001, 'Unauthorized'); return; } const upstream = new WebSocket(`ws://localhost:${devServerPort}${wsProxyPath}`); const state: UpstreamState = { ws: upstream, queue: [], ready: false }; devServerUpstreams.set(ws, state); upstream.addEventListener('open', () => { state.ready = true; for (const msg of state.queue) upstream.send(msg); state.queue.length = 0; }); upstream.addEventListener('message', (event) => { ws.send(event.data as string | ArrayBuffer); }); upstream.addEventListener('close', () => { devServerUpstreams.delete(ws); ws.close(); }); upstream.addEventListener('error', () => { devServerUpstreams.delete(ws); ws.close(); }); }, message(ws: ServerWebSocket, raw: string | Buffer) { const state = devServerUpstreams.get(ws); if (!state) return; if (state.ready) { state.ws.send(raw); } else { state.queue.push(raw); } }, close(ws: ServerWebSocket) { const state = devServerUpstreams.get(ws); if (state) { state.ws.close(); devServerUpstreams.delete(ws); } }, }; handlers['dev-server'] = devServerWebsocket; async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop') { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); try { const user = await verify(token); if (!user) return new Response('Unauthorized', { status: 401 }); if (user.jti) { if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 }); } const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; const sandboxed = user.role !== 'Super Admin'; const cwd = url.searchParams.get('cwd') ?? undefined; const command = url.searchParams.get('command') ?? undefined; const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined; const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined; const files = url.searchParams.get('files') ?? undefined; const ok = server.upgrade(req, { data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows, files }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } catch { return new Response('Unauthorized', { status: 401 }); } } function upgradeDevServerWs(req: Request, server: any) { const url = new URL(req.url); const match = url.pathname.match(/^\/api\/dev-server-proxy\/([^/]+)(\/.*)?$/); if (!match) return new Response('Not found', { status: 404 }); const proxyId = match[1]!; const entry = findEntryByProxyId(proxyId); if (!entry) return new Response('No dev server running', { status: 404 }); const wsToken = url.searchParams.get('token'); if (!wsToken) return new Response('Unauthorized', { status: 401 }); touchEntry(entry); const wsProxyPath = match[2] || '/'; const ok = server.upgrade(req, { data: { userId: 0, email: '', role: '', provider: 'dev-server' as const, sandboxed: false, devServerPort: entry.port, devServerSlug: proxyId, wsProxyPath, wsToken, }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } const server = serve({ port: Number(PORT), idleTimeout: 60, maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB routes: { '/og-image.jpg': () => new Response(Bun.file('public/og-image.jpg'), { headers: { 'Content-Type': 'image/jpeg' } }), '/novnc/*': (req) => { const file = Bun.file(`public${new URL(req.url).pathname}`); return new Response(file, { headers: { 'Content-Type': 'application/javascript' } }); }, '/vendor/*': (req) => { const file = Bun.file(`public${new URL(req.url).pathname}`); return new Response(file, { headers: { 'Content-Type': 'application/javascript' } }); }, '/api/dev-server-proxy/*': (req, server) => { if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server); return honoServer.fetch(req, server); }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { data: { provider: 'sidecar', userId: 0, email: '', username: '', role: '', sandboxed: false }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); }, '/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'), '/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'), '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), '/api/pi/chat/ws': (req, server) => upgradeWs(req, server, 'pi'), '/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'), '/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'), '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'), '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, '/api/*': honoServer.fetch, }, websocket: { open(ws) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.open(ws as any); }, message(ws, raw) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.message(ws as any, raw); }, close(ws) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.close(ws as any); }, drain() {}, }, development: process.env.NODE_ENV !== 'production' && { hmr: true, }, }); console.log(`🚀 Server running at ${server.url}`); const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792'); try { await startBrowserRelay(BROWSER_RELAY_PORT); console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`); } catch (err) { console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err); } // Initialize queue engine in API server process import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet } from './servers/queue/init'; initQueue().catch((err) => console.error('[queue] failed to initialize:', err)); // Mark any orphaned pipeline jobs from previous server run import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager'; cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err)); // Ensure PulseAudio is running with virtual sink for cliamp audio streaming (async () => { const pulseaudio = Bun.which('pulseaudio'); const pactl = Bun.which('pactl'); if (!pulseaudio || !pactl) { console.log('[cliamp] pulseaudio not installed, skipping audio setup'); return; } // Start PulseAudio daemon if not running const check = Bun.spawnSync({ cmd: [pulseaudio, '--check'], stdout: 'ignore', stderr: 'ignore' }); if (check.exitCode !== 0) { const start = Bun.spawnSync({ cmd: [pulseaudio, '--start', '-D'], stdout: 'ignore', stderr: 'ignore' }); if (start.exitCode !== 0) { console.error('[cliamp] failed to start pulseaudio'); return; } console.log('[cliamp] pulseaudio started'); } else { console.log('[cliamp] pulseaudio already running'); } // Load null sink if not already loaded const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' }); const sinkList = sinks.stdout.toString(); if (!sinkList.includes('virtual_out')) { const load = Bun.spawnSync({ cmd: [pactl, 'load-module', 'module-null-sink', 'sink_name=virtual_out', 'sink_properties=device.description=Virtual_Output'], stdout: 'pipe', stderr: 'pipe', }); if (load.exitCode !== 0) { console.error('[cliamp] failed to load null sink:', load.stderr.toString().trim()); } else { console.log('[cliamp] virtual_out null sink loaded'); } } else { console.log('[cliamp] virtual_out sink already exists'); } })(); // Pi check/install is handled by bootstrap.ts (imported above)