@horizon-republic/nestjs-jetstream 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,36 @@
1
+ <div align="center">
2
+
1
3
  # @horizon-republic/nestjs-jetstream
2
4
 
3
- Ship reliable microservices with NATS JetStream and NestJS. Events, broadcast, ordered delivery, and RPC — with two lines of config.
5
+ **Ship reliable microservices with NATS JetStream and NestJS.**
6
+ Events, broadcast, ordered delivery, and RPC — with two lines of config.
4
7
 
5
8
  [![npm version](https://img.shields.io/npm/v/@horizon-republic/nestjs-jetstream.svg)](https://www.npmjs.com/package/@horizon-republic/nestjs-jetstream)
6
9
  [![codecov](https://codecov.io/github/HorizonRepublic/nestjs-jetstream/graph/badge.svg?token=40IPSWFMT4)](https://codecov.io/github/HorizonRepublic/nestjs-jetstream)
10
+ [![CI](https://github.com/HorizonRepublic/nestjs-jetstream/actions/workflows/coverage.yml/badge.svg)](https://github.com/HorizonRepublic/nestjs-jetstream/actions)
11
+ [![Documentation](https://img.shields.io/badge/docs-online-brightgreen.svg)](https://horizonrepublic.github.io/nestjs-jetstream/)
12
+
13
+ [![Node.js](https://img.shields.io/node/v/@horizon-republic/nestjs-jetstream.svg)](https://nodejs.org)
14
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org)
7
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
- [![Socket Badge](https://badge.socket.dev/npm/package/@horizon-republic/nestjs-jetstream)](https://socket.dev/npm/package/@horizon-republic/nestjs-jetstream)
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## Why this library?
22
+
23
+ NestJS ships with a NATS transport, but it's fire-and-forget. Messages vanish if no one's listening. This library adds JetStream — so your messages **survive restarts**, **retry on failure**, and **replay for new consumers**.
24
+
25
+ You keep writing `@EventPattern()` and `@MessagePattern()`. The library handles streams, consumers, and subjects automatically.
26
+
27
+ ## What's inside
28
+
29
+ **Delivery modes** — workqueue (one consumer), broadcast (all consumers), ordered (sequential), and dual-mode RPC (Core or JetStream-backed).
30
+
31
+ **Production-ready** — dead letter queue, health checks, graceful shutdown with drain, lifecycle hooks for observability.
32
+
33
+ **Flexible** — pluggable codecs (JSON/MsgPack/Protobuf), per-stream configuration, publisher-only mode for API gateways.
9
34
 
10
35
  ## Quick Start
11
36
 
@@ -44,9 +69,13 @@ export class OrdersController {
44
69
 
45
70
  **[Read the full documentation →](https://horizonrepublic.github.io/nestjs-jetstream/)**
46
71
 
47
- - [Getting Started](https://horizonrepublic.github.io/nestjs-jetstream/docs/getting-started) installation, module setup, first handler
48
- - [Guides](https://horizonrepublic.github.io/nestjs-jetstream/docs/guides/health-checks) — health checks, graceful shutdown, lifecycle hooks
49
- - [API Reference](https://horizonrepublic.github.io/nestjs-jetstream/docs/reference/api/) full TypeDoc-generated API
72
+ | Section | What you'll learn |
73
+ |---------|-------------------|
74
+ | [Getting Started](https://horizonrepublic.github.io/nestjs-jetstream/docs/getting-started/installation) | Installation, module setup, first handler |
75
+ | [Messaging Patterns](https://horizonrepublic.github.io/nestjs-jetstream/docs/patterns/rpc) | RPC, Events, Broadcast, Ordered Events |
76
+ | [Guides](https://horizonrepublic.github.io/nestjs-jetstream/docs/guides/record-builder) | Handler context, DLQ, health checks, performance tuning |
77
+ | [Migration](https://horizonrepublic.github.io/nestjs-jetstream/docs/guides/migration) | From built-in NATS transport or between versions |
78
+ | [API Reference](https://horizonrepublic.github.io/nestjs-jetstream/docs/reference/api/) | Full TypeDoc-generated API |
50
79
 
51
80
  ## Links
52
81
 
package/dist/index.cjs CHANGED
@@ -830,6 +830,23 @@ var EventBus = class {
830
830
  emit(event, ...args) {
831
831
  const hook = this.hooks[event];
832
832
  if (!hook) return;
833
+ this.callHook(event, hook, ...args);
834
+ }
835
+ /**
836
+ * Hot-path optimized emit for MessageRouted events.
837
+ * Avoids rest/spread overhead of the generic `emit()`.
838
+ */
839
+ emitMessageRouted(subject, kind) {
840
+ const hook = this.hooks["messageRouted" /* MessageRouted */];
841
+ if (!hook) return;
842
+ this.callHook(
843
+ "messageRouted" /* MessageRouted */,
844
+ hook,
845
+ subject,
846
+ kind
847
+ );
848
+ }
849
+ callHook(event, hook, ...args) {
833
850
  try {
834
851
  const result = hook(...args);
835
852
  if (result && typeof result.catch === "function") {
@@ -959,7 +976,7 @@ var JetstreamStrategy = class extends import_microservices2.Server {
959
976
  this.eventRouter.start();
960
977
  }
961
978
  if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
962
- this.rpcRouter.start();
979
+ await this.rpcRouter.start();
963
980
  }
964
981
  }
965
982
  if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
@@ -1053,6 +1070,13 @@ var import_nats5 = require("nats");
1053
1070
  // src/context/rpc.context.ts
1054
1071
  var import_microservices3 = require("@nestjs/microservices");
1055
1072
  var RpcContext = class extends import_microservices3.BaseRpcContext {
1073
+ _shouldRetry = false;
1074
+ _retryDelay;
1075
+ _shouldTerminate = false;
1076
+ _terminateReason;
1077
+ // ---------------------------------------------------------------------------
1078
+ // Message accessors
1079
+ // ---------------------------------------------------------------------------
1056
1080
  /**
1057
1081
  * Get the underlying NATS message.
1058
1082
  *
@@ -1087,6 +1111,96 @@ var RpcContext = class extends import_microservices3.BaseRpcContext {
1087
1111
  isJetStream() {
1088
1112
  return "ack" in this.args[0];
1089
1113
  }
1114
+ // ---------------------------------------------------------------------------
1115
+ // JetStream metadata (return undefined for Core NATS messages)
1116
+ // ---------------------------------------------------------------------------
1117
+ /** How many times this message has been delivered. */
1118
+ getDeliveryCount() {
1119
+ return this.asJetStream()?.info.deliveryCount;
1120
+ }
1121
+ /** The JetStream stream this message belongs to. */
1122
+ getStream() {
1123
+ return this.asJetStream()?.info.stream;
1124
+ }
1125
+ /** The stream sequence number. */
1126
+ getSequence() {
1127
+ return this.asJetStream()?.seq;
1128
+ }
1129
+ /** The message timestamp as a `Date` (derived from `info.timestampNanos`). */
1130
+ getTimestamp() {
1131
+ const nanos = this.asJetStream()?.info.timestampNanos;
1132
+ return typeof nanos === "number" ? new Date(nanos / 1e6) : void 0;
1133
+ }
1134
+ /** The name of the service that published this message (from `x-caller-name` header). */
1135
+ getCallerName() {
1136
+ return this.getHeader("x-caller-name" /* CallerName */);
1137
+ }
1138
+ // ---------------------------------------------------------------------------
1139
+ // Handler-controlled settlement
1140
+ // ---------------------------------------------------------------------------
1141
+ /**
1142
+ * Signal the transport to retry (nak) this message instead of acknowledging it.
1143
+ *
1144
+ * Use for business-level retries without throwing errors.
1145
+ * Only affects JetStream event handlers (workqueue/broadcast).
1146
+ *
1147
+ * @param opts - Optional delay in ms before redelivery.
1148
+ * @throws Error if {@link terminate} was already called.
1149
+ */
1150
+ retry(opts) {
1151
+ this.assertJetStream("retry");
1152
+ if (this._shouldTerminate) {
1153
+ throw new Error("Cannot retry \u2014 terminate() was already called");
1154
+ }
1155
+ this._shouldRetry = true;
1156
+ this._retryDelay = opts?.delayMs;
1157
+ }
1158
+ /**
1159
+ * Signal the transport to permanently reject (term) this message.
1160
+ *
1161
+ * Use when a message is no longer relevant and should not be retried or sent to DLQ.
1162
+ * Only affects JetStream event handlers (workqueue/broadcast).
1163
+ *
1164
+ * @param reason - Optional reason for termination (logged by NATS).
1165
+ * @throws Error if {@link retry} was already called.
1166
+ */
1167
+ terminate(reason) {
1168
+ this.assertJetStream("terminate");
1169
+ if (this._shouldRetry) {
1170
+ throw new Error("Cannot terminate \u2014 retry() was already called");
1171
+ }
1172
+ this._shouldTerminate = true;
1173
+ this._terminateReason = reason;
1174
+ }
1175
+ /** Narrow to JsMsg or return null for Core messages. Used by metadata getters. */
1176
+ asJetStream() {
1177
+ return this.isJetStream() ? this.args[0] : null;
1178
+ }
1179
+ /** Ensure the message is JetStream — settlement actions are not available for Core NATS. */
1180
+ assertJetStream(method) {
1181
+ if (!this.isJetStream()) {
1182
+ throw new Error(`${method}() is only available for JetStream messages`);
1183
+ }
1184
+ }
1185
+ // ---------------------------------------------------------------------------
1186
+ // Transport-facing state (read by EventRouter)
1187
+ // ---------------------------------------------------------------------------
1188
+ /** @internal */
1189
+ get shouldRetry() {
1190
+ return this._shouldRetry;
1191
+ }
1192
+ /** @internal */
1193
+ get retryDelay() {
1194
+ return this._retryDelay;
1195
+ }
1196
+ /** @internal */
1197
+ get shouldTerminate() {
1198
+ return this._shouldTerminate;
1199
+ }
1200
+ /** @internal */
1201
+ get terminateReason() {
1202
+ return this._terminateReason;
1203
+ }
1090
1204
  };
1091
1205
 
1092
1206
  // src/utils/ack-extension.ts
@@ -1125,15 +1239,20 @@ var serializeError = (err) => {
1125
1239
 
1126
1240
  // src/utils/unwrap-result.ts
1127
1241
  var import_rxjs2 = require("rxjs");
1128
- var unwrapResult = async (result) => {
1242
+ var RESOLVED_VOID = Promise.resolve(void 0);
1243
+ var RESOLVED_NULL = Promise.resolve(null);
1244
+ var unwrapResult = (result) => {
1245
+ if (result === void 0) return RESOLVED_VOID;
1246
+ if (result === null) return RESOLVED_NULL;
1129
1247
  if ((0, import_rxjs2.isObservable)(result)) {
1130
1248
  return subscribeToFirst(result);
1131
1249
  }
1132
- const resolved = await result;
1133
- if ((0, import_rxjs2.isObservable)(resolved)) {
1134
- return subscribeToFirst(resolved);
1250
+ if (typeof result.then === "function") {
1251
+ return result.then(
1252
+ (resolved) => (0, import_rxjs2.isObservable)(resolved) ? subscribeToFirst(resolved) : resolved
1253
+ );
1135
1254
  }
1136
- return resolved;
1255
+ return Promise.resolve(result);
1137
1256
  };
1138
1257
  var subscribeToFirst = (obs) => new Promise((resolve, reject) => {
1139
1258
  let done = false;
@@ -1211,7 +1330,7 @@ var CoreRpcServer = class {
1211
1330
  this.respondWithError(msg, new Error(`No handler for subject: ${msg.subject}`));
1212
1331
  return;
1213
1332
  }
1214
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "rpc" /* Rpc */);
1333
+ this.eventBus.emitMessageRouted(msg.subject, "rpc" /* Rpc */);
1215
1334
  let data;
1216
1335
  try {
1217
1336
  data = this.codec.decode(msg.data);
@@ -1696,6 +1815,10 @@ var PatternRegistry = class {
1696
1815
  registry = /* @__PURE__ */ new Map();
1697
1816
  // Cached after registerHandlers() — the registry is immutable from that point
1698
1817
  cachedPatterns = null;
1818
+ _hasEvents = false;
1819
+ _hasCommands = false;
1820
+ _hasBroadcasts = false;
1821
+ _hasOrdered = false;
1699
1822
  /**
1700
1823
  * Register all handlers from the NestJS strategy.
1701
1824
  *
@@ -1729,6 +1852,10 @@ var PatternRegistry = class {
1729
1852
  this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
1730
1853
  }
1731
1854
  this.cachedPatterns = this.buildPatternsByKind();
1855
+ this._hasEvents = this.cachedPatterns.events.length > 0;
1856
+ this._hasCommands = this.cachedPatterns.commands.length > 0;
1857
+ this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
1858
+ this._hasOrdered = this.cachedPatterns.ordered.length > 0;
1732
1859
  this.logSummary();
1733
1860
  }
1734
1861
  /** Find handler for a full NATS subject. */
@@ -1740,16 +1867,16 @@ var PatternRegistry = class {
1740
1867
  return this.getPatternsByKind().broadcasts.map((p) => buildBroadcastSubject(p));
1741
1868
  }
1742
1869
  hasBroadcastHandlers() {
1743
- return this.getPatternsByKind().broadcasts.length > 0;
1870
+ return this._hasBroadcasts;
1744
1871
  }
1745
1872
  hasRpcHandlers() {
1746
- return this.getPatternsByKind().commands.length > 0;
1873
+ return this._hasCommands;
1747
1874
  }
1748
1875
  hasEventHandlers() {
1749
- return this.getPatternsByKind().events.length > 0;
1876
+ return this._hasEvents;
1750
1877
  }
1751
1878
  hasOrderedHandlers() {
1752
- return this.getPatternsByKind().ordered.length > 0;
1879
+ return this._hasOrdered;
1753
1880
  }
1754
1881
  /** Get fully-qualified NATS subjects for ordered handlers. */
1755
1882
  getOrderedSubjects() {
@@ -1853,13 +1980,8 @@ var EventRouter = class {
1853
1980
  const isOrdered = kind === "ordered" /* Ordered */;
1854
1981
  const ackExtensionInterval = isOrdered ? null : resolveAckExtensionInterval(this.getAckExtensionConfig(kind), this.ackWaitMap?.get(kind));
1855
1982
  const concurrency = this.getConcurrency(kind);
1856
- const route = (msg) => (0, import_rxjs4.defer)(
1857
- () => isOrdered ? this.handleOrdered(msg) : this.handle(msg, ackExtensionInterval)
1858
- ).pipe(
1859
- (0, import_rxjs4.catchError)((err) => {
1860
- this.logger.error(`Unexpected error in ${kind} event router`, err);
1861
- return import_rxjs4.EMPTY;
1862
- })
1983
+ const route = (msg) => (0, import_rxjs4.from)(
1984
+ isOrdered ? this.handleOrderedSafe(msg) : this.handleSafe(msg, ackExtensionInterval, kind)
1863
1985
  );
1864
1986
  const subscription = stream$.pipe(isOrdered ? (0, import_rxjs4.concatMap)(route) : (0, import_rxjs4.mergeMap)(route, concurrency)).subscribe();
1865
1987
  this.subscriptions.push(subscription);
@@ -1874,23 +1996,36 @@ var EventRouter = class {
1874
1996
  if (kind === "broadcast" /* Broadcast */) return this.processingConfig?.broadcast?.ackExtension;
1875
1997
  return void 0;
1876
1998
  }
1877
- /** Handle a single event message: decode -> execute handler -> ack/nak. */
1878
- handle(msg, ackExtensionInterval) {
1879
- const resolved = this.decodeMessage(msg);
1880
- if (!resolved) return import_rxjs4.EMPTY;
1881
- return (0, import_rxjs4.from)(
1882
- this.executeHandler(resolved.handler, resolved.data, resolved.ctx, msg, ackExtensionInterval)
1883
- );
1999
+ /** Handle a single event message with error isolation. */
2000
+ async handleSafe(msg, ackExtensionInterval, kind) {
2001
+ try {
2002
+ const resolved = this.decodeMessage(msg);
2003
+ if (!resolved) return;
2004
+ await this.executeHandler(
2005
+ resolved.handler,
2006
+ resolved.data,
2007
+ resolved.ctx,
2008
+ msg,
2009
+ ackExtensionInterval
2010
+ );
2011
+ } catch (err) {
2012
+ this.logger.error(`Unexpected error in ${kind} event router`, err);
2013
+ }
1884
2014
  }
1885
- /** Handle an ordered message: decode -> execute handler -> no ack/nak. */
1886
- handleOrdered(msg) {
1887
- const resolved = this.decodeMessage(msg, true);
1888
- if (!resolved) return import_rxjs4.EMPTY;
1889
- return (0, import_rxjs4.from)(
1890
- unwrapResult(resolved.handler(resolved.data, resolved.ctx)).catch((err) => {
1891
- this.logger.error(`Ordered handler error (${msg.subject}):`, err);
1892
- })
1893
- );
2015
+ /** Handle an ordered message with error isolation. */
2016
+ async handleOrderedSafe(msg) {
2017
+ try {
2018
+ const resolved = this.decodeMessage(msg, true);
2019
+ if (!resolved) return;
2020
+ await unwrapResult(resolved.handler(resolved.data, resolved.ctx));
2021
+ if (resolved.ctx.shouldRetry || resolved.ctx.shouldTerminate) {
2022
+ this.logger.warn(
2023
+ `retry()/terminate() ignored for ordered message ${msg.subject} \u2014 ordered consumers auto-acknowledge`
2024
+ );
2025
+ }
2026
+ } catch (err) {
2027
+ this.logger.error(`Ordered handler error (${msg.subject}):`, err);
2028
+ }
1894
2029
  }
1895
2030
  /** Resolve handler, decode payload, and build context. Returns null on failure. */
1896
2031
  decodeMessage(msg, isOrdered = false) {
@@ -1908,7 +2043,7 @@ var EventRouter = class {
1908
2043
  this.logger.error(`Decode error for ${msg.subject}:`, err);
1909
2044
  return null;
1910
2045
  }
1911
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "event" /* Event */);
2046
+ this.eventBus.emitMessageRouted(msg.subject, "event" /* Event */);
1912
2047
  return { handler, data, ctx: new RpcContext([msg]) };
1913
2048
  }
1914
2049
  /** Execute handler, then ack on success or nak/dead-letter on failure. */
@@ -1916,7 +2051,13 @@ var EventRouter = class {
1916
2051
  const stopAckExtension = startAckExtensionTimer(msg, ackExtensionInterval);
1917
2052
  try {
1918
2053
  await unwrapResult(handler(data, ctx));
1919
- msg.ack();
2054
+ if (ctx.shouldTerminate) {
2055
+ msg.term(ctx.terminateReason);
2056
+ } else if (ctx.shouldRetry) {
2057
+ msg.nak(ctx.retryDelay);
2058
+ } else {
2059
+ msg.ack();
2060
+ }
1920
2061
  } catch (err) {
1921
2062
  this.logger.error(`Event handler error (${msg.subject}):`, err);
1922
2063
  if (this.isDeadLetter(msg)) {
@@ -1983,6 +2124,7 @@ var RpcRouter = class {
1983
2124
  concurrency;
1984
2125
  resolvedAckExtensionInterval;
1985
2126
  subscription = null;
2127
+ cachedNc = null;
1986
2128
  /** Lazily resolve the ack extension interval (needs ackWaitMap populated at runtime). */
1987
2129
  get ackExtensionInterval() {
1988
2130
  if (this.resolvedAckExtensionInterval !== void 0) return this.resolvedAckExtensionInterval;
@@ -1993,56 +2135,50 @@ var RpcRouter = class {
1993
2135
  return this.resolvedAckExtensionInterval;
1994
2136
  }
1995
2137
  /** Start routing command messages to handlers. */
1996
- start() {
1997
- this.subscription = this.messageProvider.commands$.pipe(
1998
- (0, import_rxjs5.mergeMap)(
1999
- (msg) => (0, import_rxjs5.defer)(() => this.handle(msg)).pipe(
2000
- (0, import_rxjs5.catchError)((err) => {
2001
- this.logger.error("Unexpected error in RPC router", err);
2002
- return import_rxjs5.EMPTY;
2003
- })
2004
- ),
2005
- this.concurrency
2006
- )
2007
- ).subscribe();
2138
+ async start() {
2139
+ this.cachedNc = await this.connection.getConnection();
2140
+ this.subscription = this.messageProvider.commands$.pipe((0, import_rxjs5.mergeMap)((msg) => (0, import_rxjs5.from)(this.handleSafe(msg)), this.concurrency)).subscribe();
2008
2141
  }
2009
2142
  /** Stop routing and unsubscribe. */
2010
2143
  destroy() {
2011
2144
  this.subscription?.unsubscribe();
2012
2145
  this.subscription = null;
2013
2146
  }
2014
- /** Handle a single RPC command message. */
2015
- handle(msg) {
2016
- const handler = this.patternRegistry.getHandler(msg.subject);
2017
- if (!handler) {
2018
- msg.term(`No handler for RPC: ${msg.subject}`);
2019
- this.logger.error(`No handler for RPC subject: ${msg.subject}`);
2020
- return import_rxjs5.EMPTY;
2021
- }
2022
- const replyTo = msg.headers?.get("x-reply-to" /* ReplyTo */);
2023
- const correlationId = msg.headers?.get("x-correlation-id" /* CorrelationId */);
2024
- if (!replyTo || !correlationId) {
2025
- msg.term("Missing required headers (reply-to or correlation-id)");
2026
- this.logger.error(`Missing headers for RPC: ${msg.subject}`);
2027
- return import_rxjs5.EMPTY;
2028
- }
2029
- let data;
2147
+ /** Handle a single RPC command message with error isolation. */
2148
+ async handleSafe(msg) {
2030
2149
  try {
2031
- data = this.codec.decode(msg.data);
2150
+ const handler = this.patternRegistry.getHandler(msg.subject);
2151
+ if (!handler) {
2152
+ msg.term(`No handler for RPC: ${msg.subject}`);
2153
+ this.logger.error(`No handler for RPC subject: ${msg.subject}`);
2154
+ return;
2155
+ }
2156
+ const { headers: msgHeaders } = msg;
2157
+ const replyTo = msgHeaders?.get("x-reply-to" /* ReplyTo */);
2158
+ const correlationId = msgHeaders?.get("x-correlation-id" /* CorrelationId */);
2159
+ if (!replyTo || !correlationId) {
2160
+ msg.term("Missing required headers (reply-to or correlation-id)");
2161
+ this.logger.error(`Missing headers for RPC: ${msg.subject}`);
2162
+ return;
2163
+ }
2164
+ let data;
2165
+ try {
2166
+ data = this.codec.decode(msg.data);
2167
+ } catch (err) {
2168
+ msg.term("Decode error");
2169
+ this.logger.error(`Decode error for RPC ${msg.subject}:`, err);
2170
+ return;
2171
+ }
2172
+ this.eventBus.emitMessageRouted(msg.subject, "rpc" /* Rpc */);
2173
+ await this.executeHandler(handler, data, msg, replyTo, correlationId);
2032
2174
  } catch (err) {
2033
- msg.term("Decode error");
2034
- this.logger.error(`Decode error for RPC ${msg.subject}:`, err);
2035
- return import_rxjs5.EMPTY;
2175
+ this.logger.error("Unexpected error in RPC router", err);
2036
2176
  }
2037
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "rpc" /* Rpc */);
2038
- return (0, import_rxjs5.from)(this.executeHandler(handler, data, msg, replyTo, correlationId));
2039
2177
  }
2040
2178
  /** Execute handler, publish response, settle message. */
2041
2179
  async executeHandler(handler, data, msg, replyTo, correlationId) {
2042
- const nc = await this.connection.getConnection();
2180
+ const nc = this.cachedNc ?? await this.connection.getConnection();
2043
2181
  const ctx = new RpcContext([msg]);
2044
- const hdrs = (0, import_nats9.headers)();
2045
- hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2046
2182
  let settled = false;
2047
2183
  const stopAckExtension = startAckExtensionTimer(msg, this.ackExtensionInterval);
2048
2184
  const timeoutId = setTimeout(() => {
@@ -2061,6 +2197,8 @@ var RpcRouter = class {
2061
2197
  stopAckExtension?.();
2062
2198
  msg.ack();
2063
2199
  try {
2200
+ const hdrs = (0, import_nats9.headers)();
2201
+ hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2064
2202
  nc.publish(replyTo, this.codec.encode(result), { headers: hdrs });
2065
2203
  } catch (publishErr) {
2066
2204
  this.logger.error(`Failed to publish RPC response for ${msg.subject}`, publishErr);
@@ -2071,6 +2209,8 @@ var RpcRouter = class {
2071
2209
  clearTimeout(timeoutId);
2072
2210
  stopAckExtension?.();
2073
2211
  try {
2212
+ const hdrs = (0, import_nats9.headers)();
2213
+ hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2074
2214
  hdrs.set("x-error" /* Error */, "true");
2075
2215
  nc.publish(replyTo, this.codec.encode(serializeError(err)), { headers: hdrs });
2076
2216
  } catch (encodeErr) {
package/dist/index.d.cts CHANGED
@@ -416,6 +416,12 @@ declare class EventBus {
416
416
  * @param args - Arguments matching the hook signature for this event.
417
417
  */
418
418
  emit<K extends keyof TransportHooks>(event: K, ...args: Parameters<TransportHooks[K]>): void;
419
+ /**
420
+ * Hot-path optimized emit for MessageRouted events.
421
+ * Avoids rest/spread overhead of the generic `emit()`.
422
+ */
423
+ emitMessageRouted(subject: string, kind: MessageKind): void;
424
+ private callHook;
419
425
  }
420
426
 
421
427
  /**
@@ -491,6 +497,10 @@ declare class PatternRegistry {
491
497
  private readonly logger;
492
498
  private readonly registry;
493
499
  private cachedPatterns;
500
+ private _hasEvents;
501
+ private _hasCommands;
502
+ private _hasBroadcasts;
503
+ private _hasOrdered;
494
504
  constructor(options: JetstreamModuleOptions);
495
505
  /**
496
506
  * Register all handlers from the NestJS strategy.
@@ -613,10 +623,10 @@ declare class EventRouter {
613
623
  private subscribeToStream;
614
624
  private getConcurrency;
615
625
  private getAckExtensionConfig;
616
- /** Handle a single event message: decode -> execute handler -> ack/nak. */
617
- private handle;
618
- /** Handle an ordered message: decode -> execute handler -> no ack/nak. */
619
- private handleOrdered;
626
+ /** Handle a single event message with error isolation. */
627
+ private handleSafe;
628
+ /** Handle an ordered message with error isolation. */
629
+ private handleOrderedSafe;
620
630
  /** Resolve handler, decode payload, and build context. Returns null on failure. */
621
631
  private decodeMessage;
622
632
  /** Execute handler, then ack on success or nak/dead-letter on failure. */
@@ -652,15 +662,16 @@ declare class RpcRouter {
652
662
  private readonly concurrency;
653
663
  private resolvedAckExtensionInterval;
654
664
  private subscription;
665
+ private cachedNc;
655
666
  constructor(messageProvider: MessageProvider, patternRegistry: PatternRegistry, connection: ConnectionProvider, codec: Codec, eventBus: EventBus, rpcOptions?: RpcRouterOptions | undefined, ackWaitMap?: Map<StreamKind, number> | undefined);
656
667
  /** Lazily resolve the ack extension interval (needs ackWaitMap populated at runtime). */
657
668
  private get ackExtensionInterval();
658
669
  /** Start routing command messages to handlers. */
659
- start(): void;
670
+ start(): Promise<void>;
660
671
  /** Stop routing and unsubscribe. */
661
672
  destroy(): void;
662
- /** Handle a single RPC command message. */
663
- private handle;
673
+ /** Handle a single RPC command message with error isolation. */
674
+ private handleSafe;
664
675
  /** Execute handler, publish response, settle message. */
665
676
  private executeHandler;
666
677
  }
@@ -1137,19 +1148,33 @@ type NatsMessage = JsMsg | Msg;
1137
1148
  * Execution context for RPC and event handlers.
1138
1149
  *
1139
1150
  * Provides convenient accessors for the NATS message, subject,
1140
- * and headers without needing to interact with the raw message directly.
1151
+ * headers, and JetStream metadata without needing to interact
1152
+ * with the raw message directly.
1153
+ *
1154
+ * Handlers can also control message settlement via {@link retry}
1155
+ * and {@link terminate} instead of throwing errors.
1141
1156
  *
1142
1157
  * @example
1143
1158
  * ```typescript
1144
- * @MessagePattern('get.user')
1145
- * getUser(data: GetUserDto, @Ctx() ctx: RpcContext) {
1146
- * const traceId = ctx.getHeader('x-trace-id');
1147
- * const subject = ctx.getSubject();
1148
- * return this.userService.findOne(data.id);
1159
+ * @EventPattern('order.process')
1160
+ * async handle(@Payload() data: OrderDto, @Ctx() ctx: RpcContext) {
1161
+ * if (ctx.getDeliveryCount()! >= 3) {
1162
+ * ctx.terminate('Max business retries exceeded');
1163
+ * return;
1164
+ * }
1165
+ * if (!this.isReady()) {
1166
+ * ctx.retry({ delay: 5000 });
1167
+ * return;
1168
+ * }
1169
+ * await this.process(data);
1149
1170
  * }
1150
1171
  * ```
1151
1172
  */
1152
1173
  declare class RpcContext extends BaseRpcContext<[NatsMessage]> {
1174
+ private _shouldRetry;
1175
+ private _retryDelay;
1176
+ private _shouldTerminate;
1177
+ private _terminateReason;
1153
1178
  /**
1154
1179
  * Get the underlying NATS message.
1155
1180
  *
@@ -1176,6 +1201,50 @@ declare class RpcContext extends BaseRpcContext<[NatsMessage]> {
1176
1201
  isJetStream(): this is RpcContext & {
1177
1202
  getMessage(): JsMsg;
1178
1203
  };
1204
+ /** How many times this message has been delivered. */
1205
+ getDeliveryCount(): number | undefined;
1206
+ /** The JetStream stream this message belongs to. */
1207
+ getStream(): string | undefined;
1208
+ /** The stream sequence number. */
1209
+ getSequence(): number | undefined;
1210
+ /** The message timestamp as a `Date` (derived from `info.timestampNanos`). */
1211
+ getTimestamp(): Date | undefined;
1212
+ /** The name of the service that published this message (from `x-caller-name` header). */
1213
+ getCallerName(): string | undefined;
1214
+ /**
1215
+ * Signal the transport to retry (nak) this message instead of acknowledging it.
1216
+ *
1217
+ * Use for business-level retries without throwing errors.
1218
+ * Only affects JetStream event handlers (workqueue/broadcast).
1219
+ *
1220
+ * @param opts - Optional delay in ms before redelivery.
1221
+ * @throws Error if {@link terminate} was already called.
1222
+ */
1223
+ retry(opts?: {
1224
+ delayMs?: number;
1225
+ }): void;
1226
+ /**
1227
+ * Signal the transport to permanently reject (term) this message.
1228
+ *
1229
+ * Use when a message is no longer relevant and should not be retried or sent to DLQ.
1230
+ * Only affects JetStream event handlers (workqueue/broadcast).
1231
+ *
1232
+ * @param reason - Optional reason for termination (logged by NATS).
1233
+ * @throws Error if {@link retry} was already called.
1234
+ */
1235
+ terminate(reason?: string): void;
1236
+ /** Narrow to JsMsg or return null for Core messages. Used by metadata getters. */
1237
+ private asJetStream;
1238
+ /** Ensure the message is JetStream — settlement actions are not available for Core NATS. */
1239
+ private assertJetStream;
1240
+ /** @internal */
1241
+ get shouldRetry(): boolean;
1242
+ /** @internal */
1243
+ get retryDelay(): number | undefined;
1244
+ /** @internal */
1245
+ get shouldTerminate(): boolean;
1246
+ /** @internal */
1247
+ get terminateReason(): string | undefined;
1179
1248
  }
1180
1249
 
1181
1250
  /**
package/dist/index.d.ts CHANGED
@@ -416,6 +416,12 @@ declare class EventBus {
416
416
  * @param args - Arguments matching the hook signature for this event.
417
417
  */
418
418
  emit<K extends keyof TransportHooks>(event: K, ...args: Parameters<TransportHooks[K]>): void;
419
+ /**
420
+ * Hot-path optimized emit for MessageRouted events.
421
+ * Avoids rest/spread overhead of the generic `emit()`.
422
+ */
423
+ emitMessageRouted(subject: string, kind: MessageKind): void;
424
+ private callHook;
419
425
  }
420
426
 
421
427
  /**
@@ -491,6 +497,10 @@ declare class PatternRegistry {
491
497
  private readonly logger;
492
498
  private readonly registry;
493
499
  private cachedPatterns;
500
+ private _hasEvents;
501
+ private _hasCommands;
502
+ private _hasBroadcasts;
503
+ private _hasOrdered;
494
504
  constructor(options: JetstreamModuleOptions);
495
505
  /**
496
506
  * Register all handlers from the NestJS strategy.
@@ -613,10 +623,10 @@ declare class EventRouter {
613
623
  private subscribeToStream;
614
624
  private getConcurrency;
615
625
  private getAckExtensionConfig;
616
- /** Handle a single event message: decode -> execute handler -> ack/nak. */
617
- private handle;
618
- /** Handle an ordered message: decode -> execute handler -> no ack/nak. */
619
- private handleOrdered;
626
+ /** Handle a single event message with error isolation. */
627
+ private handleSafe;
628
+ /** Handle an ordered message with error isolation. */
629
+ private handleOrderedSafe;
620
630
  /** Resolve handler, decode payload, and build context. Returns null on failure. */
621
631
  private decodeMessage;
622
632
  /** Execute handler, then ack on success or nak/dead-letter on failure. */
@@ -652,15 +662,16 @@ declare class RpcRouter {
652
662
  private readonly concurrency;
653
663
  private resolvedAckExtensionInterval;
654
664
  private subscription;
665
+ private cachedNc;
655
666
  constructor(messageProvider: MessageProvider, patternRegistry: PatternRegistry, connection: ConnectionProvider, codec: Codec, eventBus: EventBus, rpcOptions?: RpcRouterOptions | undefined, ackWaitMap?: Map<StreamKind, number> | undefined);
656
667
  /** Lazily resolve the ack extension interval (needs ackWaitMap populated at runtime). */
657
668
  private get ackExtensionInterval();
658
669
  /** Start routing command messages to handlers. */
659
- start(): void;
670
+ start(): Promise<void>;
660
671
  /** Stop routing and unsubscribe. */
661
672
  destroy(): void;
662
- /** Handle a single RPC command message. */
663
- private handle;
673
+ /** Handle a single RPC command message with error isolation. */
674
+ private handleSafe;
664
675
  /** Execute handler, publish response, settle message. */
665
676
  private executeHandler;
666
677
  }
@@ -1137,19 +1148,33 @@ type NatsMessage = JsMsg | Msg;
1137
1148
  * Execution context for RPC and event handlers.
1138
1149
  *
1139
1150
  * Provides convenient accessors for the NATS message, subject,
1140
- * and headers without needing to interact with the raw message directly.
1151
+ * headers, and JetStream metadata without needing to interact
1152
+ * with the raw message directly.
1153
+ *
1154
+ * Handlers can also control message settlement via {@link retry}
1155
+ * and {@link terminate} instead of throwing errors.
1141
1156
  *
1142
1157
  * @example
1143
1158
  * ```typescript
1144
- * @MessagePattern('get.user')
1145
- * getUser(data: GetUserDto, @Ctx() ctx: RpcContext) {
1146
- * const traceId = ctx.getHeader('x-trace-id');
1147
- * const subject = ctx.getSubject();
1148
- * return this.userService.findOne(data.id);
1159
+ * @EventPattern('order.process')
1160
+ * async handle(@Payload() data: OrderDto, @Ctx() ctx: RpcContext) {
1161
+ * if (ctx.getDeliveryCount()! >= 3) {
1162
+ * ctx.terminate('Max business retries exceeded');
1163
+ * return;
1164
+ * }
1165
+ * if (!this.isReady()) {
1166
+ * ctx.retry({ delay: 5000 });
1167
+ * return;
1168
+ * }
1169
+ * await this.process(data);
1149
1170
  * }
1150
1171
  * ```
1151
1172
  */
1152
1173
  declare class RpcContext extends BaseRpcContext<[NatsMessage]> {
1174
+ private _shouldRetry;
1175
+ private _retryDelay;
1176
+ private _shouldTerminate;
1177
+ private _terminateReason;
1153
1178
  /**
1154
1179
  * Get the underlying NATS message.
1155
1180
  *
@@ -1176,6 +1201,50 @@ declare class RpcContext extends BaseRpcContext<[NatsMessage]> {
1176
1201
  isJetStream(): this is RpcContext & {
1177
1202
  getMessage(): JsMsg;
1178
1203
  };
1204
+ /** How many times this message has been delivered. */
1205
+ getDeliveryCount(): number | undefined;
1206
+ /** The JetStream stream this message belongs to. */
1207
+ getStream(): string | undefined;
1208
+ /** The stream sequence number. */
1209
+ getSequence(): number | undefined;
1210
+ /** The message timestamp as a `Date` (derived from `info.timestampNanos`). */
1211
+ getTimestamp(): Date | undefined;
1212
+ /** The name of the service that published this message (from `x-caller-name` header). */
1213
+ getCallerName(): string | undefined;
1214
+ /**
1215
+ * Signal the transport to retry (nak) this message instead of acknowledging it.
1216
+ *
1217
+ * Use for business-level retries without throwing errors.
1218
+ * Only affects JetStream event handlers (workqueue/broadcast).
1219
+ *
1220
+ * @param opts - Optional delay in ms before redelivery.
1221
+ * @throws Error if {@link terminate} was already called.
1222
+ */
1223
+ retry(opts?: {
1224
+ delayMs?: number;
1225
+ }): void;
1226
+ /**
1227
+ * Signal the transport to permanently reject (term) this message.
1228
+ *
1229
+ * Use when a message is no longer relevant and should not be retried or sent to DLQ.
1230
+ * Only affects JetStream event handlers (workqueue/broadcast).
1231
+ *
1232
+ * @param reason - Optional reason for termination (logged by NATS).
1233
+ * @throws Error if {@link retry} was already called.
1234
+ */
1235
+ terminate(reason?: string): void;
1236
+ /** Narrow to JsMsg or return null for Core messages. Used by metadata getters. */
1237
+ private asJetStream;
1238
+ /** Ensure the message is JetStream — settlement actions are not available for Core NATS. */
1239
+ private assertJetStream;
1240
+ /** @internal */
1241
+ get shouldRetry(): boolean;
1242
+ /** @internal */
1243
+ get retryDelay(): number | undefined;
1244
+ /** @internal */
1245
+ get shouldTerminate(): boolean;
1246
+ /** @internal */
1247
+ get terminateReason(): string | undefined;
1179
1248
  }
1180
1249
 
1181
1250
  /**
package/dist/index.js CHANGED
@@ -809,6 +809,23 @@ var EventBus = class {
809
809
  emit(event, ...args) {
810
810
  const hook = this.hooks[event];
811
811
  if (!hook) return;
812
+ this.callHook(event, hook, ...args);
813
+ }
814
+ /**
815
+ * Hot-path optimized emit for MessageRouted events.
816
+ * Avoids rest/spread overhead of the generic `emit()`.
817
+ */
818
+ emitMessageRouted(subject, kind) {
819
+ const hook = this.hooks["messageRouted" /* MessageRouted */];
820
+ if (!hook) return;
821
+ this.callHook(
822
+ "messageRouted" /* MessageRouted */,
823
+ hook,
824
+ subject,
825
+ kind
826
+ );
827
+ }
828
+ callHook(event, hook, ...args) {
812
829
  try {
813
830
  const result = hook(...args);
814
831
  if (result && typeof result.catch === "function") {
@@ -938,7 +955,7 @@ var JetstreamStrategy = class extends Server {
938
955
  this.eventRouter.start();
939
956
  }
940
957
  if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
941
- this.rpcRouter.start();
958
+ await this.rpcRouter.start();
942
959
  }
943
960
  }
944
961
  if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
@@ -1032,6 +1049,13 @@ import { headers as natsHeaders2 } from "nats";
1032
1049
  // src/context/rpc.context.ts
1033
1050
  import { BaseRpcContext } from "@nestjs/microservices";
1034
1051
  var RpcContext = class extends BaseRpcContext {
1052
+ _shouldRetry = false;
1053
+ _retryDelay;
1054
+ _shouldTerminate = false;
1055
+ _terminateReason;
1056
+ // ---------------------------------------------------------------------------
1057
+ // Message accessors
1058
+ // ---------------------------------------------------------------------------
1035
1059
  /**
1036
1060
  * Get the underlying NATS message.
1037
1061
  *
@@ -1066,6 +1090,96 @@ var RpcContext = class extends BaseRpcContext {
1066
1090
  isJetStream() {
1067
1091
  return "ack" in this.args[0];
1068
1092
  }
1093
+ // ---------------------------------------------------------------------------
1094
+ // JetStream metadata (return undefined for Core NATS messages)
1095
+ // ---------------------------------------------------------------------------
1096
+ /** How many times this message has been delivered. */
1097
+ getDeliveryCount() {
1098
+ return this.asJetStream()?.info.deliveryCount;
1099
+ }
1100
+ /** The JetStream stream this message belongs to. */
1101
+ getStream() {
1102
+ return this.asJetStream()?.info.stream;
1103
+ }
1104
+ /** The stream sequence number. */
1105
+ getSequence() {
1106
+ return this.asJetStream()?.seq;
1107
+ }
1108
+ /** The message timestamp as a `Date` (derived from `info.timestampNanos`). */
1109
+ getTimestamp() {
1110
+ const nanos = this.asJetStream()?.info.timestampNanos;
1111
+ return typeof nanos === "number" ? new Date(nanos / 1e6) : void 0;
1112
+ }
1113
+ /** The name of the service that published this message (from `x-caller-name` header). */
1114
+ getCallerName() {
1115
+ return this.getHeader("x-caller-name" /* CallerName */);
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // Handler-controlled settlement
1119
+ // ---------------------------------------------------------------------------
1120
+ /**
1121
+ * Signal the transport to retry (nak) this message instead of acknowledging it.
1122
+ *
1123
+ * Use for business-level retries without throwing errors.
1124
+ * Only affects JetStream event handlers (workqueue/broadcast).
1125
+ *
1126
+ * @param opts - Optional delay in ms before redelivery.
1127
+ * @throws Error if {@link terminate} was already called.
1128
+ */
1129
+ retry(opts) {
1130
+ this.assertJetStream("retry");
1131
+ if (this._shouldTerminate) {
1132
+ throw new Error("Cannot retry \u2014 terminate() was already called");
1133
+ }
1134
+ this._shouldRetry = true;
1135
+ this._retryDelay = opts?.delayMs;
1136
+ }
1137
+ /**
1138
+ * Signal the transport to permanently reject (term) this message.
1139
+ *
1140
+ * Use when a message is no longer relevant and should not be retried or sent to DLQ.
1141
+ * Only affects JetStream event handlers (workqueue/broadcast).
1142
+ *
1143
+ * @param reason - Optional reason for termination (logged by NATS).
1144
+ * @throws Error if {@link retry} was already called.
1145
+ */
1146
+ terminate(reason) {
1147
+ this.assertJetStream("terminate");
1148
+ if (this._shouldRetry) {
1149
+ throw new Error("Cannot terminate \u2014 retry() was already called");
1150
+ }
1151
+ this._shouldTerminate = true;
1152
+ this._terminateReason = reason;
1153
+ }
1154
+ /** Narrow to JsMsg or return null for Core messages. Used by metadata getters. */
1155
+ asJetStream() {
1156
+ return this.isJetStream() ? this.args[0] : null;
1157
+ }
1158
+ /** Ensure the message is JetStream — settlement actions are not available for Core NATS. */
1159
+ assertJetStream(method) {
1160
+ if (!this.isJetStream()) {
1161
+ throw new Error(`${method}() is only available for JetStream messages`);
1162
+ }
1163
+ }
1164
+ // ---------------------------------------------------------------------------
1165
+ // Transport-facing state (read by EventRouter)
1166
+ // ---------------------------------------------------------------------------
1167
+ /** @internal */
1168
+ get shouldRetry() {
1169
+ return this._shouldRetry;
1170
+ }
1171
+ /** @internal */
1172
+ get retryDelay() {
1173
+ return this._retryDelay;
1174
+ }
1175
+ /** @internal */
1176
+ get shouldTerminate() {
1177
+ return this._shouldTerminate;
1178
+ }
1179
+ /** @internal */
1180
+ get terminateReason() {
1181
+ return this._terminateReason;
1182
+ }
1069
1183
  };
1070
1184
 
1071
1185
  // src/utils/ack-extension.ts
@@ -1104,15 +1218,20 @@ var serializeError = (err) => {
1104
1218
 
1105
1219
  // src/utils/unwrap-result.ts
1106
1220
  import { isObservable } from "rxjs";
1107
- var unwrapResult = async (result) => {
1221
+ var RESOLVED_VOID = Promise.resolve(void 0);
1222
+ var RESOLVED_NULL = Promise.resolve(null);
1223
+ var unwrapResult = (result) => {
1224
+ if (result === void 0) return RESOLVED_VOID;
1225
+ if (result === null) return RESOLVED_NULL;
1108
1226
  if (isObservable(result)) {
1109
1227
  return subscribeToFirst(result);
1110
1228
  }
1111
- const resolved = await result;
1112
- if (isObservable(resolved)) {
1113
- return subscribeToFirst(resolved);
1229
+ if (typeof result.then === "function") {
1230
+ return result.then(
1231
+ (resolved) => isObservable(resolved) ? subscribeToFirst(resolved) : resolved
1232
+ );
1114
1233
  }
1115
- return resolved;
1234
+ return Promise.resolve(result);
1116
1235
  };
1117
1236
  var subscribeToFirst = (obs) => new Promise((resolve, reject) => {
1118
1237
  let done = false;
@@ -1190,7 +1309,7 @@ var CoreRpcServer = class {
1190
1309
  this.respondWithError(msg, new Error(`No handler for subject: ${msg.subject}`));
1191
1310
  return;
1192
1311
  }
1193
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "rpc" /* Rpc */);
1312
+ this.eventBus.emitMessageRouted(msg.subject, "rpc" /* Rpc */);
1194
1313
  let data;
1195
1314
  try {
1196
1315
  data = this.codec.decode(msg.data);
@@ -1688,6 +1807,10 @@ var PatternRegistry = class {
1688
1807
  registry = /* @__PURE__ */ new Map();
1689
1808
  // Cached after registerHandlers() — the registry is immutable from that point
1690
1809
  cachedPatterns = null;
1810
+ _hasEvents = false;
1811
+ _hasCommands = false;
1812
+ _hasBroadcasts = false;
1813
+ _hasOrdered = false;
1691
1814
  /**
1692
1815
  * Register all handlers from the NestJS strategy.
1693
1816
  *
@@ -1721,6 +1844,10 @@ var PatternRegistry = class {
1721
1844
  this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
1722
1845
  }
1723
1846
  this.cachedPatterns = this.buildPatternsByKind();
1847
+ this._hasEvents = this.cachedPatterns.events.length > 0;
1848
+ this._hasCommands = this.cachedPatterns.commands.length > 0;
1849
+ this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
1850
+ this._hasOrdered = this.cachedPatterns.ordered.length > 0;
1724
1851
  this.logSummary();
1725
1852
  }
1726
1853
  /** Find handler for a full NATS subject. */
@@ -1732,16 +1859,16 @@ var PatternRegistry = class {
1732
1859
  return this.getPatternsByKind().broadcasts.map((p) => buildBroadcastSubject(p));
1733
1860
  }
1734
1861
  hasBroadcastHandlers() {
1735
- return this.getPatternsByKind().broadcasts.length > 0;
1862
+ return this._hasBroadcasts;
1736
1863
  }
1737
1864
  hasRpcHandlers() {
1738
- return this.getPatternsByKind().commands.length > 0;
1865
+ return this._hasCommands;
1739
1866
  }
1740
1867
  hasEventHandlers() {
1741
- return this.getPatternsByKind().events.length > 0;
1868
+ return this._hasEvents;
1742
1869
  }
1743
1870
  hasOrderedHandlers() {
1744
- return this.getPatternsByKind().ordered.length > 0;
1871
+ return this._hasOrdered;
1745
1872
  }
1746
1873
  /** Get fully-qualified NATS subjects for ordered handlers. */
1747
1874
  getOrderedSubjects() {
@@ -1804,14 +1931,7 @@ var PatternRegistry = class {
1804
1931
 
1805
1932
  // src/server/routing/event.router.ts
1806
1933
  import { Logger as Logger9 } from "@nestjs/common";
1807
- import {
1808
- catchError as catchError2,
1809
- concatMap,
1810
- defer as defer3,
1811
- EMPTY as EMPTY2,
1812
- from as from2,
1813
- mergeMap
1814
- } from "rxjs";
1934
+ import { concatMap, from as from2, mergeMap } from "rxjs";
1815
1935
  var EventRouter = class {
1816
1936
  constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap) {
1817
1937
  this.messageProvider = messageProvider;
@@ -1852,13 +1972,8 @@ var EventRouter = class {
1852
1972
  const isOrdered = kind === "ordered" /* Ordered */;
1853
1973
  const ackExtensionInterval = isOrdered ? null : resolveAckExtensionInterval(this.getAckExtensionConfig(kind), this.ackWaitMap?.get(kind));
1854
1974
  const concurrency = this.getConcurrency(kind);
1855
- const route = (msg) => defer3(
1856
- () => isOrdered ? this.handleOrdered(msg) : this.handle(msg, ackExtensionInterval)
1857
- ).pipe(
1858
- catchError2((err) => {
1859
- this.logger.error(`Unexpected error in ${kind} event router`, err);
1860
- return EMPTY2;
1861
- })
1975
+ const route = (msg) => from2(
1976
+ isOrdered ? this.handleOrderedSafe(msg) : this.handleSafe(msg, ackExtensionInterval, kind)
1862
1977
  );
1863
1978
  const subscription = stream$.pipe(isOrdered ? concatMap(route) : mergeMap(route, concurrency)).subscribe();
1864
1979
  this.subscriptions.push(subscription);
@@ -1873,23 +1988,36 @@ var EventRouter = class {
1873
1988
  if (kind === "broadcast" /* Broadcast */) return this.processingConfig?.broadcast?.ackExtension;
1874
1989
  return void 0;
1875
1990
  }
1876
- /** Handle a single event message: decode -> execute handler -> ack/nak. */
1877
- handle(msg, ackExtensionInterval) {
1878
- const resolved = this.decodeMessage(msg);
1879
- if (!resolved) return EMPTY2;
1880
- return from2(
1881
- this.executeHandler(resolved.handler, resolved.data, resolved.ctx, msg, ackExtensionInterval)
1882
- );
1991
+ /** Handle a single event message with error isolation. */
1992
+ async handleSafe(msg, ackExtensionInterval, kind) {
1993
+ try {
1994
+ const resolved = this.decodeMessage(msg);
1995
+ if (!resolved) return;
1996
+ await this.executeHandler(
1997
+ resolved.handler,
1998
+ resolved.data,
1999
+ resolved.ctx,
2000
+ msg,
2001
+ ackExtensionInterval
2002
+ );
2003
+ } catch (err) {
2004
+ this.logger.error(`Unexpected error in ${kind} event router`, err);
2005
+ }
1883
2006
  }
1884
- /** Handle an ordered message: decode -> execute handler -> no ack/nak. */
1885
- handleOrdered(msg) {
1886
- const resolved = this.decodeMessage(msg, true);
1887
- if (!resolved) return EMPTY2;
1888
- return from2(
1889
- unwrapResult(resolved.handler(resolved.data, resolved.ctx)).catch((err) => {
1890
- this.logger.error(`Ordered handler error (${msg.subject}):`, err);
1891
- })
1892
- );
2007
+ /** Handle an ordered message with error isolation. */
2008
+ async handleOrderedSafe(msg) {
2009
+ try {
2010
+ const resolved = this.decodeMessage(msg, true);
2011
+ if (!resolved) return;
2012
+ await unwrapResult(resolved.handler(resolved.data, resolved.ctx));
2013
+ if (resolved.ctx.shouldRetry || resolved.ctx.shouldTerminate) {
2014
+ this.logger.warn(
2015
+ `retry()/terminate() ignored for ordered message ${msg.subject} \u2014 ordered consumers auto-acknowledge`
2016
+ );
2017
+ }
2018
+ } catch (err) {
2019
+ this.logger.error(`Ordered handler error (${msg.subject}):`, err);
2020
+ }
1893
2021
  }
1894
2022
  /** Resolve handler, decode payload, and build context. Returns null on failure. */
1895
2023
  decodeMessage(msg, isOrdered = false) {
@@ -1907,7 +2035,7 @@ var EventRouter = class {
1907
2035
  this.logger.error(`Decode error for ${msg.subject}:`, err);
1908
2036
  return null;
1909
2037
  }
1910
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "event" /* Event */);
2038
+ this.eventBus.emitMessageRouted(msg.subject, "event" /* Event */);
1911
2039
  return { handler, data, ctx: new RpcContext([msg]) };
1912
2040
  }
1913
2041
  /** Execute handler, then ack on success or nak/dead-letter on failure. */
@@ -1915,7 +2043,13 @@ var EventRouter = class {
1915
2043
  const stopAckExtension = startAckExtensionTimer(msg, ackExtensionInterval);
1916
2044
  try {
1917
2045
  await unwrapResult(handler(data, ctx));
1918
- msg.ack();
2046
+ if (ctx.shouldTerminate) {
2047
+ msg.term(ctx.terminateReason);
2048
+ } else if (ctx.shouldRetry) {
2049
+ msg.nak(ctx.retryDelay);
2050
+ } else {
2051
+ msg.ack();
2052
+ }
1919
2053
  } catch (err) {
1920
2054
  this.logger.error(`Event handler error (${msg.subject}):`, err);
1921
2055
  if (this.isDeadLetter(msg)) {
@@ -1964,7 +2098,7 @@ var EventRouter = class {
1964
2098
  // src/server/routing/rpc.router.ts
1965
2099
  import { Logger as Logger10 } from "@nestjs/common";
1966
2100
  import { headers } from "nats";
1967
- import { catchError as catchError3, defer as defer4, EMPTY as EMPTY3, from as from3, mergeMap as mergeMap2 } from "rxjs";
2101
+ import { from as from3, mergeMap as mergeMap2 } from "rxjs";
1968
2102
  var RpcRouter = class {
1969
2103
  constructor(messageProvider, patternRegistry, connection, codec, eventBus, rpcOptions, ackWaitMap) {
1970
2104
  this.messageProvider = messageProvider;
@@ -1982,6 +2116,7 @@ var RpcRouter = class {
1982
2116
  concurrency;
1983
2117
  resolvedAckExtensionInterval;
1984
2118
  subscription = null;
2119
+ cachedNc = null;
1985
2120
  /** Lazily resolve the ack extension interval (needs ackWaitMap populated at runtime). */
1986
2121
  get ackExtensionInterval() {
1987
2122
  if (this.resolvedAckExtensionInterval !== void 0) return this.resolvedAckExtensionInterval;
@@ -1992,56 +2127,50 @@ var RpcRouter = class {
1992
2127
  return this.resolvedAckExtensionInterval;
1993
2128
  }
1994
2129
  /** Start routing command messages to handlers. */
1995
- start() {
1996
- this.subscription = this.messageProvider.commands$.pipe(
1997
- mergeMap2(
1998
- (msg) => defer4(() => this.handle(msg)).pipe(
1999
- catchError3((err) => {
2000
- this.logger.error("Unexpected error in RPC router", err);
2001
- return EMPTY3;
2002
- })
2003
- ),
2004
- this.concurrency
2005
- )
2006
- ).subscribe();
2130
+ async start() {
2131
+ this.cachedNc = await this.connection.getConnection();
2132
+ this.subscription = this.messageProvider.commands$.pipe(mergeMap2((msg) => from3(this.handleSafe(msg)), this.concurrency)).subscribe();
2007
2133
  }
2008
2134
  /** Stop routing and unsubscribe. */
2009
2135
  destroy() {
2010
2136
  this.subscription?.unsubscribe();
2011
2137
  this.subscription = null;
2012
2138
  }
2013
- /** Handle a single RPC command message. */
2014
- handle(msg) {
2015
- const handler = this.patternRegistry.getHandler(msg.subject);
2016
- if (!handler) {
2017
- msg.term(`No handler for RPC: ${msg.subject}`);
2018
- this.logger.error(`No handler for RPC subject: ${msg.subject}`);
2019
- return EMPTY3;
2020
- }
2021
- const replyTo = msg.headers?.get("x-reply-to" /* ReplyTo */);
2022
- const correlationId = msg.headers?.get("x-correlation-id" /* CorrelationId */);
2023
- if (!replyTo || !correlationId) {
2024
- msg.term("Missing required headers (reply-to or correlation-id)");
2025
- this.logger.error(`Missing headers for RPC: ${msg.subject}`);
2026
- return EMPTY3;
2027
- }
2028
- let data;
2139
+ /** Handle a single RPC command message with error isolation. */
2140
+ async handleSafe(msg) {
2029
2141
  try {
2030
- data = this.codec.decode(msg.data);
2142
+ const handler = this.patternRegistry.getHandler(msg.subject);
2143
+ if (!handler) {
2144
+ msg.term(`No handler for RPC: ${msg.subject}`);
2145
+ this.logger.error(`No handler for RPC subject: ${msg.subject}`);
2146
+ return;
2147
+ }
2148
+ const { headers: msgHeaders } = msg;
2149
+ const replyTo = msgHeaders?.get("x-reply-to" /* ReplyTo */);
2150
+ const correlationId = msgHeaders?.get("x-correlation-id" /* CorrelationId */);
2151
+ if (!replyTo || !correlationId) {
2152
+ msg.term("Missing required headers (reply-to or correlation-id)");
2153
+ this.logger.error(`Missing headers for RPC: ${msg.subject}`);
2154
+ return;
2155
+ }
2156
+ let data;
2157
+ try {
2158
+ data = this.codec.decode(msg.data);
2159
+ } catch (err) {
2160
+ msg.term("Decode error");
2161
+ this.logger.error(`Decode error for RPC ${msg.subject}:`, err);
2162
+ return;
2163
+ }
2164
+ this.eventBus.emitMessageRouted(msg.subject, "rpc" /* Rpc */);
2165
+ await this.executeHandler(handler, data, msg, replyTo, correlationId);
2031
2166
  } catch (err) {
2032
- msg.term("Decode error");
2033
- this.logger.error(`Decode error for RPC ${msg.subject}:`, err);
2034
- return EMPTY3;
2167
+ this.logger.error("Unexpected error in RPC router", err);
2035
2168
  }
2036
- this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "rpc" /* Rpc */);
2037
- return from3(this.executeHandler(handler, data, msg, replyTo, correlationId));
2038
2169
  }
2039
2170
  /** Execute handler, publish response, settle message. */
2040
2171
  async executeHandler(handler, data, msg, replyTo, correlationId) {
2041
- const nc = await this.connection.getConnection();
2172
+ const nc = this.cachedNc ?? await this.connection.getConnection();
2042
2173
  const ctx = new RpcContext([msg]);
2043
- const hdrs = headers();
2044
- hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2045
2174
  let settled = false;
2046
2175
  const stopAckExtension = startAckExtensionTimer(msg, this.ackExtensionInterval);
2047
2176
  const timeoutId = setTimeout(() => {
@@ -2060,6 +2189,8 @@ var RpcRouter = class {
2060
2189
  stopAckExtension?.();
2061
2190
  msg.ack();
2062
2191
  try {
2192
+ const hdrs = headers();
2193
+ hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2063
2194
  nc.publish(replyTo, this.codec.encode(result), { headers: hdrs });
2064
2195
  } catch (publishErr) {
2065
2196
  this.logger.error(`Failed to publish RPC response for ${msg.subject}`, publishErr);
@@ -2070,6 +2201,8 @@ var RpcRouter = class {
2070
2201
  clearTimeout(timeoutId);
2071
2202
  stopAckExtension?.();
2072
2203
  try {
2204
+ const hdrs = headers();
2205
+ hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2073
2206
  hdrs.set("x-error" /* Error */, "true");
2074
2207
  nc.publish(replyTo, this.codec.encode(serializeError(err)), { headers: hdrs });
2075
2208
  } catch (encodeErr) {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@horizon-republic/nestjs-jetstream",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "description": "A NestJS transport for NATS with JetStream events, broadcast fan-out, and Core/JetStream RPC.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/HorizonRepublic/nestjs-jetstream.git"
8
8
  },
9
- "homepage": "https://github.com/HorizonRepublic/nestjs-jetstream#readme",
9
+ "homepage": "https://horizonrepublic.github.io/nestjs-jetstream/",
10
10
  "bugs": {
11
11
  "url": "https://github.com/HorizonRepublic/nestjs-jetstream/issues"
12
12
  },
@@ -67,7 +67,7 @@
67
67
  "@nestjs/platform-express": "^11.1.17",
68
68
  "@nestjs/testing": "^11.1.17",
69
69
  "@types/node": "^25.5.0",
70
- "@vitest/coverage-v8": "^4.1.1",
70
+ "@vitest/coverage-v8": "^4.1.2",
71
71
  "eslint": "^10.1.0",
72
72
  "eslint-config-prettier": "^10.1.8",
73
73
  "eslint-plugin-prefer-arrow": "^1.2.3",
@@ -77,9 +77,9 @@
77
77
  "prettier": "^3.8.1",
78
78
  "tsup": "^8.5.1",
79
79
  "tsx": "^4.21.0",
80
- "typescript": "~5.9.0",
80
+ "typescript": "~5.9.3",
81
81
  "typescript-eslint": "^8.57.2",
82
- "vitest": "^4.1.1"
82
+ "vitest": "^4.1.2"
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsup",