|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { WebSocket } from 'ws'; |
|
|
import { logger } from '../utils/logger.js'; |
|
|
import type { WebSocketMessage } from '../types/websocket.js'; |
|
|
|
|
|
export class WebSocketConnectionManager { |
|
|
|
|
|
private connections: Map<string, WebSocket> = new Map(); |
|
|
|
|
|
|
|
|
private lastActivity: Map<string, number> = new Map(); |
|
|
|
|
|
|
|
|
private messageCount: Map<string, number> = new Map(); |
|
|
|
|
|
|
|
|
private cleanupInterval: NodeJS.Timeout; |
|
|
|
|
|
|
|
|
private readonly INACTIVITY_TIMEOUT = 30 * 60 * 1000; |
|
|
private readonly RATE_LIMIT = 100; |
|
|
|
|
|
|
|
|
|
|
|
constructor() { |
|
|
|
|
|
this.cleanupInterval = setInterval(() => { |
|
|
this.cleanupInactiveConnections(); |
|
|
}, 60 * 1000); |
|
|
|
|
|
logger.info('WebSocketConnectionManager initialized'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
addConnection(sessionId: string, ws: WebSocket): void { |
|
|
|
|
|
if (this.connections.has(sessionId)) { |
|
|
logger.warn(`Replacing existing connection for session: ${sessionId}`); |
|
|
this.removeConnection(sessionId); |
|
|
} |
|
|
|
|
|
this.connections.set(sessionId, ws); |
|
|
this.lastActivity.set(sessionId, Date.now()); |
|
|
this.messageCount.set(sessionId, 0); |
|
|
|
|
|
logger.info(`WebSocket connected for session: ${sessionId}`); |
|
|
|
|
|
|
|
|
ws.on('close', () => { |
|
|
this.removeConnection(sessionId); |
|
|
}); |
|
|
|
|
|
ws.on('error', (error) => { |
|
|
logger.error({ error, sessionId }, 'WebSocket error'); |
|
|
this.removeConnection(sessionId); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
removeConnection(sessionId: string): void { |
|
|
const ws = this.connections.get(sessionId); |
|
|
if (ws) { |
|
|
try { |
|
|
if (ws.readyState === WebSocket.OPEN) { |
|
|
ws.close(); |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error({ error, sessionId }, 'Error closing WebSocket'); |
|
|
} |
|
|
} |
|
|
|
|
|
this.connections.delete(sessionId); |
|
|
this.lastActivity.delete(sessionId); |
|
|
this.messageCount.delete(sessionId); |
|
|
|
|
|
logger.info(`WebSocket disconnected for session: ${sessionId}`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async sendToSession(sessionId: string, message: WebSocketMessage): Promise<boolean> { |
|
|
const ws = this.connections.get(sessionId); |
|
|
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) { |
|
|
logger.warn(`Cannot send message to session ${sessionId}: connection not open`); |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
if (!this.checkRateLimit(sessionId)) { |
|
|
logger.warn(`Rate limit exceeded for session: ${sessionId}`); |
|
|
this.sendError(sessionId, 'RATE_LIMIT', 'Rate limit exceeded (100 messages/minute)'); |
|
|
return false; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
if (!message.timestamp) { |
|
|
message.timestamp = new Date().toISOString(); |
|
|
} |
|
|
|
|
|
ws.send(JSON.stringify(message)); |
|
|
|
|
|
|
|
|
this.lastActivity.set(sessionId, Date.now()); |
|
|
|
|
|
|
|
|
const count = this.messageCount.get(sessionId) || 0; |
|
|
this.messageCount.set(sessionId, count + 1); |
|
|
|
|
|
logger.debug({ sessionId, messageType: message.type }, 'Message sent to session'); |
|
|
|
|
|
return true; |
|
|
} catch (error) { |
|
|
logger.error({ error, sessionId }, 'Error sending message'); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sendError(sessionId: string, code: string, message: string): void { |
|
|
this.sendToSession(sessionId, { |
|
|
type: 'error', |
|
|
code, |
|
|
message, |
|
|
timestamp: new Date().toISOString(), |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hasConnection(sessionId: string): boolean { |
|
|
const ws = this.connections.get(sessionId); |
|
|
return ws !== undefined && ws.readyState === WebSocket.OPEN; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getConnectionCount(): number { |
|
|
return this.connections.size; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private checkRateLimit(sessionId: string): boolean { |
|
|
const count = this.messageCount.get(sessionId) || 0; |
|
|
return count < this.RATE_LIMIT; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private resetRateLimits(): void { |
|
|
this.messageCount.clear(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private cleanupInactiveConnections(): void { |
|
|
const now = Date.now(); |
|
|
const inactiveSessions: string[] = []; |
|
|
|
|
|
for (const [sessionId, lastTime] of this.lastActivity.entries()) { |
|
|
if (now - lastTime > this.INACTIVITY_TIMEOUT) { |
|
|
inactiveSessions.push(sessionId); |
|
|
} |
|
|
} |
|
|
|
|
|
if (inactiveSessions.length > 0) { |
|
|
logger.info(`Cleaning up ${inactiveSessions.length} inactive connections`); |
|
|
|
|
|
for (const sessionId of inactiveSessions) { |
|
|
this.removeConnection(sessionId); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
this.resetRateLimits(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
destroy(): void { |
|
|
clearInterval(this.cleanupInterval); |
|
|
|
|
|
|
|
|
for (const sessionId of this.connections.keys()) { |
|
|
this.removeConnection(sessionId); |
|
|
} |
|
|
|
|
|
logger.info('WebSocketConnectionManager destroyed'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
export const wsConnectionManager = new WebSocketConnectionManager(); |
|
|
|