fix(relay): proper gateway protocol handshake with role/scopes/ticks

- Add role: operator, scopes: [operator.read, operator.write]
- Handle connect.challenge event from gateway
- Add tick keepalive (gateway expects periodic ticks)
- Fix duplicate message listener bug
- Fix default URL fallback (remove non-existent ws. subdomain)
This commit is contained in:
2026-01-29 06:38:23 +00:00
parent bf3aa18f8e
commit f2b477c03d

View File

@@ -10,7 +10,7 @@
* (BetterAuth) (relay) (token auth) * (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 || ""; const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || process.env.VITE_WS_TOKEN || "";
type GatewayState = "disconnected" | "connecting" | "connected"; type GatewayState = "disconnected" | "connecting" | "connected";
@@ -28,6 +28,8 @@ class GatewayConnection {
private eventListeners = new Set<MessageHandler>(); private eventListeners = new Set<MessageHandler>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true; private shouldReconnect = true;
private connectSent = false;
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor() { constructor() {
this.connect(); this.connect();
@@ -36,6 +38,7 @@ class GatewayConnection {
private connect() { private connect() {
if (this.state === "connecting") return; if (this.state === "connecting") return;
this.state = "connecting"; this.state = "connecting";
this.connectSent = false;
if (!GATEWAY_TOKEN) { if (!GATEWAY_TOKEN) {
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled"); console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
@@ -56,6 +59,58 @@ class GatewayConnection {
this.ws.addEventListener("open", () => { this.ws.addEventListener("open", () => {
console.log("[gateway-relay] WebSocket open, sending handshake..."); console.log("[gateway-relay] WebSocket open, sending handshake...");
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);
}
});
this.ws.addEventListener("close", () => {
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);
pending.reject(new Error("Connection closed"));
this.pendingRequests.delete(id);
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", () => {
console.error("[gateway-relay] WebSocket error");
});
}
private sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
const connectId = nextReqId(); const connectId = nextReqId();
this.sendRaw({ this.sendRaw({
type: "req", type: "req",
@@ -66,12 +121,17 @@ class GatewayConnection {
maxProtocol: 3, maxProtocol: 3,
client: { client: {
id: "dashboard-relay", id: "dashboard-relay",
displayName: "Hammer Dashboard Relay", displayName: "Hammer Dashboard",
version: "1.0.0", version: "1.0.0",
platform: "server", platform: "server",
mode: "webchat", mode: "webchat",
instanceId: `relay-${process.pid}-${Date.now()}`, instanceId: `relay-${process.pid}-${Date.now()}`,
}, },
role: "operator",
scopes: ["operator.read", "operator.write"],
caps: [],
commands: [],
permissions: {},
auth: { auth: {
token: GATEWAY_TOKEN, token: GATEWAY_TOKEN,
}, },
@@ -80,9 +140,15 @@ class GatewayConnection {
// Wait for handshake response // Wait for handshake response
this.pendingRequests.set(connectId, { this.pendingRequests.set(connectId, {
resolve: () => { resolve: (payload) => {
console.log("[gateway-relay] Connected to gateway"); console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol);
this.state = "connected"; 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) => { reject: (err) => {
console.error("[gateway-relay] Handshake failed:", err); console.error("[gateway-relay] Handshake failed:", err);
@@ -98,35 +164,6 @@ class GatewayConnection {
} }
}, 15000), }, 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() { private scheduleReconnect() {
@@ -154,7 +191,7 @@ class GatewayConnection {
if (msg.ok !== false) { if (msg.ok !== false) {
pending.resolve(msg.payload ?? msg.result ?? {}); pending.resolve(msg.payload ?? msg.result ?? {});
} else { } 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") { } else if (msg.type === "event") {
@@ -167,6 +204,7 @@ class GatewayConnection {
} }
} }
} }
// Ignore tick responses and other frame types
} }
isConnected(): boolean { isConnected(): boolean {
@@ -200,6 +238,7 @@ class GatewayConnection {
destroy() { destroy() {
this.shouldReconnect = false; this.shouldReconnect = false;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.tickTimer) clearInterval(this.tickTimer);
if (this.ws) { if (this.ws) {
try { this.ws.close(); } catch {} try { this.ws.close(); } catch {}
} }