@ultimat3/realtime 1.0.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 +180 -0
- package/package.json +36 -0
- package/src/change-buffer.ts +69 -0
- package/src/changefeed-env.ts +146 -0
- package/src/changefeed.ts +191 -0
- package/src/channel.ts +181 -0
- package/src/client.ts +439 -0
- package/src/cursor.ts +188 -0
- package/src/errors.ts +253 -0
- package/src/fanout.ts +159 -0
- package/src/hooks.ts +230 -0
- package/src/index.ts +328 -0
- package/src/json.ts +76 -0
- package/src/live-definition.ts +144 -0
- package/src/live-query.ts +449 -0
- package/src/local-store.ts +188 -0
- package/src/matcher-bridge.ts +169 -0
- package/src/nats-commands.ts +97 -0
- package/src/nats-connection-fixture.ts +105 -0
- package/src/nats-connection.ts +464 -0
- package/src/nats-fake.ts +431 -0
- package/src/nats-jetstream.ts +226 -0
- package/src/nats-kv.ts +157 -0
- package/src/nats-protocol.ts +222 -0
- package/src/nats-socket.ts +236 -0
- package/src/nats-transport.ts +257 -0
- package/src/offline-queue.ts +206 -0
- package/src/pg-advisory-lock.ts +98 -0
- package/src/pg-auth.ts +300 -0
- package/src/pg-bytes.ts +185 -0
- package/src/pg-connection-fixture.ts +215 -0
- package/src/pg-connection.ts +337 -0
- package/src/pg-entity-row.ts +130 -0
- package/src/pg-replication-fixture.ts +261 -0
- package/src/pg-replication.ts +396 -0
- package/src/pg-socket.ts +265 -0
- package/src/pg-wire.ts +192 -0
- package/src/pgoutput.ts +297 -0
- package/src/policy-gate.ts +56 -0
- package/src/presence.ts +219 -0
- package/src/rebase.ts +198 -0
- package/src/replicator.ts +185 -0
- package/src/socket.ts +208 -0
- package/src/sync-node.ts +400 -0
- package/src/sync-protocol.ts +376 -0
- package/src/thundering-herd.ts +141 -0
- package/src/transport-env.ts +104 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Single responsibility: one NATS session over a `NatsStream` — the INFO/TLS/CONNECT handshake, the
|
|
2
|
+
// read loop that turns operations into subscription callbacks, and request/reply over an inbox.
|
|
3
|
+
// It speaks only in operations, never in bytes on a socket, so the whole session runs in a test.
|
|
4
|
+
|
|
5
|
+
import { TransportProtocolError, TransportUnavailableError } from './errors';
|
|
6
|
+
import {
|
|
7
|
+
connectMessage,
|
|
8
|
+
type NatsConnectOptions,
|
|
9
|
+
PING_MESSAGE,
|
|
10
|
+
PONG_MESSAGE,
|
|
11
|
+
pubMessage,
|
|
12
|
+
subMessage,
|
|
13
|
+
unsubMessage,
|
|
14
|
+
} from './nats-commands';
|
|
15
|
+
import {
|
|
16
|
+
type NatsHeaders,
|
|
17
|
+
type NatsMessage,
|
|
18
|
+
NatsProtocolParser,
|
|
19
|
+
type NatsServerInfo,
|
|
20
|
+
} from './nats-protocol';
|
|
21
|
+
import type { NatsStream, NatsTarget } from './nats-socket';
|
|
22
|
+
import type { Rng } from './thundering-herd';
|
|
23
|
+
|
|
24
|
+
export interface NatsConnectionOptions {
|
|
25
|
+
readonly stream: NatsStream;
|
|
26
|
+
readonly target: NatsTarget;
|
|
27
|
+
readonly name?: string | undefined;
|
|
28
|
+
/** The read loop ended: EOF, a socket fault, or a fatal `-ERR`. The transport reconnects. */
|
|
29
|
+
readonly onClose?: ((error: unknown) => void) | undefined;
|
|
30
|
+
/** A non-fatal server complaint — a permissions violation on one subject, say. */
|
|
31
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
32
|
+
/** Injected so an inbox prefix is deterministic under a seeded test. */
|
|
33
|
+
readonly rng?: Rng | undefined;
|
|
34
|
+
readonly requestTimeoutMs?: number | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NatsSubscription {
|
|
38
|
+
readonly sid: string;
|
|
39
|
+
readonly subject: string;
|
|
40
|
+
unsubscribe(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type NatsMessageHandler = (message: NatsMessage) => void;
|
|
44
|
+
|
|
45
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A subject is interpolated straight into a control line, so a space or a CRLF in one would inject
|
|
49
|
+
* a second command. This regex is a security boundary, not a style rule.
|
|
50
|
+
*/
|
|
51
|
+
const SUBJECT = /^[^\s.*>]+(\.[^\s.*>]+)*$/;
|
|
52
|
+
const SUBSCRIBE_SUBJECT = /^[^\s.]+(\.[^\s.]+)*$/;
|
|
53
|
+
|
|
54
|
+
const assertSubject = (subject: string, pattern: RegExp, what: string): void => {
|
|
55
|
+
if (!pattern.test(subject)) {
|
|
56
|
+
throw new TransportProtocolError({
|
|
57
|
+
transport: 'nats',
|
|
58
|
+
stage: what,
|
|
59
|
+
detail: `"${subject}" is not a subject: tokens are dot-separated and carry no whitespace`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const inboxToken = (rng: Rng): string =>
|
|
65
|
+
Math.floor(rng() * 0xff_ff_ff_ff)
|
|
66
|
+
.toString(16)
|
|
67
|
+
.padStart(8, '0');
|
|
68
|
+
|
|
69
|
+
interface Pending {
|
|
70
|
+
readonly collect: (message: NatsMessage) => boolean;
|
|
71
|
+
readonly settle: (error: unknown) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One connected NATS session. `open()` returns only once the server has answered the handshake. */
|
|
75
|
+
export class NatsConnection {
|
|
76
|
+
readonly #stream: NatsStream;
|
|
77
|
+
readonly #parser: NatsProtocolParser;
|
|
78
|
+
readonly #handlers = new Map<string, NatsMessageHandler>();
|
|
79
|
+
readonly #pending = new Map<string, Pending>();
|
|
80
|
+
readonly #pongs: (() => void)[] = [];
|
|
81
|
+
readonly #onClose: (error: unknown) => void;
|
|
82
|
+
readonly #onError: (error: unknown) => void;
|
|
83
|
+
readonly #inbox: string;
|
|
84
|
+
readonly #requestTimeoutMs: number;
|
|
85
|
+
#info: NatsServerInfo;
|
|
86
|
+
#sid = 0;
|
|
87
|
+
#reply = 0;
|
|
88
|
+
#inboxReady = false;
|
|
89
|
+
#closed = false;
|
|
90
|
+
|
|
91
|
+
private constructor(
|
|
92
|
+
options: NatsConnectionOptions,
|
|
93
|
+
info: NatsServerInfo,
|
|
94
|
+
inbox: string,
|
|
95
|
+
parser: NatsProtocolParser,
|
|
96
|
+
) {
|
|
97
|
+
this.#stream = options.stream;
|
|
98
|
+
this.#parser = parser;
|
|
99
|
+
this.#info = info;
|
|
100
|
+
this.#inbox = inbox;
|
|
101
|
+
this.#onClose = options.onClose ?? (() => undefined);
|
|
102
|
+
this.#onError = options.onError ?? (() => undefined);
|
|
103
|
+
this.#requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** INFO, the in-band TLS upgrade, CONNECT, and the PING/PONG that proves the server accepted it. */
|
|
107
|
+
static async open(options: NatsConnectionOptions): Promise<NatsConnection> {
|
|
108
|
+
const stream = options.stream;
|
|
109
|
+
const parser = new NatsProtocolParser();
|
|
110
|
+
const info = await readInfo(stream, parser);
|
|
111
|
+
if (options.target.tls || info.tlsRequired) {
|
|
112
|
+
// Anything the server sent after INFO would be read as ciphertext once TLS is up, so it is a
|
|
113
|
+
// wrong peer rather than an early arrival — the same guard the postgres handshake makes.
|
|
114
|
+
if (parser.buffered > 0) {
|
|
115
|
+
throw new TransportProtocolError({
|
|
116
|
+
transport: 'nats',
|
|
117
|
+
stage: 'tls',
|
|
118
|
+
detail: `the server sent ${parser.buffered} bytes after INFO but before TLS`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
stream.upgradeTls();
|
|
122
|
+
}
|
|
123
|
+
const credentials: NatsConnectOptions = {
|
|
124
|
+
name: options.name ?? 'ultimate',
|
|
125
|
+
tlsRequired: options.target.tls || info.tlsRequired,
|
|
126
|
+
user: options.target.user,
|
|
127
|
+
pass: options.target.pass,
|
|
128
|
+
authToken: options.target.token,
|
|
129
|
+
};
|
|
130
|
+
await stream.write(connectMessage(credentials));
|
|
131
|
+
await stream.write(PING_MESSAGE);
|
|
132
|
+
await expectPong(stream, parser);
|
|
133
|
+
const inbox = `_INBOX.${inboxToken(options.rng ?? Math.random)}`;
|
|
134
|
+
// The handshake parser carries on: anything it still holds is already this session's traffic.
|
|
135
|
+
const connection = new NatsConnection(options, info, inbox, parser);
|
|
136
|
+
connection.#drain();
|
|
137
|
+
void connection.#readLoop();
|
|
138
|
+
return connection;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get info(): NatsServerInfo {
|
|
142
|
+
return this.#info;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get closed(): boolean {
|
|
146
|
+
return this.#closed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Live subscription count — what a reconnect has to re-establish. */
|
|
150
|
+
get subscriptionCount(): number {
|
|
151
|
+
return this.#handlers.size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async publish(
|
|
155
|
+
subject: string,
|
|
156
|
+
payload: Uint8Array,
|
|
157
|
+
options: { readonly replyTo?: string; readonly headers?: NatsHeaders } = {},
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
this.#assertOpen();
|
|
160
|
+
assertSubject(subject, SUBJECT, 'publish');
|
|
161
|
+
// The reply subject rides in the same control line as the subject, so a CRLF in it injects a
|
|
162
|
+
// second command just as readily — one guard on `subject` alone is half a boundary.
|
|
163
|
+
if (options.replyTo !== undefined) assertSubject(options.replyTo, SUBJECT, 'publish');
|
|
164
|
+
if (payload.length > this.#info.maxPayload) {
|
|
165
|
+
throw new TransportProtocolError({
|
|
166
|
+
transport: 'nats',
|
|
167
|
+
stage: 'publish',
|
|
168
|
+
detail: `${payload.length} bytes exceeds the server's max_payload of ${this.#info.maxPayload}`,
|
|
169
|
+
fix: 'raise max_payload in the nats-server config, or split the change into smaller frames',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
await this.#stream.write(pubMessage({ subject, payload, ...options }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async subscribe(
|
|
176
|
+
subject: string,
|
|
177
|
+
handler: NatsMessageHandler,
|
|
178
|
+
queue?: string,
|
|
179
|
+
): Promise<NatsSubscription> {
|
|
180
|
+
this.#assertOpen();
|
|
181
|
+
assertSubject(subject, SUBSCRIBE_SUBJECT, 'subscribe');
|
|
182
|
+
this.#sid += 1;
|
|
183
|
+
const sid = String(this.#sid);
|
|
184
|
+
await this.#stream.write(subMessage(subject, sid, queue));
|
|
185
|
+
// Registered only once the SUB is on the wire: a handler behind a rejected write is one no
|
|
186
|
+
// server ever feeds, and `subscriptionCount` would hand it to the reconnect path as live.
|
|
187
|
+
this.#handlers.set(sid, handler);
|
|
188
|
+
return {
|
|
189
|
+
sid,
|
|
190
|
+
subject,
|
|
191
|
+
unsubscribe: async () => {
|
|
192
|
+
if (!this.#handlers.delete(sid) || this.#closed) return;
|
|
193
|
+
await this.#stream.write(unsubMessage(sid));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One request, one reply. `no_responders` turns a subject nobody serves into a 503, not a hang. */
|
|
199
|
+
async request(
|
|
200
|
+
subject: string,
|
|
201
|
+
payload: Uint8Array,
|
|
202
|
+
options: { readonly headers?: NatsHeaders; readonly timeoutMs?: number } = {},
|
|
203
|
+
): Promise<NatsMessage> {
|
|
204
|
+
const replies = await this.requestMany(subject, payload, { ...options, until: () => true });
|
|
205
|
+
const first = replies[0];
|
|
206
|
+
if (first === undefined) {
|
|
207
|
+
throw new TransportUnavailableError({
|
|
208
|
+
transport: 'nats',
|
|
209
|
+
reason: `no reply arrived on ${subject}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return first;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A request whose answer is several messages — a JetStream batch read. `until` decides which
|
|
217
|
+
* message ends the run, so the terminator stays with the API that defines it.
|
|
218
|
+
*/
|
|
219
|
+
async requestMany(
|
|
220
|
+
subject: string,
|
|
221
|
+
payload: Uint8Array,
|
|
222
|
+
options: {
|
|
223
|
+
readonly headers?: NatsHeaders;
|
|
224
|
+
readonly timeoutMs?: number;
|
|
225
|
+
readonly until: (message: NatsMessage) => boolean;
|
|
226
|
+
},
|
|
227
|
+
): Promise<readonly NatsMessage[]> {
|
|
228
|
+
this.#assertOpen();
|
|
229
|
+
await this.#ensureInbox();
|
|
230
|
+
this.#reply += 1;
|
|
231
|
+
const token = String(this.#reply);
|
|
232
|
+
const collected: NatsMessage[] = [];
|
|
233
|
+
const answer = new Promise<readonly NatsMessage[]>((resolve, reject) => {
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
this.#pending.delete(token);
|
|
236
|
+
reject(
|
|
237
|
+
new TransportUnavailableError({
|
|
238
|
+
transport: 'nats',
|
|
239
|
+
reason: `${subject} did not answer within ${options.timeoutMs ?? this.#requestTimeoutMs}ms`,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
}, options.timeoutMs ?? this.#requestTimeoutMs);
|
|
243
|
+
this.#pending.set(token, {
|
|
244
|
+
collect: (message) => {
|
|
245
|
+
collected.push(message);
|
|
246
|
+
if (!options.until(message)) return false;
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
this.#pending.delete(token);
|
|
249
|
+
resolve(collected);
|
|
250
|
+
return true;
|
|
251
|
+
},
|
|
252
|
+
settle: (error) => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
reject(error);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
await this.publish(subject, payload, {
|
|
260
|
+
replyTo: `${this.#inbox}.${token}`,
|
|
261
|
+
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// `answer` already carries a live timer and nobody downstream will await it now, so it is
|
|
265
|
+
// settled and swallowed here — otherwise the timer rejects into an unhandled rejection one
|
|
266
|
+
// whole timeout later, long after the caller saw the real cause.
|
|
267
|
+
this.#pending.get(token)?.settle(error);
|
|
268
|
+
this.#pending.delete(token);
|
|
269
|
+
await answer.catch(() => undefined);
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
return await answer;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** PING/PONG round trip: the server has processed everything written before it. */
|
|
276
|
+
async flush(): Promise<void> {
|
|
277
|
+
this.#assertOpen();
|
|
278
|
+
// The PING goes first: a resolver parked behind a rejected write would be handed the next PONG
|
|
279
|
+
// to arrive and report as flushed bytes the server never saw.
|
|
280
|
+
await this.#stream.write(PING_MESSAGE);
|
|
281
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
282
|
+
const flushed = new Promise<void>((resolve, reject) => {
|
|
283
|
+
const parked = (): void => resolve();
|
|
284
|
+
this.#pongs.push(parked);
|
|
285
|
+
timer = setTimeout(() => {
|
|
286
|
+
// Dropped from the queue first, or a late PONG resolves whoever now sits at its head.
|
|
287
|
+
const index = this.#pongs.indexOf(parked);
|
|
288
|
+
if (index >= 0) this.#pongs.splice(index, 1);
|
|
289
|
+
reject(
|
|
290
|
+
new TransportUnavailableError({
|
|
291
|
+
transport: 'nats',
|
|
292
|
+
reason: `the server did not answer PING within ${this.#requestTimeoutMs}ms`,
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
}, this.#requestTimeoutMs);
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
await flushed;
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async close(): Promise<void> {
|
|
305
|
+
if (this.#closed) return;
|
|
306
|
+
this.#closed = true;
|
|
307
|
+
this.#handlers.clear();
|
|
308
|
+
this.#stream.close();
|
|
309
|
+
this.#settleAll(
|
|
310
|
+
new TransportUnavailableError({ transport: 'nats', reason: 'the connection was closed' }),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#drain(): void {
|
|
315
|
+
for (;;) {
|
|
316
|
+
const operation = this.#parser.next();
|
|
317
|
+
if (operation === undefined) return;
|
|
318
|
+
this.#dispatch(operation);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async #readLoop(): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
for (;;) {
|
|
325
|
+
const chunk = await this.#stream.read();
|
|
326
|
+
if (chunk === undefined) break;
|
|
327
|
+
this.#parser.push(chunk);
|
|
328
|
+
this.#drain();
|
|
329
|
+
if (this.#closed) return;
|
|
330
|
+
}
|
|
331
|
+
this.#fail(
|
|
332
|
+
new TransportUnavailableError({
|
|
333
|
+
transport: 'nats',
|
|
334
|
+
reason: 'the server closed the socket',
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.#fail(error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#dispatch(operation: ReturnType<NatsProtocolParser['next']>): void {
|
|
343
|
+
if (operation === undefined) return;
|
|
344
|
+
switch (operation.kind) {
|
|
345
|
+
case 'info':
|
|
346
|
+
// A cluster hands out a new INFO whenever the topology changes; the payload cap can move.
|
|
347
|
+
this.#info = operation.info;
|
|
348
|
+
return;
|
|
349
|
+
case 'ping':
|
|
350
|
+
void this.#stream.write(PONG_MESSAGE).catch((error: unknown) => this.#onError(error));
|
|
351
|
+
return;
|
|
352
|
+
case 'pong':
|
|
353
|
+
this.#pongs.shift()?.();
|
|
354
|
+
return;
|
|
355
|
+
case 'ok':
|
|
356
|
+
return;
|
|
357
|
+
case 'err':
|
|
358
|
+
this.#complain(operation.detail);
|
|
359
|
+
return;
|
|
360
|
+
case 'msg':
|
|
361
|
+
this.#deliver(operation.message);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#deliver(message: NatsMessage): void {
|
|
366
|
+
const waiting = this.#pending.get(message.subject.slice(this.#inbox.length + 1));
|
|
367
|
+
if (message.subject.startsWith(`${this.#inbox}.`) && waiting) {
|
|
368
|
+
waiting.collect(message);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
this.#handlers.get(message.sid)?.(message);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A permissions violation kills one subject; everything else kills the session. */
|
|
375
|
+
#complain(detail: string): void {
|
|
376
|
+
const error = new TransportUnavailableError({ transport: 'nats', reason: detail });
|
|
377
|
+
if (/permissions violation/i.test(detail)) {
|
|
378
|
+
this.#onError(error);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
this.#fail(error);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#fail(error: unknown): void {
|
|
385
|
+
if (this.#closed) return;
|
|
386
|
+
this.#closed = true;
|
|
387
|
+
this.#handlers.clear();
|
|
388
|
+
this.#stream.close();
|
|
389
|
+
this.#settleAll(error);
|
|
390
|
+
this.#onClose(error);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#settleAll(error: unknown): void {
|
|
394
|
+
for (const pending of this.#pending.values()) pending.settle(error);
|
|
395
|
+
this.#pending.clear();
|
|
396
|
+
// A parked `flush()` would otherwise outlive the connection it was waiting on.
|
|
397
|
+
while (this.#pongs.length > 0) this.#pongs.shift()?.();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async #ensureInbox(): Promise<void> {
|
|
401
|
+
if (this.#inboxReady) return;
|
|
402
|
+
await this.#stream.write(subMessage(`${this.#inbox}.*`, '0'));
|
|
403
|
+
// Latched only after the write lands, so a refused SUB is retried by the next request rather
|
|
404
|
+
// than leaving every one of them to report "did not answer" against an inbox nobody serves.
|
|
405
|
+
// Two first requests racing here write the SUB twice, which the server ignores as a duplicate.
|
|
406
|
+
this.#inboxReady = true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#assertOpen(): void {
|
|
410
|
+
if (this.#closed) {
|
|
411
|
+
throw new TransportUnavailableError({
|
|
412
|
+
transport: 'nats',
|
|
413
|
+
reason: 'the connection is closed',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The first operation on any NATS connection is INFO — nothing else is legal before it. */
|
|
420
|
+
async function readInfo(stream: NatsStream, parser: NatsProtocolParser): Promise<NatsServerInfo> {
|
|
421
|
+
for (;;) {
|
|
422
|
+
const operation = parser.next();
|
|
423
|
+
if (operation?.kind === 'info') return operation.info;
|
|
424
|
+
if (operation !== undefined) {
|
|
425
|
+
throw new TransportProtocolError({
|
|
426
|
+
transport: 'nats',
|
|
427
|
+
stage: 'handshake',
|
|
428
|
+
detail: `the server opened with "${operation.kind}" rather than INFO`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const chunk = await stream.read();
|
|
432
|
+
if (chunk === undefined) {
|
|
433
|
+
throw new TransportUnavailableError({
|
|
434
|
+
transport: 'nats',
|
|
435
|
+
reason: 'the server closed the connection before sending INFO',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
parser.push(chunk);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The PONG that proves CONNECT was accepted; an `-ERR` here is always a credentials problem. */
|
|
443
|
+
async function expectPong(stream: NatsStream, parser: NatsProtocolParser): Promise<void> {
|
|
444
|
+
for (;;) {
|
|
445
|
+
const operation = parser.next();
|
|
446
|
+
if (operation?.kind === 'pong') return;
|
|
447
|
+
if (operation?.kind === 'err') {
|
|
448
|
+
throw new TransportUnavailableError({
|
|
449
|
+
transport: 'nats',
|
|
450
|
+
reason: `the server refused CONNECT: ${operation.detail}`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (operation === undefined) {
|
|
454
|
+
const chunk = await stream.read();
|
|
455
|
+
if (chunk === undefined) {
|
|
456
|
+
throw new TransportUnavailableError({
|
|
457
|
+
transport: 'nats',
|
|
458
|
+
reason: 'the server closed the connection during the handshake',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
parser.push(chunk);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|