platform-codebase/@packages/@infrastructure/websocket-client/src/namespaces/ChatNamespace.ts

293 lines
7.2 KiB
TypeScript
Executable file

/**
* Chat Namespace Client
*
* Handles /chat namespace connections for direct messages, group chats, and support tickets
*/
import { io, Socket, ManagerOptions, SocketOptions } from 'socket.io-client'
import type {
NamespaceConfig,
ChatMessage,
ChatJoinResponse,
ChatMessagePayload,
ChatMessageResponse,
ChatTypingBroadcast,
AuthenticatedPayload,
} from '../types'
export class ChatNamespace {
private socket: Socket | null = null
private config: Required<NamespaceConfig>
private reconnectAttempt = 0
private reconnectTimer: NodeJS.Timeout | null = null
constructor(config: Omit<NamespaceConfig, 'namespace'>) {
this.config = {
url: config.url,
namespace: '/chat',
token: config.token || '',
reconnection: config.reconnection !== false,
reconnectionAttempts: config.reconnectionAttempts || Infinity,
reconnectionDelay: config.reconnectionDelay || 1000,
reconnectionDelayMax: config.reconnectionDelayMax || 5000,
autoConnect: config.autoConnect !== false,
}
if (this.config.autoConnect) {
this.connect()
}
}
/**
* Connect to /chat namespace
*/
connect(): Socket {
if (this.socket?.connected) {
console.warn('[ChatNamespace] Already connected')
return this.socket
}
const socketOptions: Partial<ManagerOptions & SocketOptions> = {
reconnection: false, // Manual reconnection with exponential backoff
transports: ['websocket', 'polling'],
}
// Add authentication
if (this.config.token) {
socketOptions.auth = { token: this.config.token }
socketOptions.query = { token: this.config.token }
}
// Connect to /chat namespace
const fullUrl = `${this.config.url}${this.config.namespace}`
this.socket = io(fullUrl, socketOptions)
this.setupConnectionHandlers()
return this.socket
}
/**
* Disconnect from /chat namespace
*/
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.socket) {
this.socket.removeAllListeners()
this.socket.disconnect()
this.socket = null
}
this.reconnectAttempt = 0
}
/**
* Get the underlying Socket.IO socket instance
*/
getSocket(): Socket | null {
return this.socket
}
/**
* Check if currently connected
*/
isConnected(): boolean {
return this.socket?.connected || false
}
// ============================================================================
// Chat-specific methods
// ============================================================================
/**
* Join a chat room
*/
joinRoom(roomId: string): Promise<ChatJoinResponse> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve({ success: false, error: 'Not connected' })
return
}
this.socket.emit('chat:join', { roomId }, (response: ChatJoinResponse) => {
resolve(response)
})
})
}
/**
* Leave a chat room
*/
leaveRoom(roomId: string): Promise<{ success: boolean }> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve({ success: false })
return
}
this.socket.emit('chat:leave', { roomId }, (response: { success: boolean }) => {
resolve(response)
})
})
}
/**
* Send a message
*/
sendMessage(payload: ChatMessagePayload): Promise<ChatMessageResponse> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve({ success: false, error: 'Not connected' })
return
}
this.socket.emit('chat:message', payload, (response: ChatMessageResponse) => {
resolve(response)
})
})
}
/**
* Set typing indicator
*/
setTyping(roomId: string, typing: boolean): Promise<{ success: boolean }> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve({ success: false })
return
}
this.socket.emit('chat:typing', { roomId, typing }, (response: { success: boolean }) => {
resolve(response)
})
})
}
/**
* Mark message as read
*/
markAsRead(messageId: string): Promise<{ success: boolean }> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve({ success: false })
return
}
this.socket.emit('chat:read', { messageId }, (response: { success: boolean }) => {
resolve(response)
})
})
}
// ============================================================================
// Event listeners
// ============================================================================
/**
* Listen for authenticated event
*/
onAuthenticated(handler: (data: AuthenticatedPayload) => void): () => void {
if (!this.socket) {
return () => {}
}
this.socket.on('authenticated', handler)
return () => this.socket?.off('authenticated', handler)
}
/**
* Listen for incoming messages
*/
onMessage(handler: (message: ChatMessage) => void): () => void {
if (!this.socket) {
return () => {}
}
this.socket.on('chat:message', handler)
return () => this.socket?.off('chat:message', handler)
}
/**
* Listen for typing indicators
*/
onTyping(handler: (data: ChatTypingBroadcast) => void): () => void {
if (!this.socket) {
return () => {}
}
this.socket.on('chat:typing', handler)
return () => this.socket?.off('chat:typing', handler)
}
// ============================================================================
// Connection management (private)
// ============================================================================
private setupConnectionHandlers(): void {
if (!this.socket) {return}
this.socket.on('connect', () => {
console.log('[ChatNamespace] Connected to /chat')
this.reconnectAttempt = 0
})
this.socket.on('disconnect', (reason) => {
console.log('[ChatNamespace] Disconnected:', reason)
if (
this.config.reconnection &&
reason !== 'io client disconnect' &&
this.reconnectAttempt < this.config.reconnectionAttempts
) {
this.scheduleReconnect()
}
})
this.socket.on('connect_error', (error) => {
console.error('[ChatNamespace] Connection error:', error.message)
if (
this.config.reconnection &&
this.reconnectAttempt < this.config.reconnectionAttempts
) {
this.scheduleReconnect()
}
})
this.socket.on('error', (error) => {
console.error('[ChatNamespace] Socket error:', error)
})
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return
}
this.reconnectAttempt++
const delay = Math.min(
this.config.reconnectionDelay * Math.pow(2, this.reconnectAttempt - 1),
this.config.reconnectionDelayMax,
)
console.log(
`[ChatNamespace] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${this.config.reconnectionAttempts})`,
)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
if (this.socket) {
this.socket.connect()
} else {
this.connect()
}
}, delay)
}
}