import * as plugins from '../plugins.js'; import { type INetworkProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger, type IReverseProxyConfig } from './classes.np.types.js'; import { ConnectionPool } from './classes.np.connectionpool.js'; import { ProxyRouter } from '../classes.router.js'; /** * Handles WebSocket connections and proxying */ export class WebSocketHandler { private heartbeatInterval: NodeJS.Timeout | null = null; private wsServer: plugins.ws.WebSocketServer | null = null; private logger: ILogger; constructor( private options: INetworkProxyOptions, private connectionPool: ConnectionPool, private router: ProxyRouter ) { this.logger = createLogger(options.logLevel || 'info'); } /** * Initialize WebSocket server on an existing HTTPS server */ public initialize(server: plugins.https.Server): void { // Create WebSocket server this.wsServer = new plugins.ws.WebSocketServer({ server: server, clientTracking: true }); // Handle WebSocket connections this.wsServer.on('connection', (wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage) => { this.handleWebSocketConnection(wsIncoming, req); }); // Start the heartbeat interval this.startHeartbeat(); this.logger.info('WebSocket handler initialized'); } /** * Start the heartbeat interval to check for inactive WebSocket connections */ private startHeartbeat(): void { // Clean up existing interval if any if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } // Set up the heartbeat interval (check every 30 seconds) this.heartbeatInterval = setInterval(() => { if (!this.wsServer || this.wsServer.clients.size === 0) { return; // Skip if no active connections } this.logger.debug(`WebSocket heartbeat check for ${this.wsServer.clients.size} clients`); this.wsServer.clients.forEach((ws: plugins.wsDefault) => { const wsWithHeartbeat = ws as IWebSocketWithHeartbeat; if (wsWithHeartbeat.isAlive === false) { this.logger.debug('Terminating inactive WebSocket connection'); return wsWithHeartbeat.terminate(); } wsWithHeartbeat.isAlive = false; wsWithHeartbeat.ping(); }); }, 30000); // Make sure the interval doesn't keep the process alive if (this.heartbeatInterval.unref) { this.heartbeatInterval.unref(); } } /** * Handle a new WebSocket connection */ private handleWebSocketConnection(wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage): void { try { // Initialize heartbeat tracking wsIncoming.isAlive = true; wsIncoming.lastPong = Date.now(); // Handle pong messages to track liveness wsIncoming.on('pong', () => { wsIncoming.isAlive = true; wsIncoming.lastPong = Date.now(); }); // Find target configuration based on request const proxyConfig = this.router.routeReq(req); if (!proxyConfig) { this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`); wsIncoming.close(1008, 'No proxy configuration for this host'); return; } // Get destination target using round-robin if multiple targets const destination = this.connectionPool.getNextTarget( proxyConfig.destinationIps, proxyConfig.destinationPorts[0] ); // Build target URL const protocol = (req.socket as any).encrypted ? 'wss' : 'ws'; const targetUrl = `${protocol}://${destination.host}:${destination.port}${req.url}`; this.logger.debug(`WebSocket connection from ${req.socket.remoteAddress} to ${targetUrl}`); // Create headers for outgoing WebSocket connection const headers: { [key: string]: string } = {}; // Copy relevant headers from incoming request for (const [key, value] of Object.entries(req.headers)) { if (value && typeof value === 'string' && key.toLowerCase() !== 'connection' && key.toLowerCase() !== 'upgrade' && key.toLowerCase() !== 'sec-websocket-key' && key.toLowerCase() !== 'sec-websocket-version') { headers[key] = value; } } // Override host header if needed if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) { headers['host'] = `${destination.host}:${destination.port}`; } // Create outgoing WebSocket connection const wsOutgoing = new plugins.wsDefault(targetUrl, { headers: headers, followRedirects: true }); // Handle connection errors wsOutgoing.on('error', (err) => { this.logger.error(`WebSocket target connection error: ${err.message}`); if (wsIncoming.readyState === wsIncoming.OPEN) { wsIncoming.close(1011, 'Internal server error'); } }); // Handle outgoing connection open wsOutgoing.on('open', () => { // Forward incoming messages to outgoing connection wsIncoming.on('message', (data, isBinary) => { if (wsOutgoing.readyState === wsOutgoing.OPEN) { wsOutgoing.send(data, { binary: isBinary }); } }); // Forward outgoing messages to incoming connection wsOutgoing.on('message', (data, isBinary) => { if (wsIncoming.readyState === wsIncoming.OPEN) { wsIncoming.send(data, { binary: isBinary }); } }); // Handle closing of connections wsIncoming.on('close', (code, reason) => { this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`); if (wsOutgoing.readyState === wsOutgoing.OPEN) { wsOutgoing.close(code, reason); } }); wsOutgoing.on('close', (code, reason) => { this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`); if (wsIncoming.readyState === wsIncoming.OPEN) { wsIncoming.close(code, reason); } }); this.logger.debug(`WebSocket connection established: ${req.headers.host} -> ${destination.host}:${destination.port}`); }); } catch (error) { this.logger.error(`Error handling WebSocket connection: ${error.message}`); if (wsIncoming.readyState === wsIncoming.OPEN) { wsIncoming.close(1011, 'Internal server error'); } } } /** * Get information about active WebSocket connections */ public getConnectionInfo(): { activeConnections: number } { return { activeConnections: this.wsServer ? this.wsServer.clients.size : 0 }; } /** * Shutdown the WebSocket handler */ public shutdown(): void { // Stop heartbeat interval if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } // Close all WebSocket connections if (this.wsServer) { this.logger.info(`Closing ${this.wsServer.clients.size} WebSocket connections`); for (const client of this.wsServer.clients) { try { client.terminate(); } catch (error) { this.logger.error('Error terminating WebSocket client', error); } } // Close the server this.wsServer.close(); this.wsServer = null; } } }