@saga-bus/transport-nats 0.1.2
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 +258 -0
- package/dist/index.cjs +189 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +169 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# @saga-bus/transport-nats
|
|
2
|
+
|
|
3
|
+
NATS JetStream transport for saga-bus.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @saga-bus/transport-nats nats
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @saga-bus/transport-nats nats
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **JetStream**: Persistent message storage with replay capability
|
|
16
|
+
- **Durable Consumers**: Reliable delivery with acknowledgment tracking
|
|
17
|
+
- **Work Queues**: Competing consumer pattern for load distribution
|
|
18
|
+
- **Low Latency**: High-performance messaging
|
|
19
|
+
- **Horizontal Scaling**: Native clustering support
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { createBus } from "@saga-bus/core";
|
|
25
|
+
import { NatsTransport } from "@saga-bus/transport-nats";
|
|
26
|
+
|
|
27
|
+
const transport = new NatsTransport({
|
|
28
|
+
connectionOptions: { servers: "localhost:4222" },
|
|
29
|
+
streamName: "SAGA_EVENTS",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const bus = createBus({
|
|
33
|
+
transport,
|
|
34
|
+
// ... other config
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await bus.start();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
interface NatsTransportOptions {
|
|
44
|
+
/** Existing NATS connection */
|
|
45
|
+
connection?: NatsConnection;
|
|
46
|
+
|
|
47
|
+
/** Connection options for creating new connection */
|
|
48
|
+
connectionOptions?: ConnectionOptions;
|
|
49
|
+
|
|
50
|
+
/** JetStream options */
|
|
51
|
+
jetStreamOptions?: JetStreamOptions;
|
|
52
|
+
|
|
53
|
+
/** Subject prefix for all messages (default: "saga-bus") */
|
|
54
|
+
subjectPrefix?: string;
|
|
55
|
+
|
|
56
|
+
/** Stream name for JetStream (default: "SAGA_BUS") */
|
|
57
|
+
streamName?: string;
|
|
58
|
+
|
|
59
|
+
/** Consumer name prefix (default: "saga-bus-consumer") */
|
|
60
|
+
consumerPrefix?: string;
|
|
61
|
+
|
|
62
|
+
/** Whether to auto-create streams (default: true) */
|
|
63
|
+
autoCreateStream?: boolean;
|
|
64
|
+
|
|
65
|
+
/** Stream retention policy (default: "workqueue") */
|
|
66
|
+
retentionPolicy?: "limits" | "interest" | "workqueue";
|
|
67
|
+
|
|
68
|
+
/** Max messages in stream (-1 for unlimited) */
|
|
69
|
+
maxMessages?: number;
|
|
70
|
+
|
|
71
|
+
/** Max bytes in stream (-1 for unlimited) */
|
|
72
|
+
maxBytes?: number;
|
|
73
|
+
|
|
74
|
+
/** Max age of messages in nanoseconds */
|
|
75
|
+
maxAge?: number;
|
|
76
|
+
|
|
77
|
+
/** Number of replicas (default: 1) */
|
|
78
|
+
replicas?: number;
|
|
79
|
+
|
|
80
|
+
/** Ack wait timeout in nanoseconds (default: 30s) */
|
|
81
|
+
ackWait?: number;
|
|
82
|
+
|
|
83
|
+
/** Max redelivery attempts (default: 5) */
|
|
84
|
+
maxDeliver?: number;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Examples
|
|
89
|
+
|
|
90
|
+
### Basic Usage
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { NatsTransport } from "@saga-bus/transport-nats";
|
|
94
|
+
|
|
95
|
+
const transport = new NatsTransport({
|
|
96
|
+
connectionOptions: { servers: "localhost:4222" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await transport.start();
|
|
100
|
+
|
|
101
|
+
// Publish a message
|
|
102
|
+
await transport.publish(
|
|
103
|
+
{ type: "OrderCreated", orderId: "123" },
|
|
104
|
+
{ endpoint: "orders" }
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Subscribe to messages
|
|
108
|
+
await transport.subscribe(
|
|
109
|
+
{ endpoint: "orders", group: "order-processor" },
|
|
110
|
+
async (envelope) => {
|
|
111
|
+
console.log("Received:", envelope.payload);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### With Existing Connection
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { connect } from "nats";
|
|
120
|
+
|
|
121
|
+
const nc = await connect({
|
|
122
|
+
servers: ["nats://server1:4222", "nats://server2:4222"],
|
|
123
|
+
token: "my-secret-token",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const transport = new NatsTransport({
|
|
127
|
+
connection: nc,
|
|
128
|
+
streamName: "MY_STREAM",
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Custom Stream Configuration
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
const transport = new NatsTransport({
|
|
136
|
+
connectionOptions: { servers: "localhost:4222" },
|
|
137
|
+
streamName: "ORDERS_STREAM",
|
|
138
|
+
retentionPolicy: "limits",
|
|
139
|
+
maxMessages: 100000,
|
|
140
|
+
maxBytes: 100 * 1024 * 1024, // 100MB
|
|
141
|
+
maxAge: 24 * 60 * 60 * 1000000000, // 24 hours in nanoseconds
|
|
142
|
+
replicas: 3,
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Multiple Consumer Groups
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// Worker pool 1
|
|
150
|
+
await transport.subscribe(
|
|
151
|
+
{ endpoint: "orders", group: "order-validators" },
|
|
152
|
+
async (envelope) => {
|
|
153
|
+
await validateOrder(envelope.payload);
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Worker pool 2
|
|
158
|
+
await transport.subscribe(
|
|
159
|
+
{ endpoint: "orders", group: "order-emailers" },
|
|
160
|
+
async (envelope) => {
|
|
161
|
+
await sendOrderEmail(envelope.payload);
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Subject Hierarchy
|
|
167
|
+
|
|
168
|
+
Messages are published to subjects following this pattern:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
{subjectPrefix}.{endpoint}.{messageType}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
- `saga-bus.orders.OrderCreated`
|
|
176
|
+
- `saga-bus.payments.PaymentReceived`
|
|
177
|
+
- `myapp.inventory.StockUpdated`
|
|
178
|
+
|
|
179
|
+
Consumers subscribe to patterns using `>` wildcard:
|
|
180
|
+
- `saga-bus.orders.>` - All order messages
|
|
181
|
+
- `saga-bus.>` - All messages
|
|
182
|
+
|
|
183
|
+
## Retention Policies
|
|
184
|
+
|
|
185
|
+
| Policy | Description | Use Case |
|
|
186
|
+
|--------|-------------|----------|
|
|
187
|
+
| `limits` | Messages kept until limits reached | Event sourcing, audit logs |
|
|
188
|
+
| `interest` | Messages kept while consumers interested | Standard pub/sub |
|
|
189
|
+
| `workqueue` | Messages removed after acknowledgment | Task queues, job processing |
|
|
190
|
+
|
|
191
|
+
## Message Format
|
|
192
|
+
|
|
193
|
+
Messages are published as JSON:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"id": "msg-uuid",
|
|
198
|
+
"type": "OrderCreated",
|
|
199
|
+
"payload": { "type": "OrderCreated", "orderId": "123" },
|
|
200
|
+
"headers": {},
|
|
201
|
+
"timestamp": "2024-01-01T00:00:00.000Z",
|
|
202
|
+
"partitionKey": "order-123"
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
With NATS headers:
|
|
207
|
+
- `Nats-Msg-Id`: Unique message ID
|
|
208
|
+
- `X-Message-Type`: Message type
|
|
209
|
+
- `X-Correlation-Id`: Correlation/partition key
|
|
210
|
+
|
|
211
|
+
## Limitations
|
|
212
|
+
|
|
213
|
+
### No Delayed Messages
|
|
214
|
+
|
|
215
|
+
NATS JetStream does not support native delayed message delivery. Attempting to publish with `delayMs` will throw an error:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// This will throw an error
|
|
219
|
+
await transport.publish(message, { delayMs: 5000 });
|
|
220
|
+
// Error: NATS JetStream does not support delayed messages.
|
|
221
|
+
// Use an external scheduler for delayed delivery.
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Alternatives:**
|
|
225
|
+
- Use Redis sorted sets for scheduling
|
|
226
|
+
- Implement delay in application logic
|
|
227
|
+
- Use a separate scheduler service
|
|
228
|
+
|
|
229
|
+
## Error Handling
|
|
230
|
+
|
|
231
|
+
Messages that fail processing are automatically retried up to `maxDeliver` times:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
const transport = new NatsTransport({
|
|
235
|
+
connectionOptions: { servers: "localhost:4222" },
|
|
236
|
+
maxDeliver: 10, // Retry up to 10 times
|
|
237
|
+
ackWait: 60_000_000_000, // 60 second ack timeout
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Testing
|
|
242
|
+
|
|
243
|
+
For testing, you can run NATS locally:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Run NATS with JetStream enabled
|
|
247
|
+
docker run -p 4222:4222 nats:latest -js
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Or use the NATS CLI:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
nats-server -js
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
NatsTransport: () => NatsTransport
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/NatsTransport.ts
|
|
28
|
+
var import_nats = require("nats");
|
|
29
|
+
var import_crypto = require("crypto");
|
|
30
|
+
var sc = (0, import_nats.StringCodec)();
|
|
31
|
+
var NatsTransport = class {
|
|
32
|
+
nc = null;
|
|
33
|
+
jsm = null;
|
|
34
|
+
js = null;
|
|
35
|
+
options;
|
|
36
|
+
consumers = [];
|
|
37
|
+
started = false;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
if (!options.connection && !options.connectionOptions) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Either connection or connectionOptions must be provided"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
this.options = {
|
|
45
|
+
subjectPrefix: "saga-bus",
|
|
46
|
+
streamName: "SAGA_BUS",
|
|
47
|
+
consumerPrefix: "saga-bus-consumer",
|
|
48
|
+
autoCreateStream: true,
|
|
49
|
+
retentionPolicy: "workqueue",
|
|
50
|
+
maxMessages: -1,
|
|
51
|
+
maxBytes: -1,
|
|
52
|
+
replicas: 1,
|
|
53
|
+
ackWait: 3e10,
|
|
54
|
+
// 30 seconds in nanoseconds
|
|
55
|
+
maxDeliver: 5,
|
|
56
|
+
...options
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async start() {
|
|
60
|
+
if (this.started) return;
|
|
61
|
+
if (this.options.connection) {
|
|
62
|
+
this.nc = this.options.connection;
|
|
63
|
+
} else {
|
|
64
|
+
this.nc = await (0, import_nats.connect)(this.options.connectionOptions);
|
|
65
|
+
}
|
|
66
|
+
this.jsm = await this.nc.jetstreamManager(this.options.jetStreamOptions);
|
|
67
|
+
this.js = this.nc.jetstream(this.options.jetStreamOptions);
|
|
68
|
+
if (this.options.autoCreateStream) {
|
|
69
|
+
await this.ensureStream();
|
|
70
|
+
}
|
|
71
|
+
this.started = true;
|
|
72
|
+
}
|
|
73
|
+
async stop() {
|
|
74
|
+
if (!this.started) return;
|
|
75
|
+
for (const consumer of this.consumers) {
|
|
76
|
+
consumer.stop();
|
|
77
|
+
}
|
|
78
|
+
this.consumers.length = 0;
|
|
79
|
+
if (!this.options.connection && this.nc) {
|
|
80
|
+
await this.nc.drain();
|
|
81
|
+
}
|
|
82
|
+
this.nc = null;
|
|
83
|
+
this.jsm = null;
|
|
84
|
+
this.js = null;
|
|
85
|
+
this.started = false;
|
|
86
|
+
}
|
|
87
|
+
async subscribe(options, handler) {
|
|
88
|
+
if (!this.js || !this.jsm) throw new Error("Transport not started");
|
|
89
|
+
const { endpoint, group } = options;
|
|
90
|
+
const subject = `${this.options.subjectPrefix}.${endpoint}.>`;
|
|
91
|
+
const consumerName = group ?? `${this.options.consumerPrefix}-${endpoint}`;
|
|
92
|
+
const consumerConfig = {
|
|
93
|
+
durable_name: consumerName,
|
|
94
|
+
filter_subject: subject,
|
|
95
|
+
ack_policy: import_nats.AckPolicy.Explicit,
|
|
96
|
+
deliver_policy: import_nats.DeliverPolicy.All,
|
|
97
|
+
ack_wait: this.options.ackWait,
|
|
98
|
+
max_deliver: this.options.maxDeliver
|
|
99
|
+
};
|
|
100
|
+
await this.jsm.consumers.add(this.options.streamName, consumerConfig);
|
|
101
|
+
const consumer = await this.js.consumers.get(
|
|
102
|
+
this.options.streamName,
|
|
103
|
+
consumerName
|
|
104
|
+
);
|
|
105
|
+
const messages = await consumer.consume();
|
|
106
|
+
(async () => {
|
|
107
|
+
for await (const msg of messages) {
|
|
108
|
+
try {
|
|
109
|
+
const rawEnvelope = JSON.parse(sc.decode(msg.data));
|
|
110
|
+
const envelope = {
|
|
111
|
+
id: rawEnvelope.id,
|
|
112
|
+
type: rawEnvelope.type,
|
|
113
|
+
payload: rawEnvelope.payload,
|
|
114
|
+
headers: rawEnvelope.headers,
|
|
115
|
+
timestamp: new Date(rawEnvelope.timestamp),
|
|
116
|
+
partitionKey: rawEnvelope.partitionKey
|
|
117
|
+
};
|
|
118
|
+
await handler(envelope);
|
|
119
|
+
msg.ack();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("[NatsTransport] Message handler error:", error);
|
|
122
|
+
msg.nak();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
this.consumers.push({ stop: () => messages.stop() });
|
|
127
|
+
}
|
|
128
|
+
async publish(message, options) {
|
|
129
|
+
if (!this.js) throw new Error("Transport not started");
|
|
130
|
+
const { endpoint, key, headers: customHeaders = {}, delayMs } = options;
|
|
131
|
+
if (delayMs && delayMs > 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"NATS JetStream does not support delayed messages. Use an external scheduler for delayed delivery."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const subject = `${this.options.subjectPrefix}.${endpoint}.${message.type}`;
|
|
137
|
+
const envelope = {
|
|
138
|
+
id: (0, import_crypto.randomUUID)(),
|
|
139
|
+
type: message.type,
|
|
140
|
+
payload: message,
|
|
141
|
+
headers: customHeaders,
|
|
142
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
143
|
+
partitionKey: key
|
|
144
|
+
};
|
|
145
|
+
const h = (0, import_nats.headers)();
|
|
146
|
+
h.set("Nats-Msg-Id", envelope.id);
|
|
147
|
+
h.set("X-Message-Type", message.type);
|
|
148
|
+
if (key) h.set("X-Correlation-Id", key);
|
|
149
|
+
for (const [k, v] of Object.entries(customHeaders)) {
|
|
150
|
+
if (typeof v === "string") {
|
|
151
|
+
h.set(k, v);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const publishOptions = {
|
|
155
|
+
msgID: envelope.id,
|
|
156
|
+
headers: h
|
|
157
|
+
};
|
|
158
|
+
await this.js.publish(
|
|
159
|
+
subject,
|
|
160
|
+
sc.encode(JSON.stringify(envelope)),
|
|
161
|
+
publishOptions
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
async ensureStream() {
|
|
165
|
+
if (!this.jsm) return;
|
|
166
|
+
try {
|
|
167
|
+
await this.jsm.streams.info(this.options.streamName);
|
|
168
|
+
} catch {
|
|
169
|
+
const retentionMap = {
|
|
170
|
+
limits: import_nats.RetentionPolicy.Limits,
|
|
171
|
+
interest: import_nats.RetentionPolicy.Interest,
|
|
172
|
+
workqueue: import_nats.RetentionPolicy.Workqueue
|
|
173
|
+
};
|
|
174
|
+
await this.jsm.streams.add({
|
|
175
|
+
name: this.options.streamName,
|
|
176
|
+
subjects: [`${this.options.subjectPrefix}.>`],
|
|
177
|
+
retention: retentionMap[this.options.retentionPolicy],
|
|
178
|
+
max_msgs: this.options.maxMessages,
|
|
179
|
+
max_bytes: this.options.maxBytes,
|
|
180
|
+
num_replicas: this.options.replicas
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
186
|
+
0 && (module.exports = {
|
|
187
|
+
NatsTransport
|
|
188
|
+
});
|
|
189
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/NatsTransport.ts"],"sourcesContent":["export { NatsTransport } from \"./NatsTransport.js\";\nexport type { NatsTransportOptions } from \"./types.js\";\n","import {\n connect,\n NatsConnection,\n JetStreamManager,\n JetStreamClient,\n JetStreamPublishOptions,\n ConsumerConfig,\n AckPolicy,\n DeliverPolicy,\n StringCodec,\n headers,\n RetentionPolicy,\n} from \"nats\";\nimport type {\n Transport,\n TransportSubscribeOptions,\n TransportPublishOptions,\n MessageEnvelope,\n BaseMessage,\n} from \"@saga-bus/core\";\nimport type { NatsTransportOptions } from \"./types.js\";\nimport { randomUUID } from \"crypto\";\n\nconst sc = StringCodec();\n\n/**\n * NATS JetStream transport for saga-bus.\n *\n * @example\n * ```typescript\n * import { NatsTransport } from \"@saga-bus/transport-nats\";\n *\n * const transport = new NatsTransport({\n * connectionOptions: { servers: \"localhost:4222\" },\n * streamName: \"SAGA_EVENTS\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class NatsTransport implements Transport {\n private nc: NatsConnection | null = null;\n private jsm: JetStreamManager | null = null;\n private js: JetStreamClient | null = null;\n private readonly options: Required<\n Pick<\n NatsTransportOptions,\n | \"subjectPrefix\"\n | \"streamName\"\n | \"consumerPrefix\"\n | \"autoCreateStream\"\n | \"retentionPolicy\"\n | \"maxMessages\"\n | \"maxBytes\"\n | \"replicas\"\n | \"ackWait\"\n | \"maxDeliver\"\n >\n > &\n NatsTransportOptions;\n\n private readonly consumers: Array<{ stop: () => void }> = [];\n private started = false;\n\n constructor(options: NatsTransportOptions) {\n if (!options.connection && !options.connectionOptions) {\n throw new Error(\n \"Either connection or connectionOptions must be provided\"\n );\n }\n\n this.options = {\n subjectPrefix: \"saga-bus\",\n streamName: \"SAGA_BUS\",\n consumerPrefix: \"saga-bus-consumer\",\n autoCreateStream: true,\n retentionPolicy: \"workqueue\",\n maxMessages: -1,\n maxBytes: -1,\n replicas: 1,\n ackWait: 30_000_000_000, // 30 seconds in nanoseconds\n maxDeliver: 5,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Connect\n if (this.options.connection) {\n this.nc = this.options.connection;\n } else {\n this.nc = await connect(this.options.connectionOptions);\n }\n\n // Get JetStream manager and client\n this.jsm = await this.nc.jetstreamManager(this.options.jetStreamOptions);\n this.js = this.nc.jetstream(this.options.jetStreamOptions);\n\n // Create stream if needed\n if (this.options.autoCreateStream) {\n await this.ensureStream();\n }\n\n this.started = true;\n }\n\n async stop(): Promise<void> {\n if (!this.started) return;\n\n // Stop all consumers\n for (const consumer of this.consumers) {\n consumer.stop();\n }\n this.consumers.length = 0;\n\n // Close connection if we created it\n if (!this.options.connection && this.nc) {\n await this.nc.drain();\n }\n\n this.nc = null;\n this.jsm = null;\n this.js = null;\n this.started = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n if (!this.js || !this.jsm) throw new Error(\"Transport not started\");\n\n const { endpoint, group } = options;\n const subject = `${this.options.subjectPrefix}.${endpoint}.>`;\n const consumerName = group ?? `${this.options.consumerPrefix}-${endpoint}`;\n\n // Create durable consumer\n const consumerConfig: Partial<ConsumerConfig> = {\n durable_name: consumerName,\n filter_subject: subject,\n ack_policy: AckPolicy.Explicit,\n deliver_policy: DeliverPolicy.All,\n ack_wait: this.options.ackWait,\n max_deliver: this.options.maxDeliver,\n };\n\n await this.jsm.consumers.add(this.options.streamName, consumerConfig);\n\n // Subscribe\n const consumer = await this.js.consumers.get(\n this.options.streamName,\n consumerName\n );\n const messages = await consumer.consume();\n\n // Process messages asynchronously\n (async () => {\n for await (const msg of messages) {\n try {\n const rawEnvelope = JSON.parse(sc.decode(msg.data));\n const envelope: MessageEnvelope<TMessage> = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as TMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n await handler(envelope);\n msg.ack();\n } catch (error) {\n console.error(\"[NatsTransport] Message handler error:\", error);\n msg.nak();\n }\n }\n })();\n\n this.consumers.push({ stop: () => messages.stop() });\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.js) throw new Error(\"Transport not started\");\n\n const { endpoint, key, headers: customHeaders = {}, delayMs } = options;\n\n // NATS JetStream doesn't support delayed messages natively\n if (delayMs && delayMs > 0) {\n throw new Error(\n \"NATS JetStream does not support delayed messages. \" +\n \"Use an external scheduler for delayed delivery.\"\n );\n }\n\n const subject = `${this.options.subjectPrefix}.${endpoint}.${message.type}`;\n\n // Create envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: customHeaders as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n // Build headers\n const h = headers();\n h.set(\"Nats-Msg-Id\", envelope.id);\n h.set(\"X-Message-Type\", message.type);\n if (key) h.set(\"X-Correlation-Id\", key);\n for (const [k, v] of Object.entries(customHeaders)) {\n if (typeof v === \"string\") {\n h.set(k, v);\n }\n }\n\n // Publish\n const publishOptions: Partial<JetStreamPublishOptions> = {\n msgID: envelope.id,\n headers: h,\n };\n\n await this.js.publish(\n subject,\n sc.encode(JSON.stringify(envelope)),\n publishOptions\n );\n }\n\n private async ensureStream(): Promise<void> {\n if (!this.jsm) return;\n\n try {\n await this.jsm.streams.info(this.options.streamName);\n } catch {\n // Stream doesn't exist, create it\n const retentionMap: Record<string, RetentionPolicy> = {\n limits: RetentionPolicy.Limits,\n interest: RetentionPolicy.Interest,\n workqueue: RetentionPolicy.Workqueue,\n };\n\n await this.jsm.streams.add({\n name: this.options.streamName,\n subjects: [`${this.options.subjectPrefix}.>`],\n retention: retentionMap[this.options.retentionPolicy],\n max_msgs: this.options.maxMessages,\n max_bytes: this.options.maxBytes,\n num_replicas: this.options.replicas,\n });\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
|
|
2
|
+
import { NatsConnection, ConnectionOptions, JetStreamOptions } from 'nats';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the NATS JetStream transport.
|
|
6
|
+
*/
|
|
7
|
+
interface NatsTransportOptions {
|
|
8
|
+
/** Existing NATS connection */
|
|
9
|
+
connection?: NatsConnection;
|
|
10
|
+
/** Connection options for creating new connection */
|
|
11
|
+
connectionOptions?: ConnectionOptions;
|
|
12
|
+
/** JetStream options */
|
|
13
|
+
jetStreamOptions?: JetStreamOptions;
|
|
14
|
+
/** Subject prefix for all messages (default: "saga-bus") */
|
|
15
|
+
subjectPrefix?: string;
|
|
16
|
+
/** Stream name for JetStream (default: "SAGA_BUS") */
|
|
17
|
+
streamName?: string;
|
|
18
|
+
/** Consumer name prefix (default: "saga-bus-consumer") */
|
|
19
|
+
consumerPrefix?: string;
|
|
20
|
+
/** Whether to auto-create streams (default: true) */
|
|
21
|
+
autoCreateStream?: boolean;
|
|
22
|
+
/** Stream retention policy (default: "workqueue") */
|
|
23
|
+
retentionPolicy?: "limits" | "interest" | "workqueue";
|
|
24
|
+
/** Max messages in stream (-1 for unlimited, default: -1) */
|
|
25
|
+
maxMessages?: number;
|
|
26
|
+
/** Max bytes in stream (-1 for unlimited, default: -1) */
|
|
27
|
+
maxBytes?: number;
|
|
28
|
+
/** Max age of messages in nanoseconds */
|
|
29
|
+
maxAge?: number;
|
|
30
|
+
/** Number of replicas (default: 1) */
|
|
31
|
+
replicas?: number;
|
|
32
|
+
/** Ack wait timeout in nanoseconds (default: 30_000_000_000 = 30s) */
|
|
33
|
+
ackWait?: number;
|
|
34
|
+
/** Max redelivery attempts (default: 5) */
|
|
35
|
+
maxDeliver?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* NATS JetStream transport for saga-bus.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { NatsTransport } from "@saga-bus/transport-nats";
|
|
44
|
+
*
|
|
45
|
+
* const transport = new NatsTransport({
|
|
46
|
+
* connectionOptions: { servers: "localhost:4222" },
|
|
47
|
+
* streamName: "SAGA_EVENTS",
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* await transport.start();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
declare class NatsTransport implements Transport {
|
|
54
|
+
private nc;
|
|
55
|
+
private jsm;
|
|
56
|
+
private js;
|
|
57
|
+
private readonly options;
|
|
58
|
+
private readonly consumers;
|
|
59
|
+
private started;
|
|
60
|
+
constructor(options: NatsTransportOptions);
|
|
61
|
+
start(): Promise<void>;
|
|
62
|
+
stop(): Promise<void>;
|
|
63
|
+
subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
|
|
64
|
+
publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
|
|
65
|
+
private ensureStream;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { NatsTransport, type NatsTransportOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
|
|
2
|
+
import { NatsConnection, ConnectionOptions, JetStreamOptions } from 'nats';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the NATS JetStream transport.
|
|
6
|
+
*/
|
|
7
|
+
interface NatsTransportOptions {
|
|
8
|
+
/** Existing NATS connection */
|
|
9
|
+
connection?: NatsConnection;
|
|
10
|
+
/** Connection options for creating new connection */
|
|
11
|
+
connectionOptions?: ConnectionOptions;
|
|
12
|
+
/** JetStream options */
|
|
13
|
+
jetStreamOptions?: JetStreamOptions;
|
|
14
|
+
/** Subject prefix for all messages (default: "saga-bus") */
|
|
15
|
+
subjectPrefix?: string;
|
|
16
|
+
/** Stream name for JetStream (default: "SAGA_BUS") */
|
|
17
|
+
streamName?: string;
|
|
18
|
+
/** Consumer name prefix (default: "saga-bus-consumer") */
|
|
19
|
+
consumerPrefix?: string;
|
|
20
|
+
/** Whether to auto-create streams (default: true) */
|
|
21
|
+
autoCreateStream?: boolean;
|
|
22
|
+
/** Stream retention policy (default: "workqueue") */
|
|
23
|
+
retentionPolicy?: "limits" | "interest" | "workqueue";
|
|
24
|
+
/** Max messages in stream (-1 for unlimited, default: -1) */
|
|
25
|
+
maxMessages?: number;
|
|
26
|
+
/** Max bytes in stream (-1 for unlimited, default: -1) */
|
|
27
|
+
maxBytes?: number;
|
|
28
|
+
/** Max age of messages in nanoseconds */
|
|
29
|
+
maxAge?: number;
|
|
30
|
+
/** Number of replicas (default: 1) */
|
|
31
|
+
replicas?: number;
|
|
32
|
+
/** Ack wait timeout in nanoseconds (default: 30_000_000_000 = 30s) */
|
|
33
|
+
ackWait?: number;
|
|
34
|
+
/** Max redelivery attempts (default: 5) */
|
|
35
|
+
maxDeliver?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* NATS JetStream transport for saga-bus.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { NatsTransport } from "@saga-bus/transport-nats";
|
|
44
|
+
*
|
|
45
|
+
* const transport = new NatsTransport({
|
|
46
|
+
* connectionOptions: { servers: "localhost:4222" },
|
|
47
|
+
* streamName: "SAGA_EVENTS",
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* await transport.start();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
declare class NatsTransport implements Transport {
|
|
54
|
+
private nc;
|
|
55
|
+
private jsm;
|
|
56
|
+
private js;
|
|
57
|
+
private readonly options;
|
|
58
|
+
private readonly consumers;
|
|
59
|
+
private started;
|
|
60
|
+
constructor(options: NatsTransportOptions);
|
|
61
|
+
start(): Promise<void>;
|
|
62
|
+
stop(): Promise<void>;
|
|
63
|
+
subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
|
|
64
|
+
publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
|
|
65
|
+
private ensureStream;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { NatsTransport, type NatsTransportOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/NatsTransport.ts
|
|
2
|
+
import {
|
|
3
|
+
connect,
|
|
4
|
+
AckPolicy,
|
|
5
|
+
DeliverPolicy,
|
|
6
|
+
StringCodec,
|
|
7
|
+
headers,
|
|
8
|
+
RetentionPolicy
|
|
9
|
+
} from "nats";
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
var sc = StringCodec();
|
|
12
|
+
var NatsTransport = class {
|
|
13
|
+
nc = null;
|
|
14
|
+
jsm = null;
|
|
15
|
+
js = null;
|
|
16
|
+
options;
|
|
17
|
+
consumers = [];
|
|
18
|
+
started = false;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
if (!options.connection && !options.connectionOptions) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"Either connection or connectionOptions must be provided"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
this.options = {
|
|
26
|
+
subjectPrefix: "saga-bus",
|
|
27
|
+
streamName: "SAGA_BUS",
|
|
28
|
+
consumerPrefix: "saga-bus-consumer",
|
|
29
|
+
autoCreateStream: true,
|
|
30
|
+
retentionPolicy: "workqueue",
|
|
31
|
+
maxMessages: -1,
|
|
32
|
+
maxBytes: -1,
|
|
33
|
+
replicas: 1,
|
|
34
|
+
ackWait: 3e10,
|
|
35
|
+
// 30 seconds in nanoseconds
|
|
36
|
+
maxDeliver: 5,
|
|
37
|
+
...options
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async start() {
|
|
41
|
+
if (this.started) return;
|
|
42
|
+
if (this.options.connection) {
|
|
43
|
+
this.nc = this.options.connection;
|
|
44
|
+
} else {
|
|
45
|
+
this.nc = await connect(this.options.connectionOptions);
|
|
46
|
+
}
|
|
47
|
+
this.jsm = await this.nc.jetstreamManager(this.options.jetStreamOptions);
|
|
48
|
+
this.js = this.nc.jetstream(this.options.jetStreamOptions);
|
|
49
|
+
if (this.options.autoCreateStream) {
|
|
50
|
+
await this.ensureStream();
|
|
51
|
+
}
|
|
52
|
+
this.started = true;
|
|
53
|
+
}
|
|
54
|
+
async stop() {
|
|
55
|
+
if (!this.started) return;
|
|
56
|
+
for (const consumer of this.consumers) {
|
|
57
|
+
consumer.stop();
|
|
58
|
+
}
|
|
59
|
+
this.consumers.length = 0;
|
|
60
|
+
if (!this.options.connection && this.nc) {
|
|
61
|
+
await this.nc.drain();
|
|
62
|
+
}
|
|
63
|
+
this.nc = null;
|
|
64
|
+
this.jsm = null;
|
|
65
|
+
this.js = null;
|
|
66
|
+
this.started = false;
|
|
67
|
+
}
|
|
68
|
+
async subscribe(options, handler) {
|
|
69
|
+
if (!this.js || !this.jsm) throw new Error("Transport not started");
|
|
70
|
+
const { endpoint, group } = options;
|
|
71
|
+
const subject = `${this.options.subjectPrefix}.${endpoint}.>`;
|
|
72
|
+
const consumerName = group ?? `${this.options.consumerPrefix}-${endpoint}`;
|
|
73
|
+
const consumerConfig = {
|
|
74
|
+
durable_name: consumerName,
|
|
75
|
+
filter_subject: subject,
|
|
76
|
+
ack_policy: AckPolicy.Explicit,
|
|
77
|
+
deliver_policy: DeliverPolicy.All,
|
|
78
|
+
ack_wait: this.options.ackWait,
|
|
79
|
+
max_deliver: this.options.maxDeliver
|
|
80
|
+
};
|
|
81
|
+
await this.jsm.consumers.add(this.options.streamName, consumerConfig);
|
|
82
|
+
const consumer = await this.js.consumers.get(
|
|
83
|
+
this.options.streamName,
|
|
84
|
+
consumerName
|
|
85
|
+
);
|
|
86
|
+
const messages = await consumer.consume();
|
|
87
|
+
(async () => {
|
|
88
|
+
for await (const msg of messages) {
|
|
89
|
+
try {
|
|
90
|
+
const rawEnvelope = JSON.parse(sc.decode(msg.data));
|
|
91
|
+
const envelope = {
|
|
92
|
+
id: rawEnvelope.id,
|
|
93
|
+
type: rawEnvelope.type,
|
|
94
|
+
payload: rawEnvelope.payload,
|
|
95
|
+
headers: rawEnvelope.headers,
|
|
96
|
+
timestamp: new Date(rawEnvelope.timestamp),
|
|
97
|
+
partitionKey: rawEnvelope.partitionKey
|
|
98
|
+
};
|
|
99
|
+
await handler(envelope);
|
|
100
|
+
msg.ack();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error("[NatsTransport] Message handler error:", error);
|
|
103
|
+
msg.nak();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
this.consumers.push({ stop: () => messages.stop() });
|
|
108
|
+
}
|
|
109
|
+
async publish(message, options) {
|
|
110
|
+
if (!this.js) throw new Error("Transport not started");
|
|
111
|
+
const { endpoint, key, headers: customHeaders = {}, delayMs } = options;
|
|
112
|
+
if (delayMs && delayMs > 0) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"NATS JetStream does not support delayed messages. Use an external scheduler for delayed delivery."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const subject = `${this.options.subjectPrefix}.${endpoint}.${message.type}`;
|
|
118
|
+
const envelope = {
|
|
119
|
+
id: randomUUID(),
|
|
120
|
+
type: message.type,
|
|
121
|
+
payload: message,
|
|
122
|
+
headers: customHeaders,
|
|
123
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
124
|
+
partitionKey: key
|
|
125
|
+
};
|
|
126
|
+
const h = headers();
|
|
127
|
+
h.set("Nats-Msg-Id", envelope.id);
|
|
128
|
+
h.set("X-Message-Type", message.type);
|
|
129
|
+
if (key) h.set("X-Correlation-Id", key);
|
|
130
|
+
for (const [k, v] of Object.entries(customHeaders)) {
|
|
131
|
+
if (typeof v === "string") {
|
|
132
|
+
h.set(k, v);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const publishOptions = {
|
|
136
|
+
msgID: envelope.id,
|
|
137
|
+
headers: h
|
|
138
|
+
};
|
|
139
|
+
await this.js.publish(
|
|
140
|
+
subject,
|
|
141
|
+
sc.encode(JSON.stringify(envelope)),
|
|
142
|
+
publishOptions
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
async ensureStream() {
|
|
146
|
+
if (!this.jsm) return;
|
|
147
|
+
try {
|
|
148
|
+
await this.jsm.streams.info(this.options.streamName);
|
|
149
|
+
} catch {
|
|
150
|
+
const retentionMap = {
|
|
151
|
+
limits: RetentionPolicy.Limits,
|
|
152
|
+
interest: RetentionPolicy.Interest,
|
|
153
|
+
workqueue: RetentionPolicy.Workqueue
|
|
154
|
+
};
|
|
155
|
+
await this.jsm.streams.add({
|
|
156
|
+
name: this.options.streamName,
|
|
157
|
+
subjects: [`${this.options.subjectPrefix}.>`],
|
|
158
|
+
retention: retentionMap[this.options.retentionPolicy],
|
|
159
|
+
max_msgs: this.options.maxMessages,
|
|
160
|
+
max_bytes: this.options.maxBytes,
|
|
161
|
+
num_replicas: this.options.replicas
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
export {
|
|
167
|
+
NatsTransport
|
|
168
|
+
};
|
|
169
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/NatsTransport.ts"],"sourcesContent":["import {\n connect,\n NatsConnection,\n JetStreamManager,\n JetStreamClient,\n JetStreamPublishOptions,\n ConsumerConfig,\n AckPolicy,\n DeliverPolicy,\n StringCodec,\n headers,\n RetentionPolicy,\n} from \"nats\";\nimport type {\n Transport,\n TransportSubscribeOptions,\n TransportPublishOptions,\n MessageEnvelope,\n BaseMessage,\n} from \"@saga-bus/core\";\nimport type { NatsTransportOptions } from \"./types.js\";\nimport { randomUUID } from \"crypto\";\n\nconst sc = StringCodec();\n\n/**\n * NATS JetStream transport for saga-bus.\n *\n * @example\n * ```typescript\n * import { NatsTransport } from \"@saga-bus/transport-nats\";\n *\n * const transport = new NatsTransport({\n * connectionOptions: { servers: \"localhost:4222\" },\n * streamName: \"SAGA_EVENTS\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class NatsTransport implements Transport {\n private nc: NatsConnection | null = null;\n private jsm: JetStreamManager | null = null;\n private js: JetStreamClient | null = null;\n private readonly options: Required<\n Pick<\n NatsTransportOptions,\n | \"subjectPrefix\"\n | \"streamName\"\n | \"consumerPrefix\"\n | \"autoCreateStream\"\n | \"retentionPolicy\"\n | \"maxMessages\"\n | \"maxBytes\"\n | \"replicas\"\n | \"ackWait\"\n | \"maxDeliver\"\n >\n > &\n NatsTransportOptions;\n\n private readonly consumers: Array<{ stop: () => void }> = [];\n private started = false;\n\n constructor(options: NatsTransportOptions) {\n if (!options.connection && !options.connectionOptions) {\n throw new Error(\n \"Either connection or connectionOptions must be provided\"\n );\n }\n\n this.options = {\n subjectPrefix: \"saga-bus\",\n streamName: \"SAGA_BUS\",\n consumerPrefix: \"saga-bus-consumer\",\n autoCreateStream: true,\n retentionPolicy: \"workqueue\",\n maxMessages: -1,\n maxBytes: -1,\n replicas: 1,\n ackWait: 30_000_000_000, // 30 seconds in nanoseconds\n maxDeliver: 5,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Connect\n if (this.options.connection) {\n this.nc = this.options.connection;\n } else {\n this.nc = await connect(this.options.connectionOptions);\n }\n\n // Get JetStream manager and client\n this.jsm = await this.nc.jetstreamManager(this.options.jetStreamOptions);\n this.js = this.nc.jetstream(this.options.jetStreamOptions);\n\n // Create stream if needed\n if (this.options.autoCreateStream) {\n await this.ensureStream();\n }\n\n this.started = true;\n }\n\n async stop(): Promise<void> {\n if (!this.started) return;\n\n // Stop all consumers\n for (const consumer of this.consumers) {\n consumer.stop();\n }\n this.consumers.length = 0;\n\n // Close connection if we created it\n if (!this.options.connection && this.nc) {\n await this.nc.drain();\n }\n\n this.nc = null;\n this.jsm = null;\n this.js = null;\n this.started = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n if (!this.js || !this.jsm) throw new Error(\"Transport not started\");\n\n const { endpoint, group } = options;\n const subject = `${this.options.subjectPrefix}.${endpoint}.>`;\n const consumerName = group ?? `${this.options.consumerPrefix}-${endpoint}`;\n\n // Create durable consumer\n const consumerConfig: Partial<ConsumerConfig> = {\n durable_name: consumerName,\n filter_subject: subject,\n ack_policy: AckPolicy.Explicit,\n deliver_policy: DeliverPolicy.All,\n ack_wait: this.options.ackWait,\n max_deliver: this.options.maxDeliver,\n };\n\n await this.jsm.consumers.add(this.options.streamName, consumerConfig);\n\n // Subscribe\n const consumer = await this.js.consumers.get(\n this.options.streamName,\n consumerName\n );\n const messages = await consumer.consume();\n\n // Process messages asynchronously\n (async () => {\n for await (const msg of messages) {\n try {\n const rawEnvelope = JSON.parse(sc.decode(msg.data));\n const envelope: MessageEnvelope<TMessage> = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as TMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n await handler(envelope);\n msg.ack();\n } catch (error) {\n console.error(\"[NatsTransport] Message handler error:\", error);\n msg.nak();\n }\n }\n })();\n\n this.consumers.push({ stop: () => messages.stop() });\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.js) throw new Error(\"Transport not started\");\n\n const { endpoint, key, headers: customHeaders = {}, delayMs } = options;\n\n // NATS JetStream doesn't support delayed messages natively\n if (delayMs && delayMs > 0) {\n throw new Error(\n \"NATS JetStream does not support delayed messages. \" +\n \"Use an external scheduler for delayed delivery.\"\n );\n }\n\n const subject = `${this.options.subjectPrefix}.${endpoint}.${message.type}`;\n\n // Create envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: customHeaders as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n // Build headers\n const h = headers();\n h.set(\"Nats-Msg-Id\", envelope.id);\n h.set(\"X-Message-Type\", message.type);\n if (key) h.set(\"X-Correlation-Id\", key);\n for (const [k, v] of Object.entries(customHeaders)) {\n if (typeof v === \"string\") {\n h.set(k, v);\n }\n }\n\n // Publish\n const publishOptions: Partial<JetStreamPublishOptions> = {\n msgID: envelope.id,\n headers: h,\n };\n\n await this.js.publish(\n subject,\n sc.encode(JSON.stringify(envelope)),\n publishOptions\n );\n }\n\n private async ensureStream(): Promise<void> {\n if (!this.jsm) return;\n\n try {\n await this.jsm.streams.info(this.options.streamName);\n } catch {\n // Stream doesn't exist, create it\n const retentionMap: Record<string, RetentionPolicy> = {\n limits: RetentionPolicy.Limits,\n interest: RetentionPolicy.Interest,\n workqueue: RetentionPolicy.Workqueue,\n };\n\n await this.jsm.streams.add({\n name: this.options.streamName,\n subjects: [`${this.options.subjectPrefix}.>`],\n retention: retentionMap[this.options.retentionPolicy],\n max_msgs: this.options.maxMessages,\n max_bytes: this.options.maxBytes,\n num_replicas: this.options.replicas,\n });\n }\n }\n}\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EAMA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,kBAAkB;AAE3B,IAAM,KAAK,YAAY;AAiBhB,IAAM,gBAAN,MAAyC;AAAA,EACtC,KAA4B;AAAA,EAC5B,MAA+B;AAAA,EAC/B,KAA6B;AAAA,EACpB;AAAA,EAiBA,YAAyC,CAAC;AAAA,EACnD,UAAU;AAAA,EAElB,YAAY,SAA+B;AACzC,QAAI,CAAC,QAAQ,cAAc,CAAC,QAAQ,mBAAmB;AACrD,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAEA,SAAK,UAAU;AAAA,MACb,eAAe;AAAA,MACf,YAAY;AAAA,MACZ,gBAAgB;AAAA,MAChB,kBAAkB;AAAA,MAClB,iBAAiB;AAAA,MACjB,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU;AAAA,MACV,SAAS;AAAA;AAAA,MACT,YAAY;AAAA,MACZ,GAAG;AAAA,IACL;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,QAAS;AAGlB,QAAI,KAAK,QAAQ,YAAY;AAC3B,WAAK,KAAK,KAAK,QAAQ;AAAA,IACzB,OAAO;AACL,WAAK,KAAK,MAAM,QAAQ,KAAK,QAAQ,iBAAiB;AAAA,IACxD;AAGA,SAAK,MAAM,MAAM,KAAK,GAAG,iBAAiB,KAAK,QAAQ,gBAAgB;AACvE,SAAK,KAAK,KAAK,GAAG,UAAU,KAAK,QAAQ,gBAAgB;AAGzD,QAAI,KAAK,QAAQ,kBAAkB;AACjC,YAAM,KAAK,aAAa;AAAA,IAC1B;AAEA,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,MAAM,OAAsB;AAC1B,QAAI,CAAC,KAAK,QAAS;AAGnB,eAAW,YAAY,KAAK,WAAW;AACrC,eAAS,KAAK;AAAA,IAChB;AACA,SAAK,UAAU,SAAS;AAGxB,QAAI,CAAC,KAAK,QAAQ,cAAc,KAAK,IAAI;AACvC,YAAM,KAAK,GAAG,MAAM;AAAA,IACtB;AAEA,SAAK,KAAK;AACV,SAAK,MAAM;AACX,SAAK,KAAK;AACV,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,MAAM,UACJ,SACA,SACe;AACf,QAAI,CAAC,KAAK,MAAM,CAAC,KAAK,IAAK,OAAM,IAAI,MAAM,uBAAuB;AAElE,UAAM,EAAE,UAAU,MAAM,IAAI;AAC5B,UAAM,UAAU,GAAG,KAAK,QAAQ,aAAa,IAAI,QAAQ;AACzD,UAAM,eAAe,SAAS,GAAG,KAAK,QAAQ,cAAc,IAAI,QAAQ;AAGxE,UAAM,iBAA0C;AAAA,MAC9C,cAAc;AAAA,MACd,gBAAgB;AAAA,MAChB,YAAY,UAAU;AAAA,MACtB,gBAAgB,cAAc;AAAA,MAC9B,UAAU,KAAK,QAAQ;AAAA,MACvB,aAAa,KAAK,QAAQ;AAAA,IAC5B;AAEA,UAAM,KAAK,IAAI,UAAU,IAAI,KAAK,QAAQ,YAAY,cAAc;AAGpE,UAAM,WAAW,MAAM,KAAK,GAAG,UAAU;AAAA,MACvC,KAAK,QAAQ;AAAA,MACb;AAAA,IACF;AACA,UAAM,WAAW,MAAM,SAAS,QAAQ;AAGxC,KAAC,YAAY;AACX,uBAAiB,OAAO,UAAU;AAChC,YAAI;AACF,gBAAM,cAAc,KAAK,MAAM,GAAG,OAAO,IAAI,IAAI,CAAC;AAClD,gBAAM,WAAsC;AAAA,YAC1C,IAAI,YAAY;AAAA,YAChB,MAAM,YAAY;AAAA,YAClB,SAAS,YAAY;AAAA,YACrB,SAAS,YAAY;AAAA,YACrB,WAAW,IAAI,KAAK,YAAY,SAAS;AAAA,YACzC,cAAc,YAAY;AAAA,UAC5B;AACA,gBAAM,QAAQ,QAAQ;AACtB,cAAI,IAAI;AAAA,QACV,SAAS,OAAO;AACd,kBAAQ,MAAM,0CAA0C,KAAK;AAC7D,cAAI,IAAI;AAAA,QACV;AAAA,MACF;AAAA,IACF,GAAG;AAEH,SAAK,UAAU,KAAK,EAAE,MAAM,MAAM,SAAS,KAAK,EAAE,CAAC;AAAA,EACrD;AAAA,EAEA,MAAM,QACJ,SACA,SACe;AACf,QAAI,CAAC,KAAK,GAAI,OAAM,IAAI,MAAM,uBAAuB;AAErD,UAAM,EAAE,UAAU,KAAK,SAAS,gBAAgB,CAAC,GAAG,QAAQ,IAAI;AAGhE,QAAI,WAAW,UAAU,GAAG;AAC1B,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAEA,UAAM,UAAU,GAAG,KAAK,QAAQ,aAAa,IAAI,QAAQ,IAAI,QAAQ,IAAI;AAGzE,UAAM,WAAsC;AAAA,MAC1C,IAAI,WAAW;AAAA,MACf,MAAM,QAAQ;AAAA,MACd,SAAS;AAAA,MACT,SAAS;AAAA,MACT,WAAW,oBAAI,KAAK;AAAA,MACpB,cAAc;AAAA,IAChB;AAGA,UAAM,IAAI,QAAQ;AAClB,MAAE,IAAI,eAAe,SAAS,EAAE;AAChC,MAAE,IAAI,kBAAkB,QAAQ,IAAI;AACpC,QAAI,IAAK,GAAE,IAAI,oBAAoB,GAAG;AACtC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,aAAa,GAAG;AAClD,UAAI,OAAO,MAAM,UAAU;AACzB,UAAE,IAAI,GAAG,CAAC;AAAA,MACZ;AAAA,IACF;AAGA,UAAM,iBAAmD;AAAA,MACvD,OAAO,SAAS;AAAA,MAChB,SAAS;AAAA,IACX;AAEA,UAAM,KAAK,GAAG;AAAA,MACZ;AAAA,MACA,GAAG,OAAO,KAAK,UAAU,QAAQ,CAAC;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,eAA8B;AAC1C,QAAI,CAAC,KAAK,IAAK;AAEf,QAAI;AACF,YAAM,KAAK,IAAI,QAAQ,KAAK,KAAK,QAAQ,UAAU;AAAA,IACrD,QAAQ;AAEN,YAAM,eAAgD;AAAA,QACpD,QAAQ,gBAAgB;AAAA,QACxB,UAAU,gBAAgB;AAAA,QAC1B,WAAW,gBAAgB;AAAA,MAC7B;AAEA,YAAM,KAAK,IAAI,QAAQ,IAAI;AAAA,QACzB,MAAM,KAAK,QAAQ;AAAA,QACnB,UAAU,CAAC,GAAG,KAAK,QAAQ,aAAa,IAAI;AAAA,QAC5C,WAAW,aAAa,KAAK,QAAQ,eAAe;AAAA,QACpD,UAAU,KAAK,QAAQ;AAAA,QACvB,WAAW,KAAK,QAAQ;AAAA,QACxB,cAAc,KAAK,QAAQ;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saga-bus/transport-nats",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "NATS JetStream transport for saga-bus",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"lint": "eslint src/",
|
|
24
|
+
"check-types": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@saga-bus/core": "workspace:*"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"nats": ">=2.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@repo/eslint-config": "workspace:*",
|
|
36
|
+
"@repo/typescript-config": "workspace:*",
|
|
37
|
+
"@types/node": "^22.10.1",
|
|
38
|
+
"eslint": "^9.16.0",
|
|
39
|
+
"tsup": "^8.3.5",
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"vitest": "^2.1.8"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/d-e-a-n-f/saga-bus.git",
|
|
50
|
+
"directory": "packages/transport-nats"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/d-e-a-n-f/saga-bus/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/d-e-a-n-f/saga-bus#readme"
|
|
56
|
+
}
|