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; connect(token: string): Promise { return new Promise((resolve, reject) => { this.currentToken = token; // Already connected with same token if (this.socket?.connected) { resolve(); return; } // Already connecting if (this.isConnecting) { // Wait for existing connection attempt const checkConnection = setInterval(() => { if (this.socket?.connected) { clearInterval(checkConnection); resolve(); } }, 100); setTimeout(() => { clearInterval(checkConnection); if (!this.socket?.connected) { reject(new Error('Connection timeout')); } }, 10000); return; } this.isConnecting = true; // Clean up existing socket if any (but not if it's connecting) if (this.socket && !this.socket.connected) { this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; } // 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.socket.on('connect', () => { console.log('Socket connected:', this.socket?.id); this.isConnecting = false; this.reconnectAttempts = 0; this.notifyConnectionHandlers(true); resolve(); }); this.socket.on('disconnect', (reason) => { console.log('Socket disconnected:', reason); this.isConnecting = false; 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.reconnectAttempts++; if (this.reconnectAttempts >= this.maxReconnectAttempts) { reject(new Error('Max reconnection attempts reached')); } }); // Set up event listeners 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) { // Update the auth token for reconnection this.socket.auth = { token }; this.socket.connect(); } else { this.connect(token).catch((err) => { console.error('Socket reconnect with new token failed:', err); }); } } disconnect(): void { if (this.socket) { this.socket.disconnect(); this.socket = null; } this.currentToken = null; this.isConnecting = false; } 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; } // Timeout fallback in case acknowledgement never arrives 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();