/** * NATS Service - Central connection and subscription management * Allows connectors to register handlers for different NATS subjects */ import { connect, NatsConnection, StringCodec, Subscription } from "nats"; import { logger } from "../reporting-service/index.js"; const sc = StringCodec(); export interface NatsMessageHandler { /** * Handler function for NATS messages * - Return a value to reply (if msg has reply subject) * - Return void/undefined for pub-sub (no reply) */ handle: (data: any, subject: string) => Promise; } export interface NatsHandlerRegistration { /** * Subject pattern to subscribe to (supports wildcards: *, >) * Examples: * - "mm.16.epic01.labor-ticket.*" (single level wildcard) * - "mm.16.epic01.*" (multi-level wildcard) */ subject: string; /** * Handler for this subject */ handler: NatsMessageHandler; /** * Optional description for logging/debugging */ description?: string; } export interface NatsServiceConfig { /** * NATS server URLs */ servers: string | string[]; /** * Connection name (for monitoring) */ name: string; /** * Location reference (for subject namespacing) */ locationRef: string; /** * ERP type (epicor, infor, etc.) */ erpType: string; /** * Enable/disable NATS */ enabled: boolean; /** * Auto-reconnect settings */ reconnect?: boolean; maxReconnectAttempts?: number; reconnectTimeWait?: number; } export class NatsService { private connection: NatsConnection | null = null; private subscriptions: Map = new Map(); private config: NatsServiceConfig; private handlers: NatsHandlerRegistration[] = []; private statusPublishTimer: NodeJS.Timeout | null = null; constructor(config: NatsServiceConfig) { this.config = config; } /** * Register a handler for a specific subject pattern */ registerHandler(registration: NatsHandlerRegistration): void { logger.info("Registering NATS handler", { subject: registration.subject, description: registration.description, }); this.handlers.push(registration); } /** * Connect to NATS and start all registered handlers */ async connect(): Promise { if (!this.config.enabled) { logger.info("NATS is disabled, skipping connection"); return; } try { logger.info("Connecting to NATS...", { servers: this.config.servers, name: this.config.name, }); this.connection = await connect({ servers: this.config.servers, name: this.config.name, reconnect: this.config.reconnect ?? true, maxReconnectAttempts: this.config.maxReconnectAttempts ?? -1, reconnectTimeWait: this.config.reconnectTimeWait ?? 2000, }); logger.info("Connected to NATS", { server: this.connection.getServer(), clientId: this.connection.info?.client_id, }); // Start all registered handlers for (const registration of this.handlers) { await this.startHandler(registration); } // Start automatic status publishing this.startStatusPublishing(); // Monitor connection status this.monitorConnection(); // Graceful shutdown this.setupShutdown(); } catch (error) { logger.error("Failed to connect to NATS", { error }); throw error; } } /** * Start a single handler (subscribe to its subject) */ private async startHandler(registration: NatsHandlerRegistration): Promise { if (!this.connection) { throw new Error("NATS connection not established"); } const sub = this.connection.subscribe(registration.subject); this.subscriptions.set(registration.subject, sub); logger.info("Started NATS handler", { subject: registration.subject, description: registration.description, }); // Process messages (async () => { for await (const msg of sub) { try { const data = sc.decode(msg.data); logger.info("Received NATS message", { subject: msg.subject, hasReply: !!msg.reply, }); // Parse JSON if possible let parsedData: any; try { parsedData = JSON.parse(data); } catch { parsedData = data; } // Call handler const response = await registration.handler.handle(parsedData, msg.subject); // If there's a reply subject and handler returned something, reply if (msg.reply && response !== undefined) { const responseStr = JSON.stringify(response); msg.respond(sc.encode(responseStr)); logger.info("Sent reply", { replySubject: msg.reply }); } } catch (error) { logger.error("Error handling NATS message", { subject: msg.subject, error, }); // Send error response if reply expected if (msg.reply) { const errorResponse = { status: "error", error: { message: error instanceof Error ? error.message : "Unknown error", code: "HANDLER_ERROR", }, }; msg.respond(sc.encode(JSON.stringify(errorResponse))); } } } })(); } /** * Publish a message to a subject (for pub/sub) */ async publish(subject: string, data: any): Promise { if (!this.connection) { throw new Error("NATS connection not established"); } const message = typeof data === "string" ? data : JSON.stringify(data); this.connection.publish(subject, sc.encode(message)); logger.info("Published NATS message", { subject }); } /** * Send a request and wait for reply (for request-reply) */ async request(subject: string, data: any, timeoutMs: number = 30000): Promise { if (!this.connection) { throw new Error("NATS connection not established"); } const message = typeof data === "string" ? data : JSON.stringify(data); const response = await this.connection.request( subject, sc.encode(message), { timeout: timeoutMs } ); const responseData = sc.decode(response.data); try { return JSON.parse(responseData); } catch { return responseData; } } /** * Check if connected to NATS */ isConnected(): boolean { return this.connection !== null && !this.connection.isClosed(); } /** * Start automatic status publishing (every 30 seconds) */ private startStatusPublishing(): void { logger.info("Starting status publishing (every 30 seconds)"); // Publish immediately on start this.publishStatus(); // Then publish every 30 seconds this.statusPublishTimer = setInterval(() => { this.publishStatus(); }, 30000); } /** * Publish connector status */ private async publishStatus(): Promise { try { const status = { timestamp: new Date().toISOString(), locationRef: this.config.locationRef, erpType: this.config.erpType, natsConnected: this.isConnected(), }; await this.publish( `mm.16.${this.config.locationRef}.status`, status ); logger.debug("Published connector status"); } catch (error) { logger.error("Failed to publish status", { error }); } } /** * Monitor connection status */ private monitorConnection(): void { if (!this.connection) return; (async () => { for await (const status of this.connection!.status()) { // Only log important events, skip pingTimer if (status.type !== "pingTimer") { logger.info("NATS connection status", { type: status.type, data: status.data, }); } } })(); } /** * Setup graceful shutdown */ private setupShutdown(): void { const shutdown = async () => { logger.info("Shutting down NATS service..."); await this.disconnect(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); } /** * Disconnect from NATS */ async disconnect(): Promise { // Stop status publishing if (this.statusPublishTimer) { clearInterval(this.statusPublishTimer); this.statusPublishTimer = null; } // Close connection if (this.connection) { await this.connection.drain(); this.connection = null; this.subscriptions.clear(); logger.info("Disconnected from NATS"); } } /** * Get the location reference */ getLocationRef(): string { return this.config.locationRef; } }