import type { Socket } from 'socket.io-client' import { CHAT_SOCKET_NAMESPACE, CHAT_SOCKET_URL } from '@/config/chatSocket' let socket: Socket | null = null let connectAbortNonce = 0 export interface ChatMessageEvent { id: string conversationId: string senderId: string senderFirstName: string | null senderLastName: string | null senderAvatarUrl?: string | null senderGender?: string | null body: string | null imageUrl: string | null createdAt: string } export interface ConversationUpdatedEvent { conversationId: string lastMessageAt: string preview: string | null unreadCount?: number } export const getChatSocket = () => socket /** * Connect chat socket on-demand. * * Important: `socket.io-client` must be loaded lazily to keep anonymous * guest first-load bundles small. */ export const connectChatSocket = async (accessToken: string): Promise => { if (socket?.connected) { // Keep the stored auth token current even while already connected: if // this connection later drops (network blip, server restart) and // socket.io-client auto-reconnects, it replays whatever is in // `socket.auth` at that time — a stale token here would make every // reconnect after the token's ~15min TTL fail silently. socket.auth = { token: accessToken } return socket } if (socket) { socket.auth = { token: accessToken } socket.connect() return socket } const nonce = ++connectAbortNonce const mod = await import('socket.io-client') const { io } = mod // Another tab/navigation/token change can call disconnectChatSocket while // this module is still loading. if (nonce !== connectAbortNonce) { throw new Error('CHAT_SOCKET_CONNECT_ABORTED') } socket = io(`${CHAT_SOCKET_URL}${CHAT_SOCKET_NAMESPACE}`, { transports: ['websocket'], autoConnect: true, auth: { token: accessToken }, }) return socket } export const disconnectChatSocket = () => { if (!socket) return connectAbortNonce += 1 socket.removeAllListeners() socket.disconnect() socket = null } export interface JoinConversationAck { ok: boolean error?: string conversationId?: string } export const joinConversationRoom = (conversationId: string): Promise => { const activeSocket = socket if (!activeSocket) return Promise.resolve({ ok: false, error: 'NOT_CONNECTED' }) return new Promise((resolve) => { activeSocket.timeout(5000).emit('conversation:join', { conversationId }, (error: Error | null, ack?: JoinConversationAck) => { if (error) { resolve({ ok: false, error: 'ACK_TIMEOUT' }) return } resolve(ack ?? { ok: false, error: 'NO_ACK' }) }) }) } export const leaveConversationRoom = (conversationId: string) => { socket?.emit('conversation:leave', { conversationId }) } export const subscribeChatEvents = (handlers: { onMessage?: (message: ChatMessageEvent) => void onConversationUpdated?: (payload: ConversationUpdatedEvent) => void }) => { if (!socket) return () => undefined const messageHandler = (payload: { message: ChatMessageEvent }) => { handlers.onMessage?.(payload.message) } const conversationHandler = (payload: ConversationUpdatedEvent) => { handlers.onConversationUpdated?.(payload) } socket.on('message:new', messageHandler) socket.on('conversation:updated', conversationHandler) return () => { socket?.off('message:new', messageHandler) socket?.off('conversation:updated', conversationHandler) } }