@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 +13 -8
- package/dist/index.cjs +76 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -12
- package/dist/index.d.ts +13 -12
- package/dist/index.js +77 -52
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A production-grade NestJS transport for NATS JetStream with built-in support for
|
|
|
5
5
|
[](https://www.npmjs.com/package/@horizon-republic/nestjs-jetstream)
|
|
6
6
|
[](https://codecov.io/github/HorizonRepublic/nestjs-jetstream)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
[](https://
|
|
8
|
+
[](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
|
|
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
|
|
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
|
|
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); //
|
|
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
|
-
|
|
621
|
-
this.
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1178
|
-
this.logger.debug(`Consumer exists: ${name}`);
|
|
1179
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1957
|
+
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
1925
1958
|
onDeadLetter: options.onDeadLetter
|
|
1926
1959
|
} : void 0;
|
|
1927
1960
|
return new EventRouter(
|