@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-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,22 @@
|
|
|
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
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
DEFAULT_MAX_BUFFERED_BYTES,
|
|
19
|
-
idleSweepPeriodMs,
|
|
20
|
-
SocketRegistry,
|
|
21
|
-
SyncSocket,
|
|
22
|
-
type WsLike,
|
|
23
|
-
} from './socket';
|
|
24
|
-
import { GrantBook, type SyncAuthenticator, sweepGrants } from './sync-auth';
|
|
25
|
-
import { ackRefOf, createFrameRouter, type MutationHandler } from './sync-frames';
|
|
12
|
+
import type { TransportSubscription } from './fanout';
|
|
13
|
+
import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
|
|
14
|
+
import { CLOSE, DEFAULT_MAX_BUFFERED_BYTES, SocketRegistry, SyncSocket } from './socket';
|
|
15
|
+
import { idleSweepPeriodMs } from './socket-idle';
|
|
16
|
+
import { GrantBook, sweepGrants } from './sync-auth';
|
|
17
|
+
import { ackRefOf, createFrameRouter } from './sync-frames';
|
|
26
18
|
import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
|
|
19
|
+
import type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
|
|
27
20
|
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 };
|
|
21
|
+
import { handleUpgrade, type UpgradeTarget } from './sync-upgrade';
|
|
22
|
+
import { AcceptBudget, type DrainedSocket, drainPlan, reconnectFrame } from './thundering-herd';
|
|
41
23
|
|
|
42
24
|
// Moved to `sync-node-bounds.ts` with the refusals that read them, and re-exported here because
|
|
43
25
|
// `server.ts` publishes all three and a moved constant must not become a moved import path.
|
|
@@ -47,89 +29,10 @@ export {
|
|
|
47
29
|
DEFAULT_REAUTH_INTERVAL_MS,
|
|
48
30
|
} from './sync-node-bounds';
|
|
49
31
|
|
|
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
|
-
}
|
|
32
|
+
/** The node's shapes — options, the node itself, its socket — live beside it, in their own file. */
|
|
33
|
+
export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
|
|
34
|
+
/** Declared with the upgrade that builds it — this file only ever reads one. */
|
|
35
|
+
export type { UpgradeTarget, WsData } from './sync-upgrade';
|
|
133
36
|
|
|
134
37
|
export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
135
38
|
const sockets =
|
|
@@ -277,7 +180,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
277
180
|
registry: options.registry,
|
|
278
181
|
buildId: options.buildId,
|
|
279
182
|
presence,
|
|
280
|
-
onMutate: options.onMutate,
|
|
281
183
|
});
|
|
282
184
|
|
|
283
185
|
return {
|
|
@@ -289,7 +191,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
289
191
|
},
|
|
290
192
|
|
|
291
193
|
async start(): Promise<void> {
|
|
292
|
-
changes = await options.transport.subscribe(
|
|
194
|
+
changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
|
|
293
195
|
const envelope = parseEnvelope(payload);
|
|
294
196
|
if (!envelope) return;
|
|
295
197
|
// Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
|
|
@@ -304,6 +206,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
304
206
|
// registry's — one serial lane per query id. What this call site owes is the failure. An
|
|
305
207
|
// unhandled rejection here is a fanout that reached nobody, reported as a dead process.
|
|
306
208
|
detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
|
|
209
|
+
// The same stream feeds the declared channels: a write names no channel (axiom 2), and the
|
|
210
|
+
// hub turns this change into `records` frames on every channel it touches.
|
|
211
|
+
options.hub.deliverChange(envelope.change);
|
|
307
212
|
});
|
|
308
213
|
// One pass per heartbeat window: a member is swept only once it has actually missed its
|
|
309
214
|
// window, and the interval never holds the process open — shutdown is the drain's job.
|
|
@@ -434,6 +339,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
434
339
|
})();
|
|
435
340
|
},
|
|
436
341
|
|
|
342
|
+
drain(ws: SyncWs): void {
|
|
343
|
+
// A `records` frame backpressure refused left this socket gapped on that channel; the
|
|
344
|
+
// repair is the node's verdict, sent the moment the socket can take a frame again.
|
|
345
|
+
const socket = sockets.get(ws.data.socketId);
|
|
346
|
+
if (socket) sockets.gapRepairs.repairAll(socket);
|
|
347
|
+
},
|
|
348
|
+
|
|
437
349
|
close(ws: SyncWs): void {
|
|
438
350
|
const socket = sockets.get(ws.data.socketId);
|
|
439
351
|
if (!socket) {
|