/** * Gateway WebSocket Relay * * Maintains a single persistent WebSocket connection to the Clawdbot gateway. * Dashboard clients connect through the backend (authenticated via BetterAuth), * and messages are relayed bidirectionally. * * Architecture: * Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway * (BetterAuth) (relay) (token auth) */ const GATEWAY_URL = process.env.GATEWAY_WS_URL || process.env.VITE_WS_URL || "wss://hammer.donovankelly.xyz"; const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || process.env.VITE_WS_TOKEN || ""; type GatewayState = "disconnected" | "connecting" | "connected"; type MessageHandler = (msg: any) => void; let reqCounter = 0; function nextReqId() { return `relay-${++reqCounter}`; } class GatewayConnection { private ws: WebSocket | null = null; private state: GatewayState = "disconnected"; private pendingRequests = new Map void; reject: (e: any) => void; timer: ReturnType }>(); private eventListeners = new Set(); private reconnectTimer: ReturnType | null = null; private shouldReconnect = true; constructor() { this.connect(); } private connect() { if (this.state === "connecting") return; this.state = "connecting"; if (!GATEWAY_TOKEN) { console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled"); this.state = "disconnected"; return; } console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`); try { this.ws = new WebSocket(GATEWAY_URL); } catch (e) { console.error("[gateway-relay] Failed to create WebSocket:", e); this.state = "disconnected"; this.scheduleReconnect(); return; } this.ws.addEventListener("open", () => { console.log("[gateway-relay] WebSocket open, sending handshake..."); const connectId = nextReqId(); this.sendRaw({ type: "req", id: connectId, method: "connect", params: { minProtocol: 3, maxProtocol: 3, client: { id: "dashboard-relay", displayName: "Hammer Dashboard Relay", version: "1.0.0", platform: "server", mode: "webchat", instanceId: `relay-${process.pid}-${Date.now()}`, }, auth: { token: GATEWAY_TOKEN, }, }, }); // Wait for handshake response this.pendingRequests.set(connectId, { resolve: () => { console.log("[gateway-relay] Connected to gateway"); this.state = "connected"; }, reject: (err) => { console.error("[gateway-relay] Handshake failed:", err); this.state = "disconnected"; this.ws?.close(); }, timer: setTimeout(() => { if (this.pendingRequests.has(connectId)) { this.pendingRequests.delete(connectId); console.error("[gateway-relay] Handshake timeout"); this.state = "disconnected"; this.ws?.close(); } }, 15000), }); }); this.ws.addEventListener("message", (event) => { try { const msg = JSON.parse(String(event.data)); this.handleMessage(msg); } catch (e) { console.error("[gateway-relay] Failed to parse message:", e); } }); this.ws.addEventListener("close", () => { console.log("[gateway-relay] Disconnected"); this.state = "disconnected"; this.ws = null; // Reject all pending requests for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timer); pending.reject(new Error("Connection closed")); this.pendingRequests.delete(id); } if (this.shouldReconnect) { this.scheduleReconnect(); } }); this.ws.addEventListener("error", (e) => { console.error("[gateway-relay] WebSocket error"); }); } private scheduleReconnect() { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (this.shouldReconnect && this.state === "disconnected") { this.connect(); } }, 5000); } private sendRaw(msg: any) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } } private handleMessage(msg: any) { if (msg.type === "res") { const pending = this.pendingRequests.get(msg.id); if (pending) { clearTimeout(pending.timer); this.pendingRequests.delete(msg.id); if (msg.ok !== false) { pending.resolve(msg.payload ?? msg.result ?? {}); } else { pending.reject(new Error(msg.error?.message || "Request failed")); } } } else if (msg.type === "event") { // Forward events to all listeners for (const listener of this.eventListeners) { try { listener(msg); } catch (e) { console.error("[gateway-relay] Event listener error:", e); } } } } isConnected(): boolean { return this.state === "connected"; } async request(method: string, params?: any): Promise { if (!this.isConnected()) { throw new Error("Gateway not connected"); } return new Promise((resolve, reject) => { const id = nextReqId(); const timer = setTimeout(() => { if (this.pendingRequests.has(id)) { this.pendingRequests.delete(id); reject(new Error("Request timeout")); } }, 120000); this.pendingRequests.set(id, { resolve, reject, timer }); this.sendRaw({ type: "req", id, method, params }); }); } onEvent(handler: MessageHandler): () => void { this.eventListeners.add(handler); return () => this.eventListeners.delete(handler); } destroy() { this.shouldReconnect = false; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.ws) { try { this.ws.close(); } catch {} } this.ws = null; this.state = "disconnected"; } } // Singleton gateway connection export const gateway = new GatewayConnection(); /** * Send a chat message to the gateway */ export async function chatSend(sessionKey: string, message: string): Promise { return gateway.request("chat.send", { sessionKey, message, idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`, }); } /** * Get chat history from the gateway */ export async function chatHistory(sessionKey: string, limit = 50): Promise { return gateway.request("chat.history", { sessionKey, limit }); } /** * Abort an in-progress chat response */ export async function chatAbort(sessionKey: string): Promise { return gateway.request("chat.abort", { sessionKey }); } /** * List sessions from the gateway */ export async function sessionsList(limit = 50): Promise { return gateway.request("sessions.list", { limit }); }