@nwire/nats 0.10.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/LICENSE +21 -0
- package/README.md +130 -0
- package/dist/bus-nats.d.ts +58 -0
- package/dist/bus-nats.js +101 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/nats-bus.d.ts +158 -0
- package/dist/nats-bus.js +275 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Gefter / 200apps Ltd.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# @nwire/nats
|
|
2
|
+
|
|
3
|
+
> NATS-backed `EventBus` — pick core pub/sub or JetStream + DLQ.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Two adapters share this package, both satisfying the `@nwire/bus` `EventBus`
|
|
8
|
+
contract — the runtime doesn't know which is wired in:
|
|
9
|
+
|
|
10
|
+
| Adapter | Semantics | Durability | Pick when |
|
|
11
|
+
| --------------- | ------------- | ------------------------- | ---------------------------------------------------------- |
|
|
12
|
+
| `NatsEventBus` | At-most-once | None (core pub/sub) | Low-ceremony cross-service fan-out. |
|
|
13
|
+
| `NatsBus` (G11) | At-least-once | JetStream + DLQ + retries | Restarts must not drop events; poison messages quarantine. |
|
|
14
|
+
|
|
15
|
+
Subject layout: `<prefix>.<eventName>`. Distinct prefixes isolate
|
|
16
|
+
deployments on a shared cluster.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add @nwire/nats nats
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start — JetStream (recommended for production)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { connect } from "nats";
|
|
28
|
+
import { NatsBus } from "@nwire/nats";
|
|
29
|
+
import { lxApp } from "@amit/lx";
|
|
30
|
+
|
|
31
|
+
const bus = new NatsBus({
|
|
32
|
+
servers: "nats://nats:4222",
|
|
33
|
+
prefix: "lemida.events",
|
|
34
|
+
maxDeliver: 5,
|
|
35
|
+
ackWaitMs: 2_000,
|
|
36
|
+
connect,
|
|
37
|
+
});
|
|
38
|
+
await bus.connect();
|
|
39
|
+
|
|
40
|
+
const app = lxApp.create({ bus, publishToBus: true, appName: "lx-service" });
|
|
41
|
+
await app.start();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start — core pub/sub
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { connect } from "nats";
|
|
48
|
+
import { NatsEventBus } from "@nwire/nats";
|
|
49
|
+
|
|
50
|
+
const nc = await connect({ servers: "nats://nats:4222" });
|
|
51
|
+
const bus = new NatsEventBus({ connection: nc, prefix: "lemida" });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## `NatsBus` config
|
|
55
|
+
|
|
56
|
+
| Option | Default | Meaning |
|
|
57
|
+
| ------------ | --------------------- | --------------------------------------------------------- |
|
|
58
|
+
| `servers` | (required) | NATS URL(s). |
|
|
59
|
+
| `name` | _none_ | Client name (visible in `nats-top`). |
|
|
60
|
+
| `prefix` | `nwire.events` | Subject prefix; the stream binds `<prefix>.>`. |
|
|
61
|
+
| `streamName` | derived from `prefix` | JetStream stream name. |
|
|
62
|
+
| `dlqSubject` | `<prefix>.dlq` | Where exhausted messages land. |
|
|
63
|
+
| `maxDeliver` | `5` | How many times JetStream redelivers before DLQ. |
|
|
64
|
+
| `ackWaitMs` | `1000` | Ack window; also used as nak backoff. |
|
|
65
|
+
| `connect` | (required) | `connect` from the `nats` package (injected for testing). |
|
|
66
|
+
| `logger` | no-op | `@nwire/logger` instance. |
|
|
67
|
+
|
|
68
|
+
## DLQ pattern
|
|
69
|
+
|
|
70
|
+
When a handler throws and JetStream's `maxDeliver` is exhausted, `NatsBus`
|
|
71
|
+
publishes a `BusDeadLetterRecord` onto `dlqSubject`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
{
|
|
75
|
+
originalSubject: "nwire.events.billing.charge-failed",
|
|
76
|
+
event: { /* the full BusEventMessage */ },
|
|
77
|
+
error: { message: "card declined", stack: "..." },
|
|
78
|
+
deliveryCount: 5,
|
|
79
|
+
deadLetteredAt: "2026-05-29T12:00:00.000Z",
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Drain it into your incident pipeline by subscribing through any NATS client:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { connect } from "nats";
|
|
87
|
+
|
|
88
|
+
const raw = await connect({ servers: "nats://nats:4222" });
|
|
89
|
+
const sub = raw.subscribe("nwire.events.dlq");
|
|
90
|
+
for await (const m of sub) {
|
|
91
|
+
const record = JSON.parse(new TextDecoder().decode(m.data));
|
|
92
|
+
await sentry.captureMessage(`Dead-letter ${record.event.eventName}`, {
|
|
93
|
+
extra: record,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Local development
|
|
99
|
+
|
|
100
|
+
`docker-compose.yml` at the repo root ships a `nats` service with JetStream
|
|
101
|
+
enabled. Start it with `nwire infra up` (or `docker compose up -d nats`).
|
|
102
|
+
|
|
103
|
+
## Testing
|
|
104
|
+
|
|
105
|
+
Unit tests run without docker (fake NATS client). Integration tests spin up
|
|
106
|
+
a real `nats:2-alpine` container via `testcontainers` and are gated behind
|
|
107
|
+
`RUN_INTEGRATION=1`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pnpm --filter @nwire/nats test # unit only
|
|
111
|
+
RUN_INTEGRATION=1 pnpm --filter @nwire/nats test:integration
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Within nwire-app
|
|
115
|
+
|
|
116
|
+
Wire the bus on `createApp` and the runtime fans cross-service events
|
|
117
|
+
through it automatically — no domain code changes.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createApp } from "@nwire/forge";
|
|
121
|
+
|
|
122
|
+
const app = createApp({
|
|
123
|
+
modules: [
|
|
124
|
+
/* ... */
|
|
125
|
+
],
|
|
126
|
+
bus,
|
|
127
|
+
publishToBus: true,
|
|
128
|
+
appName: "lx-service",
|
|
129
|
+
});
|
|
130
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/nats` — NATS-backed `EventBus` for production.
|
|
3
|
+
*
|
|
4
|
+
* Subject layout: `<prefix>.<eventName>`. NATS core pub/sub by default
|
|
5
|
+
* (at-most-once, fan-out); switch to JetStream for at-least-once
|
|
6
|
+
* durability — same contract.
|
|
7
|
+
*
|
|
8
|
+
* See: architecture-sketch.html §05 (Adapters tier).
|
|
9
|
+
*/
|
|
10
|
+
import type { EventBus, BusEventMessage, BusSubscriber } from "@nwire/bus";
|
|
11
|
+
/**
|
|
12
|
+
* Minimal NATS connection contract — what we actually consume. Lets us
|
|
13
|
+
* accept either the classic `nats` package's `NatsConnection` or the newer
|
|
14
|
+
* `@nats-io/transport-node` equivalent without binding to one.
|
|
15
|
+
*/
|
|
16
|
+
export interface NatsConnectionLike {
|
|
17
|
+
publish(subject: string, data: Uint8Array | string): void;
|
|
18
|
+
subscribe(subject: string, opts?: {
|
|
19
|
+
callback?: NatsSubscriptionCallback;
|
|
20
|
+
}): NatsSubscriptionLike;
|
|
21
|
+
drain(): Promise<void>;
|
|
22
|
+
close?(): Promise<void>;
|
|
23
|
+
isClosed?(): boolean;
|
|
24
|
+
}
|
|
25
|
+
export type NatsSubscriptionCallback = (err: unknown, msg: {
|
|
26
|
+
data: Uint8Array | string;
|
|
27
|
+
subject: string;
|
|
28
|
+
}) => void | Promise<void>;
|
|
29
|
+
export interface NatsSubscriptionLike {
|
|
30
|
+
unsubscribe(): void;
|
|
31
|
+
/** Iterator form (newer NATS clients) — optional; callback form is preferred. */
|
|
32
|
+
[Symbol.asyncIterator]?(): AsyncIterator<{
|
|
33
|
+
data: Uint8Array | string;
|
|
34
|
+
subject: string;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export interface NatsEventBusOptions {
|
|
38
|
+
readonly connection: NatsConnectionLike;
|
|
39
|
+
/** Subject prefix (`<prefix>.<eventName>`). Default `'nwire'`. */
|
|
40
|
+
readonly prefix?: string;
|
|
41
|
+
/** Optional JSON serializer override (default: `JSON.stringify` + TextEncoder). */
|
|
42
|
+
readonly serialize?: (value: unknown) => Uint8Array;
|
|
43
|
+
/** Optional JSON deserializer override. */
|
|
44
|
+
readonly deserialize?: (data: Uint8Array | string) => unknown;
|
|
45
|
+
}
|
|
46
|
+
export declare class NatsEventBus implements EventBus {
|
|
47
|
+
private readonly connection;
|
|
48
|
+
private readonly prefix;
|
|
49
|
+
private readonly serialize;
|
|
50
|
+
private readonly deserialize;
|
|
51
|
+
private readonly subscriptions;
|
|
52
|
+
private stopped;
|
|
53
|
+
constructor(options: NatsEventBusOptions);
|
|
54
|
+
private subjectFor;
|
|
55
|
+
publish(msg: BusEventMessage): Promise<void>;
|
|
56
|
+
subscribe(eventName: string, subscriber: BusSubscriber): void;
|
|
57
|
+
stop(): Promise<void>;
|
|
58
|
+
}
|
package/dist/bus-nats.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/nats` — NATS-backed `EventBus` for production.
|
|
3
|
+
*
|
|
4
|
+
* Subject layout: `<prefix>.<eventName>`. NATS core pub/sub by default
|
|
5
|
+
* (at-most-once, fan-out); switch to JetStream for at-least-once
|
|
6
|
+
* durability — same contract.
|
|
7
|
+
*
|
|
8
|
+
* See: architecture-sketch.html §05 (Adapters tier).
|
|
9
|
+
*/
|
|
10
|
+
const defaultEncoder = new TextEncoder();
|
|
11
|
+
const defaultDecoder = new TextDecoder();
|
|
12
|
+
function defaultSerialize(value) {
|
|
13
|
+
return defaultEncoder.encode(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
function defaultDeserialize(data) {
|
|
16
|
+
const s = typeof data === "string" ? data : defaultDecoder.decode(data);
|
|
17
|
+
return JSON.parse(s);
|
|
18
|
+
}
|
|
19
|
+
export class NatsEventBus {
|
|
20
|
+
connection;
|
|
21
|
+
prefix;
|
|
22
|
+
serialize;
|
|
23
|
+
deserialize;
|
|
24
|
+
subscriptions = [];
|
|
25
|
+
stopped = false;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.connection = options.connection;
|
|
28
|
+
this.prefix = options.prefix ?? "nwire";
|
|
29
|
+
this.serialize = options.serialize ?? defaultSerialize;
|
|
30
|
+
this.deserialize = options.deserialize ?? defaultDeserialize;
|
|
31
|
+
}
|
|
32
|
+
subjectFor(eventName) {
|
|
33
|
+
return `${this.prefix}.${eventName}`;
|
|
34
|
+
}
|
|
35
|
+
async publish(msg) {
|
|
36
|
+
if (this.stopped) {
|
|
37
|
+
throw new Error("NatsEventBus: publish after stop");
|
|
38
|
+
}
|
|
39
|
+
const subject = this.subjectFor(msg.eventName);
|
|
40
|
+
const data = this.serialize({
|
|
41
|
+
eventName: msg.eventName,
|
|
42
|
+
payload: msg.payload,
|
|
43
|
+
envelope: msg.envelope,
|
|
44
|
+
origin: msg.origin,
|
|
45
|
+
});
|
|
46
|
+
this.connection.publish(subject, data);
|
|
47
|
+
}
|
|
48
|
+
subscribe(eventName, subscriber) {
|
|
49
|
+
if (this.stopped) {
|
|
50
|
+
throw new Error("NatsEventBus: subscribe after stop");
|
|
51
|
+
}
|
|
52
|
+
const subject = this.subjectFor(eventName);
|
|
53
|
+
const sub = this.connection.subscribe(subject, {
|
|
54
|
+
callback: async (err, raw) => {
|
|
55
|
+
if (err) {
|
|
56
|
+
// eslint-disable-next-line no-console
|
|
57
|
+
console.error(`NatsEventBus subscription error on "${subject}":`, err);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let decoded;
|
|
61
|
+
try {
|
|
62
|
+
decoded = this.deserialize(raw.data);
|
|
63
|
+
}
|
|
64
|
+
catch (decodeErr) {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.error(`NatsEventBus: malformed message on "${subject}":`, decodeErr);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await subscriber(decoded);
|
|
71
|
+
}
|
|
72
|
+
catch (subErr) {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.error(`NatsEventBus subscriber for "${subject}" threw:`, subErr);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
this.subscriptions.push(sub);
|
|
79
|
+
}
|
|
80
|
+
async stop() {
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
for (const sub of this.subscriptions) {
|
|
83
|
+
try {
|
|
84
|
+
sub.unsubscribe();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// best-effort
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.subscriptions.length = 0;
|
|
91
|
+
// Drain flushes pending publishes + closes the connection cleanly.
|
|
92
|
+
// We don't own the connection (caller passes it in) but draining
|
|
93
|
+
// before they close is the polite shape.
|
|
94
|
+
try {
|
|
95
|
+
await this.connection.drain();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// best-effort
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/nats` — NATS-backed `EventBus` adapters.
|
|
3
|
+
*
|
|
4
|
+
* Two adapters share this package:
|
|
5
|
+
*
|
|
6
|
+
* - `NatsEventBus` — core NATS pub/sub. At-most-once, fan-out. The default
|
|
7
|
+
* for low-ceremony cross-service eventing.
|
|
8
|
+
* - `NatsBus` — JetStream-backed. At-least-once, durable, with a DLQ for
|
|
9
|
+
* poison messages. Pick this when restarts must not drop events.
|
|
10
|
+
*
|
|
11
|
+
* Both satisfy the same `EventBus` contract; the runtime doesn't know the
|
|
12
|
+
* difference.
|
|
13
|
+
*/
|
|
14
|
+
export { NatsEventBus, type NatsConnectionLike as NatsCoreConnectionLike, type NatsEventBusOptions, type NatsSubscriptionCallback, type NatsSubscriptionLike, } from "./bus-nats.js";
|
|
15
|
+
export { NatsBus, natsBus, type BusDeadLetterRecord, type ConsumerLike, type JetStreamClientLike, type JetStreamConsumersLike, type JetStreamManagerLike, type JsMsgLike, type NatsBusOptions, type NatsConnectFn, type NatsConnectionLike, } from "./nats-bus.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nwire/nats` — NATS-backed `EventBus` adapters.
|
|
3
|
+
*
|
|
4
|
+
* Two adapters share this package:
|
|
5
|
+
*
|
|
6
|
+
* - `NatsEventBus` — core NATS pub/sub. At-most-once, fan-out. The default
|
|
7
|
+
* for low-ceremony cross-service eventing.
|
|
8
|
+
* - `NatsBus` — JetStream-backed. At-least-once, durable, with a DLQ for
|
|
9
|
+
* poison messages. Pick this when restarts must not drop events.
|
|
10
|
+
*
|
|
11
|
+
* Both satisfy the same `EventBus` contract; the runtime doesn't know the
|
|
12
|
+
* difference.
|
|
13
|
+
*/
|
|
14
|
+
export { NatsEventBus, } from "./bus-nats.js";
|
|
15
|
+
export { NatsBus, natsBus, } from "./nats-bus.js";
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NatsBus` — JetStream-backed `EventBus` with at-least-once delivery + DLQ.
|
|
3
|
+
*
|
|
4
|
+
* Subject layout: `<prefix>.<eventName>` (default prefix `nwire.events`).
|
|
5
|
+
* A single JetStream stream binds the prefix (`<prefix>.>`) so every event
|
|
6
|
+
* gets durably acked at publish time and replayed to subscribers via durable
|
|
7
|
+
* consumers. Handlers ack only after success; on failure the message is
|
|
8
|
+
* nak'd and re-delivered up to the consumer's `maxDeliver`. Once exhausted,
|
|
9
|
+
* the bus drops a structured record on `dlqSubject` (default `<prefix>.dlq`).
|
|
10
|
+
*
|
|
11
|
+
* Use this when you need durability across service restarts. Pair with the
|
|
12
|
+
* existing core-NATS `NatsEventBus` when at-most-once fan-out is enough.
|
|
13
|
+
*
|
|
14
|
+
* See: architecture-sketch.html §05 (Adapters tier); BRIEFS — G11 (DLQ).
|
|
15
|
+
*/
|
|
16
|
+
import type { BusEventMessage, BusSubscriber, EventBus } from "@nwire/bus";
|
|
17
|
+
import type { Logger } from "@nwire/logger";
|
|
18
|
+
/**
|
|
19
|
+
* Minimal NATS surface we actually call. Keeping it structural lets us mock
|
|
20
|
+
* in unit tests and accept either the classic `nats` package or the newer
|
|
21
|
+
* `@nats-io/transport-node` family without binding the import.
|
|
22
|
+
*/
|
|
23
|
+
export interface JetStreamClientLike {
|
|
24
|
+
publish(subject: string, data: Uint8Array): Promise<{
|
|
25
|
+
seq: number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface JetStreamManagerLike {
|
|
29
|
+
streams: {
|
|
30
|
+
info(stream: string): Promise<unknown>;
|
|
31
|
+
add(config: {
|
|
32
|
+
name: string;
|
|
33
|
+
subjects: string[];
|
|
34
|
+
retention?: string;
|
|
35
|
+
max_msgs?: number;
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
};
|
|
38
|
+
consumers: {
|
|
39
|
+
add(stream: string, config: Record<string, unknown>): Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export interface JsMsgLike {
|
|
43
|
+
readonly subject: string;
|
|
44
|
+
readonly data: Uint8Array;
|
|
45
|
+
readonly info: {
|
|
46
|
+
redeliveryCount: number;
|
|
47
|
+
deliveryCount?: number;
|
|
48
|
+
};
|
|
49
|
+
ack(): void;
|
|
50
|
+
nak(delayMs?: number): void;
|
|
51
|
+
term(): void;
|
|
52
|
+
}
|
|
53
|
+
export interface ConsumerLike {
|
|
54
|
+
consume(opts?: {
|
|
55
|
+
callback?: (msg: JsMsgLike) => void | Promise<void>;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
stop(): Promise<void> | void;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
export interface JetStreamConsumersLike {
|
|
61
|
+
get(stream: string, durable: string): Promise<ConsumerLike>;
|
|
62
|
+
}
|
|
63
|
+
export interface NatsConnectionLike {
|
|
64
|
+
jetstream(): JetStreamClientLike & {
|
|
65
|
+
consumers: JetStreamConsumersLike;
|
|
66
|
+
};
|
|
67
|
+
jetstreamManager(): Promise<JetStreamManagerLike>;
|
|
68
|
+
publish(subject: string, data: Uint8Array): void;
|
|
69
|
+
drain(): Promise<void>;
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
isClosed?(): boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Caller-supplied factory; we don't import `nats` at module load time so the
|
|
75
|
+
* package is tree-shake-friendly and unit tests don't pay the cost.
|
|
76
|
+
*/
|
|
77
|
+
export type NatsConnectFn = (opts: {
|
|
78
|
+
servers: string | string[];
|
|
79
|
+
name?: string;
|
|
80
|
+
}) => Promise<NatsConnectionLike>;
|
|
81
|
+
export interface NatsBusOptions {
|
|
82
|
+
/** One or more NATS server URLs (e.g. `nats://localhost:4222`). */
|
|
83
|
+
readonly servers: string | string[];
|
|
84
|
+
/** Client name reported to NATS — handy in `nats-top`. */
|
|
85
|
+
readonly name?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Subject prefix; the bus owns `<prefix>.>` end-to-end. Default
|
|
88
|
+
* `'nwire.events'`. Use distinct prefixes to isolate deployments on a
|
|
89
|
+
* shared cluster.
|
|
90
|
+
*/
|
|
91
|
+
readonly prefix?: string;
|
|
92
|
+
/** DLQ subject; default `'<prefix>.dlq'`. */
|
|
93
|
+
readonly dlqSubject?: string;
|
|
94
|
+
/** Stream name; default derived from prefix (`NWIRE_EVENTS`). */
|
|
95
|
+
readonly streamName?: string;
|
|
96
|
+
/** Max times JetStream will redeliver before we route to DLQ. Default 5. */
|
|
97
|
+
readonly maxDeliver?: number;
|
|
98
|
+
/** Backoff between redeliveries, milliseconds. Default 1000. */
|
|
99
|
+
readonly ackWaitMs?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Injected `connect` from the `nats` package. We accept it rather than
|
|
102
|
+
* import it so test harnesses can supply a fake without docker.
|
|
103
|
+
*/
|
|
104
|
+
readonly connect: NatsConnectFn;
|
|
105
|
+
/** Optional structured logger; defaults to no-op. */
|
|
106
|
+
readonly logger?: Logger;
|
|
107
|
+
/** JSON serializer override; defaults to `TextEncoder` + `JSON.stringify`. */
|
|
108
|
+
readonly serialize?: (value: unknown) => Uint8Array;
|
|
109
|
+
/** JSON deserializer override; defaults to `TextDecoder` + `JSON.parse`. */
|
|
110
|
+
readonly deserialize?: (data: Uint8Array) => unknown;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Shape we publish onto `dlqSubject` when a message exhausts its
|
|
114
|
+
* redelivery budget. Consumers can attach a normal `subscribe` (on the DLQ
|
|
115
|
+
* subject, treated as just another event name) to drain it into ops tools.
|
|
116
|
+
*/
|
|
117
|
+
export interface BusDeadLetterRecord {
|
|
118
|
+
readonly originalSubject: string;
|
|
119
|
+
readonly event: BusEventMessage;
|
|
120
|
+
readonly error: {
|
|
121
|
+
message: string;
|
|
122
|
+
stack?: string;
|
|
123
|
+
};
|
|
124
|
+
readonly deliveryCount: number;
|
|
125
|
+
readonly deadLetteredAt: string;
|
|
126
|
+
}
|
|
127
|
+
export declare class NatsBus implements EventBus {
|
|
128
|
+
private readonly opts;
|
|
129
|
+
private connection;
|
|
130
|
+
private js;
|
|
131
|
+
private readonly runningConsumers;
|
|
132
|
+
private stopped;
|
|
133
|
+
private connecting;
|
|
134
|
+
constructor(options: NatsBusOptions);
|
|
135
|
+
private subjectFor;
|
|
136
|
+
/**
|
|
137
|
+
* Opens the NATS connection (idempotent) and ensures the JetStream stream
|
|
138
|
+
* for `<prefix>.>` exists. Safe to call multiple times — concurrent
|
|
139
|
+
* callers share the same in-flight promise.
|
|
140
|
+
*/
|
|
141
|
+
connect(): Promise<void>;
|
|
142
|
+
publish(msg: BusEventMessage): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Subscribe with at-least-once semantics. We create a durable consumer
|
|
145
|
+
* derived from `(streamName, eventName)` so multiple processes with the
|
|
146
|
+
* same durable share work, and restarts resume cleanly.
|
|
147
|
+
*/
|
|
148
|
+
subscribe(eventName: string, subscriber: BusSubscriber): void;
|
|
149
|
+
private attachConsumer;
|
|
150
|
+
private handleDelivery;
|
|
151
|
+
private routeToDlq;
|
|
152
|
+
/** Graceful shutdown — drain consumers, then drain the connection. */
|
|
153
|
+
stop(): Promise<void>;
|
|
154
|
+
/** Alias for {@link stop} — matches the task's `close()` naming. */
|
|
155
|
+
close(): Promise<void>;
|
|
156
|
+
}
|
|
157
|
+
/** Factory mirror — for parity with the other `@nwire/*` adapters. */
|
|
158
|
+
export declare function natsBus(options: NatsBusOptions): NatsBus;
|
package/dist/nats-bus.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NatsBus` — JetStream-backed `EventBus` with at-least-once delivery + DLQ.
|
|
3
|
+
*
|
|
4
|
+
* Subject layout: `<prefix>.<eventName>` (default prefix `nwire.events`).
|
|
5
|
+
* A single JetStream stream binds the prefix (`<prefix>.>`) so every event
|
|
6
|
+
* gets durably acked at publish time and replayed to subscribers via durable
|
|
7
|
+
* consumers. Handlers ack only after success; on failure the message is
|
|
8
|
+
* nak'd and re-delivered up to the consumer's `maxDeliver`. Once exhausted,
|
|
9
|
+
* the bus drops a structured record on `dlqSubject` (default `<prefix>.dlq`).
|
|
10
|
+
*
|
|
11
|
+
* Use this when you need durability across service restarts. Pair with the
|
|
12
|
+
* existing core-NATS `NatsEventBus` when at-most-once fan-out is enough.
|
|
13
|
+
*
|
|
14
|
+
* See: architecture-sketch.html §05 (Adapters tier); BRIEFS — G11 (DLQ).
|
|
15
|
+
*/
|
|
16
|
+
const defaultEncoder = new TextEncoder();
|
|
17
|
+
const defaultDecoder = new TextDecoder();
|
|
18
|
+
function defaultSerialize(value) {
|
|
19
|
+
return defaultEncoder.encode(JSON.stringify(value));
|
|
20
|
+
}
|
|
21
|
+
function defaultDeserialize(data) {
|
|
22
|
+
return JSON.parse(defaultDecoder.decode(data));
|
|
23
|
+
}
|
|
24
|
+
function streamNameFromPrefix(prefix) {
|
|
25
|
+
return prefix.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
function noopLogger() {
|
|
28
|
+
const l = {
|
|
29
|
+
debug() { },
|
|
30
|
+
info() { },
|
|
31
|
+
warn() { },
|
|
32
|
+
error() { },
|
|
33
|
+
child() {
|
|
34
|
+
return l;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return l;
|
|
38
|
+
}
|
|
39
|
+
export class NatsBus {
|
|
40
|
+
opts;
|
|
41
|
+
connection = null;
|
|
42
|
+
js = null;
|
|
43
|
+
runningConsumers = [];
|
|
44
|
+
stopped = false;
|
|
45
|
+
connecting = null;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
const prefix = options.prefix ?? "nwire.events";
|
|
48
|
+
this.opts = {
|
|
49
|
+
servers: options.servers,
|
|
50
|
+
name: options.name,
|
|
51
|
+
prefix,
|
|
52
|
+
dlqSubject: options.dlqSubject ?? `${prefix}.dlq`,
|
|
53
|
+
streamName: options.streamName ?? streamNameFromPrefix(prefix),
|
|
54
|
+
maxDeliver: options.maxDeliver ?? 5,
|
|
55
|
+
ackWaitMs: options.ackWaitMs ?? 1000,
|
|
56
|
+
connect: options.connect,
|
|
57
|
+
logger: options.logger ?? noopLogger(),
|
|
58
|
+
serialize: options.serialize ?? defaultSerialize,
|
|
59
|
+
deserialize: options.deserialize ?? defaultDeserialize,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
subjectFor(eventName) {
|
|
63
|
+
return `${this.opts.prefix}.${eventName}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Opens the NATS connection (idempotent) and ensures the JetStream stream
|
|
67
|
+
* for `<prefix>.>` exists. Safe to call multiple times — concurrent
|
|
68
|
+
* callers share the same in-flight promise.
|
|
69
|
+
*/
|
|
70
|
+
async connect() {
|
|
71
|
+
if (this.stopped)
|
|
72
|
+
throw new Error("NatsBus: connect after stop");
|
|
73
|
+
if (this.connection)
|
|
74
|
+
return;
|
|
75
|
+
if (this.connecting)
|
|
76
|
+
return this.connecting;
|
|
77
|
+
this.connecting = (async () => {
|
|
78
|
+
const nc = await this.opts.connect({
|
|
79
|
+
servers: this.opts.servers,
|
|
80
|
+
name: this.opts.name,
|
|
81
|
+
});
|
|
82
|
+
this.connection = nc;
|
|
83
|
+
this.js = nc.jetstream();
|
|
84
|
+
const jsm = await nc.jetstreamManager();
|
|
85
|
+
const streamSubject = `${this.opts.prefix}.>`;
|
|
86
|
+
try {
|
|
87
|
+
await jsm.streams.info(this.opts.streamName);
|
|
88
|
+
this.opts.logger.debug("nats-bus: stream exists", {
|
|
89
|
+
stream: this.opts.streamName,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
await jsm.streams.add({
|
|
94
|
+
name: this.opts.streamName,
|
|
95
|
+
subjects: [streamSubject],
|
|
96
|
+
});
|
|
97
|
+
this.opts.logger.info("nats-bus: stream created", {
|
|
98
|
+
stream: this.opts.streamName,
|
|
99
|
+
subjects: streamSubject,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
try {
|
|
104
|
+
await this.connecting;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.connecting = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async publish(msg) {
|
|
111
|
+
if (this.stopped)
|
|
112
|
+
throw new Error("NatsBus: publish after stop");
|
|
113
|
+
if (!this.js)
|
|
114
|
+
await this.connect();
|
|
115
|
+
if (!this.js)
|
|
116
|
+
throw new Error("NatsBus: not connected");
|
|
117
|
+
const subject = this.subjectFor(msg.eventName);
|
|
118
|
+
const data = this.opts.serialize({
|
|
119
|
+
eventName: msg.eventName,
|
|
120
|
+
payload: msg.payload,
|
|
121
|
+
envelope: msg.envelope,
|
|
122
|
+
origin: msg.origin,
|
|
123
|
+
});
|
|
124
|
+
const ack = await this.js.publish(subject, data);
|
|
125
|
+
this.opts.logger.debug("nats-bus: published", {
|
|
126
|
+
subject,
|
|
127
|
+
seq: ack.seq,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Subscribe with at-least-once semantics. We create a durable consumer
|
|
132
|
+
* derived from `(streamName, eventName)` so multiple processes with the
|
|
133
|
+
* same durable share work, and restarts resume cleanly.
|
|
134
|
+
*/
|
|
135
|
+
subscribe(eventName, subscriber) {
|
|
136
|
+
if (this.stopped)
|
|
137
|
+
throw new Error("NatsBus: subscribe after stop");
|
|
138
|
+
void this.attachConsumer(eventName, subscriber).catch((err) => {
|
|
139
|
+
this.opts.logger.error("nats-bus: subscribe failed", {
|
|
140
|
+
eventName,
|
|
141
|
+
error: err instanceof Error ? err.message : String(err),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async attachConsumer(eventName, subscriber) {
|
|
146
|
+
if (!this.connection || !this.js)
|
|
147
|
+
await this.connect();
|
|
148
|
+
if (!this.connection || !this.js)
|
|
149
|
+
throw new Error("NatsBus: not connected");
|
|
150
|
+
const subject = this.subjectFor(eventName);
|
|
151
|
+
const durable = `${this.opts.streamName}_${eventName.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
152
|
+
const jsm = await this.connection.jetstreamManager();
|
|
153
|
+
try {
|
|
154
|
+
await jsm.consumers.add(this.opts.streamName, {
|
|
155
|
+
durable_name: durable,
|
|
156
|
+
ack_policy: "explicit",
|
|
157
|
+
filter_subject: subject,
|
|
158
|
+
max_deliver: this.opts.maxDeliver,
|
|
159
|
+
ack_wait: this.opts.ackWaitMs * 1_000_000, // nanos
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// Already exists is fine; surface other failures.
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
if (!/already in use|exists/i.test(msg))
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
const consumer = await this.js.consumers.get(this.opts.streamName, durable);
|
|
169
|
+
const running = await consumer.consume({
|
|
170
|
+
callback: async (jsMsg) => {
|
|
171
|
+
await this.handleDelivery(jsMsg, subscriber);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
this.runningConsumers.push(running);
|
|
175
|
+
}
|
|
176
|
+
async handleDelivery(jsMsg, subscriber) {
|
|
177
|
+
let decoded;
|
|
178
|
+
try {
|
|
179
|
+
decoded = this.opts.deserialize(jsMsg.data);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
this.opts.logger.error("nats-bus: malformed message — terminating", {
|
|
183
|
+
subject: jsMsg.subject,
|
|
184
|
+
error: err instanceof Error ? err.message : String(err),
|
|
185
|
+
});
|
|
186
|
+
jsMsg.term();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const deliveryCount = jsMsg.info.deliveryCount ?? jsMsg.info.redeliveryCount + 1;
|
|
190
|
+
try {
|
|
191
|
+
await subscriber(decoded);
|
|
192
|
+
jsMsg.ack();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
196
|
+
if (deliveryCount >= this.opts.maxDeliver) {
|
|
197
|
+
await this.routeToDlq(jsMsg, decoded, error, deliveryCount);
|
|
198
|
+
jsMsg.term();
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
this.opts.logger.warn("nats-bus: handler threw — nak for retry", {
|
|
202
|
+
subject: jsMsg.subject,
|
|
203
|
+
deliveryCount,
|
|
204
|
+
error: error.message,
|
|
205
|
+
});
|
|
206
|
+
jsMsg.nak(this.opts.ackWaitMs);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async routeToDlq(jsMsg, event, error, deliveryCount) {
|
|
211
|
+
if (!this.js)
|
|
212
|
+
return;
|
|
213
|
+
const record = {
|
|
214
|
+
originalSubject: jsMsg.subject,
|
|
215
|
+
event,
|
|
216
|
+
error: { message: error.message, stack: error.stack },
|
|
217
|
+
deliveryCount,
|
|
218
|
+
deadLetteredAt: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
await this.js.publish(this.opts.dlqSubject, this.opts.serialize(record));
|
|
222
|
+
this.opts.logger.error("nats-bus: routed to DLQ", {
|
|
223
|
+
subject: jsMsg.subject,
|
|
224
|
+
dlqSubject: this.opts.dlqSubject,
|
|
225
|
+
deliveryCount,
|
|
226
|
+
error: error.message,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (publishErr) {
|
|
230
|
+
this.opts.logger.error("nats-bus: DLQ publish failed", {
|
|
231
|
+
subject: jsMsg.subject,
|
|
232
|
+
error: publishErr instanceof Error ? publishErr.message : String(publishErr),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Graceful shutdown — drain consumers, then drain the connection. */
|
|
237
|
+
async stop() {
|
|
238
|
+
if (this.stopped)
|
|
239
|
+
return;
|
|
240
|
+
this.stopped = true;
|
|
241
|
+
for (const c of this.runningConsumers) {
|
|
242
|
+
try {
|
|
243
|
+
await c.stop();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// best-effort
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.runningConsumers.length = 0;
|
|
250
|
+
if (this.connection) {
|
|
251
|
+
try {
|
|
252
|
+
await this.connection.drain();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// best-effort
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
await this.connection.close();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// best-effort
|
|
262
|
+
}
|
|
263
|
+
this.connection = null;
|
|
264
|
+
}
|
|
265
|
+
this.js = null;
|
|
266
|
+
}
|
|
267
|
+
/** Alias for {@link stop} — matches the task's `close()` naming. */
|
|
268
|
+
close() {
|
|
269
|
+
return this.stop();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Factory mirror — for parity with the other `@nwire/*` adapters. */
|
|
273
|
+
export function natsBus(options) {
|
|
274
|
+
return new NatsBus(options);
|
|
275
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nwire/nats",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Nwire — NATS-backed EventBus adapter. Core pub/sub OR JetStream (at-least-once + DLQ) — same EventBus contract.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"adapter",
|
|
7
|
+
"bus",
|
|
8
|
+
"dlq",
|
|
9
|
+
"jetstream",
|
|
10
|
+
"messaging",
|
|
11
|
+
"nats",
|
|
12
|
+
"nwire",
|
|
13
|
+
"pubsub"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@nwire/bus": "0.10.0",
|
|
35
|
+
"@nwire/logger": "0.10.0",
|
|
36
|
+
"@nwire/envelope": "0.10.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.19.9",
|
|
40
|
+
"nats": "^2.29.0",
|
|
41
|
+
"testcontainers": "^10.13.2",
|
|
42
|
+
"typescript": "^5.9.3",
|
|
43
|
+
"vitest": "^4.0.18"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"nats": "^2.29.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"nats": {
|
|
50
|
+
"optional": false
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",
|
|
55
|
+
"dev": "tsc --watch",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run --dir src",
|
|
58
|
+
"test:integration": "RUN_INTEGRATION=1 vitest run --dir src"
|
|
59
|
+
}
|
|
60
|
+
}
|