import { io, Socket } from 'socket.io-client'; import type { Message, TypingIndicator, UserStatusChange, MessagesReadEvent, MessageDeliveredEvent, CreateMessageDto, } from '@/types/messaging'; type MessageHandler = (message: Message) => void; type TypingHandler = (data: TypingIndicator) => void; type StatusHandler = (data: UserStatusChange) => void; type ReadHandler = (data: MessagesReadEvent) => void; type DeliveredHandler = (data: MessageDeliveredEvent) => void; type NotificationEventHandler = (data: Record) => void; class SocketService { private socket: Socket | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; private messageHandlers: Set = new Set(); private typingStartHandlers: Set = new Set(); private typingStopHandlers: Set = new Set(); private statusHandlers: Set = new Set(); private readHandlers: Set = new Set(); private deliveredHandlers: Set = new Set(); private connectionRequestHandlers: Set = new Set(); private connectionResponseHandlers: Set = new Set(); private connectionHandlers: Set<(connected: boolean) => void> = new Set(); private authErrorHandlers: Set<() => void> = new Set(); private isConnecting = false; private currentToken: string | null = null; private listenersAttached = false; private connectPromise: Promise | null = null; connect(token: string): Promise { this.currentToken = token; // Already connected with same token if (this.socket?.connected) { return Promise.resolve(); } // Already connecting — return the existing promise if (this.isConnecting && this.connectPromise) { return this.connectPromise; } this.isConnecting = true; // Clean up existing socket if any if (this.socket && !this.socket.connected) { this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; this.listenersAttached = false; } // Extract base URL without /api/v1 path for Socket.io connection const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const baseUrl = apiUrl.replace(/\/api\/v1\/?$/, ''); this.socket = io(baseUrl, { auth: { token }, transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: this.maxReconnectAttempts, reconnectionDelay: 1000, reconnectionDelayMax: 5000, }); this.connectPromise = new Promise((resolve, reject) => { this.socket!.on('connect', () => { console.log('Socket connected:', this.socket?.id); this.isConnecting = false; this.connectPromise = null; this.reconnectAttempts = 0; this.notifyConnectionHandlers(true); resolve(); }); this.socket!.on('disconnect', (reason) => { console.log('Socket disconnected:', reason); this.isConnecting = false; this.connectPromise = null; this.notifyConnectionHandlers(false); // "io server disconnect" means the server rejected us (likely auth failure) if (reason === 'io server disconnect') { console.warn('Socket: Server disconnected us (likely token expired). Requesting token refresh...'); this.authErrorHandlers.forEach((handler) => handler()); } }); this.socket!.on('connect_error', (error) => { console.error('Socket connection error:', error.message); this.isConnecting = false; this.connectPromise = null; this.reconnectAttempts++; if (this.reconnectAttempts >= this.maxReconnectAttempts) { reject(new Error('Max reconnection attempts reached')); } }); }); // Attach event listeners only once per socket instance this.attachEventListeners(); return this.connectPromise; } /** * Attach socket event listeners — called once per socket instance */ private attachEventListeners(): void { if (this.listenersAttached || !this.socket) return; this.listenersAttached = true; this.socket.on('new_message', (message: Message) => { this.messageHandlers.forEach((handler) => handler(message)); }); this.socket.on('typing_start', (data: TypingIndicator) => { this.typingStartHandlers.forEach((handler) => handler(data)); }); this.socket.on('typing_stop', (data: TypingIndicator) => { this.typingStopHandlers.forEach((handler) => handler(data)); }); this.socket.on('user_status_change', (data: UserStatusChange) => { this.statusHandlers.forEach((handler) => handler(data)); }); this.socket.on('messages_read', (data: MessagesReadEvent) => { this.readHandlers.forEach((handler) => handler(data)); }); this.socket.on('message_delivered', (data: MessageDeliveredEvent) => { this.deliveredHandlers.forEach((handler) => handler(data)); }); this.socket.on('connection_request', (data: Record) => { this.connectionRequestHandlers.forEach((handler) => handler(data)); }); this.socket.on('connection_response', (data: Record) => { this.connectionResponseHandlers.forEach((handler) => handler(data)); }); } // Reconnect with a fresh token (used when the previous token expired) reconnectWithToken(token: string): void { this.currentToken = token; if (this.socket) { // Ensure fully disconnected before reconnecting this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; this.listenersAttached = false; } this.isConnecting = false; this.connectPromise = null; this.connect(token).catch((err) => { console.error('Socket reconnect with new token failed:', err); }); } disconnect(): void { if (this.socket) { this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; } this.currentToken = null; this.isConnecting = false; this.connectPromise = null; this.listenersAttached = false; // Clear all handler sets to prevent leaks this.messageHandlers.clear(); this.typingStartHandlers.clear(); this.typingStopHandlers.clear(); this.statusHandlers.clear(); this.readHandlers.clear(); this.deliveredHandlers.clear(); this.connectionRequestHandlers.clear(); this.connectionResponseHandlers.clear(); this.connectionHandlers.clear(); this.authErrorHandlers.clear(); } isConnected(): boolean { return this.socket?.connected || false; } // Room management joinConversation(conversationId: string): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { if (!this.socket) { resolve({ success: false, error: 'Not connected' }); return; } const timeout = setTimeout(() => { resolve({ success: false, error: 'Socket timeout' }); }, 10000); this.socket.emit('join_conversation', { conversationId }, (response: { success: boolean; error?: string }) => { clearTimeout(timeout); resolve(response); }); }); } leaveConversation(conversationId: string): void { if (this.socket) { this.socket.emit('leave_conversation', { conversationId }); } } // Messaging sendMessage( conversationId: string, message: CreateMessageDto ): Promise<{ success: boolean; message?: Message; error?: string }> { return new Promise((resolve) => { if (!this.socket) { resolve({ success: false, error: 'Not connected' }); return; } const timeout = setTimeout(() => { resolve({ success: false, error: 'Socket timeout' }); }, 10000); this.socket.emit( 'send_message', { conversationId, message }, (response: { success: boolean; message?: Message; error?: string }) => { clearTimeout(timeout); resolve(response); } ); }); } // Typing indicators startTyping(conversationId: string): void { if (this.socket) { this.socket.emit('typing_start', { conversationId }); } } stopTyping(conversationId: string): void { if (this.socket) { this.socket.emit('typing_stop', { conversationId }); } } // Mark messages as read markAsRead(conversationId: string): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { if (!this.socket) { resolve({ success: false, error: 'Not connected' }); return; } const timeout = setTimeout(() => { resolve({ success: false, error: 'Socket timeout' }); }, 10000); this.socket.emit('mark_read', { conversationId }, (response: { success: boolean; error?: string }) => { clearTimeout(timeout); resolve(response); }); }); } // Event handlers registration onNewMessage(handler: MessageHandler): () => void { this.messageHandlers.add(handler); return () => this.messageHandlers.delete(handler); } onTypingStart(handler: TypingHandler): () => void { this.typingStartHandlers.add(handler); return () => this.typingStartHandlers.delete(handler); } onTypingStop(handler: TypingHandler): () => void { this.typingStopHandlers.add(handler); return () => this.typingStopHandlers.delete(handler); } onUserStatusChange(handler: StatusHandler): () => void { this.statusHandlers.add(handler); return () => this.statusHandlers.delete(handler); } onMessagesRead(handler: ReadHandler): () => void { this.readHandlers.add(handler); return () => this.readHandlers.delete(handler); } onMessageDelivered(handler: DeliveredHandler): () => void { this.deliveredHandlers.add(handler); return () => this.deliveredHandlers.delete(handler); } onConnectionRequest(handler: NotificationEventHandler): () => void { this.connectionRequestHandlers.add(handler); return () => this.connectionRequestHandlers.delete(handler); } onConnectionResponse(handler: NotificationEventHandler): () => void { this.connectionResponseHandlers.add(handler); return () => this.connectionResponseHandlers.delete(handler); } onConnectionChange(handler: (connected: boolean) => void): () => void { this.connectionHandlers.add(handler); return () => this.connectionHandlers.delete(handler); } onAuthError(handler: () => void): () => void { this.authErrorHandlers.add(handler); return () => this.authErrorHandlers.delete(handler); } private notifyConnectionHandlers(connected: boolean): void { this.connectionHandlers.forEach((handler) => handler(connected)); } } // Singleton instance export const socketService = new SocketService();