@ultimat3/realtime 1.2.0 → 3.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 +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- 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 +99 -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 +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- 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 +324 -248
- 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/sync-protocol.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// offline mutation drain are frames in the same union. Moving a route from tier 2 to tier 3 is a
|
|
3
3
|
// config flag (`persist: true`), never a new protocol — that promise is enforced here.
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
6
|
+
import { CURSOR_ID_LIMIT, type LiveCursor } from './cursor';
|
|
6
7
|
import { ProtocolVersionError } from './errors';
|
|
7
8
|
import {
|
|
8
9
|
isJsonObject,
|
|
@@ -15,6 +16,27 @@ import {
|
|
|
15
16
|
|
|
16
17
|
export const PROTOCOL_VERSION = 1;
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What one frame may contain. Hard ceilings a caller cannot widen — the shape
|
|
21
|
+
* `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
|
|
22
|
+
* node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
|
|
23
|
+
* `input` of arbitrary depth reached `canonicalJson`, which recurses.
|
|
24
|
+
*
|
|
25
|
+
* Every number clears what this node itself produces, or the decoder refuses its own frames on
|
|
26
|
+
* the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
|
|
27
|
+
* `defaultReconnectBudget.maxPatches`.
|
|
28
|
+
*/
|
|
29
|
+
export const FRAME_LIMITS = Object.freeze({
|
|
30
|
+
cursorIds: CURSOR_ID_LIMIT,
|
|
31
|
+
patches: 4_096,
|
|
32
|
+
rows: 10_000,
|
|
33
|
+
members: 4_096,
|
|
34
|
+
/** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
|
|
35
|
+
inputDepth: 32,
|
|
36
|
+
/** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
|
|
37
|
+
inputNodes: 10_000,
|
|
38
|
+
});
|
|
39
|
+
|
|
18
40
|
export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
|
|
19
41
|
|
|
20
42
|
export interface WireError {
|
|
@@ -46,6 +68,17 @@ export type SubscribeTarget =
|
|
|
46
68
|
readonly cursor: LiveCursor | null;
|
|
47
69
|
};
|
|
48
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The opening frame, and the heartbeat's. It carries **no cursors**: resume is decided per
|
|
73
|
+
* subscription by `subscribe`, whose target already carries the cursor and whose `(name, input)`
|
|
74
|
+
* is what the node needs to authorize the read and reach the retained window at all. A cursor's
|
|
75
|
+
* `qid` is `qidOf(name, input)` — a digest, not an input — so a resume list here could never be
|
|
76
|
+
* more than a second, unauthorized restatement of that decision, and it cost every reconnect a
|
|
77
|
+
* duplicate copy of up to `CURSOR_ID_LIMIT` ids per subscription during the exact restart storm
|
|
78
|
+
* `thundering-herd.ts` exists to bound. Removing it needs no `PROTOCOL_VERSION` bump: `decode`
|
|
79
|
+
* builds a whitelist, so a node reads an old client's `resume` as absent and an old node reads a
|
|
80
|
+
* new client's omission the way it already read an empty list.
|
|
81
|
+
*/
|
|
49
82
|
export interface HelloFrame {
|
|
50
83
|
readonly type: 'hello';
|
|
51
84
|
readonly v: number;
|
|
@@ -53,7 +86,6 @@ export interface HelloFrame {
|
|
|
53
86
|
/** Server-assigned on the reply, `null` on the client's opening frame. */
|
|
54
87
|
readonly sessionId: string | null;
|
|
55
88
|
readonly actorId: string | null;
|
|
56
|
-
readonly resume: readonly LiveCursor[];
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
export interface SubscribeFrame {
|
|
@@ -70,6 +102,14 @@ export interface SnapshotFrame {
|
|
|
70
102
|
readonly sid: string;
|
|
71
103
|
readonly rows: readonly Row[];
|
|
72
104
|
readonly cursor: LiveCursor;
|
|
105
|
+
/**
|
|
106
|
+
* The entity every row of this result set belongs to — the client's identity scope, so two
|
|
107
|
+
* queries returning post #7 hold one row rather than two copies. Optional and **additive**: it
|
|
108
|
+
* is the one thing a browser cannot derive (the shape is compiled server-side from `sql`), and
|
|
109
|
+
* a client that does not receive it keeps its rows in a scope private to that subscription. Both
|
|
110
|
+
* skews are safe in both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
111
|
+
*/
|
|
112
|
+
readonly entity?: string;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
115
|
export interface PatchFrame {
|
|
@@ -115,6 +155,14 @@ export interface PresenceFrame {
|
|
|
115
155
|
readonly topic: string;
|
|
116
156
|
readonly op: 'join' | 'leave' | 'update' | 'sync';
|
|
117
157
|
readonly members: readonly PresenceMember[];
|
|
158
|
+
/**
|
|
159
|
+
* Members in the whole set behind a `sync` frame, which is capped: a 5,000-avatar row is not a UI
|
|
160
|
+
* anyone renders, and the count is what lets a client say "and 4,744 others" without holding
|
|
161
|
+
* them. Optional and **additive**, exactly like `snapshot.entity`: an old node omits it and a new
|
|
162
|
+
* one reads its absence as "this frame is the whole set", so neither skew is unreadable and
|
|
163
|
+
* `PROTOCOL_VERSION` does not move. Never set on a `join`/`leave`/`update` — those are deltas.
|
|
164
|
+
*/
|
|
165
|
+
readonly total?: number;
|
|
118
166
|
}
|
|
119
167
|
|
|
120
168
|
export interface ReconnectFrame {
|
|
@@ -185,7 +233,6 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
185
233
|
buildId: str(parsed, 'buildId'),
|
|
186
234
|
sessionId: nullableStr(parsed, 'sessionId'),
|
|
187
235
|
actorId: nullableStr(parsed, 'actorId'),
|
|
188
|
-
resume: list(parsed, 'resume').map(cursor),
|
|
189
236
|
};
|
|
190
237
|
case 'subscribe':
|
|
191
238
|
return {
|
|
@@ -195,20 +242,23 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
195
242
|
sid: str(parsed, 'sid'),
|
|
196
243
|
target: target(parsed['target']),
|
|
197
244
|
};
|
|
198
|
-
case 'snapshot':
|
|
199
|
-
|
|
245
|
+
case 'snapshot': {
|
|
246
|
+
const base = {
|
|
200
247
|
type: 'snapshot',
|
|
201
248
|
v: PROTOCOL_VERSION,
|
|
202
249
|
sid: str(parsed, 'sid'),
|
|
203
|
-
rows: list(parsed, 'rows').map(row),
|
|
250
|
+
rows: list(parsed, 'rows', FRAME_LIMITS.rows).map(row),
|
|
204
251
|
cursor: cursor(parsed['cursor']),
|
|
205
|
-
};
|
|
252
|
+
} as const;
|
|
253
|
+
const entity = nullableStr(parsed, 'entity');
|
|
254
|
+
return entity === null ? base : { ...base, entity };
|
|
255
|
+
}
|
|
206
256
|
case 'patch':
|
|
207
257
|
return {
|
|
208
258
|
type: 'patch',
|
|
209
259
|
v: PROTOCOL_VERSION,
|
|
210
260
|
sid: str(parsed, 'sid'),
|
|
211
|
-
patches: list(parsed, 'patches').map(patch),
|
|
261
|
+
patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
|
|
212
262
|
lsn: str(parsed, 'lsn'),
|
|
213
263
|
};
|
|
214
264
|
case 'mutate':
|
|
@@ -218,7 +268,7 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
218
268
|
key: str(parsed, 'key'),
|
|
219
269
|
seq: num(parsed, 'seq'),
|
|
220
270
|
name: str(parsed, 'name'),
|
|
221
|
-
input: parsed['input'] ?? null,
|
|
271
|
+
input: bounded(parsed['input'] ?? null, 'input'),
|
|
222
272
|
};
|
|
223
273
|
case 'ack':
|
|
224
274
|
return {
|
|
@@ -237,14 +287,16 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
237
287
|
strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
|
|
238
288
|
row: parsed['row'] === null ? null : row(parsed['row']),
|
|
239
289
|
};
|
|
240
|
-
case 'presence':
|
|
241
|
-
|
|
290
|
+
case 'presence': {
|
|
291
|
+
const base = {
|
|
242
292
|
type: 'presence',
|
|
243
293
|
v: PROTOCOL_VERSION,
|
|
244
294
|
topic: str(parsed, 'topic'),
|
|
245
295
|
op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
|
|
246
|
-
members: list(parsed, 'members').map(member),
|
|
247
|
-
};
|
|
296
|
+
members: list(parsed, 'members', FRAME_LIMITS.members).map(member),
|
|
297
|
+
} as const;
|
|
298
|
+
return parsed['total'] === undefined ? base : { ...base, total: num(parsed, 'total') };
|
|
299
|
+
}
|
|
248
300
|
case 'reconnect':
|
|
249
301
|
return {
|
|
250
302
|
type: 'reconnect',
|
|
@@ -261,13 +313,17 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
261
313
|
|
|
262
314
|
/** Project any thrown value onto the wire without losing the error contract's three fields. */
|
|
263
315
|
export function toWireError(error: unknown): WireError {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
316
|
+
// The throwable is an app mutator's, a live query's or a policy's, so its `toString` is the
|
|
317
|
+
// app's too: `String()` here raised inside the handler's catch and the socket got no frame at
|
|
318
|
+
// all, which a reconnect cannot repair because the same call throws the same way.
|
|
319
|
+
// `renderThrowable` keeps an Error's own words without trusting `instanceof` or `.message`, and
|
|
320
|
+
// `stringField` makes the four probes above it as total as the fallback they choose between —
|
|
321
|
+
// `shape?.code` was a raw property read on that same app value.
|
|
322
|
+
const code = stringField(error, 'code') ?? 'X_PROTOCOL_VERSION';
|
|
323
|
+
const cause = stringField(error, 'cause') ?? renderThrowable(error);
|
|
324
|
+
const fix = stringField(error, 'fix') ?? 'x doctor realtime';
|
|
325
|
+
const docs = stringField(error, 'docs');
|
|
326
|
+
return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
271
327
|
}
|
|
272
328
|
|
|
273
329
|
function fail(detail: string): ProtocolVersionError {
|
|
@@ -302,10 +358,45 @@ function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly
|
|
|
302
358
|
return found;
|
|
303
359
|
}
|
|
304
360
|
|
|
305
|
-
|
|
361
|
+
/**
|
|
362
|
+
* An array field, with the ceiling the caller had to choose. `max` is required rather than
|
|
363
|
+
* defaulted: a new list field on a new frame is a new thing an authenticated socket can make
|
|
364
|
+
* arbitrarily large, and a default would let one ship without anyone deciding its size.
|
|
365
|
+
*/
|
|
366
|
+
function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
|
|
306
367
|
const value = obj[key];
|
|
307
368
|
if (value === undefined || value === null) return [];
|
|
308
|
-
if (!Array.isArray(value)) throw fail(`field "${
|
|
369
|
+
if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
|
|
370
|
+
if (value.length > max) {
|
|
371
|
+
throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
|
|
378
|
+
* refused is a stack overflow: `qidOf` -> `canonicalJson` recurses over exactly this value, so a
|
|
379
|
+
* depth check that recursed would be the same crash one frame earlier.
|
|
380
|
+
*/
|
|
381
|
+
function bounded(value: JsonValue, label: string): JsonValue {
|
|
382
|
+
const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
|
|
383
|
+
let seen = 0;
|
|
384
|
+
while (stack.length > 0) {
|
|
385
|
+
// `pop` cannot answer undefined here — the loop guard is the length — and the check is what
|
|
386
|
+
// makes that readable to the compiler without a cast.
|
|
387
|
+
const next = stack.pop();
|
|
388
|
+
if (next === undefined) break;
|
|
389
|
+
seen += 1;
|
|
390
|
+
if (seen > FRAME_LIMITS.inputNodes) {
|
|
391
|
+
throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
|
|
392
|
+
}
|
|
393
|
+
if (next.depth > FRAME_LIMITS.inputDepth) {
|
|
394
|
+
throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
|
|
395
|
+
}
|
|
396
|
+
if (next.node === null || typeof next.node !== 'object') continue;
|
|
397
|
+
const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
|
|
398
|
+
for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
|
|
399
|
+
}
|
|
309
400
|
return value;
|
|
310
401
|
}
|
|
311
402
|
|
|
@@ -320,7 +411,7 @@ function cursor(value: unknown): LiveCursor {
|
|
|
320
411
|
qid: str(value, 'qid'),
|
|
321
412
|
lsn: str(value, 'lsn'),
|
|
322
413
|
digest: str(value, 'digest'),
|
|
323
|
-
ids: list(value, 'ids').map((id) => {
|
|
414
|
+
ids: list(value, 'ids', FRAME_LIMITS.cursorIds, 'cursor.ids').map((id) => {
|
|
324
415
|
if (typeof id !== 'string') throw fail('cursor.ids must be strings');
|
|
325
416
|
return id;
|
|
326
417
|
}),
|
|
@@ -357,7 +448,7 @@ function target(value: unknown): SubscribeTarget {
|
|
|
357
448
|
return {
|
|
358
449
|
kind,
|
|
359
450
|
qid: str(value, 'qid'),
|
|
360
|
-
input: value['input'] ?? null,
|
|
451
|
+
input: bounded(value['input'] ?? null, 'input'),
|
|
361
452
|
cursor:
|
|
362
453
|
value['cursor'] === null || value['cursor'] === undefined ? null : cursor(value['cursor']),
|
|
363
454
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The node's HTTP surface: health, readiness, load shedding and the authenticated upgrade. Split
|
|
2
|
+
// from `sync-node.ts` because deciding whether a request becomes a websocket is a different job
|
|
3
|
+
// from what the socket then does — the same line `sync-frames.ts` and `sync-listen.ts` already draw.
|
|
4
|
+
|
|
5
|
+
import { healthzPayload, readyzPayload, reportError } from '@ultimat3/core';
|
|
6
|
+
import { SocketAuthUnavailableError, SocketUnauthenticatedError } from './errors';
|
|
7
|
+
import type { SyncAuthenticator, SyncGrant } from './sync-auth';
|
|
8
|
+
import { toWireError } from './sync-protocol';
|
|
9
|
+
import type { AcceptBudget, Rng } from './thundering-herd';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the upgrade hands the socket. It carries no actor: the grant does, and one identity written
|
|
13
|
+
* in two places is two that disagree the moment a re-auth renews one of them.
|
|
14
|
+
*/
|
|
15
|
+
export interface WsData {
|
|
16
|
+
readonly socketId: string;
|
|
17
|
+
readonly clientBuildId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
|
|
21
|
+
export interface UpgradeTarget {
|
|
22
|
+
upgrade(request: Request, options: { data: WsData }): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Everything the decision reads, supplied by the node. `ready` and `socketCount` are functions
|
|
27
|
+
* rather than values because both move while a request is parked inside `authenticate` — reading
|
|
28
|
+
* them once at the top is exactly the staleness this file exists to refuse.
|
|
29
|
+
*/
|
|
30
|
+
export interface UpgradeDeps {
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
readonly maxConnections: number;
|
|
34
|
+
readonly accept: AcceptBudget;
|
|
35
|
+
readonly rng: Rng;
|
|
36
|
+
ready(): boolean;
|
|
37
|
+
socketCount(): number;
|
|
38
|
+
newSocketId(): string;
|
|
39
|
+
readonly authenticate?: SyncAuthenticator | undefined;
|
|
40
|
+
/** Recorded only after the upgrade took: a grant for a socket that never opened is never closed. */
|
|
41
|
+
onGranted(socketId: string, grant: SyncGrant): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `undefined` means the upgrade took and Bun owns the connection now. Async because `authenticate`
|
|
46
|
+
* is: the credential is decided *before* `server.upgrade`, so a refused one never costs a websocket.
|
|
47
|
+
*/
|
|
48
|
+
export async function handleUpgrade(
|
|
49
|
+
deps: UpgradeDeps,
|
|
50
|
+
request: Request,
|
|
51
|
+
server: UpgradeTarget,
|
|
52
|
+
): Promise<Response | undefined> {
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
// Health is the process's, readiness is this node's: a draining node stays healthy while it hands
|
|
55
|
+
// its sockets to the rest of the fleet.
|
|
56
|
+
if (url.pathname === '/healthz') return json(healthzPayload());
|
|
57
|
+
if (url.pathname === '/readyz') {
|
|
58
|
+
const payload = readyzPayload();
|
|
59
|
+
return deps.ready() ? json(payload) : json({ status: 503, body: payload.body });
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
|
|
62
|
+
// The count, not the rate. Shed the same way and with the same delay attached: a client refused
|
|
63
|
+
// for a full node and one refused for a fast one have the same next move, and the refusal is
|
|
64
|
+
// decided before `authenticate` so a full node costs no token service call.
|
|
65
|
+
if (deps.socketCount() >= deps.maxConnections || !deps.ready() || !deps.accept.tryAccept()) {
|
|
66
|
+
return shed(deps);
|
|
67
|
+
}
|
|
68
|
+
let grant: SyncGrant | null = null;
|
|
69
|
+
if (deps.authenticate) {
|
|
70
|
+
try {
|
|
71
|
+
grant = await deps.authenticate(request);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// A failure is not a denial. The token service timing out must not read to a client as "you
|
|
74
|
+
// may not connect" — it is told to come back, and this node is the one that pages.
|
|
75
|
+
reportError(error, { source: 'realtime', scope: { operation: 'sync.authenticate' } });
|
|
76
|
+
return wireErrorResponse(
|
|
77
|
+
503,
|
|
78
|
+
new SocketAuthUnavailableError({ detail: 'see the node log for the cause' }),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
// The decision, made before a socket exists: an upgrade is the cheapest thing to refuse and the
|
|
82
|
+
// most expensive thing to take back.
|
|
83
|
+
if (grant === null) {
|
|
84
|
+
return wireErrorResponse(
|
|
85
|
+
401,
|
|
86
|
+
new SocketUnauthenticatedError({ reason: 'authenticate() resolved no actor' }),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Asked again, because `authenticate` is app code and awaiting it is awaiting a token service: a
|
|
91
|
+
// request that passed the check above can be parked there when SIGTERM lands, and the `accept`
|
|
92
|
+
// phase is over by the time it gets here. Upgrading then is the one socket that phase exists to
|
|
93
|
+
// refuse — the load balancer has already been told this node is out, so nothing takes it over. No
|
|
94
|
+
// second `tryAccept()`: that budget was spent above.
|
|
95
|
+
if (!deps.ready()) return shed(deps);
|
|
96
|
+
const data: WsData = {
|
|
97
|
+
socketId: deps.newSocketId(),
|
|
98
|
+
clientBuildId: url.searchParams.get('build') ?? deps.buildId,
|
|
99
|
+
};
|
|
100
|
+
if (!server.upgrade(request, { data })) {
|
|
101
|
+
return new Response('expected websocket', { status: 426 });
|
|
102
|
+
}
|
|
103
|
+
if (grant) deps.onGranted(data.socketId, grant);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Load shedding with a delay attached: refusing without one just moves the herd next door. */
|
|
108
|
+
function shed(deps: UpgradeDeps): Response {
|
|
109
|
+
return new Response('retry', {
|
|
110
|
+
status: 503,
|
|
111
|
+
headers: { 'retry-after-ms': String(deps.accept.retryAfterMs(deps.rng)) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function wireErrorResponse(status: number, error: unknown): Response {
|
|
116
|
+
return json({ status, body: { error: toWireError(error) } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function json(payload: { status: number; body: unknown }): Response {
|
|
120
|
+
return new Response(JSON.stringify(payload.body), {
|
|
121
|
+
status: payload.status,
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/thundering-herd.ts
CHANGED
|
@@ -49,6 +49,22 @@ export function backoffDelay(
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The timer half of mechanism 2. Returns its own canceller rather than a handle, so nothing has to
|
|
54
|
+
* name a type that differs between Bun, the browser and `node:timers`. Injected because a reconnect
|
|
55
|
+
* only provable by sleeping is a reconnect no test proves — and an unproven one silently did not
|
|
56
|
+
* fire at all until `As of 2026-08`.
|
|
57
|
+
*/
|
|
58
|
+
export type Scheduler = (fn: () => void, ms: number) => () => void;
|
|
59
|
+
|
|
60
|
+
/** The production scheduler: the one `setTimeout` on the client's reconnect path. */
|
|
61
|
+
export const timeoutScheduler: Scheduler = (fn, ms) => {
|
|
62
|
+
const handle = setTimeout(fn, ms);
|
|
63
|
+
return () => {
|
|
64
|
+
clearTimeout(handle);
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
52
68
|
export type ReconnectReason = 'drain' | 'overload' | 'rebalance';
|
|
53
69
|
|
|
54
70
|
export interface DrainPlanEntry {
|
|
@@ -131,6 +147,11 @@ export class AcceptBudget {
|
|
|
131
147
|
return Math.floor(this.#tokens);
|
|
132
148
|
}
|
|
133
149
|
|
|
150
|
+
/** The sustained rate this bucket was built with — what a refusal has to name to be actionable. */
|
|
151
|
+
get perSecond(): number {
|
|
152
|
+
return this.#perSecond;
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
#refill(): void {
|
|
135
156
|
const now = this.#clock.monotonic();
|
|
136
157
|
const elapsed = now - this.#lastRefill;
|
package/src/transport-env.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
8
|
import type { Transport } from './fanout';
|
|
9
9
|
import { InProcessTransport } from './fanout';
|
|
10
|
+
import type { NatsConnect } from './nats-client';
|
|
10
11
|
import { assertBucket } from './nats-jetstream';
|
|
11
|
-
import type { NatsStream, NatsTarget } from './nats-socket';
|
|
12
12
|
import { NatsTransport } from './nats-transport';
|
|
13
13
|
|
|
14
14
|
/** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
|
|
@@ -51,7 +51,7 @@ export interface SelectTransportOptions {
|
|
|
51
51
|
readonly presenceTtlMs?: number | undefined;
|
|
52
52
|
readonly clock?: Clock | undefined;
|
|
53
53
|
/** Injected so a boot — reconnect included — can be proven with no network. */
|
|
54
|
-
readonly
|
|
54
|
+
readonly connect?: NatsConnect | undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const nonEmpty = (value: string | undefined): string | undefined =>
|
|
@@ -91,7 +91,7 @@ export function selectTransport(
|
|
|
91
91
|
bucket,
|
|
92
92
|
presenceTtlMs,
|
|
93
93
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
94
|
-
...(options.
|
|
94
|
+
...(options.connect === undefined ? {} : { connect: options.connect }),
|
|
95
95
|
});
|
|
96
96
|
return {
|
|
97
97
|
transport,
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Compile-time pins for the typed query hook. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
3
|
+
// type-level assertion written there can never fail. This module emits nothing and exports
|
|
4
|
+
// nothing anybody imports — a regression is a build error, the only enforcement that counts
|
|
5
|
+
// (axiom 3). What it protects is the whole reason `liveHookFor` exists: `useLiveFeed({ orgId })`
|
|
6
|
+
// carrying the query's own input and row types. Lose that and the hook still runs — it just
|
|
7
|
+
// stops catching the typo that makes a subscription match nothing.
|
|
8
|
+
|
|
9
|
+
import type { Query } from '@ultimat3/query';
|
|
10
|
+
import type { LiveHandle, Unsubscribe } from './client';
|
|
11
|
+
import type { LiveRows } from './hooks';
|
|
12
|
+
import type { LiveQueryHook, LiveQuerySource } from './query-hook';
|
|
13
|
+
|
|
14
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
15
|
+
type Assert<T extends true> = T;
|
|
16
|
+
|
|
17
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
18
|
+
|
|
19
|
+
/** The input type a `Query` accepts, read off its call signature rather than its schema. */
|
|
20
|
+
type InputOf<Q> = Q extends (input: infer I, options?: never) => unknown ? I : never;
|
|
21
|
+
|
|
22
|
+
interface FeedInput {
|
|
23
|
+
readonly orgId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FeedRow {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type FeedHook = LiveQueryHook<FeedInput, FeedRow>;
|
|
32
|
+
|
|
33
|
+
/** The hook takes the query's own input, as a value or as the thunk `useLive` reads once. */
|
|
34
|
+
export type _HookInputIsTheQueryInput = Assert<
|
|
35
|
+
Equals<Parameters<FeedHook>[0], FeedInput | (() => FeedInput)>
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
/** …and answers in the query's own row type, not the wire's `Row`. */
|
|
39
|
+
export type _HookRowsAreTheQueryRows = Assert<
|
|
40
|
+
Equals<ReturnType<ReturnType<FeedHook>>, readonly FeedRow[]>
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A wrong key is refused. Written as a negative because that is the failure being pinned: a hook
|
|
45
|
+
* whose input widened to `JsonValue` would still compile everywhere and silently accept `orgIdd`.
|
|
46
|
+
*/
|
|
47
|
+
export type _WrongInputKeyIsRefused = Assert<
|
|
48
|
+
[{ readonly orgIdd: string }] extends [Parameters<FeedHook>[0]] ? false : true
|
|
49
|
+
>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The seam itself: a declared `@ultimat3/query` `Query` assigns to the structural shape
|
|
53
|
+
* `liveHookFor` binds. Named structurally rather than imported as a value, so this stays the one
|
|
54
|
+
* place a change to `Query` — losing `isLive`, ceasing to be callable — fails, instead of every
|
|
55
|
+
* component call site in every app.
|
|
56
|
+
*/
|
|
57
|
+
export type _DeclaredQueryBindsToTheHook = Assert<
|
|
58
|
+
[Query] extends [LiveQuerySource<InputOf<Query>, Record<string, unknown>>] ? true : false
|
|
59
|
+
>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The handle `LiveClient.useLive()` returns must stay `Disposable`, or `using sub =
|
|
63
|
+
* client.useLive(...)` silently degrades to "never unsubscribes" the moment someone drops the
|
|
64
|
+
* `[Symbol.dispose]` member while refactoring `unsubscribe`.
|
|
65
|
+
*/
|
|
66
|
+
export type _LiveHandleIsDisposable = Assert<[LiveHandle] extends [Disposable] ? true : false>;
|
|
67
|
+
|
|
68
|
+
/** Same pin, one layer up: the hook's callable result set must stay `Disposable` too. */
|
|
69
|
+
export type _LiveRowsIsDisposable = Assert<[LiveRows] extends [Disposable] ? true : false>;
|
|
70
|
+
|
|
71
|
+
/** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
|
|
72
|
+
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// One serial lane per live query id, over that query's shared window.
|
|
2
|
+
//
|
|
3
|
+
// The window is a read-modify-write across awaits — match, apply, append, then one policy pass per
|
|
4
|
+
// subscriber — and nothing upstream orders the callers: `sync` fires `void registry.deliver(change)`
|
|
5
|
+
// straight off the bus. Two of those interleaving is one subscriber shown lsn 2 before lsn 1, its
|
|
6
|
+
// cursor then rewound to 1, and a gate deciding about a row against a window that has moved past it.
|
|
7
|
+
|
|
8
|
+
/** FIFO, one task at a time. A task that rejects hands its rejection to its own caller and no one else. */
|
|
9
|
+
export class WindowLock {
|
|
10
|
+
#tail: Promise<unknown> = Promise.resolve();
|
|
11
|
+
|
|
12
|
+
run<T>(work: () => Promise<T>): Promise<T> {
|
|
13
|
+
const result = this.#tail.then(work);
|
|
14
|
+
// The lane chains on a settled shadow, never on `result`: one delivery that threw must not
|
|
15
|
+
// reject every delivery queued behind it, and an unwatched shadow must not look unhandled.
|
|
16
|
+
this.#tail = result.then(ignore, ignore);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ignore = (): void => undefined;
|
package/src/nats-commands.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
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}`);
|