@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 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
+ }
@@ -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
+ }
@@ -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;
@@ -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
+ }