293 lines
7.2 KiB
TypeScript
Executable file
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)
|
|
}
|
|
}
|