veza/apps/web/src/services/websocket.ts

284 lines
9.4 KiB
TypeScript
Raw Normal View History

interface WebSocketMessage {
type: string;
data: any;
}
interface WebSocketService {
connect: () => Promise<void>;
disconnect: () => void;
send: (message: any) => 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: any[]) => void) => void; // EventEmitter-style API
2025-12-13 02:34:34 +00:00
off: (event: string, callback: (...args: any[]) => void) => void;
connectChat?: () => Promise<void>; // Alias pour compatibilité ChatInterface
2025-12-13 02:34:34 +00:00
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';
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;
}
// CORRECTION: Le serveur Rust expose le WebSocket sur /ws et requiert token en query (v0.101)
const baseUrl = (() => {
2025-12-17 13:07:35 +00:00
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`;
2025-12-17 13:07:35 +00:00
}
if (url.startsWith('/')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}${url}`;
}
2025-12-17 13:07:35 +00:00
return url;
})();
const token = TokenStorage.getAccessToken();
const wsUrl = token
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`
: baseUrl;
2025-12-13 02:34:34 +00:00
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
2025-12-13 02:34:34 +00:00
this.openHandlers.forEach((handler) => handler());
resolve();
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
2025-12-13 02:34:34 +00:00
this.messageHandlers.forEach((handler) => handler(message));
} catch (error) {
logger.error('Failed to parse WebSocket message:', { error });
}
};
this.ws.onerror = (error) => {
2025-12-13 02:34:34 +00:00
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);
};
2025-12-13 02:34:34 +00:00
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++;
2025-12-13 02:34:34 +00:00
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(
2025-12-13 02:34:34 +00:00
`[WebSocket] Connection closed. Chat server unavailable. Retrying in ${delay / 1000}s...`,
);
} else if (this.reconnectAttempts === this.maxReconnectAttempts) {
logger.error(
2025-12-13 02:34:34 +00:00
`[WebSocket] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Chat server unavailable at ${wsUrl}. Please ensure the chat server is running.`,
);
}
2025-12-13 02:34:34 +00:00
setTimeout(() => {
this.connect().catch(() => {
// Silently fail, retry will happen via reconnect logic
});
}, delay);
} else {
logger.error(
2025-12-13 02:34:34 +00:00
`[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: any): 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: any[]) => 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`);
}
}
2025-12-13 02:34:34 +00:00
off(event: string, callback: (...args: any[]) => 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.
2025-12-13 02:34:34 +00:00
default:
logger.debug(`[WebSocket] Removal for event '${event}' not implemented - handlers are lifecycle hooks`);
2025-12-13 02:34:34 +00:00
break;
}
}
connectChat(): Promise<void> {
return this.connect();
}
2025-12-13 02:34:34 +00:00
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: 'start_typing', conversation_id: conversationId });
}
stopTyping(conversationId: string): void {
this.send({ type: 'stop_typing', conversation_id: conversationId });
}
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: any) => websocketService.send(message);
const isConnected = () => websocketService.isConnected();
return { connect, disconnect, send, isConnected };
}