@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,188 @@
|
|
|
1
|
+
// Tier 3: the durable local store a `mutator`'s `local(tx, input)` half writes to.
|
|
2
|
+
//
|
|
3
|
+
// `local` must be replayable — no I/O, no Date.now(), no Math.random() — because rebase replays it.
|
|
4
|
+
// That is why every write goes through a journal keyed by the mutation's idempotency key: rollback
|
|
5
|
+
// is "undo this key's journal in reverse", not "re-fetch and hope".
|
|
6
|
+
|
|
7
|
+
import { NotImplementedError } from './errors';
|
|
8
|
+
import type { JsonObject, Row } from './json';
|
|
9
|
+
|
|
10
|
+
export interface LocalTable<R extends Row = Row> {
|
|
11
|
+
get(id: string): R | undefined;
|
|
12
|
+
all(): readonly R[];
|
|
13
|
+
insert(row: R): void;
|
|
14
|
+
upsert(row: R): void;
|
|
15
|
+
/** `patch` returns changed fields only, mirroring the canonical mutator example. */
|
|
16
|
+
update(id: string, patch: (row: R) => Partial<R>): void;
|
|
17
|
+
delete(id: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TableMap = Record<string, Row>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The transaction handle passed to `local`. In a generated app the table map comes from the app's
|
|
24
|
+
* entities, so `tx.posts` is a real property with a real row type — never an index signature.
|
|
25
|
+
*/
|
|
26
|
+
export type LocalTx<T extends TableMap = TableMap> = { readonly [K in keyof T]: LocalTable<T[K]> };
|
|
27
|
+
|
|
28
|
+
interface JournalEntry {
|
|
29
|
+
readonly table: string;
|
|
30
|
+
readonly id: string;
|
|
31
|
+
/** Row state before the write; `undefined` means "did not exist" (so undo = delete). */
|
|
32
|
+
readonly before: Row | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LocalStore<T extends TableMap = TableMap> {
|
|
36
|
+
readonly tx: LocalTx<T>;
|
|
37
|
+
table(name: string): LocalTable;
|
|
38
|
+
/** Runs `fn` while journalling every write under `key`, so it can be rolled back verbatim. */
|
|
39
|
+
apply(key: string, fn: (tx: LocalTx<T>) => void): void;
|
|
40
|
+
/** Undo one key's writes, newest first. Used by rebase before reapplying pending mutations. */
|
|
41
|
+
rollback(key: string): void;
|
|
42
|
+
/** Server confirmed: drop the journal. After this the write is no longer optimistic. */
|
|
43
|
+
commit(key: string): void;
|
|
44
|
+
pendingKeys(): readonly string[];
|
|
45
|
+
snapshot(name: string): readonly Row[];
|
|
46
|
+
reset(tables: Readonly<Record<string, readonly Row[]>>): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The reference implementation. Every rule in the tier-3 contract (journalling, ordered undo, key
|
|
51
|
+
* scoping) is implemented here; OPFS SQLite swaps the storage, not the semantics.
|
|
52
|
+
*/
|
|
53
|
+
export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalStore<T> {
|
|
54
|
+
readonly #tables = new Map<string, Map<string, Row>>();
|
|
55
|
+
readonly #journals = new Map<string, JournalEntry[]>();
|
|
56
|
+
#recordingKey: string | null = null;
|
|
57
|
+
|
|
58
|
+
readonly tx: LocalTx<T>;
|
|
59
|
+
|
|
60
|
+
constructor(tables: Readonly<Record<string, readonly Row[]>> = {}) {
|
|
61
|
+
this.reset(tables);
|
|
62
|
+
const handler: ProxyHandler<Record<string, LocalTable>> = {
|
|
63
|
+
// Symbols (`Symbol.iterator`, `then`) must not resolve to a table, or awaiting a tx would
|
|
64
|
+
// silently create one.
|
|
65
|
+
get: (_target, property) => (typeof property === 'symbol' ? undefined : this.table(property)),
|
|
66
|
+
};
|
|
67
|
+
this.tx = new Proxy({} as Record<string, LocalTable>, handler) as LocalTx<T>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
table(name: string): LocalTable {
|
|
71
|
+
const rows = this.#rows(name);
|
|
72
|
+
return {
|
|
73
|
+
get: (id) => rows.get(id),
|
|
74
|
+
all: () => [...rows.values()],
|
|
75
|
+
insert: (row) => {
|
|
76
|
+
this.#journal(name, row.id, rows.get(row.id));
|
|
77
|
+
rows.set(row.id, row);
|
|
78
|
+
},
|
|
79
|
+
upsert: (row) => {
|
|
80
|
+
const current = rows.get(row.id);
|
|
81
|
+
this.#journal(name, row.id, current);
|
|
82
|
+
rows.set(row.id, { ...(current ?? {}), ...row });
|
|
83
|
+
},
|
|
84
|
+
update: (id, patch) => {
|
|
85
|
+
const current = rows.get(id);
|
|
86
|
+
if (!current) return;
|
|
87
|
+
this.#journal(name, id, current);
|
|
88
|
+
rows.set(id, merge(current, patch(current)));
|
|
89
|
+
},
|
|
90
|
+
delete: (id) => {
|
|
91
|
+
const current = rows.get(id);
|
|
92
|
+
if (!current) return;
|
|
93
|
+
this.#journal(name, id, current);
|
|
94
|
+
rows.delete(id);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
apply(key: string, fn: (tx: LocalTx<T>) => void): void {
|
|
100
|
+
const previous = this.#recordingKey;
|
|
101
|
+
this.#recordingKey = key;
|
|
102
|
+
if (!this.#journals.has(key)) this.#journals.set(key, []);
|
|
103
|
+
try {
|
|
104
|
+
fn(this.tx);
|
|
105
|
+
} finally {
|
|
106
|
+
this.#recordingKey = previous;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
rollback(key: string): void {
|
|
111
|
+
const journal = this.#journals.get(key);
|
|
112
|
+
if (!journal) return;
|
|
113
|
+
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
114
|
+
const entry = journal[i];
|
|
115
|
+
if (!entry) continue;
|
|
116
|
+
const rows = this.#rows(entry.table);
|
|
117
|
+
if (entry.before === undefined) rows.delete(entry.id);
|
|
118
|
+
else rows.set(entry.id, entry.before);
|
|
119
|
+
}
|
|
120
|
+
this.#journals.delete(key);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
commit(key: string): void {
|
|
124
|
+
this.#journals.delete(key);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pendingKeys(): readonly string[] {
|
|
128
|
+
return [...this.#journals.keys()];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
snapshot(name: string): readonly Row[] {
|
|
132
|
+
return [...this.#rows(name).values()];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
reset(tables: Readonly<Record<string, readonly Row[]>>): void {
|
|
136
|
+
this.#tables.clear();
|
|
137
|
+
this.#journals.clear();
|
|
138
|
+
for (const [name, rows] of Object.entries(tables)) {
|
|
139
|
+
this.#tables.set(name, new Map(rows.map((row) => [row.id, row])));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#rows(name: string): Map<string, Row> {
|
|
144
|
+
const existing = this.#tables.get(name);
|
|
145
|
+
if (existing) return existing;
|
|
146
|
+
const created = new Map<string, Row>();
|
|
147
|
+
this.#tables.set(name, created);
|
|
148
|
+
return created;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Only the *first* write to a row within one key is journalled — undo must reach the base state. */
|
|
152
|
+
#journal(table: string, id: string, before: Row | undefined): void {
|
|
153
|
+
const key = this.#recordingKey;
|
|
154
|
+
if (key === null) return;
|
|
155
|
+
const journal = this.#journals.get(key);
|
|
156
|
+
if (!journal) return;
|
|
157
|
+
if (journal.some((entry) => entry.table === table && entry.id === id)) return;
|
|
158
|
+
journal.push(before === undefined ? { table, id, before: undefined } : { table, id, before });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
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
|
+
export interface OpfsLocalStoreOptions {
|
|
172
|
+
/** OPFS file name, versioned so a client-side migration can run before first read. */
|
|
173
|
+
readonly file: string;
|
|
174
|
+
readonly schemaVersion: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The production tier-3 store: SQLite over the Origin Private File System, opened in a worker so a
|
|
179
|
+
* long write never blocks the main thread. Browser-only, so it must not be reachable from a server
|
|
180
|
+
* bundle — which is why it is a factory that throws rather than a class you can accidentally new
|
|
181
|
+
* on the server.
|
|
182
|
+
*/
|
|
183
|
+
export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
|
|
184
|
+
throw new NotImplementedError({
|
|
185
|
+
what: `OPFS SQLite local store (${options.file} v${options.schemaVersion})`,
|
|
186
|
+
fix: "import { createOpfsLocalStore } from '@ultimat3/realtime/browser' (tier 3, v2); today use MemoryLocalStore or set persist: false on the query",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// One `ChangeEvent` -> the minimal patches for one subscription, via `@ultimat3/query`'s matcher.
|
|
2
|
+
//
|
|
3
|
+
// The pre-filter is the load-bearing part. Fanout is affordable only because a change that cannot
|
|
4
|
+
// touch a subscription costs three comparisons (entity, tenant, column set) and never reaches the
|
|
5
|
+
// matcher. A change touching no registered query costs one predicate check in total.
|
|
6
|
+
|
|
7
|
+
import { type LiveQuery, match, type Patch } from '@ultimat3/query';
|
|
8
|
+
import type { ChangeEvent } from './changefeed';
|
|
9
|
+
import { changedColumns, isJsonObject, type JsonObject, type Row, type RowPatch } from './json';
|
|
10
|
+
|
|
11
|
+
export interface SubscriptionShape {
|
|
12
|
+
readonly qid: string;
|
|
13
|
+
/** Dependency set: the entities and tags this query reads (`LiveQuery.reads`). */
|
|
14
|
+
readonly entities: readonly string[];
|
|
15
|
+
/** Tenant scope. A change in another tenant is dropped before any predicate runs. */
|
|
16
|
+
readonly orgId: string | null;
|
|
17
|
+
/** Read set. An update touching none of these columns cannot change the result. */
|
|
18
|
+
readonly columns?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BridgeResult {
|
|
22
|
+
readonly patches: readonly RowPatch[];
|
|
23
|
+
/**
|
|
24
|
+
* The window lost a row and the tail is unknown: the subscriber must re-read rather than guess.
|
|
25
|
+
* Handled as a re-snapshot, which is exactly the fallback the reconnect budget already pays for.
|
|
26
|
+
*/
|
|
27
|
+
readonly refill: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const NO_CHANGE: BridgeResult = { patches: [], refill: false };
|
|
31
|
+
|
|
32
|
+
export interface IncrementalMatcher {
|
|
33
|
+
readonly entities: readonly string[];
|
|
34
|
+
/** `rows` is the query's current *pre-policy* window, shared by every subscriber of the qid. */
|
|
35
|
+
match(change: ChangeEvent, rows: readonly Row[]): BridgeResult;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
|
|
39
|
+
export function canAffect(shape: SubscriptionShape, change: ChangeEvent): boolean {
|
|
40
|
+
if (!shape.entities.includes(change.entity)) return false;
|
|
41
|
+
if (shape.orgId !== null && change.orgId !== null && shape.orgId !== change.orgId) return false;
|
|
42
|
+
if (shape.columns && change.op === 'update' && change.after !== null) {
|
|
43
|
+
const touched = Object.keys(changedColumns(change.before, change.after));
|
|
44
|
+
if (touched.length > 0 && !touched.some((column) => shape.columns?.includes(column)))
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pre-filter, then match. Returns `null` for "skip this subscription" — the common case. */
|
|
51
|
+
export function bridgeChange(
|
|
52
|
+
shape: SubscriptionShape,
|
|
53
|
+
incremental: IncrementalMatcher,
|
|
54
|
+
change: ChangeEvent,
|
|
55
|
+
rows: readonly Row[],
|
|
56
|
+
): BridgeResult | null {
|
|
57
|
+
if (!canAffect(shape, change)) return null;
|
|
58
|
+
const result = incremental.match(change, rows);
|
|
59
|
+
return result.patches.length === 0 && !result.refill ? null : result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Default derivation, used by matchers that only answer "affected" without describing the delta. */
|
|
63
|
+
export function patchFromChange(change: ChangeEvent): RowPatch | null {
|
|
64
|
+
if (change.op === 'delete') {
|
|
65
|
+
const id = change.before?.id;
|
|
66
|
+
return id === undefined ? null : { op: 'delete', id, row: null, lsn: change.lsn };
|
|
67
|
+
}
|
|
68
|
+
const after = change.after;
|
|
69
|
+
if (after === null) return null;
|
|
70
|
+
const row: JsonObject =
|
|
71
|
+
change.op === 'insert' ? after : { id: after.id, ...changedColumns(change.before, after) };
|
|
72
|
+
return { op: change.op === 'insert' ? 'insert' : 'update', id: after.id, row, lsn: change.lsn };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The seam to `@ultimat3/query`. Everything else in this package talks to `IncrementalMatcher`, so
|
|
77
|
+
* swapping the matcher — or adopting an external protocol's, per the risk register — touches this
|
|
78
|
+
* function only.
|
|
79
|
+
*/
|
|
80
|
+
export function matcherFor(live: LiveQuery): IncrementalMatcher {
|
|
81
|
+
return {
|
|
82
|
+
entities: live.reads,
|
|
83
|
+
match: (change, rows) => {
|
|
84
|
+
const row = change.after ?? change.before;
|
|
85
|
+
if (!row) return NO_CHANGE;
|
|
86
|
+
const patches = match<Row>(live.name, live.shape, rows, {
|
|
87
|
+
entity: change.entity,
|
|
88
|
+
op: change.op,
|
|
89
|
+
row,
|
|
90
|
+
...(change.before === null ? {} : { before: change.before }),
|
|
91
|
+
});
|
|
92
|
+
return toBridgeResult(patches, change);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `Patch<Row>` (add/update/remove/refill, positional) -> the wire's `RowPatch`. */
|
|
98
|
+
export function toBridgeResult(patches: readonly Patch<Row>[], change: ChangeEvent): BridgeResult {
|
|
99
|
+
const out: RowPatch[] = [];
|
|
100
|
+
let refill = false;
|
|
101
|
+
for (const patch of patches) {
|
|
102
|
+
switch (patch.kind) {
|
|
103
|
+
case 'add':
|
|
104
|
+
out.push({
|
|
105
|
+
op: 'insert',
|
|
106
|
+
id: patch.row.id,
|
|
107
|
+
row: patch.row,
|
|
108
|
+
lsn: change.lsn,
|
|
109
|
+
index: patch.position,
|
|
110
|
+
});
|
|
111
|
+
break;
|
|
112
|
+
case 'update':
|
|
113
|
+
out.push({
|
|
114
|
+
op: 'update',
|
|
115
|
+
id: patch.row.id,
|
|
116
|
+
row: { id: patch.row.id, ...changedColumns(change.before, patch.row) },
|
|
117
|
+
lsn: change.lsn,
|
|
118
|
+
index: patch.position,
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
case 'remove':
|
|
122
|
+
out.push({ op: 'delete', id: patch.id, row: null, lsn: change.lsn, index: patch.position });
|
|
123
|
+
break;
|
|
124
|
+
case 'refill':
|
|
125
|
+
refill = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { patches: out, refill };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Applies patches to the shared pre-policy window so the matcher sees the current result set. */
|
|
133
|
+
export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]): Row[] {
|
|
134
|
+
const next = [...rows];
|
|
135
|
+
for (const patch of patches) {
|
|
136
|
+
const index = next.findIndex((row) => row.id === patch.id);
|
|
137
|
+
if (patch.op === 'delete') {
|
|
138
|
+
if (index >= 0) next.splice(index, 1);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (patch.row === null) continue;
|
|
142
|
+
const current = index >= 0 ? next[index] : undefined;
|
|
143
|
+
const merged: Row = { ...(current ?? {}), ...patch.row, id: patch.id };
|
|
144
|
+
if (index >= 0) next[index] = merged;
|
|
145
|
+
else if (patch.index !== undefined) next.splice(patch.index, 0, merged);
|
|
146
|
+
else next.push(merged);
|
|
147
|
+
}
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Accepts a foreign matcher's patch shape and lands it on the wire shape. Defensive by design. */
|
|
152
|
+
export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatch | null {
|
|
153
|
+
if (candidate === null || candidate === undefined || candidate === false) return null;
|
|
154
|
+
if (candidate === true) return patchFromChange(change);
|
|
155
|
+
if (!isJsonObject(candidate)) return null;
|
|
156
|
+
const op = candidate['op'];
|
|
157
|
+
if (op !== 'insert' && op !== 'update' && op !== 'delete') return patchFromChange(change);
|
|
158
|
+
const id = candidate['id'];
|
|
159
|
+
const rowValue = candidate['row'];
|
|
160
|
+
const index = candidate['index'];
|
|
161
|
+
const base: RowPatch = {
|
|
162
|
+
op,
|
|
163
|
+
id: typeof id === 'string' ? id : (change.after?.id ?? change.before?.id ?? ''),
|
|
164
|
+
row: op === 'delete' ? null : isJsonObject(rowValue) ? rowValue : (change.after ?? null),
|
|
165
|
+
lsn: typeof candidate['lsn'] === 'string' ? candidate['lsn'] : change.lsn,
|
|
166
|
+
};
|
|
167
|
+
if (base.id === '') return null;
|
|
168
|
+
return typeof index === 'number' ? { ...base, index } : base;
|
|
169
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Single responsibility: the client half of the NATS protocol — the commands a client writes.
|
|
2
|
+
// Split from the parser on purpose: encoding is pure string building with no state at all, while
|
|
3
|
+
// decoding has to carry a buffer across chunk boundaries, and mixing the two hides both.
|
|
4
|
+
|
|
5
|
+
import { TransportProtocolError } from './errors';
|
|
6
|
+
import { concatBytes, type NatsHeaders } from './nats-protocol';
|
|
7
|
+
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const CRLF = '\r\n';
|
|
10
|
+
|
|
11
|
+
export interface NatsConnectOptions {
|
|
12
|
+
readonly verbose?: boolean; // default false
|
|
13
|
+
readonly pedantic?: boolean; // default false
|
|
14
|
+
readonly name?: string; // client name, default 'ultimate'
|
|
15
|
+
readonly user?: string | undefined;
|
|
16
|
+
readonly pass?: string | undefined;
|
|
17
|
+
readonly authToken?: string | undefined;
|
|
18
|
+
readonly tlsRequired?: boolean; // default false
|
|
19
|
+
}
|
|
20
|
+
const CLIENT_VERSION = '0.0.1';
|
|
21
|
+
|
|
22
|
+
/** `CONNECT {json}\r\n` — the first frame a client sends, before subscribing or publishing. */
|
|
23
|
+
export function connectMessage(options: NatsConnectOptions = {}): Uint8Array {
|
|
24
|
+
const payload: Record<string, unknown> = {
|
|
25
|
+
verbose: options.verbose ?? false,
|
|
26
|
+
pedantic: options.pedantic ?? false,
|
|
27
|
+
tls_required: options.tlsRequired ?? false,
|
|
28
|
+
name: options.name ?? 'ultimate',
|
|
29
|
+
lang: 'bun',
|
|
30
|
+
version: CLIENT_VERSION,
|
|
31
|
+
protocol: 1,
|
|
32
|
+
headers: true,
|
|
33
|
+
no_responders: true,
|
|
34
|
+
};
|
|
35
|
+
if (options.user !== undefined && options.pass !== undefined) {
|
|
36
|
+
payload['user'] = options.user;
|
|
37
|
+
payload['pass'] = options.pass;
|
|
38
|
+
}
|
|
39
|
+
if (options.authToken !== undefined) payload['auth_token'] = options.authToken;
|
|
40
|
+
return encoder.encode(`CONNECT ${JSON.stringify(payload)}${CRLF}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A header line ends at the first CRLF, so a break inside a key or value closes the line early and
|
|
45
|
+
* everything after it is read as a fresh command. A security boundary, not a style rule.
|
|
46
|
+
*/
|
|
47
|
+
const HEADER_BREAK = /[\r\n]/;
|
|
48
|
+
|
|
49
|
+
const encodeHeaderBlock = (headers: NatsHeaders): Uint8Array => {
|
|
50
|
+
let block = `NATS/1.0${CRLF}`;
|
|
51
|
+
for (const [key, value] of headers) {
|
|
52
|
+
if (HEADER_BREAK.test(key) || HEADER_BREAK.test(value)) {
|
|
53
|
+
throw new TransportProtocolError({
|
|
54
|
+
transport: 'nats',
|
|
55
|
+
stage: 'headers',
|
|
56
|
+
// Quoted through JSON so the break that caused this cannot break the message reporting it.
|
|
57
|
+
detail: `header ${JSON.stringify(key)} carries a CR or LF, which would inject a command`,
|
|
58
|
+
fix: "strip the breaks first: headers.set(name, value.replace(/[\\r\\n]+/g, ' '))",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
block += `${key}: ${value}${CRLF}`;
|
|
62
|
+
}
|
|
63
|
+
return encoder.encode(`${block}${CRLF}`);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** `PUB` when there are no headers, `HPUB` when there are — never both shapes for one call. */
|
|
67
|
+
export function pubMessage(args: {
|
|
68
|
+
readonly subject: string;
|
|
69
|
+
readonly payload?: Uint8Array | undefined;
|
|
70
|
+
readonly replyTo?: string | undefined;
|
|
71
|
+
readonly headers?: NatsHeaders | undefined;
|
|
72
|
+
}): Uint8Array {
|
|
73
|
+
const payload = args.payload ?? new Uint8Array(0);
|
|
74
|
+
const replyPart = args.replyTo !== undefined ? ` ${args.replyTo}` : '';
|
|
75
|
+
const crlfBytes = encoder.encode(CRLF);
|
|
76
|
+
if (args.headers === undefined || args.headers.size === 0) {
|
|
77
|
+
const control = `PUB ${args.subject}${replyPart} ${payload.length}${CRLF}`;
|
|
78
|
+
return concatBytes(encoder.encode(control), payload, crlfBytes);
|
|
79
|
+
}
|
|
80
|
+
const headerBlock = encodeHeaderBlock(args.headers);
|
|
81
|
+
const total = headerBlock.length + payload.length;
|
|
82
|
+
const control = `HPUB ${args.subject}${replyPart} ${headerBlock.length} ${total}${CRLF}`;
|
|
83
|
+
return concatBytes(encoder.encode(control), headerBlock, payload, crlfBytes);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function subMessage(subject: string, sid: string, queue?: string): Uint8Array {
|
|
87
|
+
const queuePart = queue !== undefined ? ` ${queue}` : '';
|
|
88
|
+
return encoder.encode(`SUB ${subject}${queuePart} ${sid}${CRLF}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function unsubMessage(sid: string, max?: number): Uint8Array {
|
|
92
|
+
const maxPart = max !== undefined ? ` ${max}` : '';
|
|
93
|
+
return encoder.encode(`UNSUB ${sid}${maxPart}${CRLF}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const PING_MESSAGE: Uint8Array = encoder.encode(`PING${CRLF}`);
|
|
97
|
+
export const PONG_MESSAGE: Uint8Array = encoder.encode(`PONG${CRLF}`);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Single responsibility: the scripted stream and the two `open()` shorthands this package's NATS
|
|
2
|
+
// session tests share. Split out of `nats-connection.test.ts` so neither file outgrows its
|
|
3
|
+
// ceiling. Not part of the public API — `index.ts` deliberately does not re-export it.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
6
|
+
import { TransportUnavailableError } from './errors';
|
|
7
|
+
import { NatsConnection } from './nats-connection';
|
|
8
|
+
import type { FakeNatsServer } from './nats-fake';
|
|
9
|
+
import type { NatsStream, NatsTarget } from './nats-socket';
|
|
10
|
+
|
|
11
|
+
export const encoder = new TextEncoder();
|
|
12
|
+
export const decoder = new TextDecoder();
|
|
13
|
+
|
|
14
|
+
export const TARGET: NatsTarget = {
|
|
15
|
+
host: 'bus.test',
|
|
16
|
+
port: 4222,
|
|
17
|
+
tls: false,
|
|
18
|
+
user: undefined,
|
|
19
|
+
pass: undefined,
|
|
20
|
+
token: undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const codeOf = (value: unknown): string =>
|
|
24
|
+
isUltimateError(value) ? value.code : `not an UltimateError: ${String(value)}`;
|
|
25
|
+
|
|
26
|
+
export const caught = (promise: Promise<unknown>): Promise<unknown> =>
|
|
27
|
+
promise.then(
|
|
28
|
+
() => undefined,
|
|
29
|
+
(error: unknown) => error,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** A stream whose server side is a script: the test pushes bytes and reads what the client wrote. */
|
|
33
|
+
export class ScriptedStream implements NatsStream {
|
|
34
|
+
readonly writes: string[] = [];
|
|
35
|
+
/**
|
|
36
|
+
* A socket that rejects a frame — the fault the session has to survive without keeping state
|
|
37
|
+
* that claims the frame arrived. A refused frame still lands in `writes`: the client did hand
|
|
38
|
+
* it over, and counting attempts is how a test sees a retry.
|
|
39
|
+
*/
|
|
40
|
+
refuse: (frame: string) => boolean = () => false;
|
|
41
|
+
upgrades = 0;
|
|
42
|
+
closed = false;
|
|
43
|
+
readonly #queue: (Uint8Array | undefined)[] = [];
|
|
44
|
+
#waiting: ((chunk: Uint8Array | undefined) => void) | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(...script: string[]) {
|
|
47
|
+
for (const text of script) this.push(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
push(text: string): void {
|
|
51
|
+
const chunk = encoder.encode(text);
|
|
52
|
+
const waiter = this.#waiting;
|
|
53
|
+
this.#waiting = undefined;
|
|
54
|
+
if (waiter) waiter(chunk);
|
|
55
|
+
else this.#queue.push(chunk);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
eof(): void {
|
|
59
|
+
const waiter = this.#waiting;
|
|
60
|
+
this.#waiting = undefined;
|
|
61
|
+
if (waiter) waiter(undefined);
|
|
62
|
+
else this.#queue.push(undefined);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
read(): Promise<Uint8Array | undefined> {
|
|
66
|
+
if (this.#queue.length > 0) return Promise.resolve(this.#queue.shift());
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
this.#waiting = resolve;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async write(bytes: Uint8Array): Promise<void> {
|
|
73
|
+
const frame = decoder.decode(bytes);
|
|
74
|
+
this.writes.push(frame);
|
|
75
|
+
if (this.refuse(frame)) {
|
|
76
|
+
throw new TransportUnavailableError({ transport: 'nats', reason: `refused: ${frame}` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
upgradeTls(): void {
|
|
81
|
+
this.upgrades += 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(): void {
|
|
85
|
+
this.closed = true;
|
|
86
|
+
this.eof();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const INFO =
|
|
91
|
+
'INFO {"server_id":"S","version":"2.11.0","max_payload":1048576,"headers":true}\r\n';
|
|
92
|
+
|
|
93
|
+
export const openScripted = async (
|
|
94
|
+
stream: ScriptedStream,
|
|
95
|
+
target: NatsTarget = TARGET,
|
|
96
|
+
): Promise<NatsConnection> =>
|
|
97
|
+
await NatsConnection.open({ stream, target, rng: () => 0.5, requestTimeoutMs: 50 });
|
|
98
|
+
|
|
99
|
+
export const openFake = async (server: FakeNatsServer): Promise<NatsConnection> =>
|
|
100
|
+
await NatsConnection.open({
|
|
101
|
+
stream: server.connect(),
|
|
102
|
+
target: TARGET,
|
|
103
|
+
rng: () => 0.5,
|
|
104
|
+
requestTimeoutMs: 200,
|
|
105
|
+
});
|