@horizon-republic/nestjs-jetstream 2.3.6 → 2.5.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,1036 +1,61 @@
1
1
  # @horizon-republic/nestjs-jetstream
2
2
 
3
- A production-grade NestJS transport for NATS JetStream with built-in support for **Events**, **Broadcast**, and **RPC** messaging patterns.
3
+ Ship reliable microservices with NATS JetStream and NestJS. Events, broadcast, ordered delivery, and RPC with two lines of config.
4
4
 
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
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
- ## Table of Contents
11
-
12
- - [Features](#features)
13
- - [Installation](#installation)
14
- - [Quick Start](#quick-start)
15
- - [Module Configuration](#module-configuration)
16
- - [forRoot / forRootAsync](#forroot--forrootasync)
17
- - [forFeature](#forfeature)
18
- - [Full Options Reference](#full-options-reference)
19
- - [Messaging Patterns](#messaging-patterns)
20
- - [RPC (Request/Reply)](#rpc-requestreply)
21
- - [Events](#events)
22
- - [JetstreamRecord Builder](#jetstreamrecord-builder)
23
- - [Handler Context & Serialization](#handler-context--serialization)
24
- - [RpcContext](#rpccontext)
25
- - [Custom Codec](#custom-codec)
26
- - [Operations](#operations)
27
- - [Lifecycle Hooks](#lifecycle-hooks)
28
- - [Health Checks](#health-checks)
29
- - [Graceful Shutdown](#graceful-shutdown)
30
- - [Reference](#reference)
31
- - [Edge Cases & Important Notes](#edge-cases--important-notes)
32
- - [NATS Naming Conventions](#nats-naming-conventions)
33
- - [Default Stream & Consumer Configs](#default-stream--consumer-configs)
34
- - [API Reference](#api-reference)
35
- - [Development](#development)
36
- - [Testing](#testing)
37
- - [Contributing](#contributing)
38
- - [License](#license)
39
- - [Links](#links)
40
-
41
- ## Features
42
-
43
- - **Two RPC modes** — NATS Core request/reply (lowest latency) or JetStream-persisted commands
44
- - **At-least-once event delivery** — messages acked after handler success, redelivered on failure
45
- - **Broadcast events** — fan-out to all subscribing services with per-service durable consumers
46
- - **Pluggable codec** — JSON by default, swap in MessagePack, Protobuf, or any custom format
47
- - **Progressive configuration** — two lines to start, full NATS overrides for power users
48
- - **Lifecycle hooks** — observable events for connect, disconnect, errors, timeouts, shutdown
49
- - **Graceful shutdown** — drain in-flight messages before closing the connection
50
- - **Publisher-only mode** — set `consumer: false` for API gateways that only send messages
51
- - **Per-feature codec override** — different serialization per target service
52
-
53
- ## Installation
10
+ ## Quick Start
54
11
 
55
12
  ```bash
56
13
  npm install @horizon-republic/nestjs-jetstream
57
- # or
58
- pnpm add @horizon-republic/nestjs-jetstream
59
- # or
60
- yarn add @horizon-republic/nestjs-jetstream
61
14
  ```
62
15
 
63
- **Peer dependencies:**
64
-
65
- ```
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
- nats ^2.0.0
70
- reflect-metadata ^0.2.0
71
- rxjs ^7.8.0
72
- ```
73
-
74
- ## Quick Start
75
-
76
- ### 1. Register the module
77
-
78
16
  ```typescript
79
17
  // app.module.ts
80
- import { Module } from '@nestjs/common';
81
- import { JetstreamModule } from '@horizon-republic/nestjs-jetstream';
82
-
83
18
  @Module({
84
19
  imports: [
85
- // Global setup once per application
86
- JetstreamModule.forRoot({
87
- name: 'orders',
88
- servers: ['nats://localhost:4222'],
89
- }),
90
-
91
- // Client for sending messages to the "orders" service
20
+ JetstreamModule.forRoot({ name: 'orders', servers: ['nats://localhost:4222'] }),
92
21
  JetstreamModule.forFeature({ name: 'orders' }),
93
22
  ],
94
23
  })
95
24
  export class AppModule {}
96
- ```
97
-
98
- ### 2. Connect the transport
99
-
100
- ```typescript
101
- // main.ts
102
- import { NestFactory } from '@nestjs/core';
103
- import { JetstreamStrategy } from '@horizon-republic/nestjs-jetstream';
104
- import { AppModule } from './app.module';
105
-
106
- const bootstrap = async () => {
107
- const app = await NestFactory.create(AppModule);
108
-
109
- app.connectMicroservice(
110
- { strategy: app.get(JetstreamStrategy) },
111
- { inheritAppConfig: true },
112
- );
113
-
114
- await app.startAllMicroservices();
115
- await app.listen(3000);
116
- };
117
-
118
- void bootstrap();
119
- ```
120
-
121
- ### 3. Define handlers
122
-
123
- ```typescript
124
- import { Controller } from '@nestjs/common';
125
- import { EventPattern, MessagePattern, Payload } from '@nestjs/microservices';
126
25
 
26
+ // orders.controller.ts
127
27
  @Controller()
128
28
  export class OrdersController {
29
+ constructor(@Inject('orders') private client: ClientProxy) {}
30
+
129
31
  @EventPattern('order.created')
130
- handleOrderCreated(@Payload() data: { orderId: number }) {
32
+ handle(@Payload() data: { orderId: number }) {
131
33
  console.log('Order created:', data.orderId);
132
34
  }
133
35
 
134
- @MessagePattern('order.get')
135
- getOrder(@Payload() data: { id: number }) {
136
- return { id: data.id, status: 'shipped' };
137
- }
138
- }
139
- ```
140
-
141
- ### 4. Send messages
142
-
143
- ```typescript
144
- import { Controller, Get, Inject } from '@nestjs/common';
145
- import { ClientProxy } from '@nestjs/microservices';
146
-
147
- @Controller()
148
- export class AppController {
149
- constructor(@Inject('orders') private client: ClientProxy) {}
150
-
151
- @Get('create')
152
- createOrder() {
36
+ @Get('emit')
37
+ emit() {
153
38
  return this.client.emit('order.created', { orderId: 42 });
154
39
  }
155
-
156
- @Get('get')
157
- getOrder() {
158
- return this.client.send('order.get', { id: 42 });
159
- }
160
- }
161
- ```
162
-
163
- ## Module Configuration
164
-
165
- ### forRoot / forRootAsync
166
-
167
- `forRoot()` registers the transport globally. Call it once in your root `AppModule`.
168
-
169
- ```typescript
170
- JetstreamModule.forRoot({
171
- name: 'orders',
172
- servers: ['nats://localhost:4222'],
173
- rpc: { mode: 'core', timeout: 10_000 },
174
- shutdownTimeout: 15_000,
175
- hooks: {
176
- [TransportEvent.Error]: (err, ctx) => sentry.captureException(err),
177
- },
178
- })
179
- ```
180
-
181
- For async configuration (e.g., loading from `ConfigService`):
182
-
183
- ```typescript
184
- JetstreamModule.forRootAsync({
185
- name: 'orders',
186
- imports: [ConfigModule],
187
- inject: [ConfigService],
188
- useFactory: (config: ConfigService) => ({
189
- servers: [config.get('NATS_URL')],
190
- rpc: { mode: config.get('RPC_MODE') as 'core' | 'jetstream' },
191
- }),
192
- })
193
- ```
194
-
195
- Also supports `useExisting` and `useClass` patterns.
196
-
197
- ### forFeature
198
-
199
- `forFeature()` creates a lightweight client for a target service. Import in each feature module.
200
-
201
- ```typescript
202
- // The client reuses the NATS connection from forRoot().
203
- // No separate connection is created.
204
- JetstreamModule.forFeature({ name: 'users' })
205
- JetstreamModule.forFeature({ name: 'payments' })
206
-
207
- // Optionally override the codec for a specific client
208
- JetstreamModule.forFeature({ name: 'legacy-service', codec: new MsgPackCodec() })
209
- ```
210
-
211
- Inject clients by the service name:
212
-
213
- ```typescript
214
- constructor(
215
- @Inject('users') private usersClient: ClientProxy,
216
- @Inject('payments') private paymentsClient: ClientProxy,
217
- ) {}
218
- ```
219
-
220
- ### Full Options Reference
221
-
222
- ```typescript
223
- interface JetstreamModuleOptions {
224
- /** Service name. Used for stream/consumer/subject naming. */
225
- name: string;
226
-
227
- /** NATS server URLs. */
228
- servers: string[];
229
-
230
- /**
231
- * Global message codec.
232
- * @default JsonCodec
233
- */
234
- codec?: Codec;
235
-
236
- /**
237
- * RPC transport mode.
238
- * @default { mode: 'core' }
239
- */
240
- rpc?: RpcConfig;
241
-
242
- /**
243
- * Enable consumer infrastructure (streams, consumers, message routing).
244
- * Set to false for publisher-only services (e.g., API gateways).
245
- * @default true
246
- */
247
- consumer?: boolean;
248
-
249
- /** Workqueue event stream/consumer overrides. */
250
- events?: { stream?: Partial<StreamConfig>; consumer?: Partial<ConsumerConfig> };
251
-
252
- /** Broadcast event stream/consumer overrides. */
253
- broadcast?: { stream?: Partial<StreamConfig>; consumer?: Partial<ConsumerConfig> };
254
-
255
- /** Transport lifecycle hook handlers. Unset hooks are silently ignored. */
256
- hooks?: Partial<TransportHooks>;
257
-
258
- /** Async callback for dead letter handling. See Dead Letter Queue section below. */
259
- onDeadLetter?: (info: DeadLetterInfo) => Promise<void>;
260
-
261
- /**
262
- * Graceful shutdown timeout in ms.
263
- * @default 10_000
264
- */
265
- shutdownTimeout?: number;
266
-
267
- /** Raw NATS ConnectionOptions pass-through (tls, auth, reconnect, etc.). */
268
- connectionOptions?: Partial<ConnectionOptions>;
269
- }
270
- ```
271
-
272
- #### Connection Options
273
-
274
- Pass raw NATS `ConnectionOptions` for TLS, authentication, and reconnection:
275
-
276
- ```typescript
277
- JetstreamModule.forRoot({
278
- name: 'orders',
279
- servers: ['nats://nats.prod.internal:4222'],
280
- connectionOptions: {
281
- // TLS
282
- tls: {
283
- certFile: '/certs/client.crt',
284
- keyFile: '/certs/client.key',
285
- caFile: '/certs/ca.crt',
286
- },
287
- // Token auth
288
- token: process.env.NATS_TOKEN,
289
- // Or user/pass
290
- user: process.env.NATS_USER,
291
- pass: process.env.NATS_PASS,
292
- // Reconnection
293
- maxReconnectAttempts: -1, // unlimited
294
- reconnectTimeWait: 2000, // 2s between attempts
295
- },
296
- })
297
- ```
298
-
299
- #### RpcConfig
300
-
301
- Discriminated union on `mode`:
302
-
303
- | Mode | Timeout Default | Persistence | Use Case |
304
- |---------------|-----------------|------------------|----------------------------------------|
305
- | `'core'` | 30s | None | Low-latency, simple RPC |
306
- | `'jetstream'` | 3 min | JetStream stream | Commands must survive handler downtime |
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
-
310
- ```typescript
311
- // Core mode (default)
312
- rpc: { mode: 'core', timeout: 10_000 }
313
-
314
- // JetStream mode with custom stream/consumer config
315
- rpc: {
316
- mode: 'jetstream',
317
- timeout: 60_000,
318
- stream: { max_age: nanos(60_000) },
319
- consumer: { max_deliver: 3 },
320
- }
321
- ```
322
-
323
- ## Messaging Patterns
324
-
325
- ### RPC (Request/Reply)
326
-
327
- #### Core Mode (Default)
328
-
329
- Uses NATS native `request/reply` for the lowest possible latency.
330
-
331
- ```typescript
332
- // Configuration
333
- JetstreamModule.forRoot({
334
- name: 'orders',
335
- servers: ['nats://localhost:4222'],
336
- // rpc: { mode: 'core' } ← default, can be omitted
337
- })
338
- ```
339
-
340
- **How it works:**
341
-
342
- 1. Client calls `nc.request()` with a timeout
343
- 2. Server receives on a queue-group subscription (load-balanced across instances)
344
- 3. Handler executes and responds via `msg.respond()`
345
- 4. Client receives the response
346
-
347
- **Error behavior:**
348
-
349
- | Scenario | Result |
350
- |----------|--------|
351
- | Handler success | Response returned to caller |
352
- | Handler throws | Error response returned to caller |
353
- | No handler registered | Error response returned to caller |
354
- | Server not running | Client times out |
355
- | Decode error | Error response returned to caller |
356
-
357
- #### JetStream Mode
358
-
359
- Commands are persisted in a JetStream stream. Responses flow back via NATS Core inbox.
360
-
361
- ```typescript
362
- JetstreamModule.forRoot({
363
- name: 'orders',
364
- servers: ['nats://localhost:4222'],
365
- rpc: { mode: 'jetstream', timeout: 120_000 },
366
- })
367
- ```
368
-
369
- **How it works:**
370
-
371
- 1. Client publishes command to JetStream with `replyTo` and `correlationId` headers
372
- 2. Server pulls from consumer, executes handler
373
- 3. Server publishes response to the client's inbox via Core NATS
374
- 4. Server acks/terms the JetStream message
375
-
376
- **Error behavior:**
377
-
378
- | Scenario | JetStream Action | Client Result |
379
- |-----------------|------------------------|-------------------|
380
- | Handler success | `ack` | Response returned |
381
- | Handler throws | `term` (no redelivery) | Error response |
382
- | Handler timeout | `term` | Client times out |
383
- | Decode error | `term` | No response |
384
- | No handler | `term` | No response |
385
-
386
- > **Why `term` instead of `nak` for RPC errors?** Redelivering a failed command could cause duplicate side effects. The caller is responsible for retrying.
387
-
388
- ### Events
389
-
390
- #### Workqueue Events
391
-
392
- Each event is delivered to **one** handler instance (load-balanced). Messages are acked **after** the handler completes successfully.
393
-
394
- ```typescript
395
- // Sending
396
- this.client.emit('order.created', { orderId: 42 });
397
-
398
- // Handling
399
- @EventPattern('order.created')
400
- handleOrderCreated(@Payload() data: OrderCreatedDto) {
401
- // If this throws, the message is nak'd and redelivered (up to max_deliver times)
402
- await this.ordersService.process(data);
403
- }
404
- ```
405
-
406
- **Delivery semantics (at-least-once):**
407
-
408
- | Scenario | Action | Redelivery? |
409
- |------------------|--------|---------------------------------------|
410
- | Handler success | `ack` | No |
411
- | Handler throws | `nak` | Yes, up to `max_deliver` (default: 3) |
412
- | Decode error | `term` | No (malformed payload) |
413
- | No handler found | `term` | No (configuration error) |
414
-
415
- > Handlers **must be idempotent** — NATS may redeliver on failure or timeout.
416
-
417
- **Custom stream/consumer configuration:**
418
-
419
- ```typescript
420
- import { nanos } from '@horizon-republic/nestjs-jetstream';
421
-
422
- JetstreamModule.forRoot({
423
- name: 'orders',
424
- servers: ['nats://localhost:4222'],
425
- events: {
426
- stream: {
427
- max_age: nanos(3 * 24 * 60 * 60 * 1000), // 3 days instead of default 7
428
- max_bytes: 1024 * 1024 * 512, // 512 MB instead of default 5 GB
429
- },
430
- consumer: {
431
- max_deliver: 5, // retry up to 5 times instead of default 3
432
- ack_wait: nanos(30_000), // 30s ack timeout instead of default 10s
433
- },
434
- },
435
- })
436
- ```
437
-
438
- #### Broadcast Events
439
-
440
- Broadcast events are delivered to **all** subscribing services. Each service gets its own durable consumer on a shared `broadcast-stream`.
441
-
442
- ```typescript
443
- // Sending — use the 'broadcast:' prefix
444
- this.client.emit('broadcast:config.updated', { key: 'theme', value: 'dark' });
445
-
446
- // Handling — use { broadcast: true } in extras
447
- @EventPattern('config.updated', { broadcast: true })
448
- handleConfigUpdated(@Payload() data: ConfigDto) {
449
- this.configCache.invalidate(data.key);
450
- }
451
- ```
452
-
453
- Every service with this handler receives the message independently.
454
-
455
- **Delivery guarantees:** Each service has its own durable consumer on the broadcast stream. Delivery tracking is fully isolated — if service A fails to process a message, only service A retries it (`nak`). Services B, C, D are not affected. This means broadcast provides **at-least-once delivery per consumer**, not at-most-once.
456
-
457
- **Custom broadcast configuration:**
458
-
459
- ```typescript
460
- import { nanos } from '@horizon-republic/nestjs-jetstream';
461
-
462
- JetstreamModule.forRoot({
463
- name: 'orders',
464
- servers: ['nats://localhost:4222'],
465
- broadcast: {
466
- stream: {
467
- max_age: nanos(7 * 24 * 60 * 60 * 1000), // keep messages for 7 days
468
- },
469
- consumer: {
470
- max_deliver: 5,
471
- },
472
- },
473
- })
474
- ```
475
-
476
- > **Note:** The broadcast stream is shared across all services — stream-level settings (e.g., `max_age`, `max_bytes`) affect everyone. Consumer-level settings are per-service.
477
-
478
- ### JetstreamRecord Builder
479
-
480
- Attach custom headers and per-request timeouts using the builder pattern:
481
-
482
- ```typescript
483
- import { JetstreamRecordBuilder } from '@horizon-republic/nestjs-jetstream';
484
-
485
- const record = new JetstreamRecordBuilder({ id: 1 })
486
- .setHeader('x-trace-id', 'abc-123')
487
- .setHeader('x-tenant', 'acme')
488
- .setTimeout(5000)
489
- .build();
490
-
491
- // Works with both send() and emit()
492
- this.client.send('user.get', record);
493
- this.client.emit('user.created', record);
494
- ```
495
-
496
- **Reserved headers** (set automatically by the transport, cannot be overridden):
497
-
498
- | Header | Purpose |
499
- |--------------------|-------------------------------|
500
- | `x-correlation-id` | RPC request/response matching |
501
- | `x-reply-to` | JetStream RPC response inbox |
502
- | `x-error` | RPC error response flag |
503
-
504
- Attempting to set a reserved header throws an error at build time.
505
-
506
- **Additional transport headers** (set automatically, available in handlers):
507
-
508
- | Header | Purpose |
509
- |-----------------|---------------------------------------------|
510
- | `x-subject` | Original NATS subject |
511
- | `x-caller-name` | Sending service name |
512
- | `x-request-id` | Available for user-defined request tracking |
513
- | `x-trace-id` | Available for distributed tracing |
514
- | `x-span-id` | Available for distributed tracing |
515
-
516
- ## Handler Context & Serialization
517
-
518
- ### RpcContext
519
-
520
- Execution context available in all handlers via `@Ctx()`:
521
-
522
- ```typescript
523
- import { Ctx, Payload, MessagePattern } from '@nestjs/microservices';
524
- import { RpcContext } from '@horizon-republic/nestjs-jetstream';
525
-
526
- @MessagePattern('user.get')
527
- getUser(@Payload() data: GetUserDto, @Ctx() ctx: RpcContext) {
528
- const subject = ctx.getSubject(); // Full NATS subject
529
- const traceId = ctx.getHeader('x-trace-id'); // Single header value
530
- const headers = ctx.getHeaders(); // All headers (MsgHdrs)
531
- const isJs = ctx.isJetStream(); // true for JetStream messages
532
- const msg = ctx.getMessage(); // Raw JsMsg | Msg (escape hatch)
533
-
534
- return this.userService.findOne(data.id);
535
- }
536
- ```
537
-
538
- **Available methods:**
539
-
540
- | Method | Returns | Description |
541
- |------------------|------------------------|-------------------------------------------|
542
- | `getSubject()` | `string` | NATS subject the message was published to |
543
- | `getHeader(key)` | `string \| undefined` | Single header value by key |
544
- | `getHeaders()` | `MsgHdrs \| undefined` | All NATS message headers |
545
- | `isJetStream()` | `boolean` | Whether the message supports ack/nak/term |
546
- | `getMessage()` | `JsMsg \| Msg` | Raw NATS message (escape hatch) |
547
-
548
- Available on both `@EventPattern` and `@MessagePattern` handlers.
549
-
550
- ### Custom Codec
551
-
552
- The library uses JSON by default. Implement the `Codec` interface for any serialization format:
553
-
554
- ```typescript
555
- import { Codec } from '@horizon-republic/nestjs-jetstream';
556
- import { encode, decode } from '@msgpack/msgpack';
557
-
558
- class MsgPackCodec implements Codec {
559
- encode(data: unknown): Uint8Array {
560
- return encode(data);
561
- }
562
-
563
- decode(data: Uint8Array): unknown {
564
- return decode(data);
565
- }
566
- }
567
- ```
568
-
569
- ```typescript
570
- // Global codec
571
- JetstreamModule.forRoot({
572
- name: 'orders',
573
- servers: ['nats://localhost:4222'],
574
- codec: new MsgPackCodec(),
575
- })
576
-
577
- // Per-client override (falls back to global codec when omitted)
578
- JetstreamModule.forFeature({
579
- name: 'legacy-service',
580
- codec: new JsonCodec(),
581
- })
582
- ```
583
-
584
- > All services communicating with each other **must use the same codec**. A codec mismatch results in decode errors (`term`, no redelivery).
585
-
586
- ## Operations
587
-
588
- ### Lifecycle Hooks
589
-
590
- Subscribe to transport events for monitoring, alerting, or custom logic. Events without a registered hook are silently ignored — no default logging:
591
-
592
- ```typescript
593
- import { JetstreamModule, TransportEvent } from '@horizon-republic/nestjs-jetstream';
594
-
595
- JetstreamModule.forRoot({
596
- name: 'orders',
597
- servers: ['nats://localhost:4222'],
598
- hooks: {
599
- [TransportEvent.Connect]: (server) => {
600
- console.log(`Connected to ${server}`);
601
- },
602
- [TransportEvent.Disconnect]: () => {
603
- metrics.increment('nats.disconnect');
604
- },
605
- [TransportEvent.Error]: (error, context) => {
606
- sentry.captureException(error, { extra: { context } });
607
- },
608
- [TransportEvent.RpcTimeout]: (subject, correlationId) => {
609
- metrics.increment('rpc.timeout', { subject });
610
- },
611
- },
612
- })
613
- ```
614
-
615
- **Available events:**
616
-
617
- | Event | Arguments |
618
- |--------------------|---------------------------------------------|
619
- | `connect` | `(server: string)` |
620
- | `disconnect` | `()` |
621
- | `reconnect` | `(server: string)` |
622
- | `error` | `(error: Error, context?: string)` |
623
- | `rpcTimeout` | `(subject: string, correlationId: string)` |
624
- | `messageRouted` | `(subject: string, kind: 'rpc' \| 'event')` |
625
- | `shutdownStart` | `()` |
626
- | `shutdownComplete` | `()` |
627
- | `deadLetter` | `(info: DeadLetterInfo)` |
628
-
629
- #### Dead Letter Queue (DLQ)
630
-
631
- When an event handler fails on every delivery attempt (`max_deliver`), the message becomes a "dead letter." By default, NATS terminates it silently. Configure `onDeadLetter` to intercept these messages:
632
-
633
- ```typescript
634
- JetstreamModule.forRoot({
635
- name: 'my-service',
636
- servers: ['nats://localhost:4222'],
637
- onDeadLetter: async (info) => {
638
- // Persist to your DLQ store (database, S3, another queue, etc.)
639
- await dlqRepository.save({
640
- subject: info.subject,
641
- data: info.data,
642
- error: String(info.error),
643
- deliveryCount: info.deliveryCount,
644
- stream: info.stream,
645
- timestamp: info.timestamp,
646
- });
647
- },
648
- });
649
- ```
650
-
651
- **Behavior:**
652
- - Hook is awaited before `msg.term()` — if it succeeds, the message is terminated
653
- - If the hook throws, the message is `nak()`'d for retry (NATS redelivers it)
654
- - `TransportEvent.DeadLetter` is emitted for observability regardless of the hook
655
- - Applies only to events (workqueue + broadcast), not RPC
656
-
657
- **With dependency injection (`forRootAsync`):**
658
-
659
- ```typescript
660
- import { JETSTREAM_CONNECTION } from '@horizon-republic/nestjs-jetstream';
661
-
662
- JetstreamModule.forRootAsync({
663
- name: 'my-service',
664
- imports: [DlqModule],
665
- inject: [DlqService, JETSTREAM_CONNECTION],
666
- useFactory: (dlqService: DlqService, connection: ConnectionProvider) => ({
667
- servers: ['nats://localhost:4222'],
668
- onDeadLetter: async (info) => {
669
- await dlqService.persist(info);
670
- },
671
- }),
672
- });
673
- ```
674
-
675
- ### Health Checks
676
-
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.
680
-
681
- **With [@nestjs/terminus](https://docs.nestjs.com/recipes/terminus) (zero boilerplate):**
682
-
683
- ```typescript
684
- import { Controller, Get } from '@nestjs/common';
685
- import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
686
- import { JetstreamHealthIndicator } from '@horizon-republic/nestjs-jetstream';
687
-
688
- @Controller('health')
689
- export class HealthController {
690
- constructor(
691
- private health: HealthCheckService,
692
- private jetstream: JetstreamHealthIndicator,
693
- ) {}
694
-
695
- @Get()
696
- @HealthCheck()
697
- check() {
698
- return this.health.check([
699
- () => this.jetstream.isHealthy(),
700
- ]);
701
- }
702
- }
703
- ```
704
-
705
- **Standalone (without Terminus):**
706
-
707
- ```typescript
708
- const status = await this.jetstream.check();
709
- // { connected: true, server: 'nats://localhost:4222', latency: 2 }
710
- ```
711
-
712
- | Method | Returns | Throws |
713
- |-------------------|------------------------------------|------------------------------------|
714
- | `check()` | `JetstreamHealthStatus` | Never |
715
- | `isHealthy(key?)` | `{ [key]: { status: 'up', ... } }` | On unhealthy (Terminus convention) |
716
-
717
- ### Graceful Shutdown
718
-
719
- The transport shuts down automatically via NestJS `onApplicationShutdown()`:
720
-
721
- 1. Stop accepting new messages (close subscriptions, stop consumers)
722
- 2. Drain and close NATS connection (waits for in-flight messages)
723
- 3. Safety timeout if drain takes too long
724
-
725
- ```typescript
726
- JetstreamModule.forRoot({
727
- name: 'orders',
728
- servers: ['nats://localhost:4222'],
729
- shutdownTimeout: 15_000, // default: 10_000 ms
730
- })
731
- ```
732
-
733
- No manual shutdown code needed.
734
-
735
- ## Reference
736
-
737
- ### Edge Cases & Important Notes
738
-
739
- #### Event handlers must be idempotent
740
-
741
- Events use at-least-once delivery. If your handler throws, the message is `nak`'d and NATS redelivers it (up to `max_deliver` times, default 3). Design handlers to be safe for repeated execution.
742
-
743
- #### RPC error handling
744
-
745
- The transport fully supports NestJS `RpcException` and custom exception filters. Throw `RpcException` with any payload — it will be delivered to the caller as-is:
746
-
747
- ```typescript
748
- import { RpcException } from '@nestjs/microservices';
749
-
750
- @MessagePattern('user.update')
751
- updateUser(@Payload() data: UpdateUserDto) {
752
- throw new RpcException({
753
- statusCode: 400,
754
- errors: [{ field: 'email', message: 'Already taken' }],
755
- });
756
- }
757
-
758
- // Caller receives the full error object:
759
- this.client.send('user.update', data).subscribe({
760
- error: (err) => {
761
- // err = { statusCode: 400, errors: [{ field: 'email', message: 'Already taken' }] }
762
- },
763
- });
764
- ```
765
-
766
- In JetStream mode, failed RPC messages are `term`'d (not `nak`'d) to prevent duplicate side effects. The caller is responsible for implementing retry logic.
767
-
768
- #### Fire-and-forget events
769
-
770
- This library focuses on **reliable, persistent** event delivery via JetStream. If you need fire-and-forget (no persistence, no ack) for high-throughput scenarios, use the standard [NestJS NATS transport](https://docs.nestjs.com/microservices/nats) — it works perfectly alongside this library on the same NATS server.
771
-
772
- #### Publisher-only mode
773
-
774
- For services that only send messages (e.g., API gateways), disable consumer infrastructure:
775
-
776
- ```typescript
777
- JetstreamModule.forRoot({
778
- name: 'api-gateway',
779
- servers: ['nats://localhost:4222'],
780
- consumer: false, // no streams, consumers, or routers created
781
- })
782
- ```
783
-
784
- #### Broadcast stream is shared
785
-
786
- All services share a single `broadcast-stream`. Each service creates its own durable consumer with `filter_subjects` matching only its registered broadcast patterns. Stream-level configuration (`broadcast.stream`) affects all services.
787
-
788
- Broadcast consumers use the same ack/nak semantics as workqueue consumers. Because each service has an **isolated durable consumer**, a `nak` (retry) from one service only causes redelivery to that specific service — other consumers are unaffected. This gives broadcast **at-least-once delivery per consumer** with independent retry.
789
-
790
- #### Connection failure behavior
791
-
792
- If the initial NATS connection is refused, the module throws an `Error` immediately (fail fast). For transient disconnects after startup, NATS handles reconnection automatically and the `reconnect` hook fires.
793
-
794
- #### Observable return values
795
-
796
- Handlers can return Observables. The transport resolves on the **first emitted value** (or on completion if the Observable emits nothing):
797
-
798
- ```typescript
799
- @MessagePattern('user.get')
800
- getUser(@Payload() data: { id: number }): Observable<UserDto> {
801
- return this.userService.findById(data.id); // first value used as response
802
- }
803
-
804
- @EventPattern('order.created')
805
- handleOrder(@Payload() data: OrderDto): Observable<void> {
806
- return this.pipeline.process(data); // ack after first emission or completion
807
40
  }
808
41
  ```
809
42
 
810
- #### Consumer self-healing
811
-
812
- If a JetStream consumer's message iterator ends unexpectedly (e.g., NATS restart), the transport automatically re-establishes consumption with exponential backoff (100ms up to 30s). This is logged as a warning.
813
-
814
- #### NATS header size
815
-
816
- Custom headers are transmitted as NATS message headers. NATS has a default header size limit. If you're attaching large metadata, consider putting it in the message body instead.
817
-
818
- ### NATS Naming Conventions
819
-
820
- The transport generates NATS subjects, streams, and consumers based on the service `name`:
821
-
822
- | Resource | Format | Example (`name: 'orders'`) |
823
- |--------------------|---------------------------------|-------------------------------------------|
824
- | Internal name | `{name}__microservice` | `orders__microservice` |
825
- | RPC subject | `{internal}.cmd.{pattern}` | `orders__microservice.cmd.get.order` |
826
- | Event subject | `{internal}.ev.{pattern}` | `orders__microservice.ev.order.created` |
827
- | Broadcast subject | `broadcast.{pattern}` | `broadcast.config.updated` |
828
- | Event stream | `{internal}_ev-stream` | `orders__microservice_ev-stream` |
829
- | Command stream | `{internal}_cmd-stream` | `orders__microservice_cmd-stream` |
830
- | Broadcast stream | `broadcast-stream` | `broadcast-stream` |
831
- | Event consumer | `{internal}_ev-consumer` | `orders__microservice_ev-consumer` |
832
- | Command consumer | `{internal}_cmd-consumer` | `orders__microservice_cmd-consumer` |
833
- | Broadcast consumer | `{internal}_broadcast-consumer` | `orders__microservice_broadcast-consumer` |
834
-
835
- ### Default Stream & Consumer Configs
836
-
837
- All defaults can be overridden via `events`, `broadcast`, or `rpc` options.
838
-
839
- <details>
840
- <summary><strong>Event Stream</strong></summary>
841
-
842
- | Property | Value |
843
- |----------------------|------------|
844
- | Retention | Workqueue |
845
- | Storage | File |
846
- | Replicas | 1 |
847
- | Max consumers | 100 |
848
- | Max message size | 10 MB |
849
- | Max messages/subject | 5,000,000 |
850
- | Max messages | 50,000,000 |
851
- | Max bytes | 5 GB |
852
- | Max age | 7 days |
853
- | Duplicate window | 2 minutes |
854
-
855
- </details>
856
-
857
- <details>
858
- <summary><strong>Command Stream (JetStream RPC only)</strong></summary>
859
-
860
- | Property | Value |
861
- |----------------------|------------|
862
- | Retention | Workqueue |
863
- | Storage | File |
864
- | Replicas | 1 |
865
- | Max consumers | 50 |
866
- | Max message size | 5 MB |
867
- | Max messages/subject | 100,000 |
868
- | Max messages | 1,000,000 |
869
- | Max bytes | 100 MB |
870
- | Max age | 3 minutes |
871
- | Duplicate window | 30 seconds |
872
-
873
- </details>
874
-
875
- <details>
876
- <summary><strong>Broadcast Stream</strong></summary>
877
-
878
- | Property | Value |
879
- |----------------------|------------|
880
- | Retention | Limits |
881
- | Storage | File |
882
- | Replicas | 1 |
883
- | Max consumers | 200 |
884
- | Max message size | 10 MB |
885
- | Max messages/subject | 1,000,000 |
886
- | Max messages | 10,000,000 |
887
- | Max bytes | 2 GB |
888
- | Max age | 1 day |
889
- | Duplicate window | 2 minutes |
890
-
891
- </details>
892
-
893
- <details>
894
- <summary><strong>Consumer Configs</strong></summary>
895
-
896
- **Event consumer:**
897
-
898
- | Property | Value |
899
- |-----------------|------------|
900
- | Ack wait | 10 seconds |
901
- | Max deliver | 3 |
902
- | Max ack pending | 100 |
903
-
904
- **Command consumer (JetStream RPC):**
905
-
906
- | Property | Value |
907
- |-----------------|-----------|
908
- | Ack wait | 5 minutes |
909
- | Max deliver | 1 |
910
- | Max ack pending | 100 |
43
+ ## Documentation
911
44
 
912
- **Broadcast consumer:**
45
+ **[Read the full documentation →](https://horizonrepublic.github.io/nestjs-jetstream/)**
913
46
 
914
- | Property | Value |
915
- |-----------------|------------|
916
- | Ack wait | 10 seconds |
917
- | Max deliver | 3 |
918
- | Max ack pending | 100 |
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
919
50
 
920
- </details>
921
-
922
- ### API Reference
923
-
924
- #### Exports
925
-
926
- ```typescript
927
- // Module
928
- JetstreamModule
929
-
930
- // Client
931
- JetstreamClient
932
- JetstreamRecord
933
- JetstreamRecordBuilder
934
-
935
- // Server
936
- JetstreamStrategy
937
-
938
- // Codec
939
- JsonCodec
940
-
941
- // Context
942
- RpcContext
943
-
944
- // Hooks
945
- EventBus
946
- TransportEvent
947
-
948
- // Constants
949
- JETSTREAM_OPTIONS
950
- JETSTREAM_CONNECTION
951
- JETSTREAM_CODEC
952
- JETSTREAM_EVENT_BUS
953
- JetstreamHeader
954
- getClientToken
955
- nanos
956
-
957
- // Types
958
- Codec
959
- DeadLetterInfo
960
- JetstreamModuleOptions
961
- JetstreamModuleAsyncOptions
962
- JetstreamFeatureOptions
963
- RpcConfig
964
- StreamConsumerOverrides
965
- TransportHooks
966
- ```
967
-
968
- #### Helper: `nanos(ms)`
969
-
970
- Convert milliseconds to nanoseconds (required by NATS JetStream config):
971
-
972
- ```typescript
973
- import { nanos } from '@horizon-republic/nestjs-jetstream';
974
-
975
- // Use in stream/consumer overrides
976
- events: {
977
- stream: { max_age: nanos(3 * 24 * 60 * 60 * 1000) }, // 3 days
978
- consumer: { ack_wait: nanos(30_000) }, // 30s
979
- }
980
- ```
981
-
982
- ## Development
983
-
984
- ### Testing
985
-
986
- The project uses [Vitest](https://vitest.dev/) with two test suites configured as [projects](https://vitest.dev/guide/workspace):
987
-
988
- ```bash
989
- # Unit tests (no external dependencies)
990
- pnpm test
991
-
992
- # Integration tests (requires a running NATS server with JetStream)
993
- pnpm test:integration
994
-
995
- # Both suites sequentially
996
- pnpm test:all
997
-
998
- # Unit tests in watch mode
999
- pnpm test:watch
1000
-
1001
- # Unit tests with coverage report
1002
- pnpm test:cov
1003
- ```
1004
-
1005
- #### Running NATS locally
1006
-
1007
- Integration tests require a NATS server with JetStream enabled:
1008
-
1009
- ```bash
1010
- docker run -d --name nats -p 4222:4222 nats:latest -js
1011
- ```
1012
-
1013
- #### Writing tests
1014
-
1015
- - Use `sut` (system under test) for the main instance
1016
- - Use `createMock<T>()` from `@golevelup/ts-vitest` for mocking
1017
- - Follow Given-When-Then structure with comments
1018
- - Order: happy path → edge cases → error cases
1019
- - Always include `afterEach(vi.resetAllMocks)`
1020
-
1021
- ### Contributing
51
+ ## Links
1022
52
 
1023
- Contributions are welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
53
+ - [npm](https://www.npmjs.com/package/@horizon-republic/nestjs-jetstream)
54
+ - [GitHub](https://github.com/HorizonRepublic/nestjs-jetstream)
55
+ - [Documentation](https://horizonrepublic.github.io/nestjs-jetstream/)
56
+ - [Issues](https://github.com/HorizonRepublic/nestjs-jetstream/issues)
57
+ - [Discussions](https://github.com/HorizonRepublic/nestjs-jetstream/discussions)
1024
58
 
1025
59
  ## License
1026
60
 
1027
- [MIT](./LICENSE)
1028
-
1029
- ## Links
1030
-
1031
- - [NATS JetStream Documentation](https://docs.nats.io/nats-concepts/jetstream)
1032
- - [NestJS Microservices](https://docs.nestjs.com/microservices/basics)
1033
- - [GitHub Repository](https://github.com/HorizonRepublic/nestjs-jetstream)
1034
- - [npm Package](https://www.npmjs.com/package/@horizon-republic/nestjs-jetstream)
1035
- - [Report bugs](https://github.com/HorizonRepublic/nestjs-jetstream/issues)
1036
- - [Discussions](https://github.com/HorizonRepublic/nestjs-jetstream/discussions)
61
+ MIT