UNPKG

@initbit/nestjs-jetstream

Version:
423 lines 19.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.JetStream = void 0; const microservices_1 = require("@nestjs/microservices"); const common_1 = require("@nestjs/common"); const nats_1 = require("nats"); const nats_context_1 = require("./nats.context"); const nats_constants_1 = require("./nats.constants"); class JetStream extends microservices_1.Server { constructor(options) { super(); this.options = options; this.codec = (0, nats_1.JSONCodec)(); this.eventHandlers = new Map(); // Map to store subject pattern -> handler key mappings for envelope mode this.subjectToHandlerMapping = new Map(); // For backward compatibility, use durableName from options if consumerOptions.name is not provided this.durableName = options.consumerOptions?.name || options.durableName || 'default'; // If stream.name is provided but streamName is not, set streamName from stream.name if (options.stream?.name && !options.streamName) { this.options.streamName = options.stream.name; } // If streamName is provided but stream.name is not, create/update stream object else if (options.streamName && options.stream && !options.stream.name) { // Ensure this.options.stream is defined before accessing its properties if (!this.options.stream) { this.options.stream = { name: options.streamName }; } else { this.options.stream.name = options.streamName; } } // If neither is provided, set a default else if (!options.streamName && (!options.stream || !options.stream.name)) { this.options.streamName = 'default'; if (!this.options.stream) { this.options.stream = { name: 'default' }; } else { this.options.stream.name = 'default'; } } // Initialize logger this.logger = options.logger || new common_1.Logger(JetStream.name); // Ensure servers is always an array if (typeof this.options.servers === 'string') { this.options.servers = [this.options.servers]; } // If no custom mapper is provided, set a default mapper based on the `defaultMapper` option if (!this.options.mapper) { if (this.options.defaultMapper === 'envelope') { // Envelope-based mapping this.options.mapper = (msg, decoded) => { const envelope = decoded; // If envelope is invalid, fallback to subject-based routing if (!envelope || typeof envelope.type !== 'string') { this.logger.warn(`Received message without a valid envelope. Falling back to subject-based routing.`); return { handlerKey: msg.subject, data: decoded }; } // To preserve backward compatibility, we first check for an exact match on the handler key (envelope.type) // This is because older versions of the library relied on this behavior if (this.messageHandlers.has(envelope.type)) { return { handlerKey: envelope.type, data: envelope.payload, ctxExtras: envelope.meta }; } // If no direct match is found, use the subject-to-handler mapping to find the correct handler const handlerKey = this.subjectToHandlerMapping.get(msg.subject); if (handlerKey) { return { handlerKey: handlerKey, data: envelope.payload, ctxExtras: envelope.meta }; } // If no mapping is found, fallback to subject-based routing as a last resort this.logger.warn(`No handler found for message type "${envelope.type}". Falling back to subject-based routing.`); return { handlerKey: msg.subject, data: decoded }; }; } else { // Default subject-based mapping this.options.mapper = (msg, decoded) => { return { handlerKey: msg.subject, data: decoded }; }; } } } async listen(callback) { const servers = Array.isArray(this.options.servers) ? this.options.servers.join(', ') : this.options.servers; this.logger.log(`Connecting to NATS JetStream (${servers})...`); this.nc = await (0, nats_1.connect)({ servers: this.options.servers }); this.js = this.nc.jetstream(); this.jsm = await this.nc.jetstreamManager(); await this.ensureStream(this.jsm); await this.ensureConsumer(this.jsm); this.logger.log('JetStream connection established.'); this.subscribeToTopics(this.js); callback(); } async close() { this.logger.log('Closing JetStream connection...'); if (this.nc) { await this.nc.drain(); this.nc = undefined; this.js = undefined; this.jsm = undefined; } } on(event, callback) { this.eventHandlers.set(event, callback); } unwrap(value) { return value; } /** * Override addHandler to store subject-to-handler mapping when envelope mapping is used */ addHandler(pattern, callback, isEventHandler, extras) { // Call parent implementation to maintain existing behavior super.addHandler(pattern, callback, isEventHandler, extras); // If envelope mapping is enabled, store the mapping between subject pattern and handler key if (this.options.defaultMapper === 'envelope' && isEventHandler) { const normalizedPattern = this.normalizePattern(pattern); // For envelope mode, we need to determine the handler key // If extras contains a handlerKey, use that; otherwise use the pattern const handlerKey = extras?.['handlerKey'] || normalizedPattern; // Store the mapping this.subjectToHandlerMapping.set(normalizedPattern, handlerKey); this.logger?.debug?.(`Stored subject-to-handler mapping: ${normalizedPattern} -> ${handlerKey}`); } } /** * Get the subject-to-handler mapping for debugging and testing purposes * @returns Map of subject patterns to handler keys */ getSubjectToHandlerMapping() { return new Map(this.subjectToHandlerMapping); } async ensureStream(jsm) { try { // Get stream name from the appropriate source const streamName = this.options.stream?.name || this.options.streamName || 'default'; // Create stream configuration const streamConfig = { name: streamName, subjects: this.options.stream?.subjects || ['*', '>'], retention: nats_1.RetentionPolicy.Limits, storage: nats_1.StorageType.File, max_consumers: 0, sealed: false, first_seq: 0, max_msgs_per_subject: 0, max_msgs: 0, max_age: 0, max_bytes: 0, max_msg_size: 0, discard: nats_1.DiscardPolicy.Old, discard_new_per_subject: false, duplicate_window: 0, allow_rollup_hdrs: false, num_replicas: 0, deny_delete: false, deny_purge: false, allow_direct: false, mirror_direct: false }; // Add description if provided if (this.options.stream?.description) { streamConfig.description = this.options.stream.description; } // Check if stream exists try { const existingStream = await jsm.streams.info(streamName); // If stream exists, update it with new config if (existingStream) { await jsm.streams.update(streamName, streamConfig); this.logger.log(`Stream "${streamName}" updated.`); } } catch (error) { // Stream doesn't exist, create it await jsm.streams.add(streamConfig); this.logger.log(`Stream "${streamName}" created.`); } } catch (err) { this.logger.error(`Error creating/updating stream: ${err.message}`); } } async ensureConsumer(jsm) { try { // Get stream name from the appropriate source const streamName = this.options.stream?.name || this.options.streamName || 'default'; // Get consumer name from the appropriate source const consumerName = this.options.consumerOptions?.name || this.durableName; // Start with base consumer config const consumerConfig = { ack_policy: this.options.ackPolicy || nats_1.AckPolicy.Explicit, deliver_policy: this.options.deliverPolicy || nats_1.DeliverPolicy.All, replay_policy: nats_1.ReplayPolicy.Original }; // Check if we should create a durable consumer const isDurable = this.options.consumerOptions?.durable !== false; // Add durable_name if this is a durable consumer if (isDurable && consumerName) { consumerConfig.durable_name = consumerName; } // Add ackWait if specified if (this.options.ackWait !== undefined) { consumerConfig.ack_wait = this.options.ackWait * 1_000_000; // Convert to nanoseconds } // Add filterSubject if specified if (this.options.filterSubject) { consumerConfig.filter_subject = this.options.filterSubject; } // Add filterSubjects if specified if (this.options.filterSubjects && this.options.filterSubjects.length > 0) { consumerConfig.filter_subjects = this.options.filterSubjects; } // Apply any additional consumer options if (this.options.consumerOptions) { // Merge consumer options, excluding 'durable' and 'name' which are handled separately const { durable, name, ...restOptions } = this.options.consumerOptions; Object.assign(consumerConfig, restOptions); } // Create or update the consumer try { // Check if consumer exists (for durable consumers) if (isDurable && consumerName) { try { await jsm.consumers.info(streamName, consumerName); // Consumer exists, update it await jsm.consumers.update(streamName, consumerName, consumerConfig); this.logger.log(`Durable consumer "${consumerName}" updated.`); } catch (error) { // Consumer doesn't exist, create it await jsm.consumers.add(streamName, consumerConfig); this.logger.log(`Durable consumer "${consumerName}" created.`); } } else { // For non-durable consumers, always create a new one await jsm.consumers.add(streamName, consumerConfig); this.logger.log(`Ephemeral consumer created.`); } } catch (err) { this.logger.error(`Error creating/updating consumer: ${err.message}`); } } catch (err) { this.logger.error(`Error setting up consumer: ${err.message}`); } } subscribeToTopics(js) { // Log all patterns /*for (const pattern of this.messageHandlers.keys()) { // Use the pattern directly without appending streamName this.logger.log(`Subscribed to: ${pattern}`); }*/ // Subscribe to event patterns using JetStream this.subscribeToEventPatterns(js); // Subscribe to message patterns using the NATS connection if (this.nc) { this.subscribeToMessagePatterns(this.nc); } else { this.logger.error('NATS connection not established. Cannot subscribe to message patterns.'); } } async handleJetStreamMessage(message) { message.working(); // 1. Decode message once const decoded = this.codec.decode(message.data); // 2. Invoke selected mapper to obtain { handlerKey, data } with try/catch for fail-fast & nak let handlerKey; let data; let ctxExtras; try { const mapperResult = this.options.mapper(message, decoded); handlerKey = mapperResult.handlerKey; data = mapperResult.data; ctxExtras = mapperResult.ctxExtras; } catch (error) { this.logger.error(`Error in mapper: ${error}`, error.stack); message.nak(); // Fail fast and nak on mapper error return; } // 3. Resolve the handler via this.messageHandlers.get(handlerKey) (fallback error if none) const handler = this.messageHandlers.get(handlerKey); if (!handler) { this.logger.warn(`No handler for message with key: ${handlerKey}`); message.term(); // No handler, so terminate return; } // 4. Forward data & contextual NatsContext (augmented with ctxExtras) to handler // 5. Preserve current ack / nak / term logic try { const context = new nats_context_1.NatsContext([message, ctxExtras]); await this.invokeHandler(handler, data, context); message.ack(); } catch (error) { if (error === nats_constants_1.NACK) { message.nak(); } else if (error === nats_constants_1.TERM) { message.term(); } else { this.logger.error(`Error handling message: ${error}`, error.stack); // Depending on the desired behavior, you might want to NACK or TERM on unknown errors. // For now, we will let it be handled by the NATS redelivery policy. } } } async invokeHandler(handler, data, context) { const maybeObservable = await handler(data, context); const response$ = this.transformToObservable(maybeObservable); return response$.toPromise(); } async handleNatsMessage(message, handler) { const decoded = this.codec.decode(message.data); const maybeObservable = await handler(decoded, new nats_context_1.NatsContext([message, undefined])); const response$ = this.transformToObservable(maybeObservable); this.send(response$, (response) => { const encoded = this.codec.encode(response); message.respond(encoded); }); } async subscribeToEventPatterns(client) { const eventHandlers = [...this.messageHandlers.entries()].filter(([, handler]) => handler.isEventHandler); // Get stream name from the appropriate source const streamName = this.options.stream?.name || this.options.streamName || 'default'; for (const [pattern, handler] of eventHandlers) { // Create a direct ConsumerOpts object instead of using the builder const consumerOptions = { deliver_subject: (0, nats_1.createInbox)(), ack_policy: nats_1.AckPolicy.Explicit, filter_subject: pattern }; // Apply any custom consumer options if provided if (this.options.consumer) { const tempBuilder = (0, nats_1.consumerOpts)(); this.options.consumer(tempBuilder); // We can't access the built options directly, so we'll rely on the client to merge them } // Create a callback function for handling messages const callbackFn = (error, message) => { if (error) { this.logger.error(error.message, error.stack); return; } if (message) { this.handleJetStreamMessage(message).catch((err) => { this.logger.error(`Error handling JetStream message: ${err.message}`, err.stack); }); } }; try { // Use client.subscribe with the pattern, passing the stream name as part of the options // The NATS client will handle merging these options with any provided by the consumer function await client.subscribe(pattern, { config: consumerOptions, stream: streamName, mack: true, // Enable manual ack callbackFn }); this.logger.log(`Subscribed to ${pattern} events in stream ${streamName}`); } catch (error) { if (!(error instanceof nats_1.NatsError) || !error.isJetStreamError()) { throw error; } if (error.message === 'no stream matches subject') { throw new Error(`Cannot find stream with the ${pattern} event pattern. Make sure the stream "${streamName}" includes this subject.`); } } } } subscribeToMessagePatterns(connection) { const messageHandlers = [...this.messageHandlers.entries()].filter(([, handler]) => !handler.isEventHandler); for (const [pattern, handler] of messageHandlers) { connection.subscribe(pattern, { callback: (error, message) => { if (error) { return this.logger.error(error.message, error.stack); } return this.handleNatsMessage(message, handler); }, queue: this.options.queue }); this.logger.log(`Subscribed to ${pattern} messages`); } } async handleStatusUpdates(connection) { for await (const status of connection.status()) { const data = typeof status.data === 'object' ? JSON.stringify(status.data) : status.data; const message = `(${status.type}): ${data}`; switch (status.type) { case 'pingTimer': case 'reconnecting': case 'staleConnection': this.logger?.debug?.(message); break; case 'disconnect': case 'error': this.logger.error(message); break; case 'reconnect': this.logger.log(message); break; case 'ldm': this.logger.warn(message); break; case 'update': this.logger?.verbose?.(message); break; } } } } exports.JetStream = JetStream; //# sourceMappingURL=jetstream.transport.js.map