semblance/src/hooks/useWebSocket.ts

369 lines
No EOL
11 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { getWebSocketUrl } from '../services/websocketService';
interface UseWebSocketOptions {
autoConnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
enableLogging?: boolean;
}
interface WebSocketState {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
reconnectAttempts: number;
}
interface UseWebSocketReturn extends WebSocketState {
socket: Socket | null;
connect: () => void;
disconnect: () => void;
joinFocusGroup: (focusGroupId: string) => void;
leaveFocusGroup: (focusGroupId: string) => void;
on: (event: string, listener: (...args: any[]) => void) => void;
off: (event: string, listener?: (...args: any[]) => void) => void;
emit: (event: string, data?: any) => void;
}
export function useWebSocket(
token: string | null,
options: UseWebSocketOptions = {}
): UseWebSocketReturn {
const instanceId = useRef(Math.random().toString(36).substr(2, 9));
console.log(`[WebSocket-${instanceId.current}] Hook initialized`);
const {
autoConnect = true,
maxReconnectAttempts = 5,
reconnectDelay = 2000,
enableLogging = false
} = options;
const socketRef = useRef<Socket | null>(null);
const [state, setState] = useState<WebSocketState>({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0
});
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const currentFocusGroupRef = useRef<string | null>(null);
const log = useCallback((message: string, ...args: any[]) => {
if (enableLogging) {
console.log(`[WebSocket-${instanceId.current}] ${message}`, ...args);
}
}, [enableLogging]);
const updateState = useCallback((updates: Partial<WebSocketState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
const connect = useCallback(() => {
if (!token) {
log('Cannot connect: no authentication token provided');
return;
}
if (socketRef.current?.connected) {
log('Already connected');
return;
}
updateState({ isConnecting: true, error: null });
// Disconnect any existing socket first
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
const baseUrl = getWebSocketUrl();
log(`Connecting to WebSocket server at: ${baseUrl}`);
// Create socket connection with authentication
const socketOptions: any = {
auth: {
token: token
},
transports: ['websocket'],
upgrade: true,
rememberUpgrade: true,
timeout: 60000, // Very long timeout for AI processing (60 seconds)
forceNew: true,
pingInterval: 45000, // Send ping every 45 seconds
pingTimeout: 120000 // Wait 2 minutes for pong response
};
// Set WebSocket path from environment variable
const path = import.meta.env.VITE_WEBSOCKET_PATH || '/semblance_back/socket.io/';
socketOptions.path = path;
log(`Setting WebSocket path: ${socketOptions.path}`);
log(`Creating socket connection to: ${baseUrl}`);
log(`Socket options:`, socketOptions);
const socket = io(baseUrl, socketOptions);
socketRef.current = socket;
// Connection success
socket.on('connect', () => {
log('✅ Connected successfully!', { socketId: socket.id });
updateState({
isConnected: true,
isConnecting: false,
error: null,
reconnectAttempts: 0
});
// Clear any reconnection timeouts
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = undefined;
}
// CRITICAL: Re-bind listeners on each reconnect to ensure they survive
// This is needed because Socket.IO creates a new socket instance on reconnect
const listeners = listenersRef.current;
if (listeners.size > 0) {
log(`Re-binding ${listeners.size} event listeners after reconnect`);
for (const [event, listener] of listeners) {
socket.on(event, listener);
log(`Re-bound listener for event: ${event}`);
}
}
// Rejoin focus group if we were in one
if (currentFocusGroupRef.current) {
log(`Rejoining focus group: ${currentFocusGroupRef.current}`);
socket.emit('join_focus_group', {
focus_group_id: currentFocusGroupRef.current
});
}
});
// Connection error
socket.on('connect_error', (error) => {
log('Connection error:', error.message);
updateState({
isConnected: false,
isConnecting: false,
error: `Connection failed: ${error.message}`
});
// Attempt reconnection
if (state.reconnectAttempts < maxReconnectAttempts) {
const delay = reconnectDelay * Math.pow(2, state.reconnectAttempts); // Exponential backoff
log(`Reconnecting in ${delay}ms... (attempt ${state.reconnectAttempts + 1}/${maxReconnectAttempts})`);
reconnectTimeoutRef.current = setTimeout(() => {
updateState(prev => ({ ...prev, reconnectAttempts: prev.reconnectAttempts + 1 }));
connect();
}, delay);
} else {
log('Max reconnection attempts reached - WebSocket unavailable');
updateState({
error: 'WebSocket unavailable - using polling fallback',
reconnectAttempts: maxReconnectAttempts
});
}
});
// Disconnection
socket.on('disconnect', (reason) => {
log('Disconnected:', reason);
console.log(`🔌 [WebSocket-${instanceId.current}] DISCONNECT DEBUG:`, {
reason,
wasIntentional: reason === 'io client disconnect',
reconnectAttempts: state.reconnectAttempts,
maxAttempts: maxReconnectAttempts,
timestamp: new Date().toISOString()
});
updateState({
isConnected: false,
isConnecting: false,
error: reason === 'io client disconnect' ? null : `Disconnected: ${reason}`
});
// Auto-reconnect unless it was intentional
if (reason !== 'io client disconnect' && state.reconnectAttempts < maxReconnectAttempts) {
log('Attempting to reconnect...');
updateState({ isConnecting: true });
setTimeout(connect, reconnectDelay);
}
});
// Authentication success
socket.on('connected', (data) => {
log('🔐 Authentication successful', data);
});
// Focus group room events
socket.on('joined_focus_group', (data) => {
log('🏠 Joined focus group room:', data.focus_group_id);
});
socket.on('left_focus_group', (data) => {
log('🚪 Left focus group room:', data.focus_group_id);
});
// Error handling
socket.on('error', (error) => {
log('Socket error:', error);
updateState({ error: error.message || 'WebSocket error occurred' });
});
// DEBUG: Listen for ALL events
const originalEmit = socket.onevent;
socket.onevent = function(packet) {
console.log(`🔥 [WebSocket-${instanceId.current}] RAW EVENT RECEIVED:`, packet);
return originalEmit.call(this, packet);
};
}, [token, log, updateState, maxReconnectAttempts, reconnectDelay, state.reconnectAttempts]);
const disconnect = useCallback(() => {
log('Manually disconnecting...');
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = undefined;
}
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
updateState({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0
});
currentFocusGroupRef.current = null;
}, [log, updateState]);
const joinFocusGroup = useCallback((focusGroupId: string) => {
if (!socketRef.current?.connected) {
log('Cannot join focus group: not connected');
return;
}
log(`Joining focus group: ${focusGroupId}`);
currentFocusGroupRef.current = focusGroupId;
socketRef.current.emit('join_focus_group', { focus_group_id: focusGroupId });
}, [log]);
const leaveFocusGroup = useCallback((focusGroupId: string) => {
if (!socketRef.current?.connected) {
log('Cannot leave focus group: not connected');
return;
}
log(`Leaving focus group: ${focusGroupId}`);
if (currentFocusGroupRef.current === focusGroupId) {
currentFocusGroupRef.current = null;
}
socketRef.current.emit('leave_focus_group', { focus_group_id: focusGroupId });
}, [log]);
// Track registered listeners to avoid double-binding and ensure cleanup
const listenersRef = useRef(new Map<string, (...args: any[]) => void>());
const on = useCallback((event: string, listener: (...args: any[]) => void) => {
if (socketRef.current) {
// Remove any existing listener for this event first
const existingListener = listenersRef.current.get(event);
if (existingListener) {
socketRef.current.off(event, existingListener);
log(`Replaced existing listener for event: ${event}`);
}
// Add new listener and track it
socketRef.current.on(event, listener);
listenersRef.current.set(event, listener);
log(`Added listener for event: ${event}`);
}
}, [log]);
const off = useCallback((event: string, listener?: (...args: any[]) => void) => {
if (socketRef.current) {
if (listener) {
socketRef.current.off(event, listener);
// Remove from tracking if it matches
if (listenersRef.current.get(event) === listener) {
listenersRef.current.delete(event);
}
} else {
// Remove all listeners for this event
socketRef.current.off(event);
listenersRef.current.delete(event);
}
log(`Removed listener for event: ${event}`);
}
}, [log]);
const emit = useCallback((event: string, data?: any) => {
if (socketRef.current?.connected) {
socketRef.current.emit(event, data);
log(`Emitted event: ${event}`, data);
} else {
log(`Cannot emit ${event}: not connected`);
}
}, [log]);
// Auto-connect on mount if enabled (but only if not already connected)
useEffect(() => {
if (autoConnect && token && !socketRef.current?.connected) {
connect();
}
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [autoConnect, token, connect]);
// Disconnect on unmount
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
// Update connection when token changes (but avoid reconnecting if token is the same)
const lastTokenRef = useRef<string | null>(null);
useEffect(() => {
if (token && token !== lastTokenRef.current) {
lastTokenRef.current = token;
// Only reconnect if we have an active socket or if this is the first token
if (socketRef.current || !lastTokenRef.current) {
disconnect();
setTimeout(() => connect(), 100); // Small delay to ensure cleanup
}
}
}, [token, connect, disconnect]);
return {
socket: socketRef.current,
isConnected: state.isConnected,
isConnecting: state.isConnecting,
error: state.error,
reconnectAttempts: state.reconnectAttempts,
connect,
disconnect,
joinFocusGroup,
leaveFocusGroup,
on,
off,
emit
};
}