@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
package/src/nats-kv.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Single responsibility: `TransportSet` over a JetStream KV bucket — the shared, TTL'd keyed sets
|
|
2
|
+
// presence lives in. A member is one KV key, so a node that dies stops heartbeating and its members
|
|
3
|
+
// expire on the server's own clock; nothing has to be cleaned up by whoever notices the loss.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { TransportSet, TransportSetEntry } from './fanout';
|
|
7
|
+
import type { NatsConnection } from './nats-connection';
|
|
8
|
+
import { kvGet, kvLast, kvWrite } from './nats-jetstream';
|
|
9
|
+
|
|
10
|
+
/** Per-message TTL is expressed in whole seconds, and must never expire before the logical one. */
|
|
11
|
+
const TTL_GRACE_SECONDS = 1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A presence key and a member id are user data — a topic name carries dots, a socket id can carry
|
|
15
|
+
* anything — and a subject token may not. base64url over UTF-8 bytes is reversible and lands inside
|
|
16
|
+
* both the subject grammar and the KV key charset, so no name has to be rejected for its spelling.
|
|
17
|
+
*/
|
|
18
|
+
export function encodeToken(text: string): string {
|
|
19
|
+
const bytes = new TextEncoder().encode(text);
|
|
20
|
+
let binary = '';
|
|
21
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
22
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decodeToken(token: string): string {
|
|
26
|
+
const padded = token.replaceAll('-', '+').replaceAll('_', '/');
|
|
27
|
+
const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '='));
|
|
28
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StoredValue {
|
|
32
|
+
/** The caller's opaque value. */
|
|
33
|
+
readonly v: string;
|
|
34
|
+
/** The TTL it was written with, so expiry can be recomputed from the server's write time. */
|
|
35
|
+
readonly t: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parseStored = (raw: string): StoredValue | undefined => {
|
|
39
|
+
try {
|
|
40
|
+
const parsed: unknown = JSON.parse(raw);
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
42
|
+
const shape = parsed as { v?: unknown; t?: unknown };
|
|
43
|
+
if (typeof shape.v !== 'string' || typeof shape.t !== 'number') return undefined;
|
|
44
|
+
return { v: shape.v, t: shape.t };
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ttlHeader = (ttlMs: number): ReadonlyMap<string, string> =>
|
|
51
|
+
new Map([['Nats-TTL', String(Math.ceil(ttlMs / 1_000) + TTL_GRACE_SECONDS)]]);
|
|
52
|
+
|
|
53
|
+
export interface NatsKvSetOptions {
|
|
54
|
+
/** The live connection. Awaited per call, because the transport replaces it on a reconnect. */
|
|
55
|
+
readonly connection: () => Promise<NatsConnection>;
|
|
56
|
+
readonly bucket: string;
|
|
57
|
+
/** Only the fallback when a reply carries no server timestamp; the server's clock is the truth. */
|
|
58
|
+
readonly clock: Clock;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Presence's shared state. One KV key per `<set>.<member>`, one JetStream ack per write. */
|
|
62
|
+
export class NatsKvSet implements TransportSet {
|
|
63
|
+
readonly #options: NatsKvSetOptions;
|
|
64
|
+
|
|
65
|
+
constructor(options: NatsKvSetOptions) {
|
|
66
|
+
this.#options = options;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async put(key: string, member: string, value: string, ttlMs: number): Promise<void> {
|
|
70
|
+
const stored: StoredValue = { v: value, t: ttlMs };
|
|
71
|
+
await kvWrite(
|
|
72
|
+
await this.#options.connection(),
|
|
73
|
+
this.#options.bucket,
|
|
74
|
+
this.#key(key, member),
|
|
75
|
+
JSON.stringify(stored),
|
|
76
|
+
ttlHeader(ttlMs),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `false` when the member had already expired: the caller must re-`put`, which is a re-join. */
|
|
81
|
+
async touch(key: string, member: string, ttlMs: number): Promise<boolean> {
|
|
82
|
+
const record = await kvGet(
|
|
83
|
+
await this.#options.connection(),
|
|
84
|
+
this.#options.bucket,
|
|
85
|
+
this.#key(key, member),
|
|
86
|
+
);
|
|
87
|
+
if (record === undefined || record.operation !== undefined) return false;
|
|
88
|
+
const stored = parseStored(record.value);
|
|
89
|
+
if (stored === undefined) return false;
|
|
90
|
+
if (this.#expiresAt(record.writtenAt, stored.t) <= this.#now()) return false;
|
|
91
|
+
await this.put(key, member, stored.v, ttlMs);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A tombstone rather than a stream delete: the bucket denies deletes so history cannot be
|
|
97
|
+
* rewritten, and the marker carries the shortest legal TTL so it clears itself straight after.
|
|
98
|
+
*/
|
|
99
|
+
async drop(key: string, member: string): Promise<void> {
|
|
100
|
+
await kvWrite(
|
|
101
|
+
await this.#options.connection(),
|
|
102
|
+
this.#options.bucket,
|
|
103
|
+
this.#key(key, member),
|
|
104
|
+
'',
|
|
105
|
+
new Map([
|
|
106
|
+
['KV-Operation', 'DEL'],
|
|
107
|
+
['Nats-TTL', String(TTL_GRACE_SECONDS)],
|
|
108
|
+
]),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async entries(key: string): Promise<readonly TransportSetEntry[]> {
|
|
113
|
+
const records = await kvLast(
|
|
114
|
+
await this.#options.connection(),
|
|
115
|
+
this.#options.bucket,
|
|
116
|
+
`${encodeToken(key)}.*`,
|
|
117
|
+
);
|
|
118
|
+
const now = this.#now();
|
|
119
|
+
const live: TransportSetEntry[] = [];
|
|
120
|
+
for (const record of records) {
|
|
121
|
+
if (record.operation !== undefined) continue;
|
|
122
|
+
const stored = parseStored(record.value);
|
|
123
|
+
const member = this.#member(record.key);
|
|
124
|
+
if (stored === undefined || member === undefined) continue;
|
|
125
|
+
const expiresAt = this.#expiresAt(record.writtenAt, stored.t);
|
|
126
|
+
if (expiresAt > now) live.push({ member, value: stored.v, expiresAt });
|
|
127
|
+
}
|
|
128
|
+
return live;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#key(key: string, member: string): string {
|
|
132
|
+
return `${encodeToken(key)}.${encodeToken(member)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Token `[1]` is the member — `#key` writes exactly two, because `encodeToken` emits no dot.
|
|
137
|
+
* A key this class did not write is skipped rather than read: the bucket may hold anything, and
|
|
138
|
+
* one foreign key must not take the whole presence listing down with it.
|
|
139
|
+
*/
|
|
140
|
+
#member(kvKey: string): string | undefined {
|
|
141
|
+
const token = kvKey.split('.')[1];
|
|
142
|
+
if (token === undefined) return undefined;
|
|
143
|
+
try {
|
|
144
|
+
return decodeToken(token);
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#expiresAt(writtenAt: number | undefined, ttlMs: number): number {
|
|
151
|
+
return (writtenAt ?? this.#now()) + ttlMs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#now(): number {
|
|
155
|
+
return this.#options.clock.now().getTime();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Single responsibility: the NATS client protocol codec — pure encode/decode of the text
|
|
2
|
+
// protocol nats-server speaks on port 4222. No sockets, no timers, no randomness: chunks of
|
|
3
|
+
// bytes in, whole operations out, so the socket layer can be tested with no network at all.
|
|
4
|
+
|
|
5
|
+
import { TransportProtocolError } from './errors';
|
|
6
|
+
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
const CR = 0x0d;
|
|
9
|
+
const LF = 0x0a;
|
|
10
|
+
|
|
11
|
+
/** A single declared payload is bounded so a corrupt byte count cannot allocate the machine. */
|
|
12
|
+
const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const protocolError = (stage: 'read' | 'headers', detail: string): TransportProtocolError =>
|
|
15
|
+
new TransportProtocolError({ transport: 'nats', stage, detail });
|
|
16
|
+
const missingArg = (label: string): never => {
|
|
17
|
+
throw protocolError('read', `missing ${label}`);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface NatsServerInfo {
|
|
21
|
+
readonly serverId: string;
|
|
22
|
+
readonly version: string;
|
|
23
|
+
readonly maxPayload: number;
|
|
24
|
+
readonly tlsRequired: boolean;
|
|
25
|
+
readonly tlsAvailable: boolean;
|
|
26
|
+
readonly authRequired: boolean;
|
|
27
|
+
readonly headers: boolean;
|
|
28
|
+
readonly nonce: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
export type NatsHeaders = ReadonlyMap<string, string>;
|
|
31
|
+
export interface NatsMessage {
|
|
32
|
+
readonly subject: string;
|
|
33
|
+
readonly sid: string;
|
|
34
|
+
readonly replyTo: string | undefined;
|
|
35
|
+
readonly payload: Uint8Array;
|
|
36
|
+
readonly headers: NatsHeaders;
|
|
37
|
+
/** From `NATS/1.0 <code> <description>`; `undefined` on a plain MSG or a status-less header. */
|
|
38
|
+
readonly status: number | undefined;
|
|
39
|
+
readonly description: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
export type NatsOperation =
|
|
42
|
+
| { readonly kind: 'info'; readonly info: NatsServerInfo }
|
|
43
|
+
| { readonly kind: 'msg'; readonly message: NatsMessage }
|
|
44
|
+
| { readonly kind: 'ping' }
|
|
45
|
+
| { readonly kind: 'pong' }
|
|
46
|
+
| { readonly kind: 'ok' }
|
|
47
|
+
| { readonly kind: 'err'; readonly detail: string };
|
|
48
|
+
|
|
49
|
+
const EMPTY_HEADERS: NatsHeaders = new Map<string, string>();
|
|
50
|
+
const indexOfCrlf = (bytes: Uint8Array, from: number): number => {
|
|
51
|
+
for (let i = from; i < bytes.length - 1; i += 1) {
|
|
52
|
+
if (bytes[i] === CR && bytes[i + 1] === LF) return i;
|
|
53
|
+
}
|
|
54
|
+
return -1;
|
|
55
|
+
};
|
|
56
|
+
export const concatBytes = (...parts: readonly Uint8Array[]): Uint8Array => {
|
|
57
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
58
|
+
const joined = new Uint8Array(total);
|
|
59
|
+
let at = 0;
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
joined.set(part, at);
|
|
62
|
+
at += part.length;
|
|
63
|
+
}
|
|
64
|
+
return joined;
|
|
65
|
+
};
|
|
66
|
+
const splitArgs = (text: string): string[] =>
|
|
67
|
+
text.split(/[ \t]+/).filter((part) => part.length > 0);
|
|
68
|
+
const parseByteCount = (text: string | undefined): number => {
|
|
69
|
+
if (text === undefined || !/^\d+$/.test(text)) {
|
|
70
|
+
throw protocolError('read', `expected a byte count, got "${text ?? ''}"`);
|
|
71
|
+
}
|
|
72
|
+
return Number(text);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const parseInfo = (json: string): NatsServerInfo => {
|
|
76
|
+
let parsed: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(json);
|
|
79
|
+
} catch {
|
|
80
|
+
throw protocolError('read', `INFO json did not parse: "${json}"`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
83
|
+
throw protocolError('read', `INFO json was not an object: "${json}"`);
|
|
84
|
+
}
|
|
85
|
+
const info = parsed as Record<string, unknown>;
|
|
86
|
+
return {
|
|
87
|
+
serverId: typeof info['server_id'] === 'string' ? info['server_id'] : '',
|
|
88
|
+
version: typeof info['version'] === 'string' ? info['version'] : '',
|
|
89
|
+
maxPayload: typeof info['max_payload'] === 'number' ? info['max_payload'] : 1_048_576,
|
|
90
|
+
tlsRequired: info['tls_required'] === true,
|
|
91
|
+
tlsAvailable: info['tls_available'] === true,
|
|
92
|
+
authRequired: info['auth_required'] === true,
|
|
93
|
+
headers: info['headers'] === true,
|
|
94
|
+
nonce: typeof info['nonce'] === 'string' ? info['nonce'] : undefined,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Parses an HMSG/HPUB header block: `NATS/1.0[ <code> <description>]\r\nKey: Value\r\n...\r\n\r\n`. */
|
|
99
|
+
export function parseHeaders(bytes: Uint8Array): {
|
|
100
|
+
readonly headers: ReadonlyMap<string, string>;
|
|
101
|
+
readonly status: number | undefined;
|
|
102
|
+
readonly description: string | undefined;
|
|
103
|
+
} {
|
|
104
|
+
const lines = decoder.decode(bytes).split('\r\n');
|
|
105
|
+
const first = lines[0] ?? '';
|
|
106
|
+
if (!first.startsWith('NATS/1.0')) {
|
|
107
|
+
throw protocolError('headers', `header block did not start with NATS/1.0: "${first}"`);
|
|
108
|
+
}
|
|
109
|
+
const statusPart = first.slice('NATS/1.0'.length).trim();
|
|
110
|
+
let status: number | undefined;
|
|
111
|
+
let description: string | undefined;
|
|
112
|
+
if (statusPart.length > 0) {
|
|
113
|
+
const spaceAt = statusPart.search(/\s/);
|
|
114
|
+
const code = spaceAt < 0 ? statusPart : statusPart.slice(0, spaceAt);
|
|
115
|
+
if (!/^\d{3}$/.test(code)) {
|
|
116
|
+
throw protocolError('headers', `status code was not 3 digits: "${code}"`);
|
|
117
|
+
}
|
|
118
|
+
status = Number(code);
|
|
119
|
+
description = spaceAt < 0 ? '' : statusPart.slice(spaceAt + 1).trim();
|
|
120
|
+
}
|
|
121
|
+
const headers = new Map<string, string>();
|
|
122
|
+
for (const line of lines.slice(1)) {
|
|
123
|
+
if (line.length === 0) continue;
|
|
124
|
+
const colonAt = line.indexOf(':');
|
|
125
|
+
if (colonAt < 0) continue;
|
|
126
|
+
headers.set(line.slice(0, colonAt).trim().toLowerCase(), line.slice(colonAt + 1).trim());
|
|
127
|
+
}
|
|
128
|
+
return { headers, status, description };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Chunks in, whole operations out. A TCP read boundary lands anywhere. */
|
|
132
|
+
export class NatsProtocolParser {
|
|
133
|
+
#buffer: Uint8Array = new Uint8Array(0);
|
|
134
|
+
|
|
135
|
+
push(chunk: Uint8Array): void {
|
|
136
|
+
this.#buffer = this.#buffer.length === 0 ? chunk : concatBytes(this.#buffer, chunk);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Bytes read but not yet consumed — what a partial frame is holding. */
|
|
140
|
+
get buffered(): number {
|
|
141
|
+
return this.#buffer.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The next complete operation, or `undefined` when more bytes are needed. */
|
|
145
|
+
next(): NatsOperation | undefined {
|
|
146
|
+
const buffer = this.#buffer;
|
|
147
|
+
const lineEnd = indexOfCrlf(buffer, 0);
|
|
148
|
+
if (lineEnd < 0) return undefined;
|
|
149
|
+
const line = decoder.decode(buffer.subarray(0, lineEnd));
|
|
150
|
+
const spaceAt = line.search(/[ \t]/);
|
|
151
|
+
const verb = (spaceAt < 0 ? line : line.slice(0, spaceAt)).toUpperCase();
|
|
152
|
+
const rest = spaceAt < 0 ? '' : line.slice(spaceAt + 1).trim();
|
|
153
|
+
if (verb === 'MSG' || verb === 'HMSG') {
|
|
154
|
+
return this.#takeMessage(lineEnd, rest, verb === 'HMSG');
|
|
155
|
+
}
|
|
156
|
+
this.#buffer = buffer.subarray(lineEnd + 2);
|
|
157
|
+
switch (verb) {
|
|
158
|
+
case 'INFO':
|
|
159
|
+
return { kind: 'info', info: parseInfo(rest) };
|
|
160
|
+
case 'PING':
|
|
161
|
+
return { kind: 'ping' };
|
|
162
|
+
case 'PONG':
|
|
163
|
+
return { kind: 'pong' };
|
|
164
|
+
case '+OK':
|
|
165
|
+
return { kind: 'ok' };
|
|
166
|
+
case '-ERR': {
|
|
167
|
+
const trimmed = rest.trim();
|
|
168
|
+
const quoted = trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'");
|
|
169
|
+
return { kind: 'err', detail: quoted ? trimmed.slice(1, -1) : trimmed };
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
throw protocolError('read', `unknown verb "${verb}"`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#takeMessage(lineEnd: number, rest: string, headered: boolean): NatsOperation | undefined {
|
|
177
|
+
const buffer = this.#buffer;
|
|
178
|
+
const args = splitArgs(rest);
|
|
179
|
+
const minArgs = headered ? 4 : 3;
|
|
180
|
+
const maxArgs = headered ? 5 : 4;
|
|
181
|
+
if (args.length !== minArgs && args.length !== maxArgs) {
|
|
182
|
+
const verb = headered ? 'HMSG' : 'MSG';
|
|
183
|
+
throw protocolError('read', `${verb} wants ${minArgs} or ${maxArgs} args, got "${rest}"`);
|
|
184
|
+
}
|
|
185
|
+
const hasReply = args.length === maxArgs;
|
|
186
|
+
const subject = args[0] ?? missingArg('subject');
|
|
187
|
+
const sid = args[1] ?? missingArg('sid');
|
|
188
|
+
const replyTo = hasReply ? (args[2] ?? missingArg('reply-to')) : undefined;
|
|
189
|
+
const headerBytes = headered ? parseByteCount(args[hasReply ? 3 : 2]) : 0;
|
|
190
|
+
const totalBytes = parseByteCount(args[args.length - 1]);
|
|
191
|
+
if (headered && totalBytes < headerBytes) {
|
|
192
|
+
throw protocolError('read', `HMSG total ${totalBytes} is smaller than header ${headerBytes}`);
|
|
193
|
+
}
|
|
194
|
+
if (totalBytes > MAX_PAYLOAD_BYTES) {
|
|
195
|
+
throw protocolError('read', `payload of ${totalBytes} bytes exceeds ${MAX_PAYLOAD_BYTES}`);
|
|
196
|
+
}
|
|
197
|
+
const payloadStart = lineEnd + 2;
|
|
198
|
+
const headerEnd = payloadStart + headerBytes;
|
|
199
|
+
const payloadEnd = payloadStart + totalBytes;
|
|
200
|
+
const end = payloadEnd + 2;
|
|
201
|
+
if (buffer.length < end) return undefined;
|
|
202
|
+
if (buffer[payloadEnd] !== CR || buffer[payloadEnd + 1] !== LF) {
|
|
203
|
+
throw protocolError('read', `payload was not followed by CRLF at offset ${payloadEnd}`);
|
|
204
|
+
}
|
|
205
|
+
const { headers, status, description } = headered
|
|
206
|
+
? parseHeaders(buffer.subarray(payloadStart, headerEnd))
|
|
207
|
+
: { headers: EMPTY_HEADERS, status: undefined, description: undefined };
|
|
208
|
+
this.#buffer = buffer.subarray(end);
|
|
209
|
+
return {
|
|
210
|
+
kind: 'msg',
|
|
211
|
+
message: {
|
|
212
|
+
subject,
|
|
213
|
+
sid,
|
|
214
|
+
replyTo,
|
|
215
|
+
payload: buffer.subarray(headerEnd, payloadEnd),
|
|
216
|
+
headers,
|
|
217
|
+
status,
|
|
218
|
+
description,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Single responsibility: the one production `NatsStream`, over `Bun.connect` — plus the URL
|
|
2
|
+
// parsing. NATS sends its INFO line in cleartext before any TLS decision, so the upgrade is a
|
|
3
|
+
// method the caller invokes after reading it, never a handshake negotiated here at connect time.
|
|
4
|
+
|
|
5
|
+
import { TransportUnavailableError } from './errors';
|
|
6
|
+
import type { BunConnect, SocketHandlers, SocketLike } from './pg-socket';
|
|
7
|
+
|
|
8
|
+
export interface NatsTarget {
|
|
9
|
+
readonly host: string;
|
|
10
|
+
readonly port: number; // default 4222
|
|
11
|
+
/** `tls://` demands TLS; `nats://` still upgrades when the server's INFO says it is required. */
|
|
12
|
+
readonly tls: boolean;
|
|
13
|
+
readonly user: string | undefined;
|
|
14
|
+
readonly pass: string | undefined;
|
|
15
|
+
/** `nats://token@host` — NATS' single-credential form, mutually exclusive with user/pass. */
|
|
16
|
+
readonly token: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PORT = 4222;
|
|
20
|
+
|
|
21
|
+
/** `nats://user:pass@host:4222`. The one place a bus URL is read. */
|
|
22
|
+
export function parseNatsUrl(url: string): NatsTarget {
|
|
23
|
+
let parsed: URL;
|
|
24
|
+
try {
|
|
25
|
+
parsed = new URL(url);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new TransportUnavailableError({
|
|
28
|
+
transport: 'nats',
|
|
29
|
+
reason: `"${url}" is not a connection URL`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (parsed.protocol !== 'nats:' && parsed.protocol !== 'tls:') {
|
|
33
|
+
throw new TransportUnavailableError({
|
|
34
|
+
transport: 'nats',
|
|
35
|
+
reason: `the connection URL uses "${parsed.protocol}" rather than nats: or tls:`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (parsed.hostname === '') {
|
|
39
|
+
throw new TransportUnavailableError({
|
|
40
|
+
transport: 'nats',
|
|
41
|
+
reason: `"${url}" has no host`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const hasUser = parsed.username !== '';
|
|
45
|
+
const hasPass = parsed.password !== '';
|
|
46
|
+
// A password with no user matches neither credential form, and dropping it silently connects
|
|
47
|
+
// anonymously — the failure then surfaces as the server's own 'Authorization Violation', which
|
|
48
|
+
// names nothing about the URL. The URL itself is never echoed back: it holds the secret.
|
|
49
|
+
if (!hasUser && hasPass) {
|
|
50
|
+
throw new TransportUnavailableError({
|
|
51
|
+
transport: 'nats',
|
|
52
|
+
reason: `the connection URL for ${parsed.hostname} carries a password with no user`,
|
|
53
|
+
fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const user = hasUser ? decodeURIComponent(parsed.username) : undefined;
|
|
57
|
+
const pass = hasPass ? decodeURIComponent(parsed.password) : undefined;
|
|
58
|
+
return {
|
|
59
|
+
host: parsed.hostname,
|
|
60
|
+
port: parsed.port === '' ? DEFAULT_PORT : Number.parseInt(parsed.port, 10),
|
|
61
|
+
tls: parsed.protocol === 'tls:',
|
|
62
|
+
// A username with no password is NATS' bare-token form; a password makes it user/pass instead.
|
|
63
|
+
user: hasUser && hasPass ? user : undefined,
|
|
64
|
+
pass: hasUser && hasPass ? pass : undefined,
|
|
65
|
+
token: hasUser && !hasPass ? user : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The byte pipe a NATS connection runs over. Mirrors `PgStream`, plus the late TLS upgrade. */
|
|
70
|
+
export interface NatsStream {
|
|
71
|
+
/** The next chunk the server sent, or `undefined` once it closed the connection. */
|
|
72
|
+
read(): Promise<Uint8Array | undefined>;
|
|
73
|
+
write(bytes: Uint8Array): Promise<void>;
|
|
74
|
+
/** In-band TLS. Must be called before any byte other than the server's INFO is exchanged. */
|
|
75
|
+
upgradeTls(): void;
|
|
76
|
+
close(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Waiter {
|
|
80
|
+
readonly resolve: (chunk: Uint8Array | undefined) => void;
|
|
81
|
+
readonly reject: (error: Error) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Chunks the socket pushed, handed out one `read()` at a time — EOF and socket errors included. */
|
|
85
|
+
class ChunkQueue {
|
|
86
|
+
readonly #chunks: Uint8Array[] = [];
|
|
87
|
+
#waiting: Waiter | undefined;
|
|
88
|
+
#ended = false;
|
|
89
|
+
#failure: Error | undefined;
|
|
90
|
+
|
|
91
|
+
push(chunk: Uint8Array): void {
|
|
92
|
+
const waiter = this.#take();
|
|
93
|
+
if (waiter === undefined) {
|
|
94
|
+
this.#chunks.push(chunk);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
waiter.resolve(chunk);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** EOF. A reader parked on `read()` is released rather than left hanging forever. */
|
|
101
|
+
end(): void {
|
|
102
|
+
this.#ended = true;
|
|
103
|
+
this.#take()?.resolve(undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fail(error: Error): void {
|
|
107
|
+
this.#failure = error;
|
|
108
|
+
this.#ended = true;
|
|
109
|
+
this.#take()?.reject(error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
read(): Promise<Uint8Array | undefined> {
|
|
113
|
+
const next = this.#chunks.shift();
|
|
114
|
+
if (next !== undefined) return Promise.resolve(next);
|
|
115
|
+
if (this.#failure !== undefined) return Promise.reject(this.#failure);
|
|
116
|
+
if (this.#ended) return Promise.resolve(undefined);
|
|
117
|
+
if (this.#waiting !== undefined) {
|
|
118
|
+
// One reader drives this queue — the handshake, then the session's single read loop.
|
|
119
|
+
// Overwriting the parked waiter would strand it: nothing left would ever settle its promise,
|
|
120
|
+
// so the caller hangs with no error and no deadline. Refusing the second reader names it.
|
|
121
|
+
return Promise.reject(
|
|
122
|
+
new TransportUnavailableError({
|
|
123
|
+
transport: 'nats',
|
|
124
|
+
reason: 'a second read() started while one was already parked on this stream',
|
|
125
|
+
fix: 'read a NatsStream from one place only: one stream feeds exactly one read loop',
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
this.#waiting = { resolve, reject };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#take(): Waiter | undefined {
|
|
135
|
+
const waiter = this.#waiting;
|
|
136
|
+
this.#waiting = undefined;
|
|
137
|
+
return waiter;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const bunNatsStream = (target: NatsTarget): Promise<NatsStream> =>
|
|
142
|
+
natsStreamOver(Bun as unknown as BunConnect, target);
|
|
143
|
+
|
|
144
|
+
/** `bunNatsStream` with the runtime handed in, so the whole path runs in a test with no network. */
|
|
145
|
+
export async function natsStreamOver(runtime: BunConnect, target: NatsTarget): Promise<NatsStream> {
|
|
146
|
+
const queue = new ChunkQueue();
|
|
147
|
+
let draining: (() => void) | undefined;
|
|
148
|
+
let upgraded = false;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A write parked for `drain` can never get one from a socket that is gone, so it is released
|
|
152
|
+
* here; it then fails on the next `write` rather than burning a whole deadline first. The read
|
|
153
|
+
* side ends cleanly because an EOF that matters is already an error one layer up.
|
|
154
|
+
*/
|
|
155
|
+
const died = (): void => {
|
|
156
|
+
const resume = draining;
|
|
157
|
+
draining = undefined;
|
|
158
|
+
resume?.();
|
|
159
|
+
queue.end();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handlers: SocketHandlers = {
|
|
163
|
+
// Copied before it is queued or handed on: Bun owns that buffer and gives no guarantee its
|
|
164
|
+
// contents survive the handler returning, while a queued chunk is read a whole tick later.
|
|
165
|
+
data: (_socket, data) => queue.push(data.slice()),
|
|
166
|
+
close: died,
|
|
167
|
+
end: died,
|
|
168
|
+
drain: () => {
|
|
169
|
+
const resume = draining;
|
|
170
|
+
draining = undefined;
|
|
171
|
+
resume?.();
|
|
172
|
+
},
|
|
173
|
+
error: (_socket, error) =>
|
|
174
|
+
queue.fail(
|
|
175
|
+
new TransportUnavailableError({
|
|
176
|
+
transport: 'nats',
|
|
177
|
+
reason: `${target.host}:${target.port} — ${error.message}`,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
let socket: SocketLike = await runtime.connect({
|
|
183
|
+
hostname: target.host,
|
|
184
|
+
port: target.port,
|
|
185
|
+
socket: handlers,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const write = async (bytes: Uint8Array): Promise<void> => {
|
|
189
|
+
let rest = bytes;
|
|
190
|
+
while (rest.length > 0) {
|
|
191
|
+
const written = socket.write(rest);
|
|
192
|
+
if (written >= rest.length) return;
|
|
193
|
+
// A negative count is a refusal, not backpressure: no `drain` follows a dead socket, so
|
|
194
|
+
// waiting for one would park this write forever.
|
|
195
|
+
if (written < 0) {
|
|
196
|
+
throw new TransportUnavailableError({
|
|
197
|
+
transport: 'nats',
|
|
198
|
+
reason: `the socket refused a ${rest.length}-byte write to ${target.host}:${target.port}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (written > 0) rest = rest.subarray(written);
|
|
202
|
+
await new Promise<void>((resolve) => {
|
|
203
|
+
draining = resolve;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
read: () => queue.read(),
|
|
210
|
+
write,
|
|
211
|
+
upgradeTls: () => {
|
|
212
|
+
// Marked before the attempt, not after: a failed upgrade still leaves the raw socket in an
|
|
213
|
+
// indeterminate TLS-negotiation state, so a retry is refused rather than risking a second
|
|
214
|
+
// ClientHello on top of the first.
|
|
215
|
+
if (upgraded) {
|
|
216
|
+
throw new TransportUnavailableError({
|
|
217
|
+
transport: 'nats',
|
|
218
|
+
reason: `upgradeTls() was already called for ${target.host}:${target.port}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
upgraded = true;
|
|
222
|
+
// Bun hands back `[raw, tls]`; every later read and write goes through the second one, and
|
|
223
|
+
// the handlers are re-registered because the upgraded socket is a different object.
|
|
224
|
+
const next = socket.upgradeTLS({ tls: { serverName: target.host }, socket: handlers })[1];
|
|
225
|
+
if (next === undefined) {
|
|
226
|
+
throw new TransportUnavailableError({
|
|
227
|
+
transport: 'nats',
|
|
228
|
+
reason: `the runtime returned no TLS socket for the upgrade to ${target.host}:${target.port}`,
|
|
229
|
+
fix: 'bun upgrade # in-band TLS needs bun >= 1.3',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
socket = next;
|
|
233
|
+
},
|
|
234
|
+
close: () => socket.end(),
|
|
235
|
+
};
|
|
236
|
+
}
|