import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig, type Plugin } from 'vite'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { Database } from 'bun:sqlite'; const WS_PORT = 5174; // ============ Docker Target Types ============ interface DockerTarget { type: 'unix' | 'tcp' | 'hawser-edge'; socket?: string; host?: string; port?: number; hawserToken?: string; environmentId?: number; } interface EnvironmentRow { id: number; is_local?: boolean | number; connection_type?: string; socket_path?: string; host?: string; port?: number; hawser_token?: string; } // ============ Docker Target Resolution ============ function resolveDockerTarget( envId: number | undefined, getEnvironment: (id: number) => EnvironmentRow | null, defaultSocketPath: string ): DockerTarget { if (!envId) return { type: 'unix', socket: defaultSocketPath }; const env = getEnvironment(envId); if (!env) return { type: 'unix', socket: defaultSocketPath }; const isLocal = typeof env.is_local === 'boolean' ? env.is_local : Boolean(env.is_local); if (isLocal || env.connection_type === 'socket' || !env.connection_type) { return { type: 'unix', socket: env.socket_path || defaultSocketPath }; } if (env.connection_type === 'hawser-edge') { return { type: 'hawser-edge', environmentId: envId }; } return { type: 'tcp', host: env.host || 'localhost', port: env.port || 2375, hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined }; } // ============ Exec API Helpers ============ function buildExecStartHttpRequest(execId: string, target: DockerTarget): string { const body = JSON.stringify({ Detach: false, Tty: true }); const tokenHeader = target.type === 'tcp' && target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : ''; return `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${body.length}\r\n\r\n${body}`; } // ============ Stream Processing ============ function processTerminalOutput( data: string, state: { headersStripped: boolean; isChunked: boolean } ): string | null { let text = data; if (!state.headersStripped) { if (text.toLowerCase().includes('transfer-encoding: chunked')) { state.isChunked = true; } const headerEnd = text.indexOf('\r\n\r\n'); if (headerEnd > -1) { text = text.slice(headerEnd + 4); state.headersStripped = true; } else if (text.startsWith('HTTP/')) { return null; } } if (state.isChunked && text) { text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, ''); } return text || null; } // ============ Hawser Edge Exec Messages ============ function createExecStartMessage(execId: string, containerId: string, shell: string, user: string, cols = 120, rows = 30) { return { type: 'exec_start', execId, containerId, cmd: shell, user, cols, rows }; } function createExecInputMessage(execId: string, data: string) { return { type: 'exec_input', execId, data: Buffer.from(data).toString('base64') }; } function createExecResizeMessage(execId: string, cols: number, rows: number) { return { type: 'exec_resize', execId, cols, rows }; } function createExecEndMessage(execId: string, reason = 'user_closed') { return { type: 'exec_end', execId, reason }; } // Get build info function getGitCommit(): string | null { // Check COMMIT file (created by CI/CD before docker build) try { if (existsSync('COMMIT')) { const commit = require('fs').readFileSync('COMMIT', 'utf-8').trim(); if (commit && commit !== 'unknown') { return commit; } } } catch { // ignore } // Fall back to git command (local dev) try { return execSync('git rev-parse --short HEAD').toString().trim(); } catch { return null; } } function getGitBranch(): string | null { // Check BRANCH file (created by CI/CD before docker build) try { if (existsSync('BRANCH')) { const branch = require('fs').readFileSync('BRANCH', 'utf-8').trim(); if (branch && branch !== 'unknown') { return branch; } } } catch { // ignore } // Fall back to git command (local dev) try { return execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); } catch { return null; } } function getGitTag(): string | null { // First check env var (set by CI/CD via Docker build-arg) if (process.env.APP_VERSION) { return process.env.APP_VERSION; } // Check VERSION file (created by CI/CD before docker build) try { if (existsSync('VERSION')) { const version = require('fs').readFileSync('VERSION', 'utf-8').trim(); if (version && version !== 'unknown') { return version; } } } catch { // ignore } // Fall back to git tag (local dev) try { return execSync('git describe --tags --abbrev=0 2>/dev/null').toString().trim(); } catch { return null; } } // Plugin to externalize bun: protocol modules function bunExternals(): Plugin { return { name: 'bun-externals', enforce: 'pre', resolveId(source) { if (source.startsWith('bun:')) { return { id: source, external: true }; } return null; } }; } // Detect Docker socket path function detectDockerSocket(): string { if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) return process.env.DOCKER_SOCKET; if (process.env.DOCKER_HOST?.startsWith('unix://')) { const p = process.env.DOCKER_HOST.replace('unix://', ''); if (existsSync(p)) return p; } const candidates = [ '/var/run/docker.sock', join(homedir(), '.docker/run/docker.sock'), join(homedir(), '.orbstack/run/docker.sock'), '/run/docker.sock' ]; for (const s of candidates) { if (existsSync(s)) return s; } return '/var/run/docker.sock'; } // Lazy database connection for environment lookup let _db: Database | null = null; function getDb(): Database | null { if (!_db) { // Database is in data/db/dockhand.db (same as main app) const dbPath = join(process.cwd(), 'data', 'db', 'dockhand.db'); if (existsSync(dbPath)) { _db = new Database(dbPath, { readonly: true }); } } return _db; } function getEnvironment(id: number): { host: string; port: number; is_local: boolean; connection_type?: string; hawser_token?: string } | null { const db = getDb(); if (!db) return null; const row = db.prepare('SELECT * FROM environments WHERE id = ?').get(id) as any; return row ? { ...row, is_local: Boolean(row.is_local) } : null; } function getDockerTarget(envId?: number): DockerTarget { const dockerSocketPath = detectDockerSocket(); return resolveDockerTarget( envId, (id) => getEnvironment(id) as EnvironmentRow | null, dockerSocketPath ); } async function createExecForWs(containerId: string, cmd: string[], user: string, target: ReturnType): Promise<{ Id: string }> { const headers: Record = { 'Content-Type': 'application/json' }; const fetchOpts: any = { method: 'POST', headers, body: JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: cmd, User: user }) }; let url: string; if (target.type === 'unix') { url = 'http://localhost/containers/' + containerId + '/exec'; fetchOpts.unix = target.socket; } else { url = 'http://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec'; if (target.hawserToken) { headers['X-Hawser-Token'] = target.hawserToken; } } const res = await fetch(url, fetchOpts); if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text())); return res.json(); } async function resizeExecForWs(execId: string, cols: number, rows: number, target: ReturnType): Promise { try { const fetchOpts: any = { method: 'POST' }; let url: string; if (target.type === 'unix') { url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; fetchOpts.unix = target.socket; } else { url = 'http://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; if (target.hawserToken) { fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken }; } } await fetch(url, fetchOpts); } catch { // Ignore resize errors } } // Map to track Docker streams per WebSocket (keyed by unique connection ID) // Includes WebSocket reference for orphan detection const dockerStreams = new Map; state: { isChunked: boolean }; ws: any }>(); // Counter for unique WebSocket connection IDs let wsConnectionCounter = 0; // Map to track Edge exec sessions (execId -> frontend WebSocket) const edgeExecSessions = new Map(); // Cleanup interval reference - only started in dev mode let cleanupInterval: ReturnType | null = null; // Cleanup function for orphaned sessions function startCleanupInterval() { if (cleanupInterval) return; // Already running // Cleanup orphaned sessions every 5 minutes to prevent memory leaks // Only removes sessions where the WebSocket is no longer open (readyState !== 1) // This catches sessions where close handlers failed to fire cleanupInterval = setInterval(() => { let dockerCleaned = 0; let edgeCleaned = 0; for (const [connId, session] of dockerStreams.entries()) { // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED if (session.ws?.readyState !== 1) { try { session.stream?.end?.(); } catch { /* ignore */ } dockerStreams.delete(connId); dockerCleaned++; } } for (const [execId, session] of edgeExecSessions.entries()) { if (session.ws?.readyState !== 1) { edgeExecSessions.delete(execId); edgeCleaned++; } } if (dockerCleaned > 0 || edgeCleaned > 0) { console.log(`[WS Cleanup] Removed ${dockerCleaned} orphaned docker streams, ${edgeCleaned} orphaned edge sessions`); } }, 5 * 60 * 1000); } // Hawser Edge connection types (mirrors hawser.ts) interface EdgeConnection { ws: WebSocket; environmentId: number; agentId: string; agentName: string; agentVersion: string; dockerVersion: string; hostname: string; capabilities: string[]; connectedAt: Date; lastHeartbeat: Date; pendingRequests: Map; pendingStreamRequests: Map; pingInterval?: ReturnType; // Server-side ping to keep connection alive through proxies } // Container event from edge agent (matches hawser.ts) interface ContainerEventData { containerId: string; containerName?: string; image?: string; action: string; actorAttributes?: Record; timestamp: string; } // Metrics data structure from Hawser agent interface HawserMetrics { cpuUsage: number; cpuCores: number; memoryTotal: number; memoryUsed: number; memoryFree: number; diskTotal: number; diskUsed: number; diskFree: number; networkRxBytes: number; networkTxBytes: number; } // Use globalThis to share connections with hawser.ts module declare global { var __hawserEdgeConnections: Map | undefined; var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined; var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventData) => Promise) | undefined; var __hawserHandleMetrics: ((envId: number, metrics: HawserMetrics) => Promise) | undefined; } const edgeConnections: Map = globalThis.__hawserEdgeConnections ?? (globalThis.__hawserEdgeConnections = new Map()); // Function to send messages through the WebSocket (needed because ws.send must be called from vite context) globalThis.__hawserSendMessage = (envId: number, message: string): boolean => { const conn = edgeConnections.get(envId); if (!conn || !conn.ws) { return false; } try { conn.ws.send(message); return true; } catch (e) { console.error(`[Hawser WS] sendMessage error:`, e); return false; } }; // Map WebSocket to environmentId for quick lookup on close/message const wsToEnvId = new Map(); // WebSocket server for terminal connections and Hawser Edge in development mode function webSocketPlugin(): Plugin { return { name: 'websocket', configureServer() { // Start cleanup interval for dev mode only startCleanupInterval(); const dockerSocketPath = detectDockerSocket(); console.log(`[Terminal WS] Detected Docker socket at: ${dockerSocketPath}`); // Start a Bun.serve WebSocket server on a separate port Bun.serve({ port: WS_PORT, fetch(req, server) { // Upgrade HTTP requests to WebSocket if (server.upgrade(req, { data: { url: req.url } })) { return; // Return nothing if upgrade succeeds } return new Response('WebSocket server', { status: 200 }); }, websocket: { async open(ws) { const url = new URL((ws.data as any).url, `http://localhost:${WS_PORT}`); // Check if this is a Hawser Edge connection if (url.pathname === '/api/hawser/connect') { console.log('[Hawser WS] New connection pending authentication'); // Hawser connections wait for hello message to authenticate return; } // Assign unique connection ID to this WebSocket const connId = `ws-${++wsConnectionCounter}`; (ws.data as any).connId = connId; // Terminal connection handling const pathParts = url.pathname.split('/'); const containerIdIndex = pathParts.indexOf('containers') + 1; const containerId = pathParts[containerIdIndex]; const shell = url.searchParams.get('shell') || '/bin/sh'; const user = url.searchParams.get('user') || 'root'; const envIdParam = url.searchParams.get('envId'); const envId = envIdParam ? parseInt(envIdParam, 10) : undefined; if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; } const target = getDockerTarget(envId); console.log('[Terminal WS] Open connId:', connId, 'container:', containerId, 'target:', target.type); try { // Handle Hawser Edge mode differently - use WebSocket protocol if (target.type === 'hawser-edge') { const conn = edgeConnections.get(target.environmentId); if (!conn) { ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' })); ws.close(); return; } // Generate unique exec ID const execId = crypto.randomUUID(); console.log('[Terminal WS] Starting Edge exec:', execId, 'container:', containerId); // Track this session edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId }); (ws.data as any).edgeExecId = execId; // Send exec_start to the agent (using shared helper) const execStartMsg = createExecStartMessage(execId, containerId, shell, user); conn.ws.send(JSON.stringify(execStartMsg)); return; } // Direct Docker connection (unix or tcp/hawser-standard) const exec = await createExecForWs(containerId, [shell], user, target); const execId = exec.Id; // Track connection state (using object for mutability across closures) let headersStripped = false; const state = { isChunked: false }; // Create socket handler for Docker connection const socketHandler = { data(socket: any, data: Buffer) { if (ws.readyState === 1) { let text = new TextDecoder().decode(data); // Skip HTTP headers in first response (only once) if (!headersStripped) { // Check for chunked encoding in headers if (text.toLowerCase().includes('transfer-encoding: chunked')) { state.isChunked = true; } const headerEnd = text.indexOf('\r\n\r\n'); if (headerEnd > -1) { text = text.slice(headerEnd + 4); headersStripped = true; } else if (text.startsWith('HTTP/')) { // Headers split across packets, skip this entire packet return; } } // Strip chunked encoding framing if detected if (state.isChunked && text) { // Remove chunk size lines (hex number followed by \r\n) text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, ''); } if (text) { ws.send(JSON.stringify({ type: 'output', data: text })); } } }, close() { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'exit' })); ws.close(); } }, error() {}, open(socket: any) { // Send exec start request (using shared helper) const httpRequest = buildExecStartHttpRequest(execId, target); socket.write(httpRequest); } }; let dockerStream: any; if (target.type === 'unix') { dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler }); } else if (target.type === 'tcp') { dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler }); } dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws }); console.log('[Terminal WS] Stream stored for connId:', connId, 'total streams:', dockerStreams.size); } catch (error: any) { console.error('[Terminal WS] Error:', error.message); ws.send(JSON.stringify({ type: 'error', message: error.message })); ws.close(); } }, async message(ws, message) { const url = new URL((ws.data as any).url, `http://localhost:${WS_PORT}`); const connId = (ws.data as any).connId as string | undefined; console.log('[WS Message] connId:', connId, 'edgeExecId:', (ws.data as any)?.edgeExecId, 'pathname:', url.pathname.slice(0, 50)); // Handle Hawser Edge messages if (url.pathname === '/api/hawser/connect') { try { // Debug: Log raw message info const msgType = typeof message; const msgLen = typeof message === 'string' ? message.length : message instanceof ArrayBuffer ? message.byteLength : (message as Buffer).length || 0; console.log(`[Hawser WS] Received message: type=${msgType}, length=${msgLen}`); // Convert message to string properly (handles both string and ArrayBuffer) let messageStr: string; if (typeof message === 'string') { messageStr = message; } else if (message instanceof ArrayBuffer) { messageStr = new TextDecoder().decode(message); } else if (Buffer.isBuffer(message)) { messageStr = message.toString('utf-8'); } else { // Uint8Array or similar messageStr = new TextDecoder().decode(new Uint8Array(message as ArrayBuffer)); } console.log(`[Hawser WS] Decoded string length: ${messageStr.length}`); if (messageStr.length > 0) { console.log(`[Hawser WS] First 200 chars: ${messageStr.slice(0, 200)}`); } const msg = JSON.parse(messageStr); console.log(`[Hawser WS] Parsed message type: ${msg.type}`); await handleHawserMessage(ws, msg); } catch (error: any) { console.error('[Hawser WS] Error handling message:', error.message); // More detailed debug output const msgType = typeof message; const msgLen = typeof message === 'string' ? message.length : message instanceof ArrayBuffer ? message.byteLength : (message as Buffer).length || 0; console.error(`[Hawser WS] Message details: type=${msgType}, length=${msgLen}`); if (typeof message === 'string' && message.length > 0) { console.error(`[Hawser WS] Message preview: ${message.slice(0, 500)}`); } else if (message instanceof ArrayBuffer && message.byteLength > 0) { const preview = new TextDecoder().decode(message.slice(0, 500)); console.error(`[Hawser WS] ArrayBuffer preview: ${preview}`); } else if (Buffer.isBuffer(message) && message.length > 0) { console.error(`[Hawser WS] Buffer preview: ${message.toString('utf-8').slice(0, 500)}`); } ws.send(JSON.stringify({ type: 'error', error: error.message })); } return; } // Check if this is an Edge exec session const edgeExecId = (ws.data as any)?.edgeExecId; if (edgeExecId) { const session = edgeExecSessions.get(edgeExecId); if (session) { const conn = edgeConnections.get(session.environmentId); if (conn) { try { const msg = JSON.parse(message.toString()); if (msg.type === 'input') { // Forward input to agent (using shared helper) conn.ws.send(JSON.stringify(createExecInputMessage(edgeExecId, msg.data))); } else if (msg.type === 'resize') { // Forward resize to agent (using shared helper) conn.ws.send(JSON.stringify(createExecResizeMessage(edgeExecId, msg.cols, msg.rows))); } } catch (e) { console.error('[Terminal WS] Error handling Edge message:', e); } } } return; } // Terminal message handling (direct Docker connection) if (!connId) { console.log('[Terminal WS] No connId for terminal message'); return; } const d = dockerStreams.get(connId); if (!d) { console.log('[Terminal WS] No stream for connId:', connId, 'streams:', [...dockerStreams.keys()]); return; } console.log('[Terminal WS] Found stream for connId:', connId); try { const msg = JSON.parse(message.toString()); if (msg.type === 'input' && d.stream) { // Always write raw input - chunked encoding only affects reading output d.stream.write(msg.data); } else if (msg.type === 'resize' && d.execId) { resizeExecForWs(d.execId, msg.cols, msg.rows, d.target); } } catch { // If not JSON, treat as raw input if (d.stream) { d.stream.write(message); } } }, close(ws) { // Check if it's a Hawser connection const envId = wsToEnvId.get(ws); if (envId) { const conn = edgeConnections.get(envId); if (conn) { console.log(`[Hawser WS] Agent disconnected: ${conn.agentId}`); // Clear server-side ping interval if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = undefined; } // Reject pending requests for (const [, pending] of conn.pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Connection closed')); } // Clean up pending stream requests for (const [, pending] of conn.pendingStreamRequests) { pending.onEnd('Connection closed'); } edgeConnections.delete(envId); } wsToEnvId.delete(ws); return; } // Check if it's an Edge exec session const edgeExecId = (ws.data as any)?.edgeExecId; if (edgeExecId) { const session = edgeExecSessions.get(edgeExecId); if (session) { // Send exec_end to agent (using shared helper) const conn = edgeConnections.get(session.environmentId); if (conn) { conn.ws.send(JSON.stringify(createExecEndMessage(edgeExecId))); } edgeExecSessions.delete(edgeExecId); console.log(`[Terminal WS] Edge exec session closed: ${edgeExecId}`); } return; } // Terminal connection cleanup (direct Docker) const connId = (ws.data as any)?.connId as string | undefined; if (connId) { const d = dockerStreams.get(connId); if (d?.stream) { d.stream.end(); } dockerStreams.delete(connId); } } } }); console.log(`[Terminal WS] WebSocket server running on port ${WS_PORT}`); } }; } // Handle Hawser Edge protocol messages async function handleHawserMessage(ws: any, msg: any) { if (msg.type === 'hello') { // Validate token using the app's hawser module // For dev mode, we'll do a simplified validation console.log(`[Hawser WS] Hello from agent: ${msg.agentName} (${msg.agentId})`); // In dev mode, we need to validate the token against the database const db = getDb(); if (!db) { ws.send(JSON.stringify({ type: 'error', error: 'Database not available' })); ws.close(); return; } // Simple token validation (in production this would use argon2 verification) // For dev mode, just check if a token exists for any environment const tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all() as any[]; // For dev mode, accept any valid token format and use the first environment with a token const token = tokens.find((t: any) => msg.token && msg.token.startsWith(t.token_prefix.slice(0, 4))); if (!token) { console.log('[Hawser WS] Invalid token'); ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' })); ws.close(); return; } const environmentId = token.environment_id; // Update environment with agent info try { db.prepare(`UPDATE environments SET hawser_last_seen = datetime('now'), hawser_agent_id = ?, hawser_agent_name = ?, hawser_version = ?, hawser_capabilities = ? WHERE id = ?`).run( msg.agentId, msg.agentName, msg.version, JSON.stringify(msg.capabilities || []), environmentId ); } catch (e) { // Read-only DB in dev mode, ignore } // Close any existing connection for this environment const existing = edgeConnections.get(environmentId); if (existing) { const pendingCount = existing.pendingRequests.size; const streamCount = existing.pendingStreamRequests.size; console.log( `[Hawser WS] Replacing existing connection for environment ${environmentId}. ` + `Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.` ); // Reject all pending requests before closing for (const [requestId, pending] of existing.pendingRequests) { console.log(`[Hawser WS] Rejecting pending request ${requestId} due to connection replacement`); clearTimeout(pending.timeout); pending.reject(new Error('Connection replaced by new agent')); } for (const [requestId, pending] of existing.pendingStreamRequests) { console.log(`[Hawser WS] Ending stream request ${requestId} due to connection replacement`); pending.onEnd?.('Connection replaced by new agent'); } existing.pendingRequests.clear(); existing.pendingStreamRequests.clear(); existing.ws.close(1000, 'Replaced by new connection'); wsToEnvId.delete(existing.ws); } // Store connection in shared map (accessible by hawser.ts via globalThis) const connection: EdgeConnection = { ws, environmentId, agentId: msg.agentId, agentName: msg.agentName, agentVersion: msg.version || 'unknown', dockerVersion: msg.dockerVersion || 'unknown', hostname: msg.hostname || 'unknown', capabilities: msg.capabilities || [], connectedAt: new Date(), lastHeartbeat: new Date(), pendingRequests: new Map(), pendingStreamRequests: new Map() }; edgeConnections.set(environmentId, connection); wsToEnvId.set(ws, environmentId); // Send welcome ws.send(JSON.stringify({ type: 'welcome', environmentId, message: `Welcome ${msg.agentName}! Connected to Dockhand dev server.` })); // Start server-side ping interval to keep connection alive through Traefik/proxies // Traefik has ~10s idle timeout, so we ping every 5 seconds connection.pingInterval = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); } catch (e) { // Connection likely closed, clear interval if (connection.pingInterval) { clearInterval(connection.pingInterval); connection.pingInterval = undefined; } } }, 5000); console.log(`[Hawser WS] Agent ${msg.agentName} connected for environment ${environmentId}`); } else if (msg.type === 'ping') { // Agent sent ping - respond with pong to keep connection alive const envId = wsToEnvId.get(ws); if (envId) { const conn = edgeConnections.get(envId); if (conn) { conn.lastHeartbeat = new Date(); } } ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); } else if (msg.type === 'pong') { // Heartbeat response - update last seen const envId = wsToEnvId.get(ws); if (envId) { const conn = edgeConnections.get(envId); if (conn) { conn.lastHeartbeat = new Date(); } } } else if (msg.type === 'response') { // Response to a request we sent const envId = wsToEnvId.get(ws); if (envId) { const conn = edgeConnections.get(envId); if (conn) { const pending = conn.pendingRequests.get(msg.requestId); if (pending) { clearTimeout(pending.timeout); conn.pendingRequests.delete(msg.requestId); // Body is now a string (either plain text/JSON or base64-encoded binary) // isBinary flag indicates if base64 decoding is needed pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, body: msg.body || '', isBinary: msg.isBinary || false }); } } } } else if (msg.type === 'stream') { // Streaming data from agent const envId = wsToEnvId.get(ws); if (!envId) { console.warn(`[Hawser WS] Stream data from unknown WebSocket, requestId=${msg.requestId}`); return; } const conn = edgeConnections.get(envId); if (!conn) { console.warn(`[Hawser WS] Stream data for unknown environment ${envId}, requestId=${msg.requestId}`); return; } const pending = conn.pendingStreamRequests?.get(msg.requestId); if (!pending) { console.warn(`[Hawser WS] Stream data for unknown request ${msg.requestId} on env ${envId}`); return; } pending.onData(msg.data, msg.stream); } else if (msg.type === 'stream_end') { // Stream ended const envId = wsToEnvId.get(ws); if (!envId) { console.warn(`[Hawser WS] Stream end from unknown WebSocket, requestId=${msg.requestId}`); return; } const conn = edgeConnections.get(envId); if (!conn) { console.warn(`[Hawser WS] Stream end for unknown environment ${envId}, requestId=${msg.requestId}`); return; } const pending = conn.pendingStreamRequests.get(msg.requestId); if (!pending) { console.warn(`[Hawser WS] Stream end for unknown request ${msg.requestId} on env ${envId}`); return; } conn.pendingStreamRequests.delete(msg.requestId); pending.onEnd(msg.reason); } else if (msg.type === 'metrics') { // Metrics from agent - save to database for dashboard graphs const envId = wsToEnvId.get(ws); if (envId && msg.metrics) { if (globalThis.__hawserHandleMetrics) { globalThis.__hawserHandleMetrics(envId, msg.metrics).catch((err) => { console.error(`[Hawser WS] Error saving metrics:`, err); }); } } } else if (msg.type === 'exec_ready') { // Exec session is ready const session = edgeExecSessions.get(msg.execId); if (session?.ws?.readyState === 1) { console.log(`[Hawser WS] Exec ready: ${msg.execId}`); // Frontend doesn't need explicit ready message, it's already waiting for output } } else if (msg.type === 'exec_output') { // Terminal output from exec session const session = edgeExecSessions.get(msg.execId); if (session?.ws?.readyState === 1) { // Decode base64 data const data = Buffer.from(msg.data, 'base64').toString('utf-8'); session.ws.send(JSON.stringify({ type: 'output', data })); } } else if (msg.type === 'exec_end') { // Exec session ended const session = edgeExecSessions.get(msg.execId); if (session) { console.log(`[Hawser WS] Exec ended: ${msg.execId} (reason: ${msg.reason})`); if (session.ws?.readyState === 1) { session.ws.send(JSON.stringify({ type: 'exit' })); session.ws.close(); } edgeExecSessions.delete(msg.execId); } } else if (msg.type === 'container_event') { // Container event from edge agent const envId = wsToEnvId.get(ws); if (envId && msg.event) { // Call the global handler registered by hawser.ts if (globalThis.__hawserHandleContainerEvent) { globalThis.__hawserHandleContainerEvent(envId, msg.event).catch((err) => { console.error('[Hawser WS] Error handling container event:', err); }); } } } else if (msg.type === 'error' && msg.requestId) { // Error might be for an exec session const session = edgeExecSessions.get(msg.requestId); if (session?.ws?.readyState === 1) { console.error(`[Hawser WS] Exec error: ${msg.error}`); session.ws.send(JSON.stringify({ type: 'error', message: msg.error })); session.ws.close(); edgeExecSessions.delete(msg.requestId); } } } export default defineConfig({ plugins: [bunExternals(), tailwindcss(), sveltekit(), webSocketPlugin()], define: { __BUILD_DATE__: JSON.stringify(new Date().toISOString()), __BUILD_COMMIT__: JSON.stringify(getGitCommit()), __BUILD_BRANCH__: JSON.stringify(getGitBranch()), __APP_VERSION__: JSON.stringify(getGitTag()) }, optimizeDeps: { include: ['lucide-svelte', '@xterm/xterm', '@xterm/addon-fit'] }, build: { target: 'esnext', minify: 'esbuild', sourcemap: false, rollupOptions: { external: [/^bun:/] } }, ssr: { external: [/^bun:/] } });