fix(websocket): normalize gateway URLs and dedupe reconnect errors
This commit is contained in:
parent
3877d0b345
commit
c07dfb04b6
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useRef, useEffect } from 'react'
|
import { useCallback, useRef, useEffect } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { normalizeModel } from '@/lib/utils'
|
import { normalizeModel } from '@/lib/utils'
|
||||||
|
import { buildGatewayWebSocketUrl } from '@/lib/gateway-url'
|
||||||
import {
|
import {
|
||||||
getOrCreateDeviceIdentity,
|
getOrCreateDeviceIdentity,
|
||||||
signPayload,
|
signPayload,
|
||||||
|
|
@ -21,6 +22,7 @@ const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || '
|
||||||
// Heartbeat configuration
|
// Heartbeat configuration
|
||||||
const PING_INTERVAL_MS = 30_000
|
const PING_INTERVAL_MS = 30_000
|
||||||
const MAX_MISSED_PONGS = 3
|
const MAX_MISSED_PONGS = 3
|
||||||
|
const ERROR_LOG_DEDUPE_MS = 5_000
|
||||||
|
|
||||||
// Gateway message types
|
// Gateway message types
|
||||||
interface GatewayFrame {
|
interface GatewayFrame {
|
||||||
|
|
@ -54,6 +56,7 @@ export function useWebSocket() {
|
||||||
const manualDisconnectRef = useRef<boolean>(false)
|
const manualDisconnectRef = useRef<boolean>(false)
|
||||||
const nonRetryableErrorRef = useRef<string | null>(null)
|
const nonRetryableErrorRef = useRef<string | null>(null)
|
||||||
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
|
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
|
||||||
|
const lastWebSocketErrorRef = useRef<{ message: string; at: number } | null>(null)
|
||||||
|
|
||||||
// Heartbeat tracking
|
// Heartbeat tracking
|
||||||
const pingCounterRef = useRef<number>(0)
|
const pingCounterRef = useRef<number>(0)
|
||||||
|
|
@ -504,34 +507,61 @@ export function useWebSocket() {
|
||||||
getGatewayErrorHelp,
|
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 connect = useCallback((url: string, token?: string) => {
|
||||||
const state = wsRef.current?.readyState
|
const state = wsRef.current?.readyState
|
||||||
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
|
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
|
||||||
return // Already connected or connecting
|
return // Already connected or connecting
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from URL if present
|
let urlToken = ''
|
||||||
const urlObj = new URL(url, window.location.origin)
|
try {
|
||||||
const urlToken = urlObj.searchParams.get('token')
|
const parsedInput = new URL(url, window.location.origin)
|
||||||
|
urlToken = parsedInput.searchParams.get('token') || ''
|
||||||
|
} catch {
|
||||||
|
urlToken = ''
|
||||||
|
}
|
||||||
authTokenRef.current = token || urlToken || ''
|
authTokenRef.current = token || urlToken || ''
|
||||||
|
|
||||||
// Remove token from URL (we'll send it in handshake)
|
const normalizedUrl = normalizeWebSocketUrl(url)
|
||||||
urlObj.searchParams.delete('token')
|
reconnectUrl.current = normalizedUrl
|
||||||
|
|
||||||
reconnectUrl.current = url
|
|
||||||
handshakeCompleteRef.current = false
|
handshakeCompleteRef.current = false
|
||||||
manualDisconnectRef.current = false
|
manualDisconnectRef.current = false
|
||||||
nonRetryableErrorRef.current = null
|
nonRetryableErrorRef.current = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(url.split('?')[0]) // Connect without query params
|
const ws = new WebSocket(normalizedUrl)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
log.info(`Connected to ${url.split('?')[0]}`)
|
log.info(`Connected to ${normalizedUrl}`)
|
||||||
// Don't set isConnected yet - wait for handshake
|
// Don't set isConnected yet - wait for handshake
|
||||||
setConnection({
|
setConnection({
|
||||||
url: url.split('?')[0],
|
url: normalizedUrl,
|
||||||
reconnectAttempts: 0
|
reconnectAttempts: 0
|
||||||
})
|
})
|
||||||
// Wait for connect.challenge from server
|
// Wait for connect.challenge from server
|
||||||
|
|
@ -594,20 +624,33 @@ export function useWebSocket() {
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
log.error('WebSocket error:', 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({
|
addLog({
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
level: 'error',
|
level: 'error',
|
||||||
source: 'websocket',
|
source: 'websocket',
|
||||||
message: `WebSocket error occurred`
|
message: errorMessage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log.error('Failed to connect to WebSocket:', error)
|
|
||||||
setConnection({ isConnected: false })
|
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
|
// Keep ref in sync so onclose always calls the latest version of connect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue