/** * Post-build script to fix svelte-adapter-bun WebSocket issue * The adapter calls server.websocket() which doesn't exist in SvelteKit. * * IMPORTANT: Terminal WebSocket logic is shared with vite.config.ts * Core functions like resolveDockerTarget are defined in: * src/lib/server/ws-terminal-shared.ts * * When updating WebSocket terminal handling, update the shared module * and this file will use the same logic at build time. */ import { join } from 'node:path'; const BUILD_DIR = join(import.meta.dir, '../build'); async function patchHandler() { const handlerPath = join(BUILD_DIR, 'handler.js'); const handlerFile = Bun.file(handlerPath); if (!await handlerFile.exists()) { console.error('handler.js not found'); process.exit(1); } let content = await handlerFile.text(); // Replace broken server.websocket() call content = content.replace( 'const websocket = server.websocket();', 'const websocket = null;' ); // Add WebSocket upgrade detection before ssr handler const ssrIndex = content.indexOf('var ssr = async (request, bunServer) => {'); if (ssrIndex > -1) { const upgradeCode = ` var handleUpgrade = (request, bunServer) => { const url = new URL(request.url); const isUpgrade = request.headers.get('connection')?.toLowerCase().includes('upgrade') && request.headers.get('upgrade')?.toLowerCase() === 'websocket'; if (!isUpgrade) return null; // Handle terminal exec WebSocket if (url.pathname.includes('/api/containers/') && url.pathname.includes('/exec')) { 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 envId = url.searchParams.get('envId') ? parseInt(url.searchParams.get('envId'), 10) : undefined; if (bunServer.upgrade(request, { data: { type: 'terminal', containerId, shell, user, envId } })) { return new Response(null, { status: 101 }); } } // Handle Hawser Edge WebSocket if (url.pathname === '/api/hawser/connect') { if (bunServer.upgrade(request, { data: { type: 'hawser' } })) { return new Response(null, { status: 101 }); } } return null; }; `; content = content.slice(0, ssrIndex) + upgradeCode + content.slice(ssrIndex); } // Modify handler to check for upgrade first content = content.replace( 'return ssr(request, server2);', 'const upgradeResponse = handleUpgrade(request, server2); if (upgradeResponse) return upgradeResponse; return ssr(request, server2);' ); await Bun.write(handlerPath, content); console.log('✓ Patched handler.js'); } async function patchIndex() { const indexPath = join(BUILD_DIR, 'index.js'); const indexFile = Bun.file(indexPath); if (!await indexFile.exists()) { console.error('index.js not found'); process.exit(1); } let content = await indexFile.text(); const wsHandler = ` import { existsSync as _existsSync } from 'fs'; import { homedir as _homedir } from 'os'; import { Database as _Database } from 'bun:sqlite'; import { SQL as _SQL } from 'bun'; import { join as _join } from 'path'; // Database connection (supports both SQLite and PostgreSQL) let _db = null; let _isPostgres = false; function _getDb() { if (!_db) { const dbUrl = process.env.DATABASE_URL; if (dbUrl && (dbUrl.startsWith('postgres://') || dbUrl.startsWith('postgresql://'))) { _db = new _SQL(dbUrl); _isPostgres = true; } else { const _dbPath = _join(process.cwd(), 'data', 'db', 'dockhand.db'); if (_existsSync(_dbPath)) { _db = new _Database(_dbPath); } } } return _db; } async function _getEnvironment(id) { const db = _getDb(); if (!db) return null; let row; if (_isPostgres) { const result = await db.unsafe('SELECT * FROM environments WHERE id = $1', [id]); row = result[0]; } else { row = db.prepare('SELECT * FROM environments WHERE id = ?').get(id); } return row ? { ...row, is_local: Boolean(row.is_local), connection_type: row.connection_type, hawser_token: row.hawser_token } : null; } function detectDockerSocket() { 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; } for (const s of ['/var/run/docker.sock', _homedir() + '/.docker/run/docker.sock', _homedir() + '/.orbstack/run/docker.sock', '/run/docker.sock']) { if (_existsSync(s)) return s; } return '/var/run/docker.sock'; } const dockerSocketPath = detectDockerSocket(); console.log('Detected Docker socket at:', dockerSocketPath); const dockerStreams = new Map(); let _wsConnCounter = 0; async function _getDockerTarget(envId) { if (!envId) return { type: 'unix', socket: dockerSocketPath }; const env = await _getEnvironment(envId); if (!env) return { type: 'unix', socket: dockerSocketPath }; // Check for socket connection type (local Unix socket) if (env.is_local || env.connection_type === 'socket' || !env.connection_type) { return { type: 'unix', socket: env.socket_path || dockerSocketPath }; } if (env.connection_type === 'hawser-edge') return { type: 'hawser-edge', environmentId: envId }; return { type: 'tcp', host: env.host, port: env.port || 2375, hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined }; } async function createExec(containerId, cmd, user, target) { const headers = { 'Content-Type': 'application/json' }; const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: cmd, User: user }) }; let url; 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 resizeExec(execId, cols, rows, target) { try { const fetchOpts = { method: 'POST' }; let url; 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 {} } // ============ Hawser Edge Support ============ // Global edge connections map (shared with hawser.ts via globalThis) if (!globalThis.__hawserEdgeConnections) globalThis.__hawserEdgeConnections = new Map(); const _edgeConnections = globalThis.__hawserEdgeConnections; // Map WebSocket to environmentId for quick lookup const _wsToEnvId = new Map(); // Edge exec sessions (execId -> frontend WebSocket) const _edgeExecSessions = new Map(); // Validate Hawser token against database async function _validateHawserToken(token) { const db = _getDb(); if (!db) return { valid: false }; let tokens; if (_isPostgres) { tokens = await db.unsafe('SELECT * FROM hawser_tokens WHERE is_active = true'); } else { tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all(); } for (const t of tokens) { try { const isValid = await Bun.password.verify(token, t.token); if (isValid) { if (_isPostgres) { await db.unsafe('UPDATE hawser_tokens SET last_used = NOW() WHERE id = $1', [t.id]); } else { db.prepare('UPDATE hawser_tokens SET last_used = datetime(\\'now\\') WHERE id = ?').run(t.id); } return { valid: true, environmentId: t.environment_id, tokenId: t.id }; } } catch {} } return { valid: false }; } // Update environment status in database async function _updateEnvStatus(envId, conn) { const db = _getDb(); if (!db) return; try { if (conn) { if (_isPostgres) { await db.unsafe('UPDATE environments SET hawser_last_seen = NOW(), hawser_agent_id = $1, hawser_agent_name = $2, hawser_version = $3, hawser_capabilities = $4 WHERE id = $5', [conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId]); } else { db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\'), hawser_agent_id = ?, hawser_agent_name = ?, hawser_version = ?, hawser_capabilities = ? WHERE id = ?') .run(conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId); } } else { if (_isPostgres) { await db.unsafe('UPDATE environments SET hawser_last_seen = NOW() WHERE id = $1', [envId]); } else { db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\') WHERE id = ?').run(envId); } } } catch {} } // Handle Hawser Edge protocol messages async function _handleHawserMessage(ws, msg) { if (msg.type === 'hello') { console.log('[Hawser] Hello from agent:', msg.agentName, '(' + msg.agentId + ')'); const validation = await _validateHawserToken(msg.token); if (!validation.valid) { console.log('[Hawser] Invalid token'); ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' })); ws.close(); return; } const envId = validation.environmentId; const existing = _edgeConnections.get(envId); if (existing) { const pendingCount = existing.pendingRequests.size; const streamCount = existing.pendingStreamRequests.size; console.log('[Hawser] Replacing existing connection for env', envId, '- rejecting', pendingCount, 'pending requests and', streamCount, 'stream requests'); // Reject all pending requests before closing for (const [requestId, pending] of existing.pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Connection replaced by new agent')); } for (const [requestId, pending] of existing.pendingStreamRequests) { pending.onEnd?.('Connection replaced by new agent'); } existing.pendingRequests.clear(); existing.pendingStreamRequests.clear(); existing.ws.close(1000, 'Replaced'); _wsToEnvId.delete(existing.ws); } const conn = { ws, environmentId: envId, 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(), pingInterval: null }; _edgeConnections.set(envId, conn); _wsToEnvId.set(ws, envId); await _updateEnvStatus(envId, conn); ws.send(JSON.stringify({ type: 'welcome', environmentId: envId, message: 'Connected to Dockhand' })); // Start server-side ping interval to keep connection alive through Traefik/proxies (5s) conn.pingInterval = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); } catch { if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } } }, 5000); console.log('[Hawser] Agent', msg.agentName, 'connected for env', envId); } else if (msg.type === 'ping') { const envId = _wsToEnvId.get(ws); if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); } ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); } else if (msg.type === 'pong') { const envId = _wsToEnvId.get(ws); if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); } } else if (msg.type === 'response') { const envId = _wsToEnvId.get(ws); if (!envId) { console.warn('[Hawser] Response from unknown WebSocket, requestId=' + msg.requestId); return; } const conn = _edgeConnections.get(envId); if (conn) { const pending = conn.pendingRequests.get(msg.requestId); if (pending) { clearTimeout(pending.timeout); conn.pendingRequests.delete(msg.requestId); pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, body: msg.body || '', isBinary: msg.isBinary || false }); } else { console.warn('[Hawser] Response for unknown request ' + msg.requestId + ' on env ' + envId); } } } else if (msg.type === 'stream') { const envId = _wsToEnvId.get(ws); if (!envId) { console.warn('[Hawser] Stream data from unknown WebSocket, requestId=' + msg.requestId); return; } const conn = _edgeConnections.get(envId); if (conn?.pendingStreamRequests) { const pending = conn.pendingStreamRequests.get(msg.requestId); if (pending) { pending.onData(msg.data, msg.stream); } else { console.warn('[Hawser] Stream data for unknown request ' + msg.requestId + ' on env ' + envId); } } } else if (msg.type === 'stream_end') { const envId = _wsToEnvId.get(ws); if (!envId) { console.warn('[Hawser] Stream end from unknown WebSocket, requestId=' + msg.requestId); return; } const conn = _edgeConnections.get(envId); if (conn?.pendingStreamRequests) { const pending = conn.pendingStreamRequests.get(msg.requestId); if (pending) { conn.pendingStreamRequests.delete(msg.requestId); pending.onEnd(msg.reason); } else { console.warn('[Hawser] Stream end for unknown request ' + msg.requestId + ' on env ' + envId); } } } else if (msg.type === 'exec_ready') { const session = _edgeExecSessions.get(msg.execId); if (session?.ws?.readyState === 1) console.log('[Hawser] Exec ready:', msg.execId); } else if (msg.type === 'exec_output') { const session = _edgeExecSessions.get(msg.execId); if (session?.ws?.readyState === 1) { const data = Buffer.from(msg.data, 'base64').toString('utf-8'); session.ws.send(JSON.stringify({ type: 'output', data })); } } else if (msg.type === 'exec_end') { const session = _edgeExecSessions.get(msg.execId); if (session) { console.log('[Hawser] Exec ended:', msg.execId); 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') { 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] Error handling container event:', err); }); } } } 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] Error saving metrics:', err); }); } } } } // Expose send function for hawser.ts module globalThis.__hawserSendMessage = (envId, message) => { const conn = _edgeConnections.get(envId); if (!conn?.ws) return false; try { conn.ws.send(message); return true; } catch { return false; } }; // ============ Combined WebSocket Handler ============ const combinedWebsocket = { async open(ws) { const connType = ws.data?.type; // Hawser Edge connection - wait for hello message if (connType === 'hawser') { console.log('[Hawser] New connection pending authentication'); return; } // Terminal connection const connId = 'ws-' + (++_wsConnCounter); ws.data = ws.data || {}; ws.data.connId = connId; const { containerId, shell, user, envId } = ws.data; if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; } const target = await _getDockerTarget(envId); console.log('[WS] Open:', connId, containerId, 'target:', target.type); // Handle Hawser Edge terminal 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; } const execId = crypto.randomUUID(); _edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId }); ws.data.edgeExecId = execId; conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 })); return; } try { const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target); const execId = exec.Id; let dockerStream; let headersStripped = false; let isChunked = false; const socketHandler = { data(socket, data) { if (ws.readyState === 1) { let text = new TextDecoder().decode(data); if (!headersStripped) { if (text.toLowerCase().includes('transfer-encoding: chunked')) isChunked = true; const i = text.indexOf('\\r\\n\\r\\n'); if (i > -1) { text = text.slice(i + 4); headersStripped = true; } else if (text.startsWith('HTTP/')) return; } if (isChunked && text) 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) { const body = JSON.stringify({ Detach: false, Tty: true }); const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : ''; socket.write('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); } }; if (target.type === 'unix') { dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler }); } else { dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler }); } dockerStreams.set(connId, { stream: dockerStream, execId, target }); } catch (e) { ws.send(JSON.stringify({ type: 'error', message: e.message })); ws.close(); } }, async message(ws, message) { const connType = ws.data?.type; // Hawser Edge message if (connType === 'hawser') { try { let msgStr = typeof message === 'string' ? message : message instanceof ArrayBuffer ? new TextDecoder().decode(message) : Buffer.isBuffer(message) ? message.toString('utf-8') : new TextDecoder().decode(new Uint8Array(message)); const msg = JSON.parse(msgStr); await _handleHawserMessage(ws, msg); } catch (e) { console.error('[Hawser] Error:', e.message); ws.send(JSON.stringify({ type: 'error', error: e.message })); } return; } // Edge exec session input const edgeExecId = ws.data?.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') conn.ws.send(JSON.stringify({ type: 'exec_input', execId: edgeExecId, data: Buffer.from(msg.data).toString('base64') })); else if (msg.type === 'resize') conn.ws.send(JSON.stringify({ type: 'exec_resize', execId: edgeExecId, cols: msg.cols, rows: msg.rows })); } catch {} } } return; } // Terminal message const connId = ws.data?.connId; if (!connId) return; const d = dockerStreams.get(connId); if (!d) return; try { const msg = JSON.parse(message.toString()); if (msg.type === 'input' && d.stream) d.stream.write(msg.data); else if (msg.type === 'resize' && d.execId) resizeExec(d.execId, msg.cols, msg.rows, d.target); } catch { if (d.stream) d.stream.write(message); } }, close(ws) { const connType = ws.data?.type; // Hawser Edge disconnection if (connType === 'hawser') { const envId = _wsToEnvId.get(ws); if (envId) { const conn = _edgeConnections.get(envId); if (conn) { console.log('[Hawser] Agent disconnected:', conn.agentId); // Clear server-side ping interval if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } for (const [, p] of conn.pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Connection closed')); } for (const [, p] of conn.pendingStreamRequests) { p.onEnd('Connection closed'); } _edgeConnections.delete(envId); _updateEnvStatus(envId, null); } _wsToEnvId.delete(ws); } return; } // Edge exec session close const edgeExecId = ws.data?.edgeExecId; if (edgeExecId) { const session = _edgeExecSessions.get(edgeExecId); if (session) { const conn = _edgeConnections.get(session.environmentId); if (conn) conn.ws.send(JSON.stringify({ type: 'exec_end', execId: edgeExecId, reason: 'user_closed' })); _edgeExecSessions.delete(edgeExecId); } return; } // Terminal close const connId = ws.data?.connId; if (!connId) return; const d = dockerStreams.get(connId); if (d?.stream) d.stream.end(); dockerStreams.delete(connId); } }; `; const insertPoint = content.indexOf('var path = env('); if (insertPoint > -1) { content = content.slice(0, insertPoint) + wsHandler + content.slice(insertPoint); } content = content.replace( 'var { fetch: handlerFetch, websocket } = getHandler();', 'var { fetch: handlerFetch, websocket: _ } = getHandler(); var websocket = combinedWebsocket;' ); await Bun.write(indexPath, content); console.log('✓ Patched index.js'); } console.log('Patching build...'); await patchHandler(); await patchIndex(); console.log('✓ Done');