- Chat server: accept token from ?token= or access_token cookie (httpOnly) - Frontend: append token to WS URL when available (TokenStorage)
283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
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
|
|
off: (event: string, callback: (...args: any[]) => 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';
|
|
|
|
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 = (() => {
|
|
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;
|
|
})();
|
|
const token = TokenStorage.getAccessToken();
|
|
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: 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`);
|
|
}
|
|
}
|
|
|
|
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.
|
|
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: '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 };
|
|
}
|