CLN-04: Replaced any with unknown, proper interfaces, or concrete types across 17 files. Focus: error handlers, API responses, WebSocket data, and function parameters.
294 lines
9.9 KiB
TypeScript
294 lines
9.9 KiB
TypeScript
interface WebSocketMessage {
|
|
type: string;
|
|
data: unknown;
|
|
}
|
|
|
|
interface WebSocketService {
|
|
connect: () => Promise<void>;
|
|
disconnect: () => void;
|
|
send: (message: object | string) => void;
|
|
onMessage: (callback: (message: WebSocketMessage) => void) => void;
|
|
onError: (callback: (error: Event) => void) => void;
|
|
onOpen: (callback: () => void) => void;
|
|
onClose: (callback: () => void) => void;
|
|
isConnected: () => boolean;
|
|
on: (event: string, callback: (...args: unknown[]) => void) => void; // EventEmitter-style API
|
|
off: (event: string, callback: (...args: unknown[]) => void) => void;
|
|
connectChat?: () => Promise<void>; // Alias pour compatibilité ChatInterface
|
|
joinConversation: (conversationId: string) => void;
|
|
leaveConversation: (conversationId: string) => void;
|
|
sendMessage: (
|
|
conversationId: string,
|
|
content: string,
|
|
parentId?: string,
|
|
) => void;
|
|
startTyping: (conversationId: string) => void;
|
|
stopTyping: (conversationId: string) => void;
|
|
addReaction: (messageId: string, emoji: string) => void;
|
|
removeReaction: (messageId: string, emoji: string) => void;
|
|
}
|
|
|
|
import { logger } from '@/utils/logger';
|
|
import { TokenStorage } from '@/services/tokenStorage';
|
|
import { fetchStreamToken } from '@/features/streaming/services/hlsService';
|
|
|
|
class WebSocketServiceImpl implements WebSocketService {
|
|
private ws: WebSocket | null = null;
|
|
private messageHandlers: Array<(message: WebSocketMessage) => void> = [];
|
|
private errorHandlers: Array<(error: Event) => void> = [];
|
|
private openHandlers: Array<() => void> = [];
|
|
private closeHandlers: Array<() => void> = [];
|
|
private reconnectAttempts = 0;
|
|
private maxReconnectAttempts = 5;
|
|
private reconnectDelay = 3000;
|
|
|
|
async connect(): Promise<void> {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
// NOTE: Chat Server accepts token via ?token= (query) or Cookie access_token.
|
|
// SEC-03: TokenStorage.getAccessToken() returns null with httpOnly cookies — use fetchStreamToken.
|
|
const baseUrl = (() => {
|
|
const url = import.meta.env.VITE_WS_URL;
|
|
if (!url) {
|
|
if (import.meta.env.PROD) {
|
|
throw new Error('VITE_WS_URL must be defined in production');
|
|
}
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}/ws`;
|
|
}
|
|
if (url.startsWith('/')) {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}${url}`;
|
|
}
|
|
return url;
|
|
})();
|
|
let token = TokenStorage.getAccessToken();
|
|
if (!token) {
|
|
try {
|
|
token = await fetchStreamToken();
|
|
} catch (e) {
|
|
logger.warn('[WebSocket] Failed to fetch stream token, connecting without auth', {
|
|
error: e,
|
|
});
|
|
}
|
|
}
|
|
const wsUrl = token
|
|
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`
|
|
: baseUrl;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
this.reconnectAttempts = 0;
|
|
this.openHandlers.forEach((handler) => handler());
|
|
resolve();
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
this.messageHandlers.forEach((handler) => handler(message));
|
|
} catch (error) {
|
|
logger.error('Failed to parse WebSocket message:', { error });
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
this.errorHandlers.forEach((handler) => handler(error));
|
|
// CORRECTION: Ne pas rejeter immédiatement, attendre onclose
|
|
// Ne pas logger l'erreur ici car onclose sera appelé après avec plus de détails
|
|
// reject(error);
|
|
};
|
|
|
|
this.ws.onclose = (_event) => {
|
|
this.closeHandlers.forEach((handler) => handler());
|
|
|
|
// CORRECTION DURABLE: Reconnexion intelligente avec backoff exponentiel
|
|
// Ne pas spammer la console si le serveur est down
|
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this.reconnectAttempts++;
|
|
const delay =
|
|
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Backoff exponentiel
|
|
|
|
// Afficher un message seulement pour les premières tentatives ou si c'est la dernière
|
|
if (this.reconnectAttempts === 1) {
|
|
logger.warn(
|
|
`[WebSocket] Connection closed. Chat server unavailable. Retrying in ${delay / 1000}s...`,
|
|
);
|
|
} else if (this.reconnectAttempts === this.maxReconnectAttempts) {
|
|
logger.error(
|
|
`[WebSocket] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Chat server unavailable at ${wsUrl}. Please ensure the chat server is running.`,
|
|
);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this.connect().catch(() => {
|
|
// Silently fail, retry will happen via reconnect logic
|
|
});
|
|
}, delay);
|
|
} else {
|
|
logger.error(
|
|
`[WebSocket] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Chat server unavailable at ${wsUrl}. Please ensure the chat server is running on port 8081.`,
|
|
);
|
|
reject(
|
|
new Error('WebSocket connection failed after maximum retries'),
|
|
);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
disconnect(): void {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
send(message: object | string): void {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(message));
|
|
} else {
|
|
logger.error('WebSocket is not connected');
|
|
}
|
|
}
|
|
|
|
onMessage(callback: (message: WebSocketMessage) => void): void {
|
|
this.messageHandlers.push(callback);
|
|
}
|
|
|
|
onError(callback: (error: Event) => void): void {
|
|
this.errorHandlers.push(callback);
|
|
}
|
|
|
|
onOpen(callback: () => void): void {
|
|
this.openHandlers.push(callback);
|
|
}
|
|
|
|
onClose(callback: () => void): void {
|
|
this.closeHandlers.push(callback);
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
removeMessageHandler(callback: (message: WebSocketMessage) => void): void {
|
|
const index = this.messageHandlers.indexOf(callback);
|
|
if (index > -1) {
|
|
this.messageHandlers.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
// CORRECTION DURABLE: Méthode .on() pour compatibilité EventEmitter
|
|
// Utilisée par ChatInterface et store/chat
|
|
on(event: string, callback: (...args: unknown[]) => void): void {
|
|
switch (event) {
|
|
case 'message':
|
|
case 'chat_message':
|
|
this.onMessage(callback as (message: WebSocketMessage) => void);
|
|
break;
|
|
case 'connected':
|
|
case 'chat_connected':
|
|
this.onOpen(callback as () => void);
|
|
break;
|
|
case 'disconnected':
|
|
case 'chat_disconnected':
|
|
this.onClose(callback as () => void);
|
|
break;
|
|
case 'error':
|
|
case 'chat_error':
|
|
this.onError(callback as (error: Event) => void);
|
|
break;
|
|
default:
|
|
logger.warn(`[WebSocket] Event '${event}' not supported`);
|
|
}
|
|
}
|
|
|
|
off(event: string, callback: (...args: unknown[]) => void): void {
|
|
switch (event) {
|
|
case 'message':
|
|
case 'chat_message':
|
|
this.removeMessageHandler(
|
|
callback as (message: WebSocketMessage) => void,
|
|
);
|
|
break;
|
|
// NOTE: Removal for other event types (open, close, error) is not implemented
|
|
// because these handlers are stored in arrays and would require specific removal methods
|
|
// (removeOpenHandler, removeCloseHandler, removeErrorHandler). This is intentional
|
|
// as these handlers are typically lifecycle hooks that don't need to be removed.
|
|
// If removal is needed in the future, implement specific removal methods for each handler type.
|
|
default:
|
|
logger.debug(`[WebSocket] Removal for event '${event}' not implemented - handlers are lifecycle hooks`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
connectChat(): Promise<void> {
|
|
return this.connect();
|
|
}
|
|
|
|
joinConversation(conversationId: string): void {
|
|
this.send({ type: 'join_conversation', conversation_id: conversationId });
|
|
}
|
|
|
|
joinRoom(room: string): void {
|
|
this.joinConversation(room);
|
|
}
|
|
|
|
leaveConversation(conversationId: string): void {
|
|
this.send({ type: 'leave_conversation', conversation_id: conversationId });
|
|
}
|
|
|
|
sendMessage(
|
|
conversationId: string,
|
|
content: string,
|
|
parentId?: string,
|
|
): void {
|
|
this.send({
|
|
type: 'send_message',
|
|
conversation_id: conversationId,
|
|
content,
|
|
parent_id: parentId,
|
|
});
|
|
}
|
|
|
|
startTyping(conversationId: string): void {
|
|
this.send({ type: 'Typing', conversation_id: conversationId, is_typing: true });
|
|
}
|
|
|
|
stopTyping(conversationId: string): void {
|
|
this.send({ type: 'Typing', conversation_id: conversationId, is_typing: false });
|
|
}
|
|
|
|
addReaction(messageId: string, emoji: string): void {
|
|
this.send({ type: 'add_reaction', message_id: messageId, emoji });
|
|
}
|
|
|
|
removeReaction(messageId: string, emoji: string): void {
|
|
this.send({ type: 'remove_reaction', message_id: messageId, emoji });
|
|
}
|
|
}
|
|
|
|
export const websocketService = new WebSocketServiceImpl();
|
|
|
|
// CORRECTION DURABLE: Export alias pour compatibilité avec imports existants
|
|
// Certains fichiers utilisent 'wsService', d'autres 'websocketService'
|
|
export const wsService = websocketService;
|
|
|
|
// Helper hook for React components
|
|
export function useWebSocket() {
|
|
const connect = () => websocketService.connect();
|
|
const disconnect = () => websocketService.disconnect();
|
|
const send = (message: object | string) => websocketService.send(message);
|
|
const isConnected = () => websocketService.isConnected();
|
|
|
|
return { connect, disconnect, send, isConnected };
|
|
}
|