@ultimat3/realtime 1.2.0 → 2.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/CLAUDE.md +591 -0
- package/README.md +320 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +174 -19
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +96 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +151 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +170 -14
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +284 -243
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/src/live-rows.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// One live subscription's window, projected out of the identity map. The registration owns the
|
|
2
|
+
// ORDER (its ids) and the map owns the VALUES — which is what makes post #7 one object however
|
|
3
|
+
// many queries returned it, and what makes a write through any of them reach all of them.
|
|
4
|
+
|
|
5
|
+
import { orderAfterPatches } from './apply-patches';
|
|
6
|
+
import type { LiveCursor } from './cursor';
|
|
7
|
+
import { type IdentityMap, type RowKey, type RowScope, rowKey } from './identity-map';
|
|
8
|
+
import type { JsonValue, Row, RowPatch } from './json';
|
|
9
|
+
|
|
10
|
+
export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
|
|
11
|
+
|
|
12
|
+
/** One live query this client holds. Mutable: the ids and cursor a frame advances live here. */
|
|
13
|
+
export interface Registration {
|
|
14
|
+
readonly sid: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly input: JsonValue;
|
|
17
|
+
readonly setRows: (rows: readonly Row[]) => void;
|
|
18
|
+
readonly setState: (state: LiveState) => void;
|
|
19
|
+
readonly setCursor: (cursor: LiveCursor | null) => void;
|
|
20
|
+
/** Where this window's rows live in the map: the entity the server named, or a private scope. */
|
|
21
|
+
scope: RowScope;
|
|
22
|
+
/** Membership and order. The values are the map's — never a second copy of them. */
|
|
23
|
+
ids: readonly string[];
|
|
24
|
+
cursor: LiveCursor | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every open window over one identity map. It is the only writer of `Registration.ids`, so the
|
|
29
|
+
* retain/release pairs that keep the map from growing without end cannot be forgotten by a caller.
|
|
30
|
+
*/
|
|
31
|
+
export class RowWindows {
|
|
32
|
+
readonly #identity: IdentityMap;
|
|
33
|
+
/** The window a write is running for, so its own listener does not emit the same rows twice. */
|
|
34
|
+
#writing: Registration | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(identity: IdentityMap) {
|
|
37
|
+
this.#identity = identity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start rendering this registration out of the map. The returned close releases its rows and
|
|
42
|
+
* drops its listener — an unsubscribed component must stop holding rows and stop hearing about
|
|
43
|
+
* them in the same call, or one of the two outlives the other.
|
|
44
|
+
*/
|
|
45
|
+
open(registration: Registration): () => void {
|
|
46
|
+
const unsubscribe = this.#identity.subscribe((changed) => {
|
|
47
|
+
if (this.#writing === registration) return;
|
|
48
|
+
if (!holds(registration, changed)) return;
|
|
49
|
+
this.#emit(registration);
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
unsubscribe();
|
|
53
|
+
this.#identity.batch(() => {
|
|
54
|
+
for (const id of registration.ids) this.#identity.release(registration.scope, id);
|
|
55
|
+
registration.ids = [];
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A snapshot: server truth for the whole window. `entity` is the scope the server named for this
|
|
62
|
+
* subscription — the first one that arrives upgrades a private scope to the shared one, which is
|
|
63
|
+
* what lets two different queries over one entity meet on the same row.
|
|
64
|
+
*/
|
|
65
|
+
snapshot(registration: Registration, entity: string | null, rows: readonly Row[]): void {
|
|
66
|
+
const scope = entity ?? registration.scope;
|
|
67
|
+
this.#reseat(
|
|
68
|
+
registration,
|
|
69
|
+
scope,
|
|
70
|
+
rows.map((row) => row.id),
|
|
71
|
+
(held) => {
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
if (held.has(row.id)) this.#identity.merge(scope, row.id, row);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A patch list: values merged into the map, membership and order folded over the ids. */
|
|
80
|
+
patch(registration: Registration, patches: readonly RowPatch[]): void {
|
|
81
|
+
const scope = registration.scope;
|
|
82
|
+
// A `delete` is this window losing the row, never the map losing it: another window holding
|
|
83
|
+
// the same row keeps it until its own delete arrives.
|
|
84
|
+
this.#reseat(registration, scope, orderAfterPatches(registration.ids, patches), (held) => {
|
|
85
|
+
for (const patch of patches) {
|
|
86
|
+
if (patch.op === 'delete' || patch.row === null) continue;
|
|
87
|
+
if (held.has(patch.id)) this.#identity.merge(scope, patch.id, patch.row);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The window's rows, in its order. Absent ids are skipped — a released row renders as gone. */
|
|
93
|
+
rows(registration: Registration): readonly Row[] {
|
|
94
|
+
const out: Row[] = [];
|
|
95
|
+
for (const id of registration.ids) {
|
|
96
|
+
const row = this.#identity.peek(registration.scope, id);
|
|
97
|
+
if (row !== undefined) out.push(row);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Move the window to `nextIds` under `scope`, writing values in between. One batch per frame,
|
|
104
|
+
* and one emit for the window that caused it.
|
|
105
|
+
*
|
|
106
|
+
* The retain comes before the write and the release after it, so a row this window keeps across
|
|
107
|
+
* the move never reaches zero holds and gets dropped out from under the value it is about to be
|
|
108
|
+
* given. `write` only touches ids the window ends up holding — a value nobody holds is a value
|
|
109
|
+
* no release will ever reclaim.
|
|
110
|
+
*/
|
|
111
|
+
#reseat(
|
|
112
|
+
registration: Registration,
|
|
113
|
+
scope: RowScope,
|
|
114
|
+
nextIds: readonly string[],
|
|
115
|
+
write: (held: ReadonlySet<string>) => void,
|
|
116
|
+
): void {
|
|
117
|
+
const previous = this.#writing;
|
|
118
|
+
this.#writing = registration;
|
|
119
|
+
try {
|
|
120
|
+
this.#identity.batch(() => {
|
|
121
|
+
for (const id of nextIds) this.#identity.retain(scope, id);
|
|
122
|
+
write(new Set(nextIds));
|
|
123
|
+
for (const id of registration.ids) this.#identity.release(registration.scope, id);
|
|
124
|
+
registration.scope = scope;
|
|
125
|
+
registration.ids = nextIds;
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
this.#writing = previous;
|
|
129
|
+
}
|
|
130
|
+
this.#emit(registration);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#emit(registration: Registration): void {
|
|
134
|
+
registration.setRows(this.rows(registration));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function holds(registration: Registration, changed: ReadonlySet<RowKey>): boolean {
|
|
139
|
+
for (const id of registration.ids) {
|
|
140
|
+
if (changed.has(rowKey(registration.scope, id))) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
package/src/local-store.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// `local` must be replayable — no I/O, no Date.now(), no Math.random() — because rebase replays it.
|
|
4
4
|
// That is why every write goes through a journal keyed by the mutation's idempotency key: rollback
|
|
5
5
|
// is "undo this key's journal in reverse", not "re-fetch and hope".
|
|
6
|
+
//
|
|
7
|
+
// A table owns MEMBERSHIP (which ids it holds); the shared `IdentityMap` owns the VALUES, so an
|
|
8
|
+
// optimistic write and the live query rendering that row are one row, not two copies.
|
|
6
9
|
|
|
7
10
|
import { NotImplementedError } from './errors';
|
|
8
|
-
import
|
|
11
|
+
import { IdentityMap } from './identity-map';
|
|
12
|
+
import type { Row } from './json';
|
|
9
13
|
|
|
10
14
|
export interface LocalTable<R extends Row = Row> {
|
|
11
15
|
get(id: string): R | undefined;
|
|
@@ -28,11 +32,17 @@ export type LocalTx<T extends TableMap = TableMap> = { readonly [K in keyof T]:
|
|
|
28
32
|
interface JournalEntry {
|
|
29
33
|
readonly table: string;
|
|
30
34
|
readonly id: string;
|
|
31
|
-
/** Row state before the write; `undefined` means "did not exist" (so undo =
|
|
35
|
+
/** Row state before the write; `undefined` means "did not exist" (so undo = drop membership). */
|
|
32
36
|
readonly before: Row | undefined;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface LocalStore<T extends TableMap = TableMap> {
|
|
40
|
+
/**
|
|
41
|
+
* The one map every row value lives in, shared with the live-query windows on the same client.
|
|
42
|
+
* A `LiveClient` reads it off the store rather than building its own — two identity maps in one
|
|
43
|
+
* client is the bug an identity map exists to prevent, one level up.
|
|
44
|
+
*/
|
|
45
|
+
readonly identity: IdentityMap;
|
|
36
46
|
readonly tx: LocalTx<T>;
|
|
37
47
|
table(name: string): LocalTable;
|
|
38
48
|
/** Runs `fn` while journalling every write under `key`, so it can be rolled back verbatim. */
|
|
@@ -51,13 +61,16 @@ export interface LocalStore<T extends TableMap = TableMap> {
|
|
|
51
61
|
* scoping) is implemented here; OPFS SQLite swaps the storage, not the semantics.
|
|
52
62
|
*/
|
|
53
63
|
export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalStore<T> {
|
|
54
|
-
|
|
64
|
+
/** Membership and nothing else: `#members.get('posts')` is which ids this table holds. */
|
|
65
|
+
readonly #members = new Map<string, Set<string>>();
|
|
55
66
|
readonly #journals = new Map<string, JournalEntry[]>();
|
|
56
67
|
#recordingKey: string | null = null;
|
|
57
68
|
|
|
69
|
+
readonly identity: IdentityMap;
|
|
58
70
|
readonly tx: LocalTx<T>;
|
|
59
71
|
|
|
60
|
-
constructor(tables: Readonly<Record<string, readonly Row[]>> = {}) {
|
|
72
|
+
constructor(tables: Readonly<Record<string, readonly Row[]>> = {}, identity = new IdentityMap()) {
|
|
73
|
+
this.identity = identity;
|
|
61
74
|
this.reset(tables);
|
|
62
75
|
const handler: ProxyHandler<Record<string, LocalTable>> = {
|
|
63
76
|
// Symbols (`Symbol.iterator`, `then`) must not resolve to a table, or awaiting a tx would
|
|
@@ -68,30 +81,40 @@ export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalSto
|
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
table(name: string): LocalTable {
|
|
71
|
-
const
|
|
84
|
+
const ids = this.#ids(name);
|
|
85
|
+
const read = (id: string): Row | undefined =>
|
|
86
|
+
ids.has(id) ? this.identity.peek(name, id) : undefined;
|
|
72
87
|
return {
|
|
73
|
-
get:
|
|
74
|
-
all: () =>
|
|
88
|
+
get: read,
|
|
89
|
+
all: () => {
|
|
90
|
+
const rows: Row[] = [];
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const row = this.identity.peek(name, id);
|
|
93
|
+
if (row !== undefined) rows.push(row);
|
|
94
|
+
}
|
|
95
|
+
return rows;
|
|
96
|
+
},
|
|
75
97
|
insert: (row) => {
|
|
76
|
-
this.#journal(name, row.id,
|
|
77
|
-
|
|
98
|
+
this.#journal(name, row.id, read(row.id));
|
|
99
|
+
this.#join(name, row.id, ids);
|
|
100
|
+
this.identity.set(name, row);
|
|
78
101
|
},
|
|
79
102
|
upsert: (row) => {
|
|
80
|
-
|
|
81
|
-
this.#
|
|
82
|
-
|
|
103
|
+
this.#journal(name, row.id, read(row.id));
|
|
104
|
+
this.#join(name, row.id, ids);
|
|
105
|
+
this.identity.merge(name, row.id, row);
|
|
83
106
|
},
|
|
84
107
|
update: (id, patch) => {
|
|
85
|
-
const current =
|
|
108
|
+
const current = read(id);
|
|
86
109
|
if (!current) return;
|
|
87
110
|
this.#journal(name, id, current);
|
|
88
|
-
|
|
111
|
+
this.identity.merge(name, id, patch(current));
|
|
89
112
|
},
|
|
90
113
|
delete: (id) => {
|
|
91
|
-
const current =
|
|
114
|
+
const current = read(id);
|
|
92
115
|
if (!current) return;
|
|
93
116
|
this.#journal(name, id, current);
|
|
94
|
-
|
|
117
|
+
this.#leave(name, id, ids);
|
|
95
118
|
},
|
|
96
119
|
};
|
|
97
120
|
}
|
|
@@ -101,7 +124,8 @@ export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalSto
|
|
|
101
124
|
this.#recordingKey = key;
|
|
102
125
|
if (!this.#journals.has(key)) this.#journals.set(key, []);
|
|
103
126
|
try {
|
|
104
|
-
|
|
127
|
+
// One notification for the whole twin: a mutator touching twenty rows is one render.
|
|
128
|
+
this.identity.batch(() => fn(this.tx));
|
|
105
129
|
} finally {
|
|
106
130
|
this.#recordingKey = previous;
|
|
107
131
|
}
|
|
@@ -110,13 +134,19 @@ export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalSto
|
|
|
110
134
|
rollback(key: string): void {
|
|
111
135
|
const journal = this.#journals.get(key);
|
|
112
136
|
if (!journal) return;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
this.identity.batch(() => {
|
|
138
|
+
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
139
|
+
const entry = journal[i];
|
|
140
|
+
if (!entry) continue;
|
|
141
|
+
const ids = this.#ids(entry.table);
|
|
142
|
+
if (entry.before === undefined) {
|
|
143
|
+
this.#leave(entry.table, entry.id, ids);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
this.#join(entry.table, entry.id, ids);
|
|
147
|
+
this.identity.set(entry.table, entry.before);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
120
150
|
this.#journals.delete(key);
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -129,25 +159,47 @@ export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalSto
|
|
|
129
159
|
}
|
|
130
160
|
|
|
131
161
|
snapshot(name: string): readonly Row[] {
|
|
132
|
-
return
|
|
162
|
+
return this.table(name).all();
|
|
133
163
|
}
|
|
134
164
|
|
|
135
165
|
reset(tables: Readonly<Record<string, readonly Row[]>>): void {
|
|
136
|
-
this
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
this.identity.batch(() => {
|
|
167
|
+
for (const [name, ids] of this.#members) {
|
|
168
|
+
for (const id of ids) this.identity.release(name, id);
|
|
169
|
+
}
|
|
170
|
+
this.#members.clear();
|
|
171
|
+
this.#journals.clear();
|
|
172
|
+
for (const [name, rows] of Object.entries(tables)) {
|
|
173
|
+
const ids = this.#ids(name);
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
this.#join(name, row.id, ids);
|
|
176
|
+
this.identity.set(name, row);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
141
180
|
}
|
|
142
181
|
|
|
143
|
-
#
|
|
144
|
-
const existing = this.#
|
|
182
|
+
#ids(name: string): Set<string> {
|
|
183
|
+
const existing = this.#members.get(name);
|
|
145
184
|
if (existing) return existing;
|
|
146
|
-
const created = new
|
|
147
|
-
this.#
|
|
185
|
+
const created = new Set<string>();
|
|
186
|
+
this.#members.set(name, created);
|
|
148
187
|
return created;
|
|
149
188
|
}
|
|
150
189
|
|
|
190
|
+
/** Membership is what holds a value in the map, so joining and retaining are one step. */
|
|
191
|
+
#join(name: string, id: string, ids: Set<string>): void {
|
|
192
|
+
if (ids.has(id)) return;
|
|
193
|
+
ids.add(id);
|
|
194
|
+
this.identity.retain(name, id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Leaving releases: the value survives only while a live window still holds the same row. */
|
|
198
|
+
#leave(name: string, id: string, ids: Set<string>): void {
|
|
199
|
+
if (!ids.delete(id)) return;
|
|
200
|
+
this.identity.release(name, id);
|
|
201
|
+
}
|
|
202
|
+
|
|
151
203
|
/** Only the *first* write to a row within one key is journalled — undo must reach the base state. */
|
|
152
204
|
#journal(table: string, id: string, before: Row | undefined): void {
|
|
153
205
|
const key = this.#recordingKey;
|
|
@@ -159,15 +211,6 @@ export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalSto
|
|
|
159
211
|
}
|
|
160
212
|
}
|
|
161
213
|
|
|
162
|
-
/** `undefined` in a patch means "leave it alone" — a row column is never set to undefined. */
|
|
163
|
-
function merge(row: Row, patch: Partial<Row>): Row {
|
|
164
|
-
const next: JsonObject = { ...row };
|
|
165
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
166
|
-
if (value !== undefined) next[key] = value;
|
|
167
|
-
}
|
|
168
|
-
return { ...next, id: row.id };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
214
|
export interface OpfsLocalStoreOptions {
|
|
172
215
|
/** OPFS file name, versioned so a client-side migration can run before first read. */
|
|
173
216
|
readonly file: string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Single responsibility: the bus port — the narrow NATS surface this package uses, and the URL that
|
|
2
|
+
// names a server. Everything above this file is written against the port, so framing, the parser,
|
|
3
|
+
// PING/PONG, the TLS upgrade and the reconnect belong to whoever implements it: `nats` in
|
|
4
|
+
// production (`nats-lib-client.ts`), an in-memory bus in a test (`nats-fake.ts`).
|
|
5
|
+
|
|
6
|
+
import { TransportUnavailableError } from './errors';
|
|
7
|
+
|
|
8
|
+
export type NatsHeaders = ReadonlyMap<string, string>;
|
|
9
|
+
|
|
10
|
+
export interface NatsMessage {
|
|
11
|
+
readonly subject: string;
|
|
12
|
+
readonly payload: Uint8Array;
|
|
13
|
+
/** `0` unless the reply carried a status: a direct read answers `404`, a batch ends on `204`. */
|
|
14
|
+
readonly status: number;
|
|
15
|
+
/** Case-insensitive — the server spells `Nats-Subject` and the caller asks however it likes. */
|
|
16
|
+
header(name: string): string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NatsSubscription {
|
|
20
|
+
unsubscribe(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type NatsMessageHandler = (message: NatsMessage) => void;
|
|
24
|
+
|
|
25
|
+
export interface NatsRequestOptions {
|
|
26
|
+
readonly headers?: NatsHeaders | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NatsRequestManyOptions {
|
|
30
|
+
/** Collection stops at the first message this accepts, and that message is not collected. */
|
|
31
|
+
readonly until: (message: NatsMessage) => boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the bus needs from a NATS connection, and nothing more. A subscription outlives a reconnect —
|
|
36
|
+
* the client re-establishes it underneath the caller — which is why nothing above this port keeps
|
|
37
|
+
* subscription bookkeeping of its own.
|
|
38
|
+
*/
|
|
39
|
+
export interface NatsClient {
|
|
40
|
+
/** The connected server's version, as `assertServerVersion` reads it. */
|
|
41
|
+
readonly version: string;
|
|
42
|
+
/** False while a reconnect is in flight, and forever once `close()` has run. */
|
|
43
|
+
readonly connected: boolean;
|
|
44
|
+
publish(subject: string, payload: Uint8Array): void;
|
|
45
|
+
subscribe(subject: string, handler: NatsMessageHandler): NatsSubscription;
|
|
46
|
+
request(subject: string, payload: Uint8Array, options?: NatsRequestOptions): Promise<NatsMessage>;
|
|
47
|
+
requestMany(
|
|
48
|
+
subject: string,
|
|
49
|
+
payload: Uint8Array,
|
|
50
|
+
options: NatsRequestManyOptions,
|
|
51
|
+
): Promise<readonly NatsMessage[]>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface NatsTarget {
|
|
56
|
+
readonly host: string;
|
|
57
|
+
readonly port: number; // default 4222
|
|
58
|
+
/** `tls://` demands TLS; `nats://` still upgrades when the server's INFO says it is required. */
|
|
59
|
+
readonly tls: boolean;
|
|
60
|
+
readonly user: string | undefined;
|
|
61
|
+
readonly pass: string | undefined;
|
|
62
|
+
/** `nats://token@host` — NATS' single-credential form, mutually exclusive with user/pass. */
|
|
63
|
+
readonly token: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NatsClientOptions {
|
|
67
|
+
readonly url: string;
|
|
68
|
+
/** Shows up in `nats server report connections`, so an operator can name the process. */
|
|
69
|
+
readonly name?: string | undefined;
|
|
70
|
+
readonly maxReconnectAttempts?: number | undefined;
|
|
71
|
+
/** Our jitter policy, asked once per attempt — the herd is ours to spread, not the library's. */
|
|
72
|
+
readonly reconnectDelay?: (() => number) | undefined;
|
|
73
|
+
readonly requestTimeoutMs?: number | undefined;
|
|
74
|
+
/** A background failure: a dropped connection, a server error, an exhausted reconnect. */
|
|
75
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
76
|
+
/** The library re-established the connection. The cluster behind it may be a different one. */
|
|
77
|
+
readonly onReconnect?: (() => void) | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The one seam a test replaces: a client that never touches a socket. */
|
|
81
|
+
export type NatsConnect = (options: NatsClientOptions) => Promise<NatsClient>;
|
|
82
|
+
|
|
83
|
+
export const DEFAULT_NATS_PORT = 4222;
|
|
84
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `nats://user:pass@host:4222`. The one place a bus URL is read — the library takes a bare
|
|
88
|
+
* `host:port` plus credentials as options, and never looks at a URL's userinfo.
|
|
89
|
+
*/
|
|
90
|
+
export function parseNatsUrl(url: string): NatsTarget {
|
|
91
|
+
let parsed: URL;
|
|
92
|
+
try {
|
|
93
|
+
parsed = new URL(url);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new TransportUnavailableError({
|
|
96
|
+
transport: 'nats',
|
|
97
|
+
reason: `"${url}" is not a connection URL`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (parsed.protocol !== 'nats:' && parsed.protocol !== 'tls:') {
|
|
101
|
+
throw new TransportUnavailableError({
|
|
102
|
+
transport: 'nats',
|
|
103
|
+
reason: `the connection URL uses "${parsed.protocol}" rather than nats: or tls:`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (parsed.hostname === '') {
|
|
107
|
+
throw new TransportUnavailableError({ transport: 'nats', reason: `"${url}" has no host` });
|
|
108
|
+
}
|
|
109
|
+
const hasUser = parsed.username !== '';
|
|
110
|
+
const hasPass = parsed.password !== '';
|
|
111
|
+
// A password with no user matches neither credential form, and dropping it silently connects
|
|
112
|
+
// anonymously — the failure then surfaces as the server's own 'Authorization Violation', which
|
|
113
|
+
// names nothing about the URL. The URL itself is never echoed back: it holds the secret.
|
|
114
|
+
if (!hasUser && hasPass) {
|
|
115
|
+
throw new TransportUnavailableError({
|
|
116
|
+
transport: 'nats',
|
|
117
|
+
reason: `the connection URL for ${parsed.hostname} carries a password with no user`,
|
|
118
|
+
fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const user = hasUser ? decodeURIComponent(parsed.username) : undefined;
|
|
122
|
+
const pass = hasPass ? decodeURIComponent(parsed.password) : undefined;
|
|
123
|
+
return {
|
|
124
|
+
host: parsed.hostname,
|
|
125
|
+
port: parsed.port === '' ? DEFAULT_NATS_PORT : Number.parseInt(parsed.port, 10),
|
|
126
|
+
tls: parsed.protocol === 'tls:',
|
|
127
|
+
// A username with no password is NATS' bare-token form; a password makes it user/pass instead.
|
|
128
|
+
user: hasUser && hasPass ? user : undefined,
|
|
129
|
+
pass: hasUser && hasPass ? pass : undefined,
|
|
130
|
+
token: hasUser && !hasPass ? user : undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|