@ultimat3/realtime 20.2.1 → 21.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 +186 -122
- package/README.md +121 -126
- 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 +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -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 +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- 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 +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- 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 +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- 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 +214 -0
- package/src/use-query.ts +255 -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/socket.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
systemClock,
|
|
17
17
|
uuid,
|
|
18
18
|
} from '@ultimat3/core';
|
|
19
|
+
import { GapRepairs } from './channel-gaps';
|
|
20
|
+
import type { ChannelRecordsFrame } from './channel-wire';
|
|
19
21
|
import { CLOSE } from './close-codes';
|
|
20
22
|
import { encode, type Frame } from './sync-protocol';
|
|
21
23
|
import { AcceptBudget } from './thundering-herd';
|
|
@@ -81,10 +83,10 @@ export const DEFAULT_FRAME_BURST = 256;
|
|
|
81
83
|
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
|
-
* Channel frames this process dropped under backpressure. A
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* only
|
|
86
|
+
* Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
|
|
87
|
+
* and REPAIRED — the socket is sent `replay-gap` once it drains and re-reads the channel's catch-up
|
|
88
|
+
* query (`channel_replay_gaps_total` counts those) — while a dropped `events` frame is ephemeral
|
|
89
|
+
* by definition and is only counted. The two series side by side are the whole delivery story.
|
|
88
90
|
*
|
|
89
91
|
* Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
|
|
90
92
|
* series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
|
|
@@ -94,7 +96,7 @@ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
|
94
96
|
*/
|
|
95
97
|
const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
|
|
96
98
|
unit: '{frame}',
|
|
97
|
-
description: 'Channel frames dropped by socket backpressure
|
|
99
|
+
description: 'Channel frames dropped by socket backpressure',
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
export function actorIdOf(actor: Actor | null): string | null {
|
|
@@ -123,6 +125,12 @@ export class SyncSocket {
|
|
|
123
125
|
* flush re-snapshots instead of silently diverging.
|
|
124
126
|
*/
|
|
125
127
|
readonly desynced = new Set<string>();
|
|
128
|
+
/**
|
|
129
|
+
* Channel topics whose `records` stream this socket lost a frame of, with the epoch it was lost
|
|
130
|
+
* in. The channel twin of `desynced`: written on a drop, read on the next delivery or drain, which
|
|
131
|
+
* sends `replay-gap` and clears it. Bounded by `topics`, so it adds nothing a socket did not hold.
|
|
132
|
+
*/
|
|
133
|
+
readonly gaps = new Map<string, string>();
|
|
126
134
|
/**
|
|
127
135
|
* Inbound frames this socket may still have routed. The accept budget spends one token per
|
|
128
136
|
* UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
|
|
@@ -255,6 +263,7 @@ export class SyncSocket {
|
|
|
255
263
|
|
|
256
264
|
unsubscribeTopic(topic: string): void {
|
|
257
265
|
this.topics.delete(topic);
|
|
266
|
+
this.gaps.delete(topic);
|
|
258
267
|
}
|
|
259
268
|
|
|
260
269
|
touch(): void {
|
|
@@ -313,6 +322,8 @@ export class SocketRegistry {
|
|
|
313
322
|
readonly #clock: Clock;
|
|
314
323
|
readonly #idleTimeoutMs: number;
|
|
315
324
|
#droppedChannelFrames = 0;
|
|
325
|
+
/** The repair side of `SyncSocket.gaps`: `repairAll(socket)` is what Bun's `drain` calls. */
|
|
326
|
+
readonly gapRepairs = new GapRepairs();
|
|
316
327
|
|
|
317
328
|
constructor(options: SocketRegistryOptions = {}) {
|
|
318
329
|
this.#clock = options.clock ?? systemClock;
|
|
@@ -425,17 +436,45 @@ export class SocketRegistry {
|
|
|
425
436
|
else dropped += 1;
|
|
426
437
|
}
|
|
427
438
|
if (members.size === 0) this.#byTopic.delete(topic);
|
|
428
|
-
if (dropped > 0)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
439
|
+
if (dropped > 0) this.#countDropped(topic, dropped);
|
|
440
|
+
return sent;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A `records` delivery. An owed `replay-gap` goes out first; a socket that then drops the frame is
|
|
445
|
+
* marked for the next one. `deliver`'s twin, because only a records stream can be repaired.
|
|
446
|
+
*/
|
|
447
|
+
deliverRecords(topic: string, epoch: string, frame: ChannelRecordsFrame): number {
|
|
448
|
+
const members = this.#byTopic.get(topic);
|
|
449
|
+
if (!members) return 0;
|
|
450
|
+
let sent = 0;
|
|
451
|
+
let dropped = 0;
|
|
452
|
+
for (const socket of members) {
|
|
453
|
+
if (socket.closed) {
|
|
454
|
+
members.delete(socket);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
this.gapRepairs.repair(socket, topic);
|
|
458
|
+
if (socket.send(frame)) {
|
|
459
|
+
sent += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
dropped += 1;
|
|
463
|
+
socket.gaps.set(topic, epoch);
|
|
435
464
|
}
|
|
465
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
466
|
+
if (dropped > 0) this.#countDropped(topic, dropped);
|
|
436
467
|
return sent;
|
|
437
468
|
}
|
|
438
469
|
|
|
470
|
+
#countDropped(topic: string, dropped: number): void {
|
|
471
|
+
this.#droppedChannelFrames += dropped;
|
|
472
|
+
// Two readers, one event, one spelling: the series an operator alerts on and the line that
|
|
473
|
+
// says which topic it was.
|
|
474
|
+
channelFramesDropped.add(dropped);
|
|
475
|
+
logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
|
|
476
|
+
}
|
|
477
|
+
|
|
439
478
|
/**
|
|
440
479
|
* Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
|
|
441
480
|
* of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
|
package/src/sync-auth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Who a socket is, and for how long. The `sync` node evaluates no credential of its own — an app
|
|
2
|
-
// supplies `authenticate
|
|
3
|
-
//
|
|
2
|
+
// supplies `authenticate` — so this file owns the shape of that answer, the per-node book that
|
|
3
|
+
// holds it, and the pass that re-decides one whose window has closed.
|
|
4
4
|
|
|
5
5
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
6
6
|
|
package/src/sync-frames.ts
CHANGED
|
@@ -4,52 +4,55 @@
|
|
|
4
4
|
|
|
5
5
|
import { logger } from '@ultimat3/core';
|
|
6
6
|
import type { ChannelHub } from './channel';
|
|
7
|
-
import {
|
|
7
|
+
import type { Topic } from './channel-decl';
|
|
8
8
|
import { FrameRateLimitError } from './errors';
|
|
9
9
|
import { FrameLanes, laneKeyOf } from './frame-lanes';
|
|
10
|
-
import type { JsonValue, Row } from './json';
|
|
11
10
|
import type { LiveQueryRegistry } from './live-query';
|
|
12
11
|
import { type PresenceRegistry, presenceFrame } from './presence';
|
|
13
|
-
import {
|
|
14
|
-
import { type Frame, PROTOCOL_VERSION
|
|
15
|
-
|
|
16
|
-
/** Server-authoritative mutation execution. Injected: `sync` never owns business logic. */
|
|
17
|
-
export type MutationHandler = (args: {
|
|
18
|
-
socket: SyncSocket;
|
|
19
|
-
name: string;
|
|
20
|
-
key: string;
|
|
21
|
-
seq: number;
|
|
22
|
-
input: JsonValue;
|
|
23
|
-
}) => Promise<{ lsn?: string | null; entity?: string; row?: Row | null }>;
|
|
12
|
+
import type { SyncSocket } from './socket';
|
|
13
|
+
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
24
14
|
|
|
25
15
|
export interface FrameRouterOptions {
|
|
26
16
|
readonly hub: ChannelHub;
|
|
27
17
|
readonly registry: LiveQueryRegistry;
|
|
28
18
|
readonly buildId: string;
|
|
29
19
|
readonly presence?: PresenceRegistry | undefined;
|
|
30
|
-
readonly onMutate?: MutationHandler | undefined;
|
|
31
20
|
}
|
|
32
21
|
|
|
33
22
|
export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
|
|
34
23
|
|
|
35
24
|
/**
|
|
36
|
-
* What a
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* The socket id is the answer for a frame nothing could read (a decode failure) and for the kinds
|
|
42
|
-
* that carry no reference of their own, because there is nothing else true to say.
|
|
25
|
+
* What a refusal ack refers to. `ack.ref` is how a client finds the thing that failed — the sid
|
|
26
|
+
* of the subscription it refused, so that one window renders `failed` and no other does. The
|
|
27
|
+
* socket id is the answer for a frame nothing could read (a decode failure) and for the kinds that
|
|
28
|
+
* carry no reference of their own, because there is nothing else true to say.
|
|
43
29
|
*/
|
|
44
30
|
export function ackRefOf(frame: Frame | null, socketId: string): string {
|
|
45
31
|
if (frame === null) return socketId;
|
|
46
|
-
if (frame.type === 'mutate') return frame.key;
|
|
47
32
|
if (frame.type === 'subscribe') return frame.sid;
|
|
48
33
|
return socketId;
|
|
49
34
|
}
|
|
50
35
|
|
|
51
36
|
export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
|
|
52
37
|
const presence = options.presence;
|
|
38
|
+
/** Per socket: the topic each channel sid was joined under. Dies with the socket. */
|
|
39
|
+
const channelTopics = new WeakMap<SyncSocket, Map<string, Topic>>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Subscribing to a channel declared `events: true` IS joining its presence set: presence has no
|
|
43
|
+
* frame of its own (it rides that channel's `events`), so a second round trip saying "and I am
|
|
44
|
+
* here" would be a second way to do one thing. A records-only channel has no roster. Repeating the
|
|
45
|
+
* frame is therefore also the heartbeat — `join` re-`put`s the member. The roster's answer is
|
|
46
|
+
* read though nothing here can repair it: a dropped roster costs one heartbeat of blank room, and
|
|
47
|
+
* the log is the only trace it leaves anywhere.
|
|
48
|
+
*/
|
|
49
|
+
const joinPresence = async (socket: SyncSocket, name: Topic): Promise<void> => {
|
|
50
|
+
if (!presence || options.hub.channelOf(name)?.channel.events !== true) return;
|
|
51
|
+
const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
|
|
52
|
+
if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
|
|
53
|
+
logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
53
56
|
// Weakly keyed, so one socket's lanes die with it and no close path has to remember them.
|
|
54
57
|
const lanes = new WeakMap<SyncSocket, FrameLanes>();
|
|
55
58
|
|
|
@@ -94,29 +97,23 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
94
97
|
return;
|
|
95
98
|
}
|
|
96
99
|
case 'subscribe': {
|
|
97
|
-
if (frame.target.kind === '
|
|
98
|
-
|
|
100
|
+
if (frame.target.kind === 'channel') {
|
|
101
|
+
// The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds it
|
|
102
|
+
// by the sid its add was answered under — never by re-deriving it from client data.
|
|
103
|
+
const topics = channelTopics.get(socket) ?? new Map<string, Topic>();
|
|
104
|
+
channelTopics.set(socket, topics);
|
|
99
105
|
if (frame.op === 'drop') {
|
|
106
|
+
const name = topics.get(frame.sid);
|
|
107
|
+
if (name === undefined) return;
|
|
108
|
+
topics.delete(frame.sid);
|
|
109
|
+
const events = options.hub.channelOf(name)?.channel.events === true;
|
|
100
110
|
options.hub.unsubscribe(socket, name);
|
|
101
|
-
if (presence) await presence.leave(name, socket.id);
|
|
111
|
+
if (presence && events) await presence.leave(name, socket.id);
|
|
102
112
|
return;
|
|
103
113
|
}
|
|
104
|
-
await options.hub.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// and a client that skipped it would be invisible in a room it is receiving from.
|
|
108
|
-
// Repeating the frame is therefore also the heartbeat — `join` re-`put`s the member.
|
|
109
|
-
if (presence) {
|
|
110
|
-
const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
|
|
111
|
-
// The answer is read even though nothing here can repair it. A roster has no cursor and
|
|
112
|
-
// no re-send path of its own: the client renders an empty room until it repeats this
|
|
113
|
-
// very frame as its heartbeat, which re-joins and re-rosters. Membership on the shared
|
|
114
|
-
// set is already correct, so the drop costs one client one heartbeat of blank room —
|
|
115
|
-
// and the log is the only trace it leaves anywhere.
|
|
116
|
-
if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
|
|
117
|
-
logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
|
|
118
|
-
}
|
|
119
|
-
}
|
|
114
|
+
const name = await options.hub.subscribeChannel(socket, frame.target);
|
|
115
|
+
topics.set(frame.sid, name);
|
|
116
|
+
await joinPresence(socket, name);
|
|
120
117
|
return;
|
|
121
118
|
}
|
|
122
119
|
if (frame.op === 'drop') {
|
|
@@ -140,86 +137,16 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
|
|
|
140
137
|
if (!socket.send(reply)) socket.markDesynced(frame.sid);
|
|
141
138
|
return;
|
|
142
139
|
}
|
|
143
|
-
case 'mutate': {
|
|
144
|
-
if (!options.onMutate) {
|
|
145
|
-
// A failure receipt is still a receipt: dropped, the client's mutation stays `inflight`
|
|
146
|
-
// and is neither rolled back nor retried, so this one reads the answer too.
|
|
147
|
-
const sent = socket.send({
|
|
148
|
-
type: 'ack',
|
|
149
|
-
v: PROTOCOL_VERSION,
|
|
150
|
-
ref: frame.key,
|
|
151
|
-
lsn: null,
|
|
152
|
-
error: toWireError({
|
|
153
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
154
|
-
cause: 'this sync node was started without a mutation handler',
|
|
155
|
-
fix: 'pass onMutate to createSyncNode({ onMutate })',
|
|
156
|
-
}),
|
|
157
|
-
});
|
|
158
|
-
if (!sent) undeliverable(socket, frame.key, 'ack');
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
const result = await options.onMutate({
|
|
162
|
-
socket,
|
|
163
|
-
name: frame.name,
|
|
164
|
-
key: frame.key,
|
|
165
|
-
seq: frame.seq,
|
|
166
|
-
input: frame.input,
|
|
167
|
-
});
|
|
168
|
-
// The rebase FIRST, and the ack last. The ack is the receipt, and a receipt is what
|
|
169
|
-
// retires the client's record of the mutation — its journal row and its rebase-log entry,
|
|
170
|
-
// both of which stay forever otherwise. A rebase that lands after that has no entry left
|
|
171
|
-
// to read the mutator's conflict strategy off (every merge silently becomes server-wins)
|
|
172
|
-
// and no sequence to decide which later optimistic writes to replay over server truth.
|
|
173
|
-
// These are two frames on one socket, so the order is the only coordination there is.
|
|
174
|
-
if (result.entity !== undefined) {
|
|
175
|
-
const sent = socket.send({
|
|
176
|
-
type: 'rebase',
|
|
177
|
-
v: PROTOCOL_VERSION,
|
|
178
|
-
key: frame.key,
|
|
179
|
-
entity: result.entity,
|
|
180
|
-
strategy: 'server-wins',
|
|
181
|
-
row: result.row ?? null,
|
|
182
|
-
});
|
|
183
|
-
// The ack retires the client's rebase-log entry, so acking a rebase that never left is
|
|
184
|
-
// the divergence the ordering above exists to prevent — one frame later instead of one
|
|
185
|
-
// frame earlier. Nothing is acked; the mutation stays unsettled and is replayed.
|
|
186
|
-
if (!sent) return undeliverable(socket, frame.key, 'rebase');
|
|
187
|
-
}
|
|
188
|
-
if (
|
|
189
|
-
!socket.send({
|
|
190
|
-
type: 'ack',
|
|
191
|
-
v: PROTOCOL_VERSION,
|
|
192
|
-
ref: frame.key,
|
|
193
|
-
lsn: result.lsn ?? null,
|
|
194
|
-
error: null,
|
|
195
|
-
})
|
|
196
|
-
) {
|
|
197
|
-
undeliverable(socket, frame.key, 'ack');
|
|
198
|
-
}
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
140
|
// Server-authored frames are never received from a client.
|
|
202
141
|
case 'snapshot':
|
|
203
142
|
case 'patch':
|
|
204
143
|
case 'ack':
|
|
205
|
-
case '
|
|
206
|
-
case '
|
|
144
|
+
case 'records':
|
|
145
|
+
case 'events':
|
|
146
|
+
case 'replay-gap':
|
|
207
147
|
case 'reconnect':
|
|
208
148
|
case 'update-available':
|
|
209
149
|
return;
|
|
210
150
|
}
|
|
211
151
|
}
|
|
212
152
|
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* A settlement the socket refused. There is nothing on the node to mark — the mutation is applied
|
|
216
|
-
* and the server keeps no per-mutation state — and a client only returns an `inflight` mutation to
|
|
217
|
-
* its queue when the connection dies (`requeueInflight`), so a receipt dropped on a socket that
|
|
218
|
-
* stays up is a write the client neither retires nor retries, with the server believing it settled.
|
|
219
|
-
* Closing IS the repair: the queue hands the mutation back and the reconnect replays it under the
|
|
220
|
-
* same idempotency key.
|
|
221
|
-
*/
|
|
222
|
-
function undeliverable(socket: SyncSocket, key: string, kind: 'rebase' | 'ack'): void {
|
|
223
|
-
logger.warn('sync.settlement_dropped', { socketId: socket.id, key, kind });
|
|
224
|
-
socket.close(CLOSE.overloaded, 'settlement undeliverable');
|
|
225
|
-
}
|
package/src/sync-meta.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The page's sync target, read off the `<meta>` tags the document shell renders — the default
|
|
2
|
+
// when the island bootstrap passed none. The names are core's (`page-meta.ts`): one literal per
|
|
3
|
+
// fact, written by `@ultimat3/render`, read here.
|
|
4
|
+
|
|
5
|
+
import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core/page';
|
|
6
|
+
import type { SyncTarget } from './page-store';
|
|
7
|
+
|
|
8
|
+
/** The minimum of a DOM this reads — so a test can hand one in without a browser. */
|
|
9
|
+
export interface MetaDocument {
|
|
10
|
+
querySelector(selector: string): { getAttribute(name: string): string | null } | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function meta(doc: MetaDocument, name: string): string | undefined {
|
|
14
|
+
const content = doc.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
|
|
15
|
+
return content === null || content === undefined || content === '' ? undefined : content;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `ultimate-sync` resolved against the page (`/_x/sync` is same-origin), with the scheme turned
|
|
20
|
+
* to the socket's: `https:` → `wss:`, `http:` → `ws:`; an absolute `ws(s)://` stays as written.
|
|
21
|
+
* `undefined` when the document names no sync node — a page with no realtime.
|
|
22
|
+
*/
|
|
23
|
+
export function syncTargetFromMeta(doc: MetaDocument, href: string): SyncTarget | undefined {
|
|
24
|
+
const sync = meta(doc, CLIENT_SYNC_META);
|
|
25
|
+
if (sync === undefined) return undefined;
|
|
26
|
+
const url = new URL(sync, href);
|
|
27
|
+
if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
28
|
+
else if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
29
|
+
return { url: url.toString(), buildId: meta(doc, CLIENT_BUILD_META) ?? '' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The worker script URL, or `undefined` when the app built none (the in-page host is used). */
|
|
33
|
+
export function syncWorkerFromMeta(doc: MetaDocument, href: string): string | undefined {
|
|
34
|
+
const worker = meta(doc, CLIENT_SYNC_WORKER_META);
|
|
35
|
+
return worker === undefined ? undefined : new URL(worker, href).toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** This page's target, when there is a page to read. */
|
|
39
|
+
export function pageSyncTarget(): SyncTarget | undefined {
|
|
40
|
+
if (typeof document === 'undefined' || typeof location === 'undefined') return undefined;
|
|
41
|
+
return syncTargetFromMeta(document, location.href);
|
|
42
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// What a `sync` node IS, as types: the options `createSyncNode` takes, the node it returns and the
|
|
2
|
+
// socket Bun hands its handlers. Apart from `sync-node.ts` so the lifecycle there stays under the
|
|
3
|
+
// file ceiling, and so a host can name the shapes without reading the lifecycle.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { ChannelHub } from './channel';
|
|
7
|
+
import type { Transport } from './fanout';
|
|
8
|
+
import type { LiveQueryRegistry } from './live-query';
|
|
9
|
+
import type { PresenceRegistry } from './presence';
|
|
10
|
+
import type { SocketRegistry, WsLike } from './socket';
|
|
11
|
+
import type { SyncAuthenticator } from './sync-auth';
|
|
12
|
+
import type { UpgradeTarget, WsData } from './sync-upgrade';
|
|
13
|
+
import type { AcceptBudget, DrainedSocket, Rng } from './thundering-herd';
|
|
14
|
+
|
|
15
|
+
export type SyncWs = WsLike & { readonly data: WsData };
|
|
16
|
+
|
|
17
|
+
export interface SyncNodeOptions {
|
|
18
|
+
readonly hub: ChannelHub;
|
|
19
|
+
readonly registry: LiveQueryRegistry;
|
|
20
|
+
readonly transport: Transport;
|
|
21
|
+
readonly buildId: string;
|
|
22
|
+
readonly presence?: PresenceRegistry;
|
|
23
|
+
readonly sockets?: SocketRegistry;
|
|
24
|
+
readonly accept?: AcceptBudget;
|
|
25
|
+
/** Concurrent sockets this node will hold. The count the accept budget does not bound. */
|
|
26
|
+
readonly maxConnections?: number;
|
|
27
|
+
/** Inbound bytes one frame may carry, handed to whatever server mounts `websocket`. */
|
|
28
|
+
readonly maxFrameBytes?: number;
|
|
29
|
+
/** Sustained inbound frames one socket may have routed per second. */
|
|
30
|
+
readonly maxFramesPerSecond?: number;
|
|
31
|
+
/** Burst allowance on that rate, per socket. */
|
|
32
|
+
readonly frameBurst?: number;
|
|
33
|
+
/**
|
|
34
|
+
* When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
|
|
35
|
+
* this node builds every socket it holds — so unforwarded they were reachable only by abandoning
|
|
36
|
+
* `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
|
|
37
|
+
*/
|
|
38
|
+
readonly maxBufferedBytes?: number;
|
|
39
|
+
readonly maxDroppedFrames?: number;
|
|
40
|
+
/**
|
|
41
|
+
* How long a socket may route no frame before this node evicts it. Every ceiling on a socket
|
|
42
|
+
* `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
|
|
43
|
+
* was only settable by constructing the registry yourself, and nothing swept it either way.
|
|
44
|
+
*/
|
|
45
|
+
readonly idleTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Who is dialling. Injected because `sync` owns no business logic and
|
|
48
|
+
* imports no authenticator, so an app supplies the one function that turns an upgrade request
|
|
49
|
+
* into an actor — from `@ultimat3/auth` or from anywhere else.
|
|
50
|
+
*
|
|
51
|
+
* **Omitted, every socket on this node is anonymous** and every policy downstream — the topic
|
|
52
|
+
* guard, `authorize`, `visible`, the per-tenant subscription cap — decides against `null`. That
|
|
53
|
+
* is a single-tenant node, and `start()` says so in the log.
|
|
54
|
+
*/
|
|
55
|
+
readonly authenticate?: SyncAuthenticator;
|
|
56
|
+
/** How often an expired grant is re-decided. The clock a socket's authority runs on. */
|
|
57
|
+
readonly reauthenticateIntervalMs?: number;
|
|
58
|
+
readonly clock?: Clock;
|
|
59
|
+
readonly rng?: Rng;
|
|
60
|
+
/** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
|
|
61
|
+
readonly path?: string;
|
|
62
|
+
readonly drainSpreadMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SyncNode {
|
|
66
|
+
readonly sockets: SocketRegistry;
|
|
67
|
+
readonly ready: boolean;
|
|
68
|
+
/** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
|
|
69
|
+
readonly path: string;
|
|
70
|
+
start(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
|
|
73
|
+
* `/readyz` answers 503 so the load balancer stops routing here, and an upgrade arriving in the
|
|
74
|
+
* meantime is shed with a retry delay instead of landing on a process that is going away. It is
|
|
75
|
+
* NOT `stop()`: a draining node still owes its clients their patches, and `stop()` releases the
|
|
76
|
+
* change subscription that carries them.
|
|
77
|
+
*/
|
|
78
|
+
stopAccepting(): void;
|
|
79
|
+
stop(): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Async because `authenticate` is: the credential is decided *before* `server.upgrade`, so a
|
|
82
|
+
* refused one never costs a websocket. Bun's `fetch` may return a promise, and an upgrade that
|
|
83
|
+
* awaits first is still an upgrade.
|
|
84
|
+
*/
|
|
85
|
+
fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined>;
|
|
86
|
+
readonly websocket: {
|
|
87
|
+
idleTimeout: number;
|
|
88
|
+
backpressureLimit: number;
|
|
89
|
+
/** Inbound ceiling. Declared here so every host that mounts this handler inherits it. */
|
|
90
|
+
maxPayloadLength: number;
|
|
91
|
+
sendPings: boolean;
|
|
92
|
+
open(ws: SyncWs): void;
|
|
93
|
+
message(ws: SyncWs, message: string | Uint8Array): void;
|
|
94
|
+
/** Bun calls it when a backed-up socket has drained: owed `replay-gap` frames go out here. */
|
|
95
|
+
drain(ws: SyncWs): void;
|
|
96
|
+
close(ws: SyncWs): void;
|
|
97
|
+
};
|
|
98
|
+
/** Sends every client a distinct reconnect delay, then closes. Returns the plan for tests/logs. */
|
|
99
|
+
drain(options?: { graceMs?: number }): Promise<readonly DrainedSocket[]>;
|
|
100
|
+
}
|
package/src/sync-node.ts
CHANGED
|
@@ -4,40 +4,27 @@
|
|
|
4
4
|
// client may reconnect to any node and resume from its cursor, which is why drain is allowed to
|
|
5
5
|
// redistribute connections at all.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
7
|
+
import { logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
|
|
8
|
+
import type { Topic } from './channel';
|
|
9
9
|
import { detach } from './detach';
|
|
10
10
|
import { evictInChunks } from './drain-evictions';
|
|
11
11
|
import { isClientFault } from './errors';
|
|
12
|
-
import type {
|
|
13
|
-
import
|
|
14
|
-
import type { PresenceRegistry } from './presence';
|
|
15
|
-
import { CHANGE_SUBJECT_PREFIX, parseEnvelope, SeqGapDetector } from './replicator';
|
|
12
|
+
import type { TransportSubscription } from './fanout';
|
|
13
|
+
import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
|
|
16
14
|
import {
|
|
17
15
|
CLOSE,
|
|
18
16
|
DEFAULT_MAX_BUFFERED_BYTES,
|
|
19
17
|
idleSweepPeriodMs,
|
|
20
18
|
SocketRegistry,
|
|
21
19
|
SyncSocket,
|
|
22
|
-
type WsLike,
|
|
23
20
|
} from './socket';
|
|
24
|
-
import { GrantBook,
|
|
25
|
-
import { ackRefOf, createFrameRouter
|
|
21
|
+
import { GrantBook, sweepGrants } from './sync-auth';
|
|
22
|
+
import { ackRefOf, createFrameRouter } from './sync-frames';
|
|
26
23
|
import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
|
|
24
|
+
import type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
|
|
27
25
|
import { decode, type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
|
|
28
|
-
import { handleUpgrade, type UpgradeTarget
|
|
29
|
-
import {
|
|
30
|
-
AcceptBudget,
|
|
31
|
-
type DrainedSocket,
|
|
32
|
-
drainPlan,
|
|
33
|
-
type Rng,
|
|
34
|
-
reconnectFrame,
|
|
35
|
-
} from './thundering-herd';
|
|
36
|
-
|
|
37
|
-
/** Declared with the upgrade that builds it — this file only ever reads one. */
|
|
38
|
-
export type { UpgradeTarget, WsData } from './sync-upgrade';
|
|
39
|
-
|
|
40
|
-
export type SyncWs = WsLike & { readonly data: WsData };
|
|
26
|
+
import { handleUpgrade, type UpgradeTarget } from './sync-upgrade';
|
|
27
|
+
import { AcceptBudget, type DrainedSocket, drainPlan, reconnectFrame } from './thundering-herd';
|
|
41
28
|
|
|
42
29
|
// Moved to `sync-node-bounds.ts` with the refusals that read them, and re-exported here because
|
|
43
30
|
// `server.ts` publishes all three and a moved constant must not become a moved import path.
|
|
@@ -47,89 +34,10 @@ export {
|
|
|
47
34
|
DEFAULT_REAUTH_INTERVAL_MS,
|
|
48
35
|
} from './sync-node-bounds';
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
readonly buildId: string;
|
|
55
|
-
readonly presence?: PresenceRegistry;
|
|
56
|
-
readonly sockets?: SocketRegistry;
|
|
57
|
-
readonly accept?: AcceptBudget;
|
|
58
|
-
/** Concurrent sockets this node will hold. The count the accept budget does not bound. */
|
|
59
|
-
readonly maxConnections?: number;
|
|
60
|
-
/** Inbound bytes one frame may carry, handed to whatever server mounts `websocket`. */
|
|
61
|
-
readonly maxFrameBytes?: number;
|
|
62
|
-
/** Sustained inbound frames one socket may have routed per second. */
|
|
63
|
-
readonly maxFramesPerSecond?: number;
|
|
64
|
-
/** Burst allowance on that rate, per socket. */
|
|
65
|
-
readonly frameBurst?: number;
|
|
66
|
-
/**
|
|
67
|
-
* When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
|
|
68
|
-
* this node builds every socket it holds — so unforwarded they were reachable only by abandoning
|
|
69
|
-
* `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
|
|
70
|
-
*/
|
|
71
|
-
readonly maxBufferedBytes?: number;
|
|
72
|
-
readonly maxDroppedFrames?: number;
|
|
73
|
-
/**
|
|
74
|
-
* How long a socket may route no frame before this node evicts it. Every ceiling on a socket
|
|
75
|
-
* `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
|
|
76
|
-
* was only settable by constructing the registry yourself, and nothing swept it either way.
|
|
77
|
-
*/
|
|
78
|
-
readonly idleTimeoutMs?: number;
|
|
79
|
-
readonly onMutate?: MutationHandler;
|
|
80
|
-
/**
|
|
81
|
-
* Who is dialling. Injected for the same reason `onMutate` is: `sync` owns no business logic and
|
|
82
|
-
* imports no authenticator, so an app supplies the one function that turns an upgrade request
|
|
83
|
-
* into an actor — from `@ultimat3/auth` or from anywhere else.
|
|
84
|
-
*
|
|
85
|
-
* **Omitted, every socket on this node is anonymous** and every policy downstream — the topic
|
|
86
|
-
* guard, `authorize`, `visible`, the per-tenant subscription cap — decides against `null`. That
|
|
87
|
-
* is a single-tenant node, and `start()` says so in the log.
|
|
88
|
-
*/
|
|
89
|
-
readonly authenticate?: SyncAuthenticator;
|
|
90
|
-
/** How often an expired grant is re-decided. The clock a socket's authority runs on. */
|
|
91
|
-
readonly reauthenticateIntervalMs?: number;
|
|
92
|
-
readonly clock?: Clock;
|
|
93
|
-
readonly rng?: Rng;
|
|
94
|
-
/** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
|
|
95
|
-
readonly path?: string;
|
|
96
|
-
readonly drainSpreadMs?: number;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface SyncNode {
|
|
100
|
-
readonly sockets: SocketRegistry;
|
|
101
|
-
readonly ready: boolean;
|
|
102
|
-
/** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
|
|
103
|
-
readonly path: string;
|
|
104
|
-
start(): Promise<void>;
|
|
105
|
-
/**
|
|
106
|
-
* Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
|
|
107
|
-
* `/readyz` answers 503 so the load balancer stops routing here, and an upgrade arriving in the
|
|
108
|
-
* meantime is shed with a retry delay instead of landing on a process that is going away. It is
|
|
109
|
-
* NOT `stop()`: a draining node still owes its clients their patches, and `stop()` releases the
|
|
110
|
-
* change subscription that carries them.
|
|
111
|
-
*/
|
|
112
|
-
stopAccepting(): void;
|
|
113
|
-
stop(): Promise<void>;
|
|
114
|
-
/**
|
|
115
|
-
* Async because `authenticate` is: the credential is decided *before* `server.upgrade`, so a
|
|
116
|
-
* refused one never costs a websocket. Bun's `fetch` may return a promise, and an upgrade that
|
|
117
|
-
* awaits first is still an upgrade.
|
|
118
|
-
*/
|
|
119
|
-
fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined>;
|
|
120
|
-
readonly websocket: {
|
|
121
|
-
idleTimeout: number;
|
|
122
|
-
backpressureLimit: number;
|
|
123
|
-
/** Inbound ceiling. Declared here so every host that mounts this handler inherits it. */
|
|
124
|
-
maxPayloadLength: number;
|
|
125
|
-
sendPings: boolean;
|
|
126
|
-
open(ws: SyncWs): void;
|
|
127
|
-
message(ws: SyncWs, message: string | Uint8Array): void;
|
|
128
|
-
close(ws: SyncWs): void;
|
|
129
|
-
};
|
|
130
|
-
/** Sends every client a distinct reconnect delay, then closes. Returns the plan for tests/logs. */
|
|
131
|
-
drain(options?: { graceMs?: number }): Promise<readonly DrainedSocket[]>;
|
|
132
|
-
}
|
|
37
|
+
/** The node's shapes — options, the node itself, its socket — live beside it, in their own file. */
|
|
38
|
+
export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
|
|
39
|
+
/** Declared with the upgrade that builds it — this file only ever reads one. */
|
|
40
|
+
export type { UpgradeTarget, WsData } from './sync-upgrade';
|
|
133
41
|
|
|
134
42
|
export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
135
43
|
const sockets =
|
|
@@ -277,7 +185,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
277
185
|
registry: options.registry,
|
|
278
186
|
buildId: options.buildId,
|
|
279
187
|
presence,
|
|
280
|
-
onMutate: options.onMutate,
|
|
281
188
|
});
|
|
282
189
|
|
|
283
190
|
return {
|
|
@@ -289,7 +196,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
289
196
|
},
|
|
290
197
|
|
|
291
198
|
async start(): Promise<void> {
|
|
292
|
-
changes = await options.transport.subscribe(
|
|
199
|
+
changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
|
|
293
200
|
const envelope = parseEnvelope(payload);
|
|
294
201
|
if (!envelope) return;
|
|
295
202
|
// Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
|
|
@@ -304,6 +211,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
304
211
|
// registry's — one serial lane per query id. What this call site owes is the failure. An
|
|
305
212
|
// unhandled rejection here is a fanout that reached nobody, reported as a dead process.
|
|
306
213
|
detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
|
|
214
|
+
// The same stream feeds the declared channels: a write names no channel (axiom 2), and the
|
|
215
|
+
// hub turns this change into `records` frames on every channel it touches.
|
|
216
|
+
options.hub.deliverChange(envelope.change);
|
|
307
217
|
});
|
|
308
218
|
// One pass per heartbeat window: a member is swept only once it has actually missed its
|
|
309
219
|
// window, and the interval never holds the process open — shutdown is the drain's job.
|
|
@@ -434,6 +344,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
434
344
|
})();
|
|
435
345
|
},
|
|
436
346
|
|
|
347
|
+
drain(ws: SyncWs): void {
|
|
348
|
+
// A `records` frame backpressure refused left this socket gapped on that channel; the
|
|
349
|
+
// repair is the node's verdict, sent the moment the socket can take a frame again.
|
|
350
|
+
const socket = sockets.get(ws.data.socketId);
|
|
351
|
+
if (socket) sockets.gapRepairs.repairAll(socket);
|
|
352
|
+
},
|
|
353
|
+
|
|
437
354
|
close(ws: SyncWs): void {
|
|
438
355
|
const socket = sockets.get(ws.data.socketId);
|
|
439
356
|
if (!socket) {
|