UNPKG

@pulzar/core

Version:

Next-generation Node.js framework for ultra-fast web applications with zero-reflection DI, GraphQL, WebSockets, events, and edge runtime support

605 lines 19.9 kB
import { EventError, } from "../types"; import { logger } from "../../utils/logger"; export class NATSEventAdapter { name = "nats"; version = "1.0.0"; capabilities = { persistence: true, clustering: true, partitioning: false, consumerGroups: true, deadLetterQueue: true, exactly_once: false, at_least_once: true, ordering: true, wildcards: true, replay: true, backpressure: true, }; client; jetStream; jetStreamManager; config; connected = false; subscriptions = new Map(); stats = { published: 0, delivered: 0, acknowledged: 0, failed: 0, retries: 0, dlqSize: 0, activeSubscriptions: 0, throughputPerSecond: 0, averageLatency: 0, errorRate: 0, backpressureEvents: 0, lastActivity: new Date(), }; constructor(config = {}) { this.config = { servers: config.servers || "nats://localhost:4222", user: config.user, pass: config.pass, token: config.token, nkey: config.nkey, jwt: config.jwt, seed: config.seed, timeout: config.timeout || 2000, reconnect: config.reconnect !== false, maxReconnectAttempts: config.maxReconnectAttempts || -1, reconnectTimeWait: config.reconnectTimeWait || 2000, jetstream: { enabled: config.jetstream?.enabled !== false, domain: config.jetstream?.domain, apiPrefix: config.jetstream?.apiPrefix, timeout: config.jetstream?.timeout || 5000, }, }; } /** * Connect to NATS server */ async connect() { if (this.connected) { return; } try { // Try to dynamically import NATS const nats = await this.importNATS(); if (!nats) { throw new EventError("NATS package not installed. Run: npm install nats", "NATS_NOT_INSTALLED"); } // Connect to NATS this.client = await nats.connect({ servers: this.config.servers, user: this.config.user, pass: this.config.pass, token: this.config.token, timeout: this.config.timeout, reconnect: this.config.reconnect, maxReconnectAttempts: this.config.maxReconnectAttempts, reconnectTimeWait: this.config.reconnectTimeWait, }); // Setup JetStream if enabled if (this.config.jetstream.enabled) { this.jetStream = this.client.jetstream({ domain: this.config.jetstream.domain, apiPrefix: this.config.jetstream.apiPrefix, timeout: this.config.jetstream.timeout, }); this.jetStreamManager = await this.client.jetstreamManager(); } // Setup connection event handlers this.setupEventHandlers(); this.connected = true; logger.info("NATS adapter connected", { servers: this.config.servers, jetstream: this.config.jetstream.enabled, }); } catch (error) { logger.error("Failed to connect to NATS", { error }); throw new EventError(`NATS connection failed: ${error.message}`, "CONNECTION_FAILED", undefined, error); } } /** * Disconnect from NATS */ async disconnect() { if (!this.connected || !this.client) { return; } try { // Close all subscriptions for (const [id, subscription] of this.subscriptions) { try { await subscription.unsubscribe(); } catch (error) { logger.warn(`Failed to unsubscribe ${id}`, { error }); } } this.subscriptions.clear(); // Flush pending messages await this.client.flush(); // Close connection await this.client.close(); this.connected = false; this.resetStats(); logger.info("NATS adapter disconnected"); } catch (error) { logger.error("Error disconnecting from NATS", { error }); throw error; } } /** * Check if connected */ isConnected() { return this.connected && this.client && !this.client.isClosed(); } /** * Publish event to NATS/JetStream */ async publish(subject, event) { if (!this.connected) { throw new EventError("NATS not connected", "NOT_CONNECTED"); } const startTime = Date.now(); try { const payload = this.serialize(event); let result; if (this.jetStream) { // Use JetStream for persistent messaging result = await this.jetStream.publish(subject, payload, { msgID: event.id, headers: this.createHeaders(event), }); } else { // Use core NATS for simple pub/sub this.client.publish(subject, payload); result = { seq: 0, duplicate: false, stream: "", }; } this.updateStats("published", Date.now() - startTime); return { messageId: event.id, partition: result.stream, offset: result.seq?.toString(), timestamp: new Date().toISOString(), }; } catch (error) { this.updateStats("failed"); logger.error("Failed to publish to NATS", { subject, error }); throw new EventError(`NATS publish failed: ${error.message}`, "PUBLISH_FAILED", event, error); } } /** * Subscribe to events */ async subscribe(subject, handler, options = {}) { if (!this.connected) { throw new EventError("NATS not connected", "NOT_CONNECTED"); } try { let subscription; const subscriptionId = this.generateId(); if (this.jetStream && options.durable) { // JetStream durable consumer subscription = await this.createJetStreamConsumer(subject, handler, options, subscriptionId); } else if (this.jetStream) { // JetStream ephemeral consumer subscription = await this.jetStream.subscribe(subject, { callback: (err, msg) => { if (err) { logger.error("JetStream subscription error", { error: err }); return; } this.handleMessage(msg, handler, options); }, max: options.maxMsgs, queue: options.consumerGroup, }); } else { // Core NATS subscription subscription = this.client.subscribe(subject, { callback: (err, msg) => { if (err) { logger.error("NATS subscription error", { error: err }); return; } this.handleMessage(msg, handler, options); }, max: options.maxMsgs, queue: options.consumerGroup, }); } const handle = { id: subscriptionId, subject, active: true, consumerGroup: options.consumerGroup, createdAt: new Date(), unsubscribe: async () => { await this.unsubscribe(handle); }, }; this.subscriptions.set(subscriptionId, subscription); this.stats.activeSubscriptions = this.subscriptions.size; logger.debug("NATS subscription created", { subject, subscriptionId, durable: options.durable, consumerGroup: options.consumerGroup, }); return handle; } catch (error) { logger.error("Failed to subscribe to NATS", { subject, error }); throw new EventError(`NATS subscribe failed: ${error.message}`, "SUBSCRIBE_FAILED", undefined, error); } } /** * Unsubscribe from events */ async unsubscribe(handle) { const subscription = this.subscriptions.get(handle.id); if (!subscription) { return; } try { await subscription.unsubscribe(); this.subscriptions.delete(handle.id); this.stats.activeSubscriptions = this.subscriptions.size; logger.debug("NATS subscription removed", { subscriptionId: handle.id }); } catch (error) { logger.error("Failed to unsubscribe from NATS", { subscriptionId: handle.id, error, }); throw error; } } /** * Acknowledge message */ async ack(event) { if (event._ackToken) { try { event._ackToken.ack(); this.stats.acknowledged++; } catch (error) { logger.error("Failed to ack NATS message", { eventId: event.id, error, }); throw error; } } } /** * Negative acknowledge message */ async nack(event, requeue = false) { if (event._ackToken) { try { if (requeue) { event._ackToken.nak(); } else { event._ackToken.term(); } } catch (error) { logger.error("Failed to nack NATS message", { eventId: event.id, error, }); throw error; } } } /** * Flush pending messages */ async flush() { if (this.client) { await this.client.flush(); } } /** * Get adapter statistics */ async getStats() { // Get JetStream account info if available if (this.jetStreamManager) { try { const accountInfo = await this.jetStreamManager.getAccountInfo(); // Update stats based on JetStream info } catch (error) { logger.debug("Failed to get JetStream account info", { error }); } } return { ...this.stats }; } /** * Health check */ async healthCheck() { const checks = [ { name: "connection", status: this.isConnected() ? "pass" : "fail", message: this.isConnected() ? "Connected to NATS" : "Not connected to NATS", }, ]; // JetStream health check if (this.jetStreamManager) { try { await this.jetStreamManager.getAccountInfo(); checks.push({ name: "jetstream", status: "pass", message: "JetStream is available", }); } catch (error) { checks.push({ name: "jetstream", status: "fail", message: `JetStream error: ${error.message}`, }); } } const hasFailures = checks.some((c) => c.status === "fail"); const hasWarnings = checks.some((c) => c.status === "warn"); return { status: hasFailures ? "unhealthy" : hasWarnings ? "degraded" : "healthy", checks, timestamp: new Date(), }; } /** * Create JetStream consumer */ async createJetStreamConsumer(subject, handler, options, subscriptionId) { const streamName = this.getStreamName(subject); // Ensure stream exists await this.ensureStream(streamName, [subject]); const consumerConfig = { name: options.consumerGroup || subscriptionId, durable: options.durable ? options.consumerGroup || subscriptionId : undefined, deliverPolicy: options.deliverPolicy || "all", ackPolicy: options.ackPolicy === "auto" ? "all" : options.ackPolicy || "explicit", ackWait: options.ackTimeout ? options.ackTimeout * 1_000_000 : 30_000_000_000, // nanoseconds maxDeliver: options.maxDeliver || 5, filterSubject: subject, maxAckPending: options.maxAckPending || 1000, }; // Create or get consumer const consumer = await this.jetStreamManager.consumers.add(streamName, consumerConfig); // Create subscription const subscription = await this.jetStream.subscribe(subject, { consumer: consumer.name, stream: streamName, callback: (err, msg) => { if (err) { logger.error("JetStream consumer error", { error: err }); return; } this.handleMessage(msg, handler, options); }, }); return subscription; } /** * Handle incoming message */ async handleMessage(msg, handler, options) { const startTime = Date.now(); try { const event = this.deserialize(msg.data, msg); // Apply filter if provided if (options.filter) { const matches = await options.filter(event); if (!matches) { // Ack filtered messages if (msg.ack) msg.ack(); return; } } // Store ack token for manual acknowledgment event._ackToken = msg.ack ? msg : undefined; await handler(event); // Auto-ack if configured if (options.ackPolicy === "auto" && msg.ack) { msg.ack(); this.stats.acknowledged++; } this.updateStats("delivered", Date.now() - startTime); } catch (error) { this.updateStats("failed"); logger.error("Failed to handle NATS message", { error }); // Handle message failure based on policy if (msg.ack) { if (options.maxDeliver && msg.info?.redeliveryCount >= options.maxDeliver) { msg.term(); // Send to DLQ } else { msg.nak(); // Requeue for retry } } throw error; } } /** * Ensure stream exists */ async ensureStream(name, subjects) { if (!this.jetStreamManager) { return; } try { // Try to get existing stream await this.jetStreamManager.streams.info(name); } catch (error) { // Create stream if it doesn't exist const streamConfig = { name, subjects, storage: "file", retention: "limits", maxAge: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days in nanoseconds replicas: 1, }; await this.jetStreamManager.streams.add(streamConfig); logger.info("Created JetStream stream", { name, subjects }); } } /** * Get stream name from subject */ getStreamName(subject) { // Convert subject to stream name (replace dots with underscores) return `STREAM_${subject.replace(/\./g, "_").replace(/\*/g, "STAR").replace(/>/g, "GT").toUpperCase()}`; } /** * Create NATS headers */ createHeaders(event) { const headers = {}; // Copy event headers for (const [key, value] of Object.entries(event.headers)) { headers[key] = String(value); } // Add metadata as headers if (event.metadata.correlationId) { headers["X-Correlation-ID"] = event.metadata.correlationId; } if (event.metadata.traceId) { headers["X-Trace-ID"] = event.metadata.traceId; } if (event.metadata.userId) { headers["X-User-ID"] = event.metadata.userId; } return headers; } /** * Serialize event for transmission */ serialize(event) { const payload = JSON.stringify(event); return new TextEncoder().encode(payload); } /** * Deserialize received message */ deserialize(data, msg) { const payload = new TextDecoder().decode(data); const event = JSON.parse(payload); // Add internal fields event._adapter = this.name; if (msg.seq) { event._offset = msg.seq; } if (msg.stream) { event._partition = msg.stream; } return event; } /** * Setup connection event handlers */ setupEventHandlers() { if (!this.client) return; this.client.closed().then(() => { this.connected = false; logger.warn("NATS connection closed"); }); // Handle reconnection events if (this.client.addEventListener) { this.client.addEventListener("reconnect", () => { logger.info("NATS reconnected"); }); this.client.addEventListener("error", (error) => { logger.error("NATS connection error", { error }); }); } } /** * Try to import NATS package */ async importNATS() { try { return await import("nats"); } catch (error) { logger.warn("NATS package not available", { error }); return null; } } /** * Update statistics */ updateStats(type, latency) { this.stats[type]++; this.stats.lastActivity = new Date(); if (latency !== undefined) { // Update average latency (simple moving average) this.stats.averageLatency = (this.stats.averageLatency + latency) / 2; } // Calculate error rate const total = this.stats.published + this.stats.delivered; this.stats.errorRate = total > 0 ? this.stats.failed / total : 0; } /** * Reset statistics */ resetStats() { this.stats = { published: 0, delivered: 0, acknowledged: 0, failed: 0, retries: 0, dlqSize: 0, activeSubscriptions: 0, throughputPerSecond: 0, averageLatency: 0, errorRate: 0, backpressureEvents: 0, lastActivity: new Date(), }; } /** * Generate unique ID */ generateId() { return `nats-${Date.now()}-${Math.random().toString(36).slice(2)}`; } } export default NATSEventAdapter; //# sourceMappingURL=nats.js.map