fix: WebSocket heartbeat to prevent idle drops + debounce reconnect sound
30s ping/pong keepalive prevents proxy/NAT from killing idle connections. Reconnect sound now only plays if downtime exceeds 3s, suppressing phantom audio from brief network blips.
This commit is contained in:
@@ -604,6 +604,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast-path heartbeat — never reaches business logic
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle authenticated events
|
// Handle authenticated events
|
||||||
if (userId && username) {
|
if (userId && username) {
|
||||||
handleClientEvent(parsed, userId, username);
|
handleClientEvent(parsed, userId, username);
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export type ClientEvent =
|
|||||||
| { type: 'dm_call_accept'; dmChannelId: string }
|
| { type: 'dm_call_accept'; dmChannelId: string }
|
||||||
| { type: 'dm_call_reject'; dmChannelId: string }
|
| { type: 'dm_call_reject'; dmChannelId: string }
|
||||||
| { type: 'dm_call_end'; 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
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
@@ -220,6 +221,7 @@ export type ServerEvent =
|
|||||||
| { type: 'channel_updated'; channel: Channel; serverId: string }
|
| { type: 'channel_updated'; channel: Channel; serverId: string }
|
||||||
| { type: 'channel_deleted'; channelId: string; serverId: string }
|
| { type: 'channel_deleted'; channelId: string; serverId: string }
|
||||||
| { type: 'server_updated'; server: Server }
|
| { type: 'server_updated'; server: Server }
|
||||||
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
// ─── API Request/Response Types ─────────────────────────────────────────────
|
// ─── API Request/Response Types ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function SoundController() {
|
|||||||
// Refs to track previous states
|
// Refs to track previous states
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const prevIsWsConnected = useRef<boolean>(false);
|
const prevIsWsConnected = useRef<boolean>(false);
|
||||||
|
const wsDisconnectedAt = useRef<number>(0);
|
||||||
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
|
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
|
||||||
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
|
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
|
||||||
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
|
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
|
||||||
@@ -24,12 +25,17 @@ export function SoundController() {
|
|||||||
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
||||||
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(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(() => {
|
useEffect(() => {
|
||||||
if (isInitialMount.current) return;
|
if (isInitialMount.current) return;
|
||||||
|
if (!isWsConnected && prevIsWsConnected.current) {
|
||||||
|
// Record when we lost connection
|
||||||
|
wsDisconnectedAt.current = Date.now();
|
||||||
|
}
|
||||||
if (isWsConnected && !prevIsWsConnected.current) {
|
if (isWsConnected && !prevIsWsConnected.current) {
|
||||||
const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected;
|
const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected;
|
||||||
if (!isInActiveVoice) {
|
const downtime = wsDisconnectedAt.current > 0 ? Date.now() - wsDisconnectedAt.current : Infinity;
|
||||||
|
if (!isInActiveVoice && downtime > 3000) {
|
||||||
audioManager.playSound('reconnect');
|
audioManager.playSound('reconnect');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ServerEvent, ClientEvent } from '@opencord/shared';
|
|||||||
let globalWs: WebSocket | null = null;
|
let globalWs: WebSocket | null = null;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
let currentToken: string | null = null;
|
let currentToken: string | null = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
|
||||||
@@ -280,6 +281,10 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
// Heartbeat response — no action needed
|
||||||
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
console.error('WebSocket error:', event.message);
|
console.error('WebSocket error:', event.message);
|
||||||
break;
|
break;
|
||||||
@@ -300,6 +305,14 @@ function connect(): void {
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
|
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) => {
|
ws.onmessage = (e) => {
|
||||||
@@ -313,6 +326,10 @@ function connect(): void {
|
|||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
globalWs = null;
|
globalWs = null;
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = undefined;
|
||||||
|
}
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
@@ -332,6 +349,10 @@ function disconnect(): void {
|
|||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = undefined;
|
reconnectTimer = undefined;
|
||||||
}
|
}
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = undefined;
|
||||||
|
}
|
||||||
if (globalWs) {
|
if (globalWs) {
|
||||||
globalWs.close();
|
globalWs.close();
|
||||||
globalWs = null;
|
globalWs = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user