diff --git a/backend/src/lib/gateway-relay.ts b/backend/src/lib/gateway-relay.ts index 74358b4..f33765e 100644 --- a/backend/src/lib/gateway-relay.ts +++ b/backend/src/lib/gateway-relay.ts @@ -10,7 +10,7 @@ * (BetterAuth) (relay) (token auth) */ -const GATEWAY_URL = process.env.GATEWAY_WS_URL || process.env.VITE_WS_URL || "wss://ws.hammer.donovankelly.xyz"; +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"; @@ -28,6 +28,8 @@ class GatewayConnection { private eventListeners = new Set(); private reconnectTimer: ReturnType | null = null; private shouldReconnect = true; + private connectSent = false; + private tickTimer: ReturnType | null = null; constructor() { this.connect(); @@ -36,6 +38,7 @@ class GatewayConnection { private connect() { if (this.state === "connecting") return; this.state = "connecting"; + this.connectSent = false; if (!GATEWAY_TOKEN) { console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled"); @@ -56,53 +59,23 @@ class GatewayConnection { 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.sendConnect(); }); this.ws.addEventListener("message", (event) => { try { const msg = JSON.parse(String(event.data)); + + // Handle connect.challenge — gateway may send this before we connect + if (msg.type === "event" && msg.event === "connect.challenge") { + console.log("[gateway-relay] Received connect challenge"); + // Token auth doesn't need signing; send connect if not yet sent + if (!this.connectSent) { + this.sendConnect(); + } + return; + } + this.handleMessage(msg); } catch (e) { console.error("[gateway-relay] Failed to parse message:", e); @@ -113,6 +86,11 @@ class GatewayConnection { console.log("[gateway-relay] Disconnected"); this.state = "disconnected"; this.ws = null; + this.connectSent = false; + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } // Reject all pending requests for (const [id, pending] of this.pendingRequests) { clearTimeout(pending.timer); @@ -124,11 +102,70 @@ class GatewayConnection { } }); - this.ws.addEventListener("error", (e) => { + this.ws.addEventListener("error", () => { console.error("[gateway-relay] WebSocket error"); }); } + private sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + + const connectId = nextReqId(); + this.sendRaw({ + type: "req", + id: connectId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "dashboard-relay", + displayName: "Hammer Dashboard", + version: "1.0.0", + platform: "server", + mode: "webchat", + instanceId: `relay-${process.pid}-${Date.now()}`, + }, + role: "operator", + scopes: ["operator.read", "operator.write"], + caps: [], + commands: [], + permissions: {}, + auth: { + token: GATEWAY_TOKEN, + }, + }, + }); + + // Wait for handshake response + this.pendingRequests.set(connectId, { + resolve: (payload) => { + console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol); + this.state = "connected"; + + // Start tick keepalive (gateway expects periodic ticks) + const tickInterval = payload?.policy?.tickIntervalMs || 15000; + this.tickTimer = setInterval(() => { + this.sendRaw({ type: "tick" }); + }, tickInterval); + }, + 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), + }); + } + private scheduleReconnect() { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { @@ -154,7 +191,7 @@ class GatewayConnection { if (msg.ok !== false) { pending.resolve(msg.payload ?? msg.result ?? {}); } else { - pending.reject(new Error(msg.error?.message || "Request failed")); + pending.reject(new Error(msg.error?.message || msg.error || "Request failed")); } } } else if (msg.type === "event") { @@ -167,6 +204,7 @@ class GatewayConnection { } } } + // Ignore tick responses and other frame types } isConnected(): boolean { @@ -200,6 +238,7 @@ class GatewayConnection { destroy() { this.shouldReconnect = false; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.tickTimer) clearInterval(this.tickTimer); if (this.ws) { try { this.ws.close(); } catch {} }