@zmdb/transport-nats 1.0.0-beta.1
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 +674 -0
- package/README.md +61 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +197 -0
- package/dist/index.js.map +1 -0
- package/dist/matcher.d.ts +9 -0
- package/dist/matcher.d.ts.map +1 -0
- package/dist/matcher.js +84 -0
- package/dist/matcher.js.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +251 -0
- package/src/matcher.ts +90 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
createInbox,
|
|
4
|
+
type NatsConnection,
|
|
5
|
+
type NodeConnectionOptions,
|
|
6
|
+
type Subscription,
|
|
7
|
+
} from '@nats-io/transport-node';
|
|
8
|
+
import {
|
|
9
|
+
abortError,
|
|
10
|
+
decodeDelivery,
|
|
11
|
+
decodeReply,
|
|
12
|
+
encodeDelivery,
|
|
13
|
+
encodeReply,
|
|
14
|
+
InFlight,
|
|
15
|
+
MessageTimeoutError,
|
|
16
|
+
reportTransportError,
|
|
17
|
+
withinGrace,
|
|
18
|
+
type MessageReply,
|
|
19
|
+
type TransportErrorSink,
|
|
20
|
+
type TransportStrategy,
|
|
21
|
+
} from '@zmdb/app/messaging';
|
|
22
|
+
|
|
23
|
+
import { createNatsSubjectMatcher } from './matcher.js';
|
|
24
|
+
|
|
25
|
+
function bytes(text: string): Uint8Array {
|
|
26
|
+
return new TextEncoder().encode(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NatsSubscription {
|
|
30
|
+
/** NATS subject; `*` and a final `>` use native NATS token semantics. */
|
|
31
|
+
readonly subject: string;
|
|
32
|
+
/** Queue group for horizontally balanced delivery. */
|
|
33
|
+
readonly queue?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NatsStrategyOptions {
|
|
37
|
+
readonly connection?: NodeConnectionOptions;
|
|
38
|
+
readonly name?: string;
|
|
39
|
+
readonly onError: TransportErrorSink;
|
|
40
|
+
readonly subscriptions: readonly NatsSubscription[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateSubscriptions(subscriptions: readonly NatsSubscription[]): readonly NatsSubscription[] {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
return subscriptions.map(subscription => {
|
|
46
|
+
if (subscription.subject.length === 0) {
|
|
47
|
+
throw new RangeError('@zmdb/transport-nats: a NATS subscription subject cannot be empty');
|
|
48
|
+
}
|
|
49
|
+
if (subscription.queue !== undefined && subscription.queue.length === 0) {
|
|
50
|
+
throw new RangeError('@zmdb/transport-nats: a NATS queue group cannot be empty');
|
|
51
|
+
}
|
|
52
|
+
const key = `${subscription.subject}\u0000${subscription.queue ?? ''}`;
|
|
53
|
+
if (seen.has(key)) {
|
|
54
|
+
throw new Error(`@zmdb/transport-nats: duplicate NATS subscription "${subscription.subject}"`);
|
|
55
|
+
}
|
|
56
|
+
seen.add(key);
|
|
57
|
+
return { ...subscription };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Core NATS strategy.
|
|
63
|
+
*
|
|
64
|
+
* Core NATS is at-most-once: successful settlements need no acknowledgement,
|
|
65
|
+
* while `retry` and `dead` cannot be honoured after delivery. Queue groups and
|
|
66
|
+
* subject wildcards are native subscription features; wildcard membership is
|
|
67
|
+
* also checked through a startup-built trie rather than a pattern scan.
|
|
68
|
+
*/
|
|
69
|
+
export function createNatsStrategy(options: NatsStrategyOptions): TransportStrategy {
|
|
70
|
+
const subscriptions = validateSubscriptions(options.subscriptions);
|
|
71
|
+
const matcher = createNatsSubjectMatcher(subscriptions.map(subscription => subscription.subject));
|
|
72
|
+
const inFlight = new InFlight(options.onError);
|
|
73
|
+
const name = options.name ?? 'nats';
|
|
74
|
+
let connection: NatsConnection | undefined;
|
|
75
|
+
let activeSubscriptions: Subscription[] = [];
|
|
76
|
+
let started = false;
|
|
77
|
+
let closed = false;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
capabilities: { redelivery: false, deadLetter: false, requestResponse: true },
|
|
82
|
+
|
|
83
|
+
async listen(dispatch): Promise<void> {
|
|
84
|
+
if (started) {
|
|
85
|
+
throw new Error('@zmdb/transport-nats: NATS strategy is already listening');
|
|
86
|
+
}
|
|
87
|
+
if (closed) {
|
|
88
|
+
throw new Error('@zmdb/transport-nats: NATS strategy is closed');
|
|
89
|
+
}
|
|
90
|
+
started = true;
|
|
91
|
+
|
|
92
|
+
const nextConnection = await connect(options.connection);
|
|
93
|
+
const nextSubscriptions: Subscription[] = [];
|
|
94
|
+
try {
|
|
95
|
+
for (const subscription of subscriptions) {
|
|
96
|
+
const opened = nextConnection.subscribe(subscription.subject, {
|
|
97
|
+
...(subscription.queue === undefined ? {} : { queue: subscription.queue }),
|
|
98
|
+
callback(error, message): void {
|
|
99
|
+
if (error !== null) {
|
|
100
|
+
reportTransportError(options.onError, error);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
void inFlight.run(async () => {
|
|
104
|
+
if (!matcher.matches(message.subject)) {
|
|
105
|
+
throw new Error(`@zmdb/transport-nats: NATS delivered unsubscribed subject "${message.subject}"`);
|
|
106
|
+
}
|
|
107
|
+
const delivery = decodeDelivery(
|
|
108
|
+
message.subject,
|
|
109
|
+
message.string(),
|
|
110
|
+
1,
|
|
111
|
+
message.reply === undefined ? {} : { replyTo: message.reply },
|
|
112
|
+
);
|
|
113
|
+
const outcome = await dispatch(delivery);
|
|
114
|
+
if (outcome.reply !== undefined && !message.respond(bytes(encodeReply(outcome.reply)))) {
|
|
115
|
+
throw new Error('@zmdb/transport-nats: NATS request has no reply subject');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
nextSubscriptions.push(opened);
|
|
121
|
+
}
|
|
122
|
+
await nextConnection.flush();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await nextConnection.close();
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
connection = nextConnection;
|
|
128
|
+
activeSubscriptions = nextSubscriptions;
|
|
129
|
+
void nextConnection.closed().then(
|
|
130
|
+
error => {
|
|
131
|
+
if (error !== undefined) {
|
|
132
|
+
reportTransportError(options.onError, error);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
error => reportTransportError(options.onError, error),
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async send(request): Promise<MessageReply> {
|
|
140
|
+
const activeConnection = connection;
|
|
141
|
+
if (activeConnection === undefined) {
|
|
142
|
+
throw new Error('@zmdb/transport-nats: NATS strategy is not listening');
|
|
143
|
+
}
|
|
144
|
+
if (request.signal.aborted) {
|
|
145
|
+
throw abortError(request.signal);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const inbox = createInbox();
|
|
149
|
+
let responseSubscription: Subscription | undefined;
|
|
150
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
let abort = (): void => undefined;
|
|
152
|
+
let resolveResponse = (_reply: MessageReply): void => undefined;
|
|
153
|
+
let rejectResponse = (_error: unknown): void => undefined;
|
|
154
|
+
let finished = false;
|
|
155
|
+
const response = new Promise<MessageReply>((resolve, reject) => {
|
|
156
|
+
resolveResponse = resolve;
|
|
157
|
+
rejectResponse = reject;
|
|
158
|
+
});
|
|
159
|
+
const cleanup = (): void => {
|
|
160
|
+
responseSubscription?.unsubscribe();
|
|
161
|
+
request.signal.removeEventListener('abort', abort);
|
|
162
|
+
if (timer !== undefined) {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const fail = (error: unknown): void => {
|
|
167
|
+
if (finished) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
finished = true;
|
|
171
|
+
cleanup();
|
|
172
|
+
rejectResponse(error);
|
|
173
|
+
};
|
|
174
|
+
const succeed = (reply: MessageReply): void => {
|
|
175
|
+
if (finished) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
finished = true;
|
|
179
|
+
cleanup();
|
|
180
|
+
resolveResponse(reply);
|
|
181
|
+
};
|
|
182
|
+
abort = (): void => fail(abortError(request.signal));
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
responseSubscription = activeConnection.subscribe(inbox, {
|
|
186
|
+
max: 1,
|
|
187
|
+
callback(error, message): void {
|
|
188
|
+
if (error !== null) {
|
|
189
|
+
fail(error);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
succeed(decodeReply(message.string()));
|
|
194
|
+
} catch (decodeError) {
|
|
195
|
+
fail(decodeError);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
request.signal.addEventListener('abort', abort, { once: true });
|
|
200
|
+
timer = setTimeout(() => {
|
|
201
|
+
fail(new MessageTimeoutError(request.pattern, request.timeoutMs, request.correlationId));
|
|
202
|
+
}, request.timeoutMs);
|
|
203
|
+
await activeConnection.flush();
|
|
204
|
+
if (!finished) {
|
|
205
|
+
activeConnection.publish(
|
|
206
|
+
request.pattern,
|
|
207
|
+
bytes(encodeDelivery(request.payload, request, { correlationId: request.correlationId })),
|
|
208
|
+
{ reply: inbox },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
fail(error);
|
|
213
|
+
}
|
|
214
|
+
return response;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async emit(pattern, payload, carrier): Promise<void> {
|
|
218
|
+
const activeConnection = connection;
|
|
219
|
+
if (activeConnection === undefined) {
|
|
220
|
+
throw new Error('@zmdb/transport-nats: NATS strategy is not listening');
|
|
221
|
+
}
|
|
222
|
+
activeConnection.publish(pattern, bytes(encodeDelivery(payload, carrier)));
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async close(graceMs): Promise<void> {
|
|
226
|
+
if (closed) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
closed = true;
|
|
230
|
+
const activeConnection = connection;
|
|
231
|
+
const subscriptionsToClose = activeSubscriptions;
|
|
232
|
+
connection = undefined;
|
|
233
|
+
activeSubscriptions = [];
|
|
234
|
+
if (activeConnection === undefined) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const graceful = (async (): Promise<void> => {
|
|
239
|
+
await Promise.all(subscriptionsToClose.map(subscription => subscription.drain()));
|
|
240
|
+
inFlight.stop();
|
|
241
|
+
await inFlight.settled();
|
|
242
|
+
await activeConnection.flush();
|
|
243
|
+
await activeConnection.close();
|
|
244
|
+
})();
|
|
245
|
+
if (!(await withinGrace(graceful, graceMs))) {
|
|
246
|
+
await activeConnection.close();
|
|
247
|
+
throw new Error(`@zmdb/transport-nats: NATS strategy did not drain within ${String(graceMs)}ms`);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
class SubjectNode {
|
|
2
|
+
readonly literals = new Map<string, SubjectNode>();
|
|
3
|
+
one: SubjectNode | undefined;
|
|
4
|
+
tail = false;
|
|
5
|
+
terminal = false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function tokens(value: string, description: string): readonly string[] {
|
|
9
|
+
const parts = value.split('.');
|
|
10
|
+
if (parts.length === 0 || parts.some(part => part.length === 0)) {
|
|
11
|
+
throw new RangeError(`@zmdb/transport-nats: ${description} must contain non-empty dot-separated tokens`);
|
|
12
|
+
}
|
|
13
|
+
return parts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function add(root: SubjectNode, pattern: string): void {
|
|
17
|
+
const parts = tokens(pattern, 'a NATS subscription');
|
|
18
|
+
let node = root;
|
|
19
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
20
|
+
const part = parts[index];
|
|
21
|
+
if (part === '>') {
|
|
22
|
+
if (index !== parts.length - 1) {
|
|
23
|
+
throw new RangeError('@zmdb/transport-nats: a NATS > wildcard must be the final token');
|
|
24
|
+
}
|
|
25
|
+
node.tail = true;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (part === '*') {
|
|
29
|
+
node.one ??= new SubjectNode();
|
|
30
|
+
node = node.one;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (part?.includes('*') || part?.includes('>')) {
|
|
34
|
+
throw new RangeError('@zmdb/transport-nats: NATS wildcards must occupy a whole token');
|
|
35
|
+
}
|
|
36
|
+
let child = node.literals.get(part ?? '');
|
|
37
|
+
if (child === undefined) {
|
|
38
|
+
child = new SubjectNode();
|
|
39
|
+
node.literals.set(part ?? '', child);
|
|
40
|
+
}
|
|
41
|
+
node = child;
|
|
42
|
+
}
|
|
43
|
+
node.terminal = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface NatsSubjectMatcher {
|
|
47
|
+
matches(subject: string): boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compile NATS `*`/`>` subscriptions into a trie. Matching walks subject
|
|
52
|
+
* tokens and active trie nodes; it never iterates the configured patterns.
|
|
53
|
+
*/
|
|
54
|
+
export function createNatsSubjectMatcher(patterns: readonly string[]): NatsSubjectMatcher {
|
|
55
|
+
const root = new SubjectNode();
|
|
56
|
+
for (const pattern of patterns) {
|
|
57
|
+
add(root, pattern);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
matches(subject): boolean {
|
|
61
|
+
const parts = tokens(subject, 'a NATS subject');
|
|
62
|
+
let active = new Set<SubjectNode>([root]);
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
const next = new Set<SubjectNode>();
|
|
65
|
+
for (const node of active) {
|
|
66
|
+
if (node.tail) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const literal = node.literals.get(part);
|
|
70
|
+
if (literal !== undefined) {
|
|
71
|
+
next.add(literal);
|
|
72
|
+
}
|
|
73
|
+
if (node.one !== undefined) {
|
|
74
|
+
next.add(node.one);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
active = next;
|
|
78
|
+
if (active.size === 0) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const node of active) {
|
|
83
|
+
if (node.terminal) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|