From c07dfb04b6bcbd89748484ea3ae10ac357e222a2 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 19:21:32 +0700 Subject: [PATCH] fix(websocket): normalize gateway URLs and dedupe reconnect errors --- src/lib/websocket.ts | 73 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 99bbe30..fa0e6b2 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -3,6 +3,7 @@ import { useCallback, useRef, useEffect } from 'react' import { useMissionControl } from '@/store' import { normalizeModel } from '@/lib/utils' +import { buildGatewayWebSocketUrl } from '@/lib/gateway-url' import { getOrCreateDeviceIdentity, signPayload, @@ -21,6 +22,7 @@ const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || ' // Heartbeat configuration const PING_INTERVAL_MS = 30_000 const MAX_MISSED_PONGS = 3 +const ERROR_LOG_DEDUPE_MS = 5_000 // Gateway message types interface GatewayFrame { @@ -54,6 +56,7 @@ export function useWebSocket() { const manualDisconnectRef = useRef(false) const nonRetryableErrorRef = useRef(null) const connectRef = useRef<(url: string, token?: string) => void>(() => {}) + const lastWebSocketErrorRef = useRef<{ message: string; at: number } | null>(null) // Heartbeat tracking const pingCounterRef = useRef(0) @@ -504,34 +507,61 @@ export function useWebSocket() { getGatewayErrorHelp, ]) + const normalizeWebSocketUrl = useCallback((rawUrl: string): string => { + const built = buildGatewayWebSocketUrl({ + host: rawUrl, + port: Number(process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'), + browserProtocol: window.location.protocol, + }) + + const parsed = new URL(built, window.location.origin) + parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : parsed.protocol === 'http:' ? 'ws:' : parsed.protocol + parsed.pathname = '/' + parsed.search = '' + parsed.hash = '' + return parsed.toString().replace(/\/$/, '') + }, []) + + const shouldSuppressWebSocketError = useCallback((message: string): boolean => { + const now = Date.now() + const previous = lastWebSocketErrorRef.current + if (previous && previous.message === message && now - previous.at < ERROR_LOG_DEDUPE_MS) { + return true + } + lastWebSocketErrorRef.current = { message, at: now } + return false + }, []) + const connect = useCallback((url: string, token?: string) => { const state = wsRef.current?.readyState if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) { return // Already connected or connecting } - // Extract token from URL if present - const urlObj = new URL(url, window.location.origin) - const urlToken = urlObj.searchParams.get('token') + let urlToken = '' + try { + const parsedInput = new URL(url, window.location.origin) + urlToken = parsedInput.searchParams.get('token') || '' + } catch { + urlToken = '' + } authTokenRef.current = token || urlToken || '' - // Remove token from URL (we'll send it in handshake) - urlObj.searchParams.delete('token') - - reconnectUrl.current = url + const normalizedUrl = normalizeWebSocketUrl(url) + reconnectUrl.current = normalizedUrl handshakeCompleteRef.current = false manualDisconnectRef.current = false nonRetryableErrorRef.current = null try { - const ws = new WebSocket(url.split('?')[0]) // Connect without query params + const ws = new WebSocket(normalizedUrl) wsRef.current = ws ws.onopen = () => { - log.info(`Connected to ${url.split('?')[0]}`) + log.info(`Connected to ${normalizedUrl}`) // Don't set isConnected yet - wait for handshake setConnection({ - url: url.split('?')[0], + url: normalizedUrl, reconnectAttempts: 0 }) // Wait for connect.challenge from server @@ -594,20 +624,33 @@ export function useWebSocket() { ws.onerror = (error) => { log.error('WebSocket error:', error) + const errorMessage = 'WebSocket error occurred' + if (!shouldSuppressWebSocketError(errorMessage)) { + addLog({ + id: `error-${Date.now()}`, + timestamp: Date.now(), + level: 'error', + source: 'websocket', + message: errorMessage + }) + } + } + + } catch (error) { + log.error('Failed to connect to WebSocket:', error) + const errorMessage = 'Failed to initialize WebSocket connection' + if (!shouldSuppressWebSocketError(errorMessage)) { addLog({ id: `error-${Date.now()}`, timestamp: Date.now(), level: 'error', source: 'websocket', - message: `WebSocket error occurred` + message: errorMessage }) } - - } catch (error) { - log.error('Failed to connect to WebSocket:', error) setConnection({ isConnected: false }) } - }, [setConnection, handleGatewayFrame, addLog, stopHeartbeat]) + }, [setConnection, handleGatewayFrame, addLog, stopHeartbeat, normalizeWebSocketUrl, shouldSuppressWebSocketError]) // Keep ref in sync so onclose always calls the latest version of connect useEffect(() => {