@ultimat3/realtime 20.2.1 → 22.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 +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/sync-protocol.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// The wire
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
// The wire, READ-ONLY for the client: channel and live-query subscriptions go up, snapshots,
|
|
2
|
+
// patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
|
|
3
|
+
// so there is one write path, with the action's authz, idempotency store and contract behind it.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
6
|
+
import type {
|
|
7
|
+
ChannelEventsFrame,
|
|
8
|
+
ChannelRecordsFrame,
|
|
9
|
+
ChannelSubscribeTarget,
|
|
10
|
+
ReplayGapFrame,
|
|
11
|
+
} from './channel-wire';
|
|
12
|
+
import type { LiveCursor } from './cursor';
|
|
12
13
|
import {
|
|
13
14
|
isJsonObject,
|
|
14
15
|
isRow,
|
|
@@ -17,41 +18,19 @@ import {
|
|
|
17
18
|
type Row,
|
|
18
19
|
type RowPatch,
|
|
19
20
|
} from './json';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* What one frame may contain. Hard ceilings a caller cannot widen — the shape
|
|
35
|
-
* `packages/mcp/src/query-limits.ts` uses — because every one of them is read off a socket the
|
|
36
|
-
* node has already paid for: an unbounded `cursor.ids` was consumed raw into a `Set`, and an
|
|
37
|
-
* `input` of arbitrary depth reached `canonicalJson`, which recurses.
|
|
38
|
-
*
|
|
39
|
-
* Every number clears what this node itself produces, or the decoder refuses its own frames on
|
|
40
|
-
* the next reconnect: `cursorIds` is `CURSOR_ID_LIMIT`, `patches` clears
|
|
41
|
-
* `defaultReconnectBudget.maxPatches`.
|
|
42
|
-
*/
|
|
43
|
-
export const FRAME_LIMITS = Object.freeze({
|
|
44
|
-
cursorIds: CURSOR_ID_LIMIT,
|
|
45
|
-
patches: 4_096,
|
|
46
|
-
rows: 10_000,
|
|
47
|
-
members: 4_096,
|
|
48
|
-
/** Nesting one `input` may reach. 32 is far past any query's real argument shape. */
|
|
49
|
-
inputDepth: 32,
|
|
50
|
-
/** Values one `input` may hold in total, so a flat-but-enormous object is refused too. */
|
|
51
|
-
inputNodes: 10_000,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
|
|
21
|
+
import { ProtocolVersionError } from './page-errors';
|
|
22
|
+
import { channelTarget, events, records, replayGap } from './wire-channel';
|
|
23
|
+
import { bounded, fail, list, nullableStr, num, object, pick, str } from './wire-read';
|
|
24
|
+
import { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
ChannelEventsFrame,
|
|
28
|
+
ChannelRecordsFrame,
|
|
29
|
+
ChannelSubscribeTarget,
|
|
30
|
+
ReplayGapFrame,
|
|
31
|
+
} from './channel-wire';
|
|
32
|
+
/** The version and the ceilings live below this file so the readers can share them without a cycle. */
|
|
33
|
+
export { FRAME_LIMITS, PROTOCOL_VERSION } from './wire-version';
|
|
55
34
|
|
|
56
35
|
export interface WireError {
|
|
57
36
|
readonly code: string;
|
|
@@ -69,7 +48,8 @@ export interface PresenceMember {
|
|
|
69
48
|
}
|
|
70
49
|
|
|
71
50
|
export type SubscribeTarget =
|
|
72
|
-
|
|
51
|
+
/** A declared `channel()`: its NAME and params, never a topic string the client spelled. */
|
|
52
|
+
| ChannelSubscribeTarget
|
|
73
53
|
| {
|
|
74
54
|
/**
|
|
75
55
|
* Client -> server, `qid` carries the *query name*; the server derives the real qid from
|
|
@@ -124,6 +104,12 @@ export interface SnapshotFrame {
|
|
|
124
104
|
* skews are safe in both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
125
105
|
*/
|
|
126
106
|
readonly entity?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Each row's RECORD key, parallel to `rows`, sent only when some key is not its row's `id` (a
|
|
109
|
+
* composite primary key). The server renders it with the entity's projection; the browser never
|
|
110
|
+
* derives a key.
|
|
111
|
+
*/
|
|
112
|
+
readonly keys?: readonly string[];
|
|
127
113
|
}
|
|
128
114
|
|
|
129
115
|
export interface PatchFrame {
|
|
@@ -134,51 +120,18 @@ export interface PatchFrame {
|
|
|
134
120
|
readonly lsn: string;
|
|
135
121
|
}
|
|
136
122
|
|
|
137
|
-
export interface MutateFrame {
|
|
138
|
-
readonly type: 'mutate';
|
|
139
|
-
readonly v: number;
|
|
140
|
-
/** Idempotency key. The server collapses repeats; the client never renumbers. */
|
|
141
|
-
readonly key: string;
|
|
142
|
-
readonly seq: number;
|
|
143
|
-
readonly name: string;
|
|
144
|
-
readonly input: JsonValue;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
123
|
export interface AckFrame {
|
|
148
124
|
readonly type: 'ack';
|
|
149
125
|
readonly v: number;
|
|
150
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* What a refusal refers to: the sid of a subscription the node refused, or the socket id for a
|
|
128
|
+
* frame it could not read at all. The socket carries no writes, so an ack is never a receipt.
|
|
129
|
+
*/
|
|
151
130
|
readonly ref: string;
|
|
152
131
|
readonly lsn: string | null;
|
|
153
132
|
readonly error: WireError | null;
|
|
154
133
|
}
|
|
155
134
|
|
|
156
|
-
export interface RebaseFrame {
|
|
157
|
-
readonly type: 'rebase';
|
|
158
|
-
readonly v: number;
|
|
159
|
-
readonly key: string;
|
|
160
|
-
readonly entity: string;
|
|
161
|
-
readonly strategy: ConflictStrategyName;
|
|
162
|
-
/** Server truth for the row the mutation touched; `null` when the server deleted it. */
|
|
163
|
-
readonly row: Row | null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export interface PresenceFrame {
|
|
167
|
-
readonly type: 'presence';
|
|
168
|
-
readonly v: number;
|
|
169
|
-
readonly topic: string;
|
|
170
|
-
readonly op: 'join' | 'leave' | 'update' | 'sync';
|
|
171
|
-
readonly members: readonly PresenceMember[];
|
|
172
|
-
/**
|
|
173
|
-
* Members in the whole set behind a `sync` frame, which is capped: a 5,000-avatar row is not a UI
|
|
174
|
-
* anyone renders, and the count is what lets a client say "and 4,744 others" without holding
|
|
175
|
-
* them. Optional and **additive**, exactly like `snapshot.entity`: an old node omits it and a new
|
|
176
|
-
* one reads its absence as "this frame is the whole set", so neither skew is unreadable and
|
|
177
|
-
* `PROTOCOL_VERSION` does not move. Never set on a `join`/`leave`/`update` — those are deltas.
|
|
178
|
-
*/
|
|
179
|
-
readonly total?: number;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
135
|
export interface ReconnectFrame {
|
|
183
136
|
readonly type: 'reconnect';
|
|
184
137
|
readonly v: number;
|
|
@@ -198,12 +151,12 @@ export type Frame =
|
|
|
198
151
|
| SubscribeFrame
|
|
199
152
|
| SnapshotFrame
|
|
200
153
|
| PatchFrame
|
|
201
|
-
| MutateFrame
|
|
202
154
|
| AckFrame
|
|
203
|
-
| RebaseFrame
|
|
204
|
-
| PresenceFrame
|
|
205
155
|
| ReconnectFrame
|
|
206
|
-
| UpdateAvailableFrame
|
|
156
|
+
| UpdateAvailableFrame
|
|
157
|
+
| ChannelRecordsFrame
|
|
158
|
+
| ChannelEventsFrame
|
|
159
|
+
| ReplayGapFrame;
|
|
207
160
|
|
|
208
161
|
export type FrameKind = Frame['type'];
|
|
209
162
|
|
|
@@ -212,12 +165,12 @@ export const FRAME_KINDS: readonly FrameKind[] = [
|
|
|
212
165
|
'subscribe',
|
|
213
166
|
'snapshot',
|
|
214
167
|
'patch',
|
|
215
|
-
'mutate',
|
|
216
168
|
'ack',
|
|
217
|
-
'rebase',
|
|
218
|
-
'presence',
|
|
219
169
|
'reconnect',
|
|
220
170
|
'update-available',
|
|
171
|
+
'records',
|
|
172
|
+
'events',
|
|
173
|
+
'replay-gap',
|
|
221
174
|
];
|
|
222
175
|
|
|
223
176
|
export function encode(frame: Frame): string {
|
|
@@ -265,7 +218,14 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
265
218
|
cursor: cursor(parsed['cursor']),
|
|
266
219
|
} as const;
|
|
267
220
|
const entity = nullableStr(parsed, 'entity');
|
|
268
|
-
|
|
221
|
+
const scoped = entity === null ? base : { ...base, entity };
|
|
222
|
+
if (parsed['keys'] === undefined) return scoped;
|
|
223
|
+
const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
|
|
224
|
+
if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
|
|
225
|
+
return key;
|
|
226
|
+
});
|
|
227
|
+
if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
|
|
228
|
+
return { ...scoped, keys };
|
|
269
229
|
}
|
|
270
230
|
case 'patch':
|
|
271
231
|
return {
|
|
@@ -275,15 +235,6 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
275
235
|
patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
|
|
276
236
|
lsn: str(parsed, 'lsn'),
|
|
277
237
|
};
|
|
278
|
-
case 'mutate':
|
|
279
|
-
return {
|
|
280
|
-
type: 'mutate',
|
|
281
|
-
v: PROTOCOL_VERSION,
|
|
282
|
-
key: str(parsed, 'key'),
|
|
283
|
-
seq: num(parsed, 'seq'),
|
|
284
|
-
name: str(parsed, 'name'),
|
|
285
|
-
input: bounded(parsed['input'] ?? null, 'input'),
|
|
286
|
-
};
|
|
287
238
|
case 'ack':
|
|
288
239
|
return {
|
|
289
240
|
type: 'ack',
|
|
@@ -292,25 +243,6 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
292
243
|
lsn: nullableStr(parsed, 'lsn'),
|
|
293
244
|
error: wireError(parsed['error']),
|
|
294
245
|
};
|
|
295
|
-
case 'rebase':
|
|
296
|
-
return {
|
|
297
|
-
type: 'rebase',
|
|
298
|
-
v: PROTOCOL_VERSION,
|
|
299
|
-
key: str(parsed, 'key'),
|
|
300
|
-
entity: str(parsed, 'entity'),
|
|
301
|
-
strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
|
|
302
|
-
row: parsed['row'] === null ? null : row(parsed['row']),
|
|
303
|
-
};
|
|
304
|
-
case 'presence': {
|
|
305
|
-
const base = {
|
|
306
|
-
type: 'presence',
|
|
307
|
-
v: PROTOCOL_VERSION,
|
|
308
|
-
topic: str(parsed, 'topic'),
|
|
309
|
-
op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
|
|
310
|
-
members: list(parsed, 'members', FRAME_LIMITS.members).map(member),
|
|
311
|
-
} as const;
|
|
312
|
-
return parsed['total'] === undefined ? base : { ...base, total: num(parsed, 'total') };
|
|
313
|
-
}
|
|
314
246
|
case 'reconnect':
|
|
315
247
|
return {
|
|
316
248
|
type: 'reconnect',
|
|
@@ -320,6 +252,12 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
320
252
|
};
|
|
321
253
|
case 'update-available':
|
|
322
254
|
return { type: 'update-available', v: PROTOCOL_VERSION, buildId: str(parsed, 'buildId') };
|
|
255
|
+
case 'records':
|
|
256
|
+
return records(parsed);
|
|
257
|
+
case 'events':
|
|
258
|
+
return events(parsed);
|
|
259
|
+
case 'replay-gap':
|
|
260
|
+
return replayGap(parsed);
|
|
323
261
|
default:
|
|
324
262
|
throw fail(`unknown frame type ${JSON.stringify(kind)}`);
|
|
325
263
|
}
|
|
@@ -340,80 +278,6 @@ export function toWireError(error: unknown): WireError {
|
|
|
340
278
|
return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
341
279
|
}
|
|
342
280
|
|
|
343
|
-
function fail(detail: string): ProtocolVersionError {
|
|
344
|
-
return new ProtocolVersionError({ got: detail, expected: PROTOCOL_VERSION, detail });
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function str(obj: JsonObject, key: string): string {
|
|
348
|
-
const value = obj[key];
|
|
349
|
-
if (typeof value !== 'string') throw fail(`field "${key}" must be a string`);
|
|
350
|
-
return value;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function nullableStr(obj: JsonObject, key: string): string | null {
|
|
354
|
-
const value = obj[key];
|
|
355
|
-
if (value === null || value === undefined) return null;
|
|
356
|
-
if (typeof value !== 'string') throw fail(`field "${key}" must be a string or null`);
|
|
357
|
-
return value;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function num(obj: JsonObject, key: string): number {
|
|
361
|
-
const value = obj[key];
|
|
362
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
363
|
-
throw fail(`field "${key}" must be a finite number`);
|
|
364
|
-
}
|
|
365
|
-
return value;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly T[]): T {
|
|
369
|
-
const value = str(obj, key);
|
|
370
|
-
const found = allowed.find((candidate) => candidate === value);
|
|
371
|
-
if (found === undefined) throw fail(`field "${key}" must be one of ${allowed.join('|')}`);
|
|
372
|
-
return found;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* An array field, with the ceiling the caller had to choose. `max` is required rather than
|
|
377
|
-
* defaulted: a new list field on a new frame is a new thing an authenticated socket can make
|
|
378
|
-
* arbitrarily large, and a default would let one ship without anyone deciding its size.
|
|
379
|
-
*/
|
|
380
|
-
function list(obj: JsonObject, key: string, max: number, label = key): JsonValue[] {
|
|
381
|
-
const value = obj[key];
|
|
382
|
-
if (value === undefined || value === null) return [];
|
|
383
|
-
if (!Array.isArray(value)) throw fail(`field "${label}" must be an array`);
|
|
384
|
-
if (value.length > max) {
|
|
385
|
-
throw fail(`field "${label}" carries ${value.length} entries, over the limit of ${max}`);
|
|
386
|
-
}
|
|
387
|
-
return value;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* A client-supplied value, walked ITERATIVELY to its limits. Iteratively because the thing being
|
|
392
|
-
* refused is a stack overflow: `queryHash` -> `canonicalJson` recurses over exactly this value, so a
|
|
393
|
-
* depth check that recursed would be the same crash one frame earlier.
|
|
394
|
-
*/
|
|
395
|
-
function bounded(value: JsonValue, label: string): JsonValue {
|
|
396
|
-
const stack: { node: JsonValue; depth: number }[] = [{ node: value, depth: 1 }];
|
|
397
|
-
let seen = 0;
|
|
398
|
-
while (stack.length > 0) {
|
|
399
|
-
// `pop` cannot answer undefined here — the loop guard is the length — and the check is what
|
|
400
|
-
// makes that readable to the compiler without a cast.
|
|
401
|
-
const next = stack.pop();
|
|
402
|
-
if (next === undefined) break;
|
|
403
|
-
seen += 1;
|
|
404
|
-
if (seen > FRAME_LIMITS.inputNodes) {
|
|
405
|
-
throw fail(`field "${label}" holds more than ${FRAME_LIMITS.inputNodes} values`);
|
|
406
|
-
}
|
|
407
|
-
if (next.depth > FRAME_LIMITS.inputDepth) {
|
|
408
|
-
throw fail(`field "${label}" is nested deeper than ${FRAME_LIMITS.inputDepth}`);
|
|
409
|
-
}
|
|
410
|
-
if (next.node === null || typeof next.node !== 'object') continue;
|
|
411
|
-
const children = Array.isArray(next.node) ? next.node : Object.values(next.node);
|
|
412
|
-
for (const child of children) stack.push({ node: child, depth: next.depth + 1 });
|
|
413
|
-
}
|
|
414
|
-
return value;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
281
|
function row(value: unknown): Row {
|
|
418
282
|
if (!isRow(value)) throw fail('row must be an object with a string "id"');
|
|
419
283
|
return value;
|
|
@@ -440,23 +304,15 @@ function patch(value: unknown): RowPatch {
|
|
|
440
304
|
row: value['row'] === null || value['row'] === undefined ? null : object(value['row']),
|
|
441
305
|
lsn: str(value, 'lsn'),
|
|
442
306
|
};
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
function member(value: unknown): PresenceMember {
|
|
447
|
-
if (!isJsonObject(value)) throw fail('presence member must be an object');
|
|
448
|
-
return {
|
|
449
|
-
id: str(value, 'id'),
|
|
450
|
-
actorId: nullableStr(value, 'actorId'),
|
|
451
|
-
meta: object(value['meta'] ?? {}),
|
|
452
|
-
updatedAt: num(value, 'updatedAt'),
|
|
453
|
-
};
|
|
307
|
+
const indexed = value['index'] === undefined ? base : { ...base, index: num(value, 'index') };
|
|
308
|
+
const key = nullableStr(value, 'key');
|
|
309
|
+
return key === null ? indexed : { ...indexed, key };
|
|
454
310
|
}
|
|
455
311
|
|
|
456
312
|
function target(value: unknown): SubscribeTarget {
|
|
457
313
|
if (!isJsonObject(value)) throw fail('subscribe.target must be an object');
|
|
458
|
-
const kind = pick(value, 'kind', ['
|
|
459
|
-
if (kind === '
|
|
314
|
+
const kind = pick(value, 'kind', ['query', 'channel'] as const);
|
|
315
|
+
if (kind === 'channel') return channelTarget(value);
|
|
460
316
|
return {
|
|
461
317
|
kind,
|
|
462
318
|
qid: str(value, 'qid'),
|
|
@@ -466,11 +322,6 @@ function target(value: unknown): SubscribeTarget {
|
|
|
466
322
|
};
|
|
467
323
|
}
|
|
468
324
|
|
|
469
|
-
function object(value: unknown): JsonObject {
|
|
470
|
-
if (!isJsonObject(value)) throw fail('expected a JSON object');
|
|
471
|
-
return value;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
325
|
function wireError(value: unknown): WireError | null {
|
|
475
326
|
if (value === null || value === undefined) return null;
|
|
476
327
|
if (!isJsonObject(value)) throw fail('ack.error must be an object or null');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The SharedWorker entry (plan 101, slice 11): every tab of this origin and principal connects a
|
|
2
|
+
// port, and the one engine behind them holds the one socket. `x build` bundles this file as a
|
|
3
|
+
// classic script at `/_x/sync-worker/<hash>.js`. No other code lives here.
|
|
4
|
+
|
|
5
|
+
import { browserSocket, dialUrl } from './browser-socket';
|
|
6
|
+
import { messagePort, SocketEngine } from './socket-engine';
|
|
7
|
+
|
|
8
|
+
const engine = new SocketEngine({ dial: (target) => browserSocket(dialUrl(target)) });
|
|
9
|
+
|
|
10
|
+
(globalThis as { onconnect?: (event: MessageEvent) => void }).onconnect = (event) => {
|
|
11
|
+
for (const port of event.ports) engine.attach(messagePort(port));
|
|
12
|
+
};
|
package/src/thundering-herd.ts
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Three mechanisms, in the order they fire:
|
|
5
5
|
// 1. drainPlan() — the draining node assigns each client a distinct delay slot before closing
|
|
6
|
-
// 2.
|
|
6
|
+
// 2. policyDelay() — the client's own jittered retry, for failures nobody scheduled
|
|
7
7
|
// 3. AcceptBudget — the receiving node's token bucket, so recovery sheds instead of collapsing
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
backoffDelay,
|
|
10
11
|
type Clock,
|
|
11
|
-
backoffDelay as coreBackoffDelay,
|
|
12
12
|
finiteOption,
|
|
13
13
|
type JitterMode,
|
|
14
14
|
type Random,
|
|
15
15
|
systemClock,
|
|
16
|
-
} from '@ultimat3/core';
|
|
16
|
+
} from '@ultimat3/core/page';
|
|
17
17
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
18
18
|
|
|
19
19
|
/** Injected so tests are deterministic and `local` mutators stay replayable. */
|
|
@@ -40,21 +40,40 @@ export const defaultBackoff: BackoffPolicy = {
|
|
|
40
40
|
jitter: 'full',
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/** The longest a browser waits between two dials, whatever the attempt. */
|
|
44
|
+
export const BROWSER_RECONNECT_MAX_MS = 4_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A browser's redial: the same curve, capped at seconds rather than `defaultBackoff`'s thirty.
|
|
48
|
+
* Measured after a deploy — six dials in ~3s, then a full-jitter roll under a 30s cap left the
|
|
49
|
+
* node that came back (and the `update-available` it had for the tab) unreached for 27s. `equal`
|
|
50
|
+
* jitter, because at the cap it keeps a floor: a SIGKILLed node's herd redials inside a 2-4s
|
|
51
|
+
* window rather than at once, and the `AcceptBudget` sheds the excess before any query runs. A
|
|
52
|
+
* PLANNED restart never reaches this: the drain's `reconnect` frame assigns each socket its slot.
|
|
53
|
+
*/
|
|
54
|
+
export const browserBackoff: BackoffPolicy = {
|
|
55
|
+
baseMs: 500,
|
|
56
|
+
maxMs: BROWSER_RECONNECT_MAX_MS,
|
|
57
|
+
factor: 2,
|
|
58
|
+
jitter: 'equal',
|
|
59
|
+
};
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
|
-
*
|
|
62
|
+
* A {@link BackoffPolicy} mapped onto `@ultimat3/core`'s `backoffDelay` — the one curve. Attempt is
|
|
63
|
+
* 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `baseMs`.
|
|
45
64
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
65
|
+
* Internal to this package and never re-exported from the barrel. Until 22.0.0 `.` exported a
|
|
66
|
+
* 0-based `backoffDelay` of its own that shifted by one before delegating, which made two counting
|
|
67
|
+
* conventions under one name — and a caller that passed a 1-based count to it (the channel
|
|
68
|
+
* catch-up retry did) waited twice as long as it meant to, with no error anywhere.
|
|
50
69
|
*/
|
|
51
|
-
export function
|
|
70
|
+
export function policyDelay(
|
|
71
|
+
policy: BackoffPolicy,
|
|
52
72
|
attempt: number,
|
|
53
|
-
policy: BackoffPolicy = defaultBackoff,
|
|
54
73
|
rng: Rng = Math.random,
|
|
55
74
|
): number {
|
|
56
|
-
return
|
|
57
|
-
attempt
|
|
75
|
+
return backoffDelay({
|
|
76
|
+
attempt,
|
|
58
77
|
base: policy.baseMs,
|
|
59
78
|
max: policy.maxMs,
|
|
60
79
|
factor: policy.factor,
|
package/src/transport-env.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
// Single responsibility: environment → fanout transport. The one place a boot
|
|
2
|
-
// process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
3
|
-
// custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
|
|
1
|
+
// Single responsibility: `realtime.transport` + environment → fanout transport. The one place a boot
|
|
2
|
+
// decides whether this process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
3
|
+
// `sync` container and any custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
|
|
4
4
|
// the bucket's whole-stream age limit and `PresenceRegistry`'s TTL are the same number seen from
|
|
5
5
|
// two sides, and a caller that had to pass each one separately could quietly set them apart.
|
|
6
6
|
|
|
7
|
-
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { finiteOption } from '@ultimat3/core';
|
|
7
|
+
import type { Clock, RealtimeConfig } from '@ultimat3/core';
|
|
8
|
+
import { ConfigInvalidError, finiteOption } from '@ultimat3/core';
|
|
9
9
|
import type { Transport } from './fanout';
|
|
10
10
|
import { InProcessTransport } from './fanout';
|
|
11
11
|
import type { NatsConnect } from './nats-client';
|
|
12
12
|
import { assertBucket } from './nats-jetstream';
|
|
13
13
|
import { NatsTransport } from './nats-transport';
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
|
|
17
|
+
* `NATS_URL` is the conventional bus variable: read under `transport: 'nats'` when `urlEnv` names
|
|
18
|
+
* it, and under `'memory'` only to refuse it — see `selectTransport`.
|
|
19
|
+
*/
|
|
16
20
|
export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
|
|
17
21
|
|
|
22
|
+
/** The two fields of `app.config.ts`'s `realtime` section that decide the bus. */
|
|
23
|
+
export type RealtimeTopology = Pick<RealtimeConfig, 'transport' | 'urlEnv'>;
|
|
24
|
+
|
|
25
|
+
/** Named in every refusal, so the reader edits the file the decision lives in. */
|
|
26
|
+
const CONFIG_FILE = 'app.config.ts';
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
|
|
20
30
|
* one presence namespace, and a room name that collided would list the other app's members.
|
|
@@ -59,13 +69,22 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
59
69
|
value === undefined || value.trim().length === 0 ? undefined : value.trim();
|
|
60
70
|
|
|
61
71
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
72
|
+
* The config decides the transport and the environment supplies its url — never the other way
|
|
73
|
+
* round. Until 22.0.0 `NATS_URL` alone decided and `realtime.transport` / `realtime.urlEnv` were
|
|
74
|
+
* read by nothing, so `transport: 'nats'` with the variable unset booted the in-process bus and
|
|
75
|
+
* reached no other node, with no error on either side. Both mismatches are refused here:
|
|
76
|
+
*
|
|
77
|
+
* - `'nats'` with the variable `urlEnv` names unset or blank.
|
|
78
|
+
* - `'memory'` with a bus url set (`NATS_URL`, or the variable `urlEnv` names). An operator who set
|
|
79
|
+
* one expected fanout across nodes; keeping every change in this heap instead is the same silent
|
|
80
|
+
* failure seen from the other side, so the two are refused rather than reconciled.
|
|
81
|
+
*
|
|
82
|
+
* The bucket name is validated here rather than on first connect: a typo'd bucket is a boot that
|
|
83
|
+
* reports a healthy bus and then fails every presence write.
|
|
66
84
|
*/
|
|
67
85
|
export function selectTransport(
|
|
68
86
|
env: TransportEnvironment,
|
|
87
|
+
topology: RealtimeTopology,
|
|
69
88
|
options: SelectTransportOptions = {},
|
|
70
89
|
): TransportSelection {
|
|
71
90
|
const presenceTtlMs = finiteOption(
|
|
@@ -73,22 +92,32 @@ export function selectTransport(
|
|
|
73
92
|
'presenceTtlMs',
|
|
74
93
|
options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
|
|
75
94
|
);
|
|
76
|
-
const url = nonEmpty(env['NATS_URL']);
|
|
77
95
|
|
|
78
|
-
if (
|
|
96
|
+
if (topology.transport === 'memory') {
|
|
97
|
+
refuseStrayBusUrl(env, topology);
|
|
79
98
|
const transport = new InProcessTransport(
|
|
80
99
|
options.clock === undefined ? {} : { clock: options.clock },
|
|
81
100
|
);
|
|
82
101
|
return {
|
|
83
102
|
transport,
|
|
84
103
|
mode: 'embedded',
|
|
85
|
-
detail:
|
|
104
|
+
detail: `in-process fanout — set realtime.transport 'nats' in ${CONFIG_FILE} and NATS_URL to reach the other nodes`,
|
|
86
105
|
bucket: null,
|
|
87
106
|
presenceTtlMs,
|
|
88
107
|
connect: () => Promise.resolve(),
|
|
89
108
|
};
|
|
90
109
|
}
|
|
91
110
|
|
|
111
|
+
const urlEnv = topology.urlEnv ?? 'NATS_URL';
|
|
112
|
+
const url = nonEmpty(env[urlEnv]);
|
|
113
|
+
if (url === undefined) {
|
|
114
|
+
throw new ConfigInvalidError({
|
|
115
|
+
cause: `realtime.transport is 'nats' and realtime.urlEnv names ${urlEnv}, which is unset in this process's environment, so no node would be reachable`,
|
|
116
|
+
fix: `set ${urlEnv} to the nats-server url for every realtime role (web, sync, replicator), or set realtime: { transport: 'memory' } in ${CONFIG_FILE} for a single node`,
|
|
117
|
+
meta: { key: 'realtime.urlEnv', variable: urlEnv },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
|
|
93
122
|
assertBucket(bucket);
|
|
94
123
|
const transport = new NatsTransport({
|
|
@@ -101,9 +130,21 @@ export function selectTransport(
|
|
|
101
130
|
return {
|
|
102
131
|
transport,
|
|
103
132
|
mode: 'external',
|
|
104
|
-
detail:
|
|
133
|
+
detail: urlEnv,
|
|
105
134
|
bucket,
|
|
106
135
|
presenceTtlMs,
|
|
107
136
|
connect: () => transport.connect(),
|
|
108
137
|
};
|
|
109
138
|
}
|
|
139
|
+
|
|
140
|
+
/** `'memory'` with a bus url in the environment: the conflict `selectTransport` refuses. */
|
|
141
|
+
function refuseStrayBusUrl(env: TransportEnvironment, topology: RealtimeTopology): void {
|
|
142
|
+
const names = topology.urlEnv === undefined ? ['NATS_URL'] : ['NATS_URL', topology.urlEnv];
|
|
143
|
+
const set = names.find((name) => nonEmpty(env[name]) !== undefined);
|
|
144
|
+
if (set === undefined) return;
|
|
145
|
+
throw new ConfigInvalidError({
|
|
146
|
+
cause: `${set} is set but realtime.transport is 'memory', so this process would fan out in its own heap and reach no other node`,
|
|
147
|
+
fix: `set realtime: { transport: 'nats', urlEnv: '${set}' } in ${CONFIG_FILE} to use the bus, or unset ${set} for a single node`,
|
|
148
|
+
meta: { key: 'realtime.transport', variable: set },
|
|
149
|
+
});
|
|
150
|
+
}
|