UNPKG

tcp-serial-relay

Version:

Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard

451 lines (387 loc) 12.8 kB
// src/services/secondary-tcp-client.js const net = require('net'); const EventEmitter = require('events'); const { logger, dataLogger } = require('../utils/logger'); const { createConnectionRetryHandler } = require('../utils/retry-handler'); /** * Secondary TCP Client for TCP-to-TCP relay functionality * This client connects to a secondary TCP server when connectionType is 'tcp' */ class SecondaryTcpClient extends EventEmitter { constructor(config) { super(); this.config = config; this.socket = null; this.isConnected = false; this.isConnecting = false; this.retryHandler = createConnectionRetryHandler({ maxRetries: config.maxRetries || 3, baseDelay: config.retryDelay || 2000 }); this.connectionAttempts = 0; this.totalBytesReceived = 0; this.totalBytesSent = 0; this.lastHeartbeatTime = 0; this.heartbeatInterval = null; this.reconnectInProgress = false; } /** * Connect to secondary TCP server * @returns {Promise} Promise that resolves when connected */ async connect() { if (this.isConnected) { logger.debug('Secondary TCP client already connected'); return; } if (this.isConnecting) { logger.debug('Secondary TCP connection already in progress'); return; } logger.info('Initiating secondary TCP connection', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort }); try { await this.retryHandler.execute( async (attempt) => { this.connectionAttempts = attempt; return this.attemptConnection(); }, 'Secondary TCP connection', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort } ); // Start heartbeat after successful connection this.startHeartbeat(); return; } catch (error) { logger.error('All connection attempts failed', { error: error.message, host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort }); throw error; } } /** * Attempt a single connection * @returns {Promise} Promise that resolves when connected */ attemptConnection() { return new Promise((resolve, reject) => { this.isConnecting = true; // Create new socket this.socket = new net.Socket(); // Set connection timeout this.socket.setTimeout(this.config.connectionTimeout || 10000); // Connection success handler this.socket.on('connect', () => { this.isConnected = true; this.isConnecting = false; this.reconnectInProgress = false; this.socket.setTimeout(0); // Disable timeout once connected this.lastHeartbeatTime = Date.now(); logger.info('Secondary TCP connection established', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort, localAddress: this.socket.localAddress, localPort: this.socket.localPort, attempts: this.connectionAttempts, socketState: this.getSocketState() }); this.emit('connected', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort, attempts: this.connectionAttempts }); resolve(); }); // Connection timeout handler this.socket.on('timeout', () => { this.cleanup(); const error = new Error(`Secondary TCP connection timeout to ${this.config.secondaryTcpIp}:${this.config.secondaryTcpPort}`); logger.warn('Secondary TCP connection timeout', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort, timeoutMs: this.config.connectionTimeout || 10000 }); reject(error); }); // Connection error handler this.socket.on('error', (error) => { this.cleanup(); logger.warn('Secondary TCP connection error', { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort, error: error.message, code: error.code }); reject(error); }); // Data received handler this.socket.on('data', (data) => { this.handleIncomingData(data); }); // Connection close handler this.socket.on('close', (hadError) => { this.handleDisconnection(hadError); }); // Attempt connection this.socket.connect(this.config.secondaryTcpPort, this.config.secondaryTcpIp); }); } /** * Handle incoming data from secondary TCP connection * @param {Buffer} data - Received data */ handleIncomingData(data) { this.totalBytesReceived += data.length; const dataHex = data.toString('hex'); const dataAscii = data.toString('ascii').replace(/[^\x20-\x7E]/g, '.'); logger.debug('Secondary TCP data received', { bytes: data.length, hex: dataHex, totalReceived: this.totalBytesReceived }); if (this.config.logDataTransfers) { dataLogger.silly(`SECONDARY_TCP->RELAY: ${data.length} bytes | HEX: ${dataHex} | ASCII: ${dataAscii}`); } this.emit('data', data, { source: 'secondary-tcp', bytes: data.length, hex: dataHex, ascii: dataAscii }); } /** * Handle disconnection * @param {boolean} hadError - Whether disconnection was due to error */ handleDisconnection(hadError) { const wasConnected = this.isConnected; this.cleanup(); logger.warn('Secondary TCP connection closed', { hadError, wasConnected, host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort, totalBytesReceived: this.totalBytesReceived, totalBytesSent: this.totalBytesSent }); this.emit('disconnected', { hadError, wasConnected, totalBytesReceived: this.totalBytesReceived, totalBytesSent: this.totalBytesSent }); // Stop heartbeat on disconnection this.stopHeartbeat(); } /** * Get current socket state for diagnostics * @returns {object} Socket state information */ getSocketState() { if (!this.socket) { return { exists: false }; } return { exists: true, destroyed: this.socket.destroyed, writable: this.socket.writable, connecting: this.socket.connecting, pending: this.socket.pending, readyState: this.socket.readyState }; } /** * Start heartbeat to verify connection */ startHeartbeat() { this.stopHeartbeat(); // Clear any existing heartbeat // Set up heartbeat interval (every 10 seconds by default) const heartbeatIntervalMs = this.config.heartbeatInterval || 10000; this.heartbeatInterval = setInterval(() => { this.checkConnection(); }, heartbeatIntervalMs); logger.debug('Started secondary TCP heartbeat', { intervalMs: heartbeatIntervalMs }); } /** * Stop heartbeat */ stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; logger.debug('Stopped secondary TCP heartbeat'); } } /** * Check if connection is still valid */ checkConnection() { if (!this.isConnected || !this.socket) { logger.debug('Heartbeat detected disconnected state'); return; } const socketState = this.getSocketState(); // If socket is destroyed or not writable, connection is invalid if (socketState.destroyed || !socketState.writable) { logger.warn('Heartbeat detected invalid socket state', { socketState }); this.handleDisconnection(false); return; } // Update last heartbeat time this.lastHeartbeatTime = Date.now(); logger.debug('Secondary TCP heartbeat successful', { socketState }); } /** * Reconnect to secondary TCP server * @returns {Promise} Promise that resolves when reconnected */ async reconnect() { if (this.reconnectInProgress) { logger.debug('Reconnection already in progress'); return; } this.reconnectInProgress = true; logger.info('Attempting to reconnect to secondary TCP server'); // Clean up existing connection this.cleanup(); try { // Attempt to connect again await this.connect(); logger.info('Successfully reconnected to secondary TCP server'); return true; } catch (error) { logger.error('Failed to reconnect to secondary TCP server', { error: error.message }); this.reconnectInProgress = false; throw error; } } /** * Send data through secondary TCP connection * @param {Buffer} data - Data to send * @returns {Promise} Promise that resolves when data is sent */ async send(data) { // Enhanced connection verification if (!this.isConnected || !this.socket || this.socket.destroyed || !this.socket.writable) { const socketState = this.getSocketState(); logger.warn('Secondary TCP client not in valid state for sending', { socketState }); // Try to reconnect if socket is in invalid state try { logger.info('Attempting reconnection before sending data'); await this.reconnect(); } catch (reconnectError) { throw new Error(`Secondary TCP client reconnection failed: ${reconnectError.message}`); } } return new Promise((resolve, reject) => { this.socket.write(data, (error) => { if (error) { logger.error('Secondary TCP send error', { error: error.message, code: error.code, socketState: this.getSocketState(), dataLength: data.length }); // Attempt to reconnect and resend on error this.reconnect().then(() => { logger.info('Reconnected, retrying data send'); return this.send(data); }).then(resolve).catch(reject); } else { this.totalBytesSent += data.length; this.lastHeartbeatTime = Date.now(); // Update heartbeat time on successful send const dataHex = data.toString('hex'); const dataAscii = data.toString('ascii').replace(/[^\x20-\x7E]/g, '.'); logger.debug('Secondary TCP data sent', { bytes: data.length, hex: dataHex, totalSent: this.totalBytesSent, socketState: this.getSocketState() }); if (this.config.logDataTransfers) { dataLogger.silly(`RELAY->SECONDARY_TCP: ${data.length} bytes | HEX: ${dataHex} | ASCII: ${dataAscii}`); } this.emit('dataSent', data, { destination: 'secondary-tcp', bytes: data.length, hex: dataHex, ascii: dataAscii }); resolve(); } }); }); } /** * Clean up resources */ cleanup() { this.isConnected = false; this.isConnecting = false; // Stop heartbeat this.stopHeartbeat(); if (this.socket) { // Remove all listeners to prevent memory leaks this.socket.removeAllListeners(); try { this.socket.destroy(); } catch (error) { logger.debug('Error destroying socket', { error: error.message }); } this.socket = null; } } /** * Close secondary TCP connection * @returns {Promise} Promise that resolves when closed */ async close() { logger.info('Closing secondary TCP connection'); return new Promise((resolve) => { if (!this.socket || this.socket.destroyed) { this.cleanup(); resolve(); return; } this.socket.once('close', () => { this.cleanup(); logger.info('Secondary TCP connection closed'); resolve(); }); this.socket.end(); // Force close after timeout setTimeout(() => { if (this.socket && !this.socket.destroyed) { this.socket.destroy(); } this.cleanup(); resolve(); }, 5000); }); } /** * Get connection statistics * @returns {object} Connection stats */ getStats() { return { isConnected: this.isConnected, isConnecting: this.isConnecting, connectionAttempts: this.connectionAttempts, totalBytesReceived: this.totalBytesReceived, totalBytesSent: this.totalBytesSent, config: { host: this.config.secondaryTcpIp, port: this.config.secondaryTcpPort } }; } } module.exports = SecondaryTcpClient;