Skip to content

Commit 32a473e

Browse files
authored
Merge pull request #225 from builderz-labs/fix/issue-219-websocket-port-forwarding
fix(websocket): normalize gateway URLs and dedupe reconnect errors
2 parents 3877d0b + c07dfb0 commit 32a473e

1 file changed

Lines changed: 58 additions & 15 deletions

File tree

src/lib/websocket.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useRef, useEffect } from 'react'
44
import { useMissionControl } from '@/store'
55
import { normalizeModel } from '@/lib/utils'
6+
import { buildGatewayWebSocketUrl } from '@/lib/gateway-url'
67
import {
78
getOrCreateDeviceIdentity,
89
signPayload,
@@ -21,6 +22,7 @@ const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || '
2122
// Heartbeat configuration
2223
const PING_INTERVAL_MS = 30_000
2324
const MAX_MISSED_PONGS = 3
25+
const ERROR_LOG_DEDUPE_MS = 5_000
2426

2527
// Gateway message types
2628
interface GatewayFrame {
@@ -54,6 +56,7 @@ export function useWebSocket() {
5456
const manualDisconnectRef = useRef<boolean>(false)
5557
const nonRetryableErrorRef = useRef<string | null>(null)
5658
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
59+
const lastWebSocketErrorRef = useRef<{ message: string; at: number } | null>(null)
5760

5861
// Heartbeat tracking
5962
const pingCounterRef = useRef<number>(0)
@@ -504,34 +507,61 @@ export function useWebSocket() {
504507
getGatewayErrorHelp,
505508
])
506509

510+
const normalizeWebSocketUrl = useCallback((rawUrl: string): string => {
511+
const built = buildGatewayWebSocketUrl({
512+
host: rawUrl,
513+
port: Number(process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'),
514+
browserProtocol: window.location.protocol,
515+
})
516+
517+
const parsed = new URL(built, window.location.origin)
518+
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : parsed.protocol === 'http:' ? 'ws:' : parsed.protocol
519+
parsed.pathname = '/'
520+
parsed.search = ''
521+
parsed.hash = ''
522+
return parsed.toString().replace(/\/$/, '')
523+
}, [])
524+
525+
const shouldSuppressWebSocketError = useCallback((message: string): boolean => {
526+
const now = Date.now()
527+
const previous = lastWebSocketErrorRef.current
528+
if (previous && previous.message === message && now - previous.at < ERROR_LOG_DEDUPE_MS) {
529+
return true
530+
}
531+
lastWebSocketErrorRef.current = { message, at: now }
532+
return false
533+
}, [])
534+
507535
const connect = useCallback((url: string, token?: string) => {
508536
const state = wsRef.current?.readyState
509537
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
510538
return // Already connected or connecting
511539
}
512540

513-
// Extract token from URL if present
514-
const urlObj = new URL(url, window.location.origin)
515-
const urlToken = urlObj.searchParams.get('token')
541+
let urlToken = ''
542+
try {
543+
const parsedInput = new URL(url, window.location.origin)
544+
urlToken = parsedInput.searchParams.get('token') || ''
545+
} catch {
546+
urlToken = ''
547+
}
516548
authTokenRef.current = token || urlToken || ''
517549

518-
// Remove token from URL (we'll send it in handshake)
519-
urlObj.searchParams.delete('token')
520-
521-
reconnectUrl.current = url
550+
const normalizedUrl = normalizeWebSocketUrl(url)
551+
reconnectUrl.current = normalizedUrl
522552
handshakeCompleteRef.current = false
523553
manualDisconnectRef.current = false
524554
nonRetryableErrorRef.current = null
525555

526556
try {
527-
const ws = new WebSocket(url.split('?')[0]) // Connect without query params
557+
const ws = new WebSocket(normalizedUrl)
528558
wsRef.current = ws
529559

530560
ws.onopen = () => {
531-
log.info(`Connected to ${url.split('?')[0]}`)
561+
log.info(`Connected to ${normalizedUrl}`)
532562
// Don't set isConnected yet - wait for handshake
533563
setConnection({
534-
url: url.split('?')[0],
564+
url: normalizedUrl,
535565
reconnectAttempts: 0
536566
})
537567
// Wait for connect.challenge from server
@@ -594,20 +624,33 @@ export function useWebSocket() {
594624

595625
ws.onerror = (error) => {
596626
log.error('WebSocket error:', error)
627+
const errorMessage = 'WebSocket error occurred'
628+
if (!shouldSuppressWebSocketError(errorMessage)) {
629+
addLog({
630+
id: `error-${Date.now()}`,
631+
timestamp: Date.now(),
632+
level: 'error',
633+
source: 'websocket',
634+
message: errorMessage
635+
})
636+
}
637+
}
638+
639+
} catch (error) {
640+
log.error('Failed to connect to WebSocket:', error)
641+
const errorMessage = 'Failed to initialize WebSocket connection'
642+
if (!shouldSuppressWebSocketError(errorMessage)) {
597643
addLog({
598644
id: `error-${Date.now()}`,
599645
timestamp: Date.now(),
600646
level: 'error',
601647
source: 'websocket',
602-
message: `WebSocket error occurred`
648+
message: errorMessage
603649
})
604650
}
605-
606-
} catch (error) {
607-
log.error('Failed to connect to WebSocket:', error)
608651
setConnection({ isConnected: false })
609652
}
610-
}, [setConnection, handleGatewayFrame, addLog, stopHeartbeat])
653+
}, [setConnection, handleGatewayFrame, addLog, stopHeartbeat, normalizeWebSocketUrl, shouldSuppressWebSocketError])
611654

612655
// Keep ref in sync so onclose always calls the latest version of connect
613656
useEffect(() => {

0 commit comments

Comments
 (0)