@initbit/nestjs-jetstream
Version:
NestJS custom NATS JetStream transport
423 lines • 19.6 kB
JavaScript
"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