import * as plugins from './smartipc.plugins.js'; import { IpcTransport, createTransport } from './classes.transports.js'; import type { IIpcMessageEnvelope, IIpcTransportOptions } from './classes.transports.js'; /** * Options for IPC channel */ export interface IIpcChannelOptions extends IIpcTransportOptions { /** Enable automatic reconnection */ autoReconnect?: boolean; /** Initial reconnect delay in ms */ reconnectDelay?: number; /** Maximum reconnect delay in ms */ maxReconnectDelay?: number; /** Reconnect delay multiplier */ reconnectMultiplier?: number; /** Maximum number of reconnect attempts */ maxReconnectAttempts?: number; /** Enable heartbeat */ heartbeat?: boolean; /** Heartbeat interval in ms */ heartbeatInterval?: number; /** Heartbeat timeout in ms */ heartbeatTimeout?: number; /** Initial grace period before heartbeat timeout in ms */ heartbeatInitialGracePeriodMs?: number; /** Throw on heartbeat timeout (default: true, set false to emit event instead) */ heartbeatThrowOnTimeout?: boolean; } /** * Request/Response tracking */ interface IPendingRequest { resolve: (value: T) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout; } /** * IPC Channel with connection management, auto-reconnect, and typed messaging */ export class IpcChannel extends plugins.EventEmitter { private transport: IpcTransport; private options: IIpcChannelOptions; private pendingRequests = new Map(); private messageHandlers = new Map any | Promise>(); private reconnectAttempts = 0; private reconnectTimer?: NodeJS.Timeout; private heartbeatTimer?: NodeJS.Timeout; private heartbeatCheckTimer?: NodeJS.Timeout; private heartbeatGraceTimer?: NodeJS.Timeout; private lastHeartbeat: number = Date.now(); private connectionStartTime: number = Date.now(); private isReconnecting = false; private isClosing = false; // Metrics private metrics = { messagesSent: 0, messagesReceived: 0, bytesSent: 0, bytesReceived: 0, reconnects: 0, heartbeatTimeouts: 0, errors: 0, requestTimeouts: 0, connectedAt: 0 }; constructor(options: IIpcChannelOptions) { super(); this.options = { autoReconnect: true, reconnectDelay: 1000, maxReconnectDelay: 30000, reconnectMultiplier: 1.5, maxReconnectAttempts: Infinity, heartbeat: true, heartbeatInterval: 5000, heartbeatTimeout: 10000, ...options }; // Normalize heartbeatThrowOnTimeout to boolean (defensive for JS consumers) const throwOnTimeout = (this.options as any).heartbeatThrowOnTimeout; if (throwOnTimeout !== undefined) { if (throwOnTimeout === 'false') { this.options.heartbeatThrowOnTimeout = false; } else if (throwOnTimeout === 'true') { this.options.heartbeatThrowOnTimeout = true; } else if (typeof throwOnTimeout !== 'boolean') { this.options.heartbeatThrowOnTimeout = Boolean(throwOnTimeout); } } this.transport = createTransport(this.options); this.setupTransportHandlers(); } /** * Setup transport event handlers */ private setupTransportHandlers(): void { this.transport.on('connect', () => { this.reconnectAttempts = 0; this.isReconnecting = false; this.metrics.connectedAt = Date.now(); this.startHeartbeat(); this.emit('connect'); }); this.transport.on('disconnect', (reason) => { this.stopHeartbeat(); this.clearPendingRequests(new Error(`Disconnected: ${reason || 'Unknown reason'}`)); this.emit('disconnect', reason); if (this.options.autoReconnect && !this.isClosing) { this.scheduleReconnect(); } }); this.transport.on('error', (error) => { this.emit('error', error); }); this.transport.on('message', (message: IIpcMessageEnvelope) => { this.handleMessage(message); }); // Forward per-client disconnects from transports that support multi-client servers // We re-emit a 'clientDisconnected' event with the clientId if known so higher layers can act. // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.transport as any).on?.('clientDisconnected', (_socket: any, clientId?: string) => { this.emit('clientDisconnected', clientId); }); this.transport.on('drain', () => { this.emit('drain'); }); } /** * Connect the channel */ public async connect(): Promise { if (this.transport.isConnected()) { return; } try { await this.transport.connect(); } catch (error) { this.emit('error', error); if (this.options.autoReconnect && !this.isClosing) { this.scheduleReconnect(); } else { throw error; } } } /** * Disconnect the channel */ public async disconnect(): Promise { this.isClosing = true; this.stopHeartbeat(); this.cancelReconnect(); this.clearPendingRequests(new Error('Channel closed')); await this.transport.disconnect(); } /** * Schedule a reconnection attempt */ private scheduleReconnect(): void { if (this.isReconnecting || this.isClosing) { return; } if (this.options.maxReconnectAttempts !== Infinity && this.reconnectAttempts >= this.options.maxReconnectAttempts) { this.emit('error', new Error('Maximum reconnection attempts reached')); return; } this.isReconnecting = true; this.reconnectAttempts++; // Calculate delay with exponential backoff and jitter const baseDelay = Math.min( this.options.reconnectDelay! * Math.pow(this.options.reconnectMultiplier!, this.reconnectAttempts - 1), this.options.maxReconnectDelay! ); const jitter = Math.random() * 0.1 * baseDelay; // 10% jitter const delay = baseDelay + jitter; this.emit('reconnecting', { attempt: this.reconnectAttempts, delay }); this.reconnectTimer = setTimeout(async () => { try { await this.transport.connect(); } catch (error) { // Connection failed, will be rescheduled by disconnect handler } }, delay); } /** * Cancel scheduled reconnection */ private cancelReconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } this.isReconnecting = false; } /** * Start heartbeat mechanism */ private startHeartbeat(): void { if (!this.options.heartbeat) { return; } this.stopHeartbeat(); this.lastHeartbeat = Date.now(); this.connectionStartTime = Date.now(); // Send heartbeat messages this.heartbeatTimer = setInterval(() => { this.sendMessage('__heartbeat__', { timestamp: Date.now() }).catch(() => { // Ignore heartbeat send errors }); }, this.options.heartbeatInterval!); // Delay starting the check until after the grace period const gracePeriod = this.options.heartbeatInitialGracePeriodMs || 0; if (gracePeriod > 0) { // Use a timer to delay the first check this.heartbeatGraceTimer = setTimeout(() => { this.startHeartbeatCheck(); }, gracePeriod); } else { // No grace period, start checking immediately this.startHeartbeatCheck(); } } /** * Start heartbeat timeout checking (separated for grace period handling) */ private startHeartbeatCheck(): void { // Check for heartbeat timeout this.heartbeatCheckTimer = setInterval(() => { const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat; if (timeSinceLastHeartbeat > this.options.heartbeatTimeout!) { const error = new Error('Heartbeat timeout'); if (this.options.heartbeatThrowOnTimeout !== false) { // Default behavior: emit error which may cause disconnect this.emit('error', error); this.transport.disconnect().catch(() => {}); } else { // Emit heartbeatTimeout event instead of error this.emit('heartbeatTimeout', error); // Clear timers to avoid repeated events this.stopHeartbeat(); } } }, Math.max(1000, Math.floor(this.options.heartbeatTimeout! / 2))); } /** * Stop heartbeat mechanism */ private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } if (this.heartbeatCheckTimer) { clearInterval(this.heartbeatCheckTimer); this.heartbeatCheckTimer = undefined; } if (this.heartbeatGraceTimer) { clearTimeout(this.heartbeatGraceTimer); this.heartbeatGraceTimer = undefined; } } /** * Handle incoming messages */ private handleMessage(message: IIpcMessageEnvelope): void { // Track metrics this.metrics.messagesReceived++; this.metrics.bytesReceived += JSON.stringify(message).length; // Handle heartbeat and send response if (message.type === '__heartbeat__') { this.lastHeartbeat = Date.now(); // Reply so the sender also observes liveness this.transport.send({ id: plugins.crypto.randomUUID(), type: '__heartbeat_response__', correlationId: message.id, timestamp: Date.now(), payload: { timestamp: Date.now() }, headers: message.headers?.clientId ? { clientId: message.headers.clientId } : undefined }).catch(() => {}); return; } // Handle heartbeat response if (message.type === '__heartbeat_response__') { this.lastHeartbeat = Date.now(); return; } // Handle request/response if (message.correlationId && this.pendingRequests.has(message.correlationId)) { const pending = this.pendingRequests.get(message.correlationId)!; this.pendingRequests.delete(message.correlationId); if (pending.timer) { clearTimeout(pending.timer); } if (message.headers?.error) { pending.reject(new Error(message.headers.error)); } else { pending.resolve(message.payload); } return; } // Handle regular messages if (this.messageHandlers.has(message.type)) { const handler = this.messageHandlers.get(message.type)!; // If message expects a response if (message.headers?.requiresResponse && message.id) { Promise.resolve() .then(() => handler(message.payload)) .then((result) => { const response: IIpcMessageEnvelope = { id: plugins.crypto.randomUUID(), type: `${message.type}_response`, correlationId: message.id, timestamp: Date.now(), payload: result, headers: message.headers?.clientId ? { clientId: message.headers.clientId } : undefined }; return this.transport.send(response); }) .catch((error: any) => { const response: IIpcMessageEnvelope = { id: plugins.crypto.randomUUID(), type: `${message.type}_response`, correlationId: message.id, timestamp: Date.now(), payload: null, headers: { error: error.message, ...(message.headers?.clientId ? { clientId: message.headers.clientId } : {}) } }; return this.transport.send(response); }); } else { // Fire and forget try { handler(message.payload); } catch (error) { this.emit('error', error); } } } else { // Emit unhandled message this.emit('message', message); } } /** * Send a message without expecting a response */ public async sendMessage(type: string, payload: any, headers?: Record): Promise { // Extract correlationId from headers and place it at top level const { correlationId, ...restHeaders } = headers ?? {}; const message: IIpcMessageEnvelope = { id: plugins.crypto.randomUUID(), type, timestamp: Date.now(), payload, ...(correlationId ? { correlationId } : {}), headers: Object.keys(restHeaders).length ? restHeaders : undefined }; const success = await this.transport.send(message); if (!success) { this.metrics.errors++; throw new Error('Failed to send message'); } // Track metrics this.metrics.messagesSent++; this.metrics.bytesSent += JSON.stringify(message).length; } /** * Send a request and wait for response */ public async request( type: string, payload: TReq, options?: { timeout?: number; headers?: Record } ): Promise { const messageId = plugins.crypto.randomUUID(); const timeout = options?.timeout || 30000; const message: IIpcMessageEnvelope = { id: messageId, type, timestamp: Date.now(), payload, headers: { ...options?.headers, requiresResponse: true } }; return new Promise((resolve, reject) => { // Setup timeout const timer = setTimeout(() => { this.pendingRequests.delete(messageId); reject(new Error(`Request timeout for ${type}`)); }, timeout); // Store pending request this.pendingRequests.set(messageId, { resolve, reject, timer }); // Send message with better error handling this.transport.send(message) .then((success) => { if (!success) { this.pendingRequests.delete(messageId); clearTimeout(timer); reject(new Error('Failed to send message')); } }) .catch((error) => { this.pendingRequests.delete(messageId); clearTimeout(timer); reject(error); }); }); } /** * Register a message handler */ public on(event: string, handler: (payload: any) => any | Promise): this { if (event === 'message' || event === 'connect' || event === 'disconnect' || event === 'error' || event === 'reconnecting' || event === 'drain' || event === 'heartbeatTimeout' || event === 'clientDisconnected') { // Special handling for channel events super.on(event, handler); } else { // Register as message type handler this.messageHandlers.set(event, handler); } return this; } /** * Clear all pending requests */ private clearPendingRequests(error: Error): void { for (const [id, pending] of this.pendingRequests) { if (pending.timer) { clearTimeout(pending.timer); } pending.reject(error); } this.pendingRequests.clear(); } /** * Check if channel is connected */ public isConnected(): boolean { return this.transport.isConnected(); } /** * Get channel statistics */ public getStats(): { connected: boolean; reconnectAttempts: number; pendingRequests: number; isReconnecting: boolean; metrics: { messagesSent: number; messagesReceived: number; bytesSent: number; bytesReceived: number; reconnects: number; heartbeatTimeouts: number; errors: number; requestTimeouts: number; uptime?: number; }; } { return { connected: this.transport.isConnected(), reconnectAttempts: this.reconnectAttempts, pendingRequests: this.pendingRequests.size, isReconnecting: this.isReconnecting, metrics: { ...this.metrics, uptime: this.metrics.connectedAt ? Date.now() - this.metrics.connectedAt : undefined } }; } }