diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index dabaefb5..5103991e 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -604,6 +604,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise { return; } + // Fast-path heartbeat — never reaches business logic + if (parsed.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })); + return; + } + // Handle authenticated events if (userId && username) { handleClientEvent(parsed, userId, username); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e071f723..b2d6a29c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -186,7 +186,8 @@ export type ClientEvent = | { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string } - | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean }; + | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean } + | { type: 'ping' }; // Server → Client Events export type ServerEvent = @@ -220,6 +221,7 @@ export type ServerEvent = | { type: 'channel_updated'; channel: Channel; serverId: string } | { type: 'channel_deleted'; channelId: string; serverId: string } | { type: 'server_updated'; server: Server } + | { type: 'pong' } | { type: 'error'; message: string }; // ─── API Request/Response Types ───────────────────────────────────────────── diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx index 3ba2c641..2c431e8a 100644 --- a/packages/web/src/components/voice/SoundController.tsx +++ b/packages/web/src/components/voice/SoundController.tsx @@ -13,6 +13,7 @@ export function SoundController() { // Refs to track previous states const isInitialMount = useRef(true); const prevIsWsConnected = useRef(false); + const wsDisconnectedAt = useRef(0); const prevIsMuted = useRef(useVoiceStore.getState().isMuted); const prevIsDeafened = useRef(useVoiceStore.getState().isDeafened); const prevIsCameraOn = useRef(useVoiceStore.getState().isCameraOn); @@ -24,12 +25,17 @@ export function SoundController() { const incomingCallLoop = useRef(null); const outgoingCallLoop = useRef(null); - // WebSocket Reconnect Sound — suppress during active voice (LiveKit handles its own reconnection) + // WebSocket Reconnect Sound — suppress during active voice and brief blips (<3s) useEffect(() => { if (isInitialMount.current) return; + if (!isWsConnected && prevIsWsConnected.current) { + // Record when we lost connection + wsDisconnectedAt.current = Date.now(); + } if (isWsConnected && !prevIsWsConnected.current) { const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected; - if (!isInActiveVoice) { + const downtime = wsDisconnectedAt.current > 0 ? Date.now() - wsDisconnectedAt.current : Infinity; + if (!isInActiveVoice && downtime > 3000) { audioManager.playSound('reconnect'); } } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f956afab..e6bbf80d 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -9,6 +9,7 @@ import type { ServerEvent, ClientEvent } from '@opencord/shared'; let globalWs: WebSocket | null = null; let reconnectAttempts = 0; let reconnectTimer: ReturnType | undefined; +let heartbeatInterval: ReturnType | undefined; let currentToken: string | null = null; let isInitialized = false; @@ -280,6 +281,10 @@ function handleEvent(event: ServerEvent): void { break; } + case 'pong': + // Heartbeat response — no action needed + break; + case 'error': console.error('WebSocket error:', event.message); break; @@ -300,6 +305,14 @@ function connect(): void { ws.onopen = () => { reconnectAttempts = 0; ws.send(JSON.stringify({ type: 'auth', token: currentToken })); + + // Start heartbeat to keep connection alive through proxies/NATs + if (heartbeatInterval) clearInterval(heartbeatInterval); + heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 30_000); }; ws.onmessage = (e) => { @@ -313,6 +326,10 @@ function connect(): void { ws.onclose = () => { globalWs = null; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = undefined; + } if (currentToken) { const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; @@ -332,6 +349,10 @@ function disconnect(): void { clearTimeout(reconnectTimer); reconnectTimer = undefined; } + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = undefined; + } if (globalWs) { globalWs.close(); globalWs = null;