tcp-serial-relay
Version:
Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard
453 lines (399 loc) • 12.2 kB
JavaScript
// src/services/tcp-client.js - Enhanced error handling
const net = require('net');
const EventEmitter = require('events');
const { logger, dataLogger } = require('../utils/logger');
const { createConnectionRetryHandler } = require('../utils/retry-handler');
/**
* TCP Client with automatic reconnection and robust error handling
*/
class TcpClient extends EventEmitter {
constructor(config) {
super();
this.config = config;
this.socket = null;
this.isConnected = false;
this.isConnecting = false;
this.isClosing = false;
this.retryHandler = createConnectionRetryHandler({
maxRetries: config.maxRetries || 3,
baseDelay: config.retryDelay || 2000
});
this.connectionAttempts = 0;
this.totalBytesReceived = 0;
this.totalBytesSent = 0;
this.lastError = null;
}
/**
* Connect to TCP server
* @returns {Promise} Promise that resolves when connected
*/
async connect() {
if (this.isConnected) {
logger.debug('TCP client already connected');
return;
}
if (this.isConnecting) {
logger.debug('TCP connection already in progress');
return;
}
logger.info('Initiating the TCP connection yall - attempt ' + this.connectionAttempts, {
host: this.config.tcpIp,
port: this.config.tcpPort
});
return this.retryHandler.execute(
async (attempt) => {
this.connectionAttempts = attempt;
return this.attemptConnection();
},
'TCP connection',
{
host: this.config.tcpIp,
port: this.config.tcpPort
}
);
}
/**
* Attempt a single connection
* @returns {Promise} Promise that resolves when connected
*/
attemptConnection() {
return new Promise((resolve, reject) => {
this.isConnecting = true;
this.lastError = null;
// 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.socket.setTimeout(0); // Disable timeout once connected
logger.info('TCP connection established', {
host: this.config.tcpIp,
port: this.config.tcpPort,
localAddress: this.socket.localAddress,
localPort: this.socket.localPort,
attempts: this.connectionAttempts
});
this.emit('connected', {
host: this.config.tcpIp,
port: this.config.tcpPort,
attempts: this.connectionAttempts
});
resolve();
});
// Connection timeout handler
this.socket.on('timeout', () => {
const error = new Error(`TCP connection timeout to ${this.config.tcpIp}:${this.config.tcpPort}`);
this.handleConnectionError(error);
reject(error);
});
// Connection error handler - Enhanced to prevent uncaught exceptions
this.socket.on('error', (error) => {
this.handleConnectionError(error);
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
try {
this.socket.connect(this.config.tcpPort, this.config.tcpIp);
} catch (error) {
this.handleConnectionError(error);
reject(error);
}
});
}
/**
* Enhanced error handling to prevent uncaught exceptions
* @param {Error} error - The error that occurred
*/
handleConnectionError(error) {
this.lastError = error;
this.cleanup();
// Determine error severity and type
const errorInfo = {
host: this.config.tcpIp,
port: this.config.tcpPort,
error: error.message,
code: error.code,
errno: error.errno,
syscall: error.syscall,
isConnectionError: this.isConnectionError(error),
isNetworkError: this.isNetworkError(error)
};
if (this.isConnectionError(error)) {
logger.warn('TCP connection error (retryable)', errorInfo);
} else if (this.isNetworkError(error)) {
logger.error('TCP network error (may require attention)', errorInfo);
} else {
logger.error('TCP unexpected error', errorInfo);
}
// Emit error event instead of letting it bubble up as uncaught
this.emit('error', {
...errorInfo,
retryable: this.isRetryableError(error)
});
}
/**
* Check if error is a connection-related error
* @param {Error} error - Error to check
* @returns {boolean} True if connection error
*/
isConnectionError(error) {
const connectionErrors = [
'ECONNREFUSED',
'ENOTFOUND',
'ETIMEDOUT',
'EHOSTUNREACH',
'ENETUNREACH'
];
return connectionErrors.includes(error.code);
}
/**
* Check if error is a network-related error
* @param {Error} error - Error to check
* @returns {boolean} True if network error
*/
isNetworkError(error) {
const networkErrors = [
'ECONNRESET',
'EPIPE',
'ENOTCONN',
'ESHUTDOWN'
];
return networkErrors.includes(error.code);
}
/**
* Check if error is retryable
* @param {Error} error - Error to check
* @returns {boolean} True if retryable
*/
isRetryableError(error) {
// Most connection and some network errors are retryable
return this.isConnectionError(error) ||
['ECONNRESET', 'ETIMEDOUT'].includes(error.code);
}
/**
* Handle incoming data from TCP connection
* @param {Buffer} data - Received data
*/
handleIncomingData(data) {
try {
this.totalBytesReceived += data.length;
const dataHex = data.toString('hex');
const dataAscii = data.toString('ascii').replace(/[^\x20-\x7E]/g, '.');
logger.debug('TCP data received', {
bytes: data.length,
hex: dataHex,
totalReceived: this.totalBytesReceived
});
if (this.config.logDataTransfers) {
dataLogger.silly(`TCP->RELAY: ${data.length} bytes | HEX: ${dataHex} | ASCII: ${dataAscii}`);
}
this.emit('data', data, {
source: 'tcp',
bytes: data.length,
hex: dataHex,
ascii: dataAscii
});
} catch (error) {
logger.error('Error processing TCP data', {
error: error.message,
dataLength: data?.length || 0
});
this.emit('dataError', error);
}
}
/**
* Handle disconnection
* @param {boolean} hadError - Whether disconnection was due to error
*/
handleDisconnection(hadError) {
const wasConnected = this.isConnected;
logger.warn('TCP connection closed', {
hadError,
wasConnected,
isClosing: this.isClosing,
host: this.config.tcpIp,
port: this.config.tcpPort,
totalBytesReceived: this.totalBytesReceived,
totalBytesSent: this.totalBytesSent,
lastError: this.lastError?.message
});
this.cleanup();
this.emit('disconnected', {
hadError,
wasConnected,
isClosing: this.isClosing,
totalBytesReceived: this.totalBytesReceived,
totalBytesSent: this.totalBytesSent,
lastError: this.lastError
});
}
async write(data) {
this.socket.write(data);
}
/**
* Send data through TCP connection with error handling
* @param {Buffer} data - Data to send
* @returns {Promise} Promise that resolves when data is sent
*/
async send(data) {
if (!this.isConnected || !this.socket) {
throw new Error('TCP client not connected');
}
if (this.socket.destroyed || this.socket.readyState !== 'open') {
throw new Error('TCP socket is not in a writable state');
}
return new Promise((resolve, reject) => {
try {
this.socket.write(data, (error) => {
if (error) {
logger.error('TCP send error', {
error: error.message,
code: error.code,
dataLength: data.length
});
reject(error);
} else {
logger.info('TCP data sent', {
bytes: data.length,
hex: data.toString('hex'),
totalSent: this.totalBytesSent
});
this.totalBytesSent += data.length;
const dataHex = data.toString('hex');
const dataAscii = data.toString('ascii').replace(/[^\x20-\x7E]/g, '.');
logger.debug('TCP data sent', {
bytes: data.length,
hex: dataHex,
totalSent: this.totalBytesSent
});
if (this.config.logDataTransfers) {
dataLogger.silly(`RELAY->TCP: ${data.length} bytes | HEX: ${dataHex} | ASCII: ${dataAscii}`);
}
this.emit('dataSent', data, {
destination: 'tcp',
bytes: data.length,
hex: dataHex,
ascii: dataAscii
});
resolve();
}
});
} catch (error) {
logger.error('TCP send exception', {
error: error.message,
dataLength: data.length
});
reject(error);
}
});
}
/**
* Clean up connection state
*/
cleanup() {
this.isConnected = false;
this.isConnecting = false;
if (this.socket) {
try {
// Remove all listeners to prevent memory leaks
this.socket.removeAllListeners();
if (!this.socket.destroyed) {
this.socket.destroy();
}
} catch (error) {
logger.warn('Error during socket cleanup', {
error: error.message
});
} finally {
this.socket = null;
}
}
}
/**
* Close TCP connection gracefully
* @returns {Promise} Promise that resolves when closed
*/
async close() {
if (this.isClosing) {
logger.debug('TCP close already in progress');
return;
}
this.isClosing = true;
logger.info('Closing TCP connection gracefully');
return new Promise((resolve) => {
if (!this.socket || this.socket.destroyed) {
this.cleanup();
resolve();
return;
}
// Set up close handler
const onClose = () => {
this.cleanup();
logger.info('TCP connection closed gracefully');
resolve();
};
this.socket.once('close', onClose);
try {
// Try graceful close first
this.socket.end();
// Force close after timeout
setTimeout(() => {
if (this.socket && !this.socket.destroyed) {
logger.warn('TCP graceful close timeout, forcing close');
this.socket.destroy();
}
this.cleanup();
resolve();
}, 3000);
} catch (error) {
logger.warn('Error during TCP close', {
error: error.message
});
this.cleanup();
resolve();
}
});
}
/**
* Get connection statistics
* @returns {object} Connection stats
*/
getStats() {
return {
isConnected: this.isConnected,
isConnecting: this.isConnecting,
isClosing: this.isClosing,
connectionAttempts: this.connectionAttempts,
totalBytesReceived: this.totalBytesReceived,
totalBytesSent: this.totalBytesSent,
lastError: this.lastError?.message || null,
config: {
host: this.config.tcpIp,
port: this.config.tcpPort,
timeout: this.config.connectionTimeout
}
};
}
/**
* Check if connection is healthy
* @returns {boolean} True if connection is healthy
*/
isHealthy() {
return this.isConnected &&
this.socket &&
!this.socket.destroyed &&
this.socket.readyState === 'open';
}
}
module.exports = TcpClient;