@horizon-republic/nestjs-jetstream 2.3.4 → 2.3.6

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
@@ -5,7 +5,7 @@ A production-grade NestJS transport for NATS JetStream with built-in support for
5
5
  [![npm version](https://img.shields.io/npm/v/@horizon-republic/nestjs-jetstream.svg)](https://www.npmjs.com/package/@horizon-republic/nestjs-jetstream)
6
6
  [![codecov](https://codecov.io/github/HorizonRepublic/nestjs-jetstream/graph/badge.svg?token=40IPSWFMT4)](https://codecov.io/github/HorizonRepublic/nestjs-jetstream)
7
7
  [![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://badge.socket.dev/npm/package/@horizon-republic/nestjs-jetstream)
8
+ [![Socket Badge](https://badge.socket.dev/npm/package/@horizon-republic/nestjs-jetstream)](https://socket.dev/npm/package/@horizon-republic/nestjs-jetstream)
9
9
 
10
10
  ## Table of Contents
11
11
 
@@ -63,9 +63,9 @@ yarn add @horizon-republic/nestjs-jetstream
63
63
  **Peer dependencies:**
64
64
 
65
65
  ```
66
- @nestjs/common ^11.0.0
67
- @nestjs/core ^11.0.0
68
- @nestjs/microservices ^11.0.0
66
+ @nestjs/common ^10.2.0 || ^11.0.0
67
+ @nestjs/core ^10.2.0 || ^11.0.0
68
+ @nestjs/microservices ^10.2.0 || ^11.0.0
69
69
  nats ^2.0.0
70
70
  reflect-metadata ^0.2.0
71
71
  rxjs ^7.8.0
@@ -305,6 +305,8 @@ Discriminated union on `mode`:
305
305
  | `'core'` | 30s | None | Low-latency, simple RPC |
306
306
  | `'jetstream'` | 3 min | JetStream stream | Commands must survive handler downtime |
307
307
 
308
+ > **Note:** `timeout` controls both the **client-side wait** (how long the caller waits for a response) and the **server-side handler limit** (how long the handler is allowed to run before being terminated). Both sides use the same value from their own `forRoot()` config.
309
+
308
310
  ```typescript
309
311
  // Core mode (default)
310
312
  rpc: { mode: 'core', timeout: 10_000 }
@@ -348,7 +350,8 @@ JetstreamModule.forRoot({
348
350
  |----------|--------|
349
351
  | Handler success | Response returned to caller |
350
352
  | Handler throws | Error response returned to caller |
351
- | No handler running | Client times out |
353
+ | No handler registered | Error response returned to caller |
354
+ | Server not running | Client times out |
352
355
  | Decode error | Error response returned to caller |
353
356
 
354
357
  #### JetStream Mode
@@ -671,7 +674,9 @@ JetstreamModule.forRootAsync({
671
674
 
672
675
  ### Health Checks
673
676
 
674
- `JetstreamHealthIndicator` is automatically registered and exported by `forRoot()`. It checks NATS connection status and measures round-trip latency. `@nestjs/terminus` is **not required** — the indicator follows the Terminus API convention so it works seamlessly when Terminus is present, but can also be used standalone.
677
+ `JetstreamHealthIndicator` is automatically registered and exported by `forRoot()`. It checks NATS connection status and measures round-trip latency. `@nestjs/terminus` is **not required** — the indicator follows the Terminus API convention and can be used standalone.
678
+
679
+ > **Note:** `isHealthy()` throws a plain `Error` with attached status details rather than Terminus's `HealthCheckError`. Terminus will report the service as unhealthy, but the structured `{ status: 'down', server, latency }` details may not appear in the response body. For full Terminus formatting, use the `check()` method in a custom wrapper.
675
680
 
676
681
  **With [@nestjs/terminus](https://docs.nestjs.com/recipes/terminus) (zero boilerplate):**
677
682
 
@@ -788,7 +793,7 @@ If the initial NATS connection is refused, the module throws an `Error` immediat
788
793
 
789
794
  #### Observable return values
790
795
 
791
- Handlers can return Observables. The transport takes the **first emitted value** for RPC responses and awaits completion for events:
796
+ Handlers can return Observables. The transport resolves on the **first emitted value** (or on completion if the Observable emits nothing):
792
797
 
793
798
  ```typescript
794
799
  @MessagePattern('user.get')
@@ -798,7 +803,7 @@ getUser(@Payload() data: { id: number }): Observable<UserDto> {
798
803
 
799
804
  @EventPattern('order.created')
800
805
  handleOrder(@Payload() data: OrderDto): Observable<void> {
801
- return this.pipeline.process(data); // awaits completion before ack
806
+ return this.pipeline.process(data); // ack after first emission or completion
802
807
  }
803
808
  ```
804
809
 
package/dist/index.cjs CHANGED
@@ -592,6 +592,7 @@ var ConnectionProvider = class {
592
592
  connection = null;
593
593
  connectionPromise = null;
594
594
  jsmInstance = null;
595
+ jsmPromise = null;
595
596
  /**
596
597
  * Establish NATS connection. Idempotent — returns cached connection on subsequent calls.
597
598
  *
@@ -617,10 +618,16 @@ var ConnectionProvider = class {
617
618
  */
618
619
  async getJetStreamManager() {
619
620
  if (this.jsmInstance) return this.jsmInstance;
620
- const nc = await this.getConnection();
621
- this.jsmInstance = await nc.jetstreamManager();
622
- this.logger.log("JetStream manager initialized");
623
- return this.jsmInstance;
621
+ if (this.jsmPromise) return this.jsmPromise;
622
+ this.jsmPromise = (async () => {
623
+ const nc = await this.getConnection();
624
+ this.jsmInstance = await nc.jetstreamManager();
625
+ this.logger.log("JetStream manager initialized");
626
+ return this.jsmInstance;
627
+ })().finally(() => {
628
+ this.jsmPromise = null;
629
+ });
630
+ return this.jsmPromise;
624
631
  }
625
632
  /** Direct access to the raw NATS connection, or `null` if not yet connected. */
626
633
  get unwrap() {
@@ -632,6 +639,12 @@ var ConnectionProvider = class {
632
639
  * Sequence: drain → wait for close. Falls back to force-close on error.
633
640
  */
634
641
  async shutdown() {
642
+ if (this.connectionPromise) {
643
+ try {
644
+ await this.connectionPromise;
645
+ } catch {
646
+ }
647
+ }
635
648
  if (!this.connection || this.connection.isClosed()) return;
636
649
  try {
637
650
  await this.connection.drain();
@@ -645,6 +658,7 @@ var ConnectionProvider = class {
645
658
  this.connection = null;
646
659
  this.connectionPromise = null;
647
660
  this.jsmInstance = null;
661
+ this.jsmPromise = null;
648
662
  }
649
663
  }
650
664
  /** Internal: establish the physical connection with reconnect monitoring. */
@@ -678,6 +692,7 @@ var ConnectionProvider = class {
678
692
  break;
679
693
  case import_nats4.Events.Reconnect:
680
694
  this.jsmInstance = null;
695
+ this.jsmPromise = null;
681
696
  this.eventBus.emit("reconnect" /* Reconnect */, nc.getServer());
682
697
  break;
683
698
  case import_nats4.Events.Error:
@@ -716,7 +731,14 @@ var EventBus = class {
716
731
  const hook = this.hooks[event];
717
732
  if (!hook) return;
718
733
  try {
719
- hook(...args);
734
+ const result = hook(...args);
735
+ if (result && typeof result.catch === "function") {
736
+ result.catch((err) => {
737
+ this.logger.error(
738
+ `Async hook "${event}" rejected: ${err instanceof Error ? err.message : err}`
739
+ );
740
+ });
741
+ }
720
742
  } catch (err) {
721
743
  this.logger.error(
722
744
  `Hook "${event}" threw an error: ${err instanceof Error ? err.message : err}`
@@ -819,6 +841,7 @@ var JetstreamStrategy = class extends import_microservices2.Server {
819
841
  if (streamKinds.length > 0) {
820
842
  await this.streamProvider.ensureStreams(streamKinds);
821
843
  const consumers = await this.consumerProvider.ensureConsumers(streamKinds);
844
+ this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
822
845
  this.messageProvider.start(consumers);
823
846
  if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers()) {
824
847
  this.eventRouter.start();
@@ -882,6 +905,18 @@ var JetstreamStrategy = class extends import_microservices2.Server {
882
905
  }
883
906
  return kinds;
884
907
  }
908
+ /** Build max_deliver map from actual NATS consumer configs (not options). */
909
+ buildMaxDeliverMap(consumers) {
910
+ const map = /* @__PURE__ */ new Map();
911
+ for (const [, info] of consumers) {
912
+ const stream = info.stream_name;
913
+ const maxDeliver = info.config.max_deliver;
914
+ if (stream && maxDeliver !== void 0 && maxDeliver > 0) {
915
+ map.set(stream, maxDeliver);
916
+ }
917
+ }
918
+ return map;
919
+ }
885
920
  isCoreRpcMode() {
886
921
  return !this.options.rpc || this.options.rpc.mode === "core";
887
922
  }
@@ -955,11 +990,13 @@ var unwrapResult = async (result) => {
955
990
  };
956
991
  var subscribeToFirst = (obs) => new Promise((resolve, reject) => {
957
992
  let done = false;
958
- obs.subscribe({
993
+ let subscription = null;
994
+ subscription = obs.subscribe({
959
995
  next: (val) => {
960
996
  if (!done) {
961
997
  done = true;
962
998
  resolve(val);
999
+ subscription?.unsubscribe();
963
1000
  }
964
1001
  },
965
1002
  error: reject,
@@ -967,6 +1004,9 @@ var subscribeToFirst = (obs) => new Promise((resolve, reject) => {
967
1004
  if (!done) resolve(void 0);
968
1005
  }
969
1006
  });
1007
+ if (done) {
1008
+ subscription.unsubscribe();
1009
+ }
970
1010
  });
971
1011
 
972
1012
  // src/server/core-rpc.server.ts
@@ -1012,6 +1052,7 @@ var CoreRpcServer = class {
1012
1052
  const handler = this.patternRegistry.getHandler(msg.subject);
1013
1053
  if (!handler) {
1014
1054
  this.logger.warn(`No handler for Core RPC: ${msg.subject}`);
1055
+ this.respondWithError(msg, new Error(`No handler for subject: ${msg.subject}`));
1015
1056
  return;
1016
1057
  }
1017
1058
  this.eventBus.emit("messageRouted" /* MessageRouted */, msg.subject, "rpc");
@@ -1174,9 +1215,9 @@ var ConsumerProvider = class {
1174
1215
  const name = config.durable_name;
1175
1216
  this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
1176
1217
  try {
1177
- const info = await jsm.consumers.info(stream, name);
1178
- this.logger.debug(`Consumer exists: ${name}`);
1179
- return info;
1218
+ await jsm.consumers.info(stream, name);
1219
+ this.logger.debug(`Consumer exists, updating: ${name}`);
1220
+ return await jsm.consumers.update(stream, name, config);
1180
1221
  } catch (err) {
1181
1222
  if (err instanceof import_nats7.NatsError && err.api_error?.err_code === CONSUMER_NOT_FOUND) {
1182
1223
  this.logger.log(`Creating consumer: ${name}`);
@@ -1194,6 +1235,9 @@ var ConsumerProvider = class {
1194
1235
  const overrides = this.getOverrides(kind);
1195
1236
  if (kind === "broadcast") {
1196
1237
  const broadcastPatterns = this.patternRegistry.getBroadcastPatterns();
1238
+ if (broadcastPatterns.length === 0) {
1239
+ throw new Error("Broadcast consumer requested but no broadcast patterns are registered");
1240
+ }
1197
1241
  if (broadcastPatterns.length === 1) {
1198
1242
  return {
1199
1243
  ...defaults,
@@ -1253,8 +1297,8 @@ var MessageProvider = class {
1253
1297
  this.eventBus = eventBus;
1254
1298
  }
1255
1299
  logger = new import_common7.Logger("Jetstream:Message");
1256
- destroy$ = new import_rxjs3.Subject();
1257
1300
  activeIterators = /* @__PURE__ */ new Set();
1301
+ destroy$ = new import_rxjs3.Subject();
1258
1302
  eventMessages$ = new import_rxjs3.Subject();
1259
1303
  commandMessages$ = new import_rxjs3.Subject();
1260
1304
  broadcastMessages$ = new import_rxjs3.Subject();
@@ -1285,7 +1329,7 @@ var MessageProvider = class {
1285
1329
  (0, import_rxjs3.merge)(...flows).pipe((0, import_rxjs3.takeUntil)(this.destroy$)).subscribe();
1286
1330
  }
1287
1331
  }
1288
- /** Stop all consumer flows and complete all subjects. */
1332
+ /** Stop all consumer flows and reinitialize subjects for potential restart. */
1289
1333
  destroy() {
1290
1334
  this.destroy$.next();
1291
1335
  this.destroy$.complete();
@@ -1296,17 +1340,23 @@ var MessageProvider = class {
1296
1340
  this.eventMessages$.complete();
1297
1341
  this.commandMessages$.complete();
1298
1342
  this.broadcastMessages$.complete();
1343
+ this.destroy$ = new import_rxjs3.Subject();
1344
+ this.eventMessages$ = new import_rxjs3.Subject();
1345
+ this.commandMessages$ = new import_rxjs3.Subject();
1346
+ this.broadcastMessages$ = new import_rxjs3.Subject();
1299
1347
  }
1300
1348
  /** Create a self-healing consumer flow for a specific kind. */
1301
1349
  createFlow(kind, info) {
1302
1350
  const target$ = this.getTargetSubject(kind);
1303
1351
  let consecutiveFailures = 0;
1352
+ let lastRunFailed = false;
1304
1353
  return (0, import_rxjs3.defer)(() => this.consumeOnce(info, target$)).pipe(
1305
1354
  (0, import_rxjs3.tap)(() => {
1306
- consecutiveFailures = 0;
1355
+ lastRunFailed = false;
1307
1356
  }),
1308
1357
  (0, import_rxjs3.catchError)((err) => {
1309
1358
  consecutiveFailures++;
1359
+ lastRunFailed = true;
1310
1360
  this.logger.error(`Consumer ${info.name} error, will restart:`, err);
1311
1361
  this.eventBus.emit(
1312
1362
  "error" /* Error */,
@@ -1317,13 +1367,11 @@ var MessageProvider = class {
1317
1367
  }),
1318
1368
  (0, import_rxjs3.repeat)({
1319
1369
  delay: () => {
1370
+ if (!lastRunFailed) {
1371
+ consecutiveFailures = 0;
1372
+ }
1320
1373
  const delay = Math.min(100 * Math.pow(2, consecutiveFailures), 3e4);
1321
1374
  this.logger.warn(`Consumer ${info.name} stream ended, restarting in ${delay}ms...`);
1322
- this.eventBus.emit(
1323
- "error" /* Error */,
1324
- new Error(`Consumer ${info.name} stream ended`),
1325
- "message-provider"
1326
- );
1327
1375
  return (0, import_rxjs3.timer)(delay);
1328
1376
  }
1329
1377
  }),
@@ -1466,6 +1514,14 @@ var EventRouter = class {
1466
1514
  }
1467
1515
  logger = new import_common9.Logger("Jetstream:EventRouter");
1468
1516
  subscriptions = [];
1517
+ /**
1518
+ * Update the max_deliver thresholds from actual NATS consumer configs.
1519
+ * Called after consumers are ensured so the DLQ map reflects reality.
1520
+ */
1521
+ updateMaxDeliverMap(consumerMaxDelivers) {
1522
+ if (!this.deadLetterConfig) return;
1523
+ this.deadLetterConfig.maxDeliverByStream = consumerMaxDelivers;
1524
+ }
1469
1525
  /** Start routing event and broadcast messages to handlers. */
1470
1526
  start() {
1471
1527
  this.subscribeToStream(this.messageProvider.events$, "workqueue");
@@ -1515,10 +1571,7 @@ var EventRouter = class {
1515
1571
  /** Execute handler, then ack on success or nak/dead-letter on failure. */
1516
1572
  async executeHandler(handler, data, ctx, msg) {
1517
1573
  try {
1518
- const result = await handler(data, ctx);
1519
- if ((0, import_rxjs4.isObservable)(result)) {
1520
- await (0, import_rxjs4.lastValueFrom)(result, { defaultValue: void 0 });
1521
- }
1574
+ await unwrapResult(handler(data, ctx));
1522
1575
  msg.ack();
1523
1576
  } catch (err) {
1524
1577
  this.logger.error(`Event handler error (${msg.subject}):`, err);
@@ -1533,7 +1586,7 @@ var EventRouter = class {
1533
1586
  isDeadLetter(msg) {
1534
1587
  if (!this.deadLetterConfig) return false;
1535
1588
  const maxDeliver = this.deadLetterConfig.maxDeliverByStream.get(msg.info.stream);
1536
- if (maxDeliver === void 0) return false;
1589
+ if (maxDeliver === void 0 || maxDeliver <= 0) return false;
1537
1590
  return msg.info.deliveryCount >= maxDeliver;
1538
1591
  }
1539
1592
  /** Handle a dead letter: invoke callback, then term or nak based on result. */
@@ -1791,26 +1844,6 @@ var JetstreamModule = class {
1791
1844
  // -------------------------------------------------------------------
1792
1845
  // Provider factories
1793
1846
  // -------------------------------------------------------------------
1794
- /** Create all providers for synchronous forRoot(). */
1795
- /**
1796
- * Build a map of stream name -> max_deliver for dead letter detection.
1797
- * Each stream kind (ev, broadcast) has its own consumer config with potentially
1798
- * different max_deliver values.
1799
- */
1800
- static buildMaxDeliverMap(options) {
1801
- const map = /* @__PURE__ */ new Map();
1802
- const defaultEventMax = DEFAULT_EVENT_CONSUMER_CONFIG.max_deliver ?? 3;
1803
- const defaultBroadcastMax = DEFAULT_BROADCAST_CONSUMER_CONFIG.max_deliver ?? 3;
1804
- map.set(
1805
- streamName(options.name, "ev"),
1806
- options.events?.consumer?.max_deliver ?? defaultEventMax
1807
- );
1808
- map.set(
1809
- streamName(options.name, "broadcast"),
1810
- options.broadcast?.consumer?.max_deliver ?? defaultBroadcastMax
1811
- );
1812
- return map;
1813
- }
1814
1847
  static createCoreProviders(options) {
1815
1848
  return [
1816
1849
  {
@@ -1921,7 +1954,7 @@ var JetstreamModule = class {
1921
1954
  useFactory: (options, messageProvider, patternRegistry, codec, eventBus) => {
1922
1955
  if (options.consumer === false) return null;
1923
1956
  const deadLetterConfig = options.onDeadLetter ? {
1924
- maxDeliverByStream: JetstreamModule.buildMaxDeliverMap(options),
1957
+ maxDeliverByStream: /* @__PURE__ */ new Map(),
1925
1958
  onDeadLetter: options.onDeadLetter
1926
1959
  } : void 0;
1927
1960
  return new EventRouter(