@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/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The SERVER half of the public API: the bus, the Postgres replication path, the sync node and the
|
|
2
2
|
// live-query registry it fans out through. Split from `index.ts` because `nats` require()s
|
|
3
3
|
// `stream/web` and the WAL decoder is a Postgres client — one barrel carrying both made the browser
|
|
4
|
-
// island
|
|
4
|
+
// island realtime promises unbuildable. Every name here has exactly one home; the shared
|
|
5
5
|
// vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
|
|
6
6
|
|
|
7
7
|
// ---- the retained change window one node fans out from ------------------------------------------
|
|
@@ -38,14 +38,10 @@ export {
|
|
|
38
38
|
export {
|
|
39
39
|
ChannelHub,
|
|
40
40
|
type ChannelHubOptions,
|
|
41
|
-
channelFrame,
|
|
42
41
|
DEFAULT_MAX_TOPICS_PER_NODE,
|
|
43
|
-
type Topic,
|
|
44
|
-
type TopicGuard,
|
|
45
|
-
type TopicGuardArgs,
|
|
46
|
-
type TopicGuardResult,
|
|
47
|
-
topic,
|
|
48
42
|
} from './channel';
|
|
43
|
+
export { type ChannelDescription, describeChannels } from './channel-describe';
|
|
44
|
+
export { RealtimeTopologyError } from './errors';
|
|
49
45
|
export {
|
|
50
46
|
InProcessTransport,
|
|
51
47
|
type InProcessTransportOptions,
|
|
@@ -67,6 +63,11 @@ export {
|
|
|
67
63
|
LiveQueryRegistry,
|
|
68
64
|
type LiveQueryRegistryOptions,
|
|
69
65
|
} from './live-query';
|
|
66
|
+
export {
|
|
67
|
+
type LiveReplicator,
|
|
68
|
+
type LiveReplicatorOptions,
|
|
69
|
+
startLiveReplicator,
|
|
70
|
+
} from './live-replicator';
|
|
70
71
|
export {
|
|
71
72
|
applyToWindow,
|
|
72
73
|
type BridgeResult,
|
|
@@ -114,7 +115,7 @@ export { openNatsClient } from './nats-lib-client';
|
|
|
114
115
|
export { NatsTransport, type NatsTransportOptions } from './nats-transport';
|
|
115
116
|
export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
|
|
116
117
|
// ---- the postgres replication path ------------------------------------------------------------
|
|
117
|
-
export {
|
|
118
|
+
export { entityRow } from './pg-entity-row';
|
|
118
119
|
export {
|
|
119
120
|
changeLsn,
|
|
120
121
|
commitPositionOf,
|
|
@@ -171,16 +172,15 @@ export {
|
|
|
171
172
|
actorIdOf,
|
|
172
173
|
CLOSE,
|
|
173
174
|
DEFAULT_FRAME_BURST,
|
|
174
|
-
DEFAULT_IDLE_TIMEOUT_MS,
|
|
175
175
|
DEFAULT_MAX_BUFFERED_BYTES,
|
|
176
176
|
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
177
|
-
idleSweepPeriodMs,
|
|
178
177
|
SocketRegistry,
|
|
179
178
|
type SocketRegistryOptions,
|
|
180
179
|
SyncSocket,
|
|
181
180
|
type SyncSocketOptions,
|
|
182
181
|
type WsLike,
|
|
183
182
|
} from './socket';
|
|
183
|
+
export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
|
|
184
184
|
export type {
|
|
185
185
|
GateFailed,
|
|
186
186
|
GateStage,
|
|
@@ -200,7 +200,6 @@ export {
|
|
|
200
200
|
createFrameRouter,
|
|
201
201
|
type FrameRouter,
|
|
202
202
|
type FrameRouterOptions,
|
|
203
|
-
type MutationHandler,
|
|
204
203
|
} from './sync-frames';
|
|
205
204
|
export {
|
|
206
205
|
type ListenOptions,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// How many dropped frames close a socket: more than `maxDroppedFrames` inside one window, never a
|
|
2
|
+
// lifetime count. Split from `socket.ts`, which counts the drops; this decides what they mean.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The window `maxDroppedFrames` is counted over. Ten seconds: long enough that a burst of
|
|
6
|
+
* backpressure drops closes a socket that cannot keep up, short enough that a connection open for
|
|
7
|
+
* hours is never closed for a lifetime's worth of isolated drops.
|
|
8
|
+
*/
|
|
9
|
+
export const DROP_WINDOW_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The instants of the drops inside the current window. A burst of backpressure drops closes a
|
|
13
|
+
* socket that cannot keep up; the same number spread over a connection open for hours is a flaky
|
|
14
|
+
* link the cursor repairs, and closing for it was a healthy socket lost after 33 lifetime drops.
|
|
15
|
+
*/
|
|
16
|
+
export class DropWindow {
|
|
17
|
+
readonly #max: number;
|
|
18
|
+
readonly #recent: number[] = [];
|
|
19
|
+
|
|
20
|
+
constructor(max: number) {
|
|
21
|
+
this.#max = max;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Records one drop at `now` (monotonic ms) and answers whether the window is over its ceiling. */
|
|
25
|
+
overflowed(now: number): boolean {
|
|
26
|
+
this.#recent.push(now);
|
|
27
|
+
while ((this.#recent[0] ?? now) <= now - DROP_WINDOW_MS) this.#recent.shift();
|
|
28
|
+
return this.#recent.length > this.#max;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// The socket engine (plan 101, slice 11): ONE sync socket shared by every tab of an origin and
|
|
2
|
+
// principal, talking to each tab only over a `MessagePort`. Host-agnostic — the SharedWorker entry
|
|
3
|
+
// and the in-page fallback run this same code. Each tab keeps its own `LiveClient` and store; to
|
|
4
|
+
// it, its port IS a socket. This file is the socket's lifecycle — dial, beat, redial, reap; the
|
|
5
|
+
// multiplexing (one membership per topic, frames routed per port) is `socket-routes.ts`.
|
|
6
|
+
|
|
7
|
+
import { type Clock, finiteOption, systemClock } from '@ultimat3/core/page';
|
|
8
|
+
import type { ClientSocket } from './client-contract';
|
|
9
|
+
import { DEFAULT_HEARTBEAT_MS, Heartbeat } from './client-heartbeat';
|
|
10
|
+
import type { SyncTarget } from './page-store';
|
|
11
|
+
import type { AttachedPort, PortLike, PortMessage } from './socket-port';
|
|
12
|
+
import { PortRouter } from './socket-routes';
|
|
13
|
+
import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
14
|
+
import {
|
|
15
|
+
type BackoffPolicy,
|
|
16
|
+
browserBackoff,
|
|
17
|
+
policyDelay,
|
|
18
|
+
type Rng,
|
|
19
|
+
type Scheduler,
|
|
20
|
+
timeoutScheduler,
|
|
21
|
+
} from './thundering-herd';
|
|
22
|
+
|
|
23
|
+
// The port seam is its own module; these are the names every host already imports from here.
|
|
24
|
+
export { messagePort, type PortLike, type PortMessage } from './socket-port';
|
|
25
|
+
|
|
26
|
+
export interface SocketEngineOptions {
|
|
27
|
+
/** Dials the real socket. Production: `browserSocket(dialUrl(target))`. */
|
|
28
|
+
readonly dial: (target: SyncTarget) => ClientSocket;
|
|
29
|
+
readonly scheduler?: Scheduler;
|
|
30
|
+
readonly clock?: Clock;
|
|
31
|
+
readonly backoff?: BackoffPolicy;
|
|
32
|
+
readonly rng?: Rng;
|
|
33
|
+
/** The engine's own beat on the real socket, and the unit a silent port is reaped in. */
|
|
34
|
+
readonly heartbeatMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A tab beats every heartbeat; three missed beats and its port is reaped — a tab has no close. */
|
|
38
|
+
export const REAP_AFTER_BEATS = 3;
|
|
39
|
+
|
|
40
|
+
export class SocketEngine {
|
|
41
|
+
readonly #options: SocketEngineOptions;
|
|
42
|
+
readonly #clock: Clock;
|
|
43
|
+
readonly #schedule: Scheduler;
|
|
44
|
+
readonly #beatMs: number;
|
|
45
|
+
readonly #ports = new Map<number, AttachedPort>();
|
|
46
|
+
readonly #router: PortRouter;
|
|
47
|
+
readonly #heartbeat: Heartbeat;
|
|
48
|
+
#next = 1;
|
|
49
|
+
#target: SyncTarget | null = null;
|
|
50
|
+
#socket: ClientSocket | null = null;
|
|
51
|
+
#up = false;
|
|
52
|
+
#attempt = 0;
|
|
53
|
+
#reconnect: (() => void) | null = null;
|
|
54
|
+
#reaper: (() => void) | null = null;
|
|
55
|
+
#hello: Frame | null = null;
|
|
56
|
+
#update: string | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(options: SocketEngineOptions) {
|
|
59
|
+
this.#options = options;
|
|
60
|
+
this.#clock = options.clock ?? systemClock;
|
|
61
|
+
this.#schedule = options.scheduler ?? timeoutScheduler;
|
|
62
|
+
this.#beatMs = finiteOption(
|
|
63
|
+
'SocketEngine',
|
|
64
|
+
'heartbeatMs',
|
|
65
|
+
options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
|
|
66
|
+
);
|
|
67
|
+
this.#heartbeat = new Heartbeat({
|
|
68
|
+
intervalMs: this.#beatMs,
|
|
69
|
+
schedule: this.#schedule,
|
|
70
|
+
now: () => this.#now(),
|
|
71
|
+
beat: () => this.#beat(),
|
|
72
|
+
onSilence: () => this.#lost(),
|
|
73
|
+
});
|
|
74
|
+
this.#router = new PortRouter({
|
|
75
|
+
send: (frame) => this.#send(frame),
|
|
76
|
+
post: (attached, message) => this.#post(attached, message),
|
|
77
|
+
port: (id) => this.#ports.get(id),
|
|
78
|
+
ports: () => this.#ports.values(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Ports attached right now. Tests and the worker's own diagnostics read it. */
|
|
83
|
+
get ports(): number {
|
|
84
|
+
return this.#ports.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
attach(port: PortLike): void {
|
|
88
|
+
const attached: AttachedPort = {
|
|
89
|
+
id: this.#next++,
|
|
90
|
+
port,
|
|
91
|
+
open: false,
|
|
92
|
+
asked: false,
|
|
93
|
+
lastSeen: this.#now(),
|
|
94
|
+
topics: new Set(),
|
|
95
|
+
lives: new Map(),
|
|
96
|
+
};
|
|
97
|
+
this.#ports.set(attached.id, attached);
|
|
98
|
+
port.onmessage = (event) => this.#fromPort(attached, event.data);
|
|
99
|
+
this.#armReaper();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#fromPort(attached: AttachedPort, message: unknown): void {
|
|
103
|
+
if (!this.#ports.has(attached.id) || typeof message !== 'object' || message === null) return;
|
|
104
|
+
attached.lastSeen = this.#now();
|
|
105
|
+
const typed = message as PortMessage;
|
|
106
|
+
if (typed.t === 'open') {
|
|
107
|
+
const arriving = !attached.asked;
|
|
108
|
+
attached.open = true;
|
|
109
|
+
attached.asked = true;
|
|
110
|
+
this.#target ??= typed.target;
|
|
111
|
+
if (this.#up) {
|
|
112
|
+
this.#post(attached, { t: 'open', target: typed.target });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// A page arriving is a fresh reason to believe the node is back (a reload, a deploy): the
|
|
116
|
+
// wait an earlier page's failures built up is not this page's to inherit.
|
|
117
|
+
if (arriving && this.#socket === null) this.#restartCurve();
|
|
118
|
+
this.#dial();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (typed.t === 'close') {
|
|
122
|
+
// The tab closed its virtual socket — a redial on the same port. Its wants go; the port stays.
|
|
123
|
+
this.#router.release(attached);
|
|
124
|
+
attached.open = false;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typed.t === 'bye') {
|
|
128
|
+
this.#detach(attached);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (typed.t === 'frame' && this.#up) this.#fromTab(attached, typed.data);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#fromTab(attached: AttachedPort, data: string): void {
|
|
135
|
+
let frame: Frame;
|
|
136
|
+
try {
|
|
137
|
+
frame = decode(data);
|
|
138
|
+
} catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (frame.type === 'hello') {
|
|
142
|
+
// The tab's beat is this engine's ping: answer it, and add the one thing a beat learns.
|
|
143
|
+
this.#post(attached, { t: 'frame', data: encode(this.#helloReply()) });
|
|
144
|
+
if (this.#update !== null) {
|
|
145
|
+
const update: Frame = {
|
|
146
|
+
type: 'update-available',
|
|
147
|
+
v: PROTOCOL_VERSION,
|
|
148
|
+
buildId: this.#update,
|
|
149
|
+
};
|
|
150
|
+
this.#post(attached, { t: 'frame', data: encode(update) });
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (frame.type === 'subscribe') this.#router.subscribe(attached, frame);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Release the port itself: a tab that said `bye`, or one silent for three beats. */
|
|
158
|
+
#detach(attached: AttachedPort): void {
|
|
159
|
+
if (!this.#ports.delete(attached.id)) return;
|
|
160
|
+
this.#router.release(attached);
|
|
161
|
+
attached.port.onmessage = null;
|
|
162
|
+
attached.port.close?.();
|
|
163
|
+
if (this.#ports.size === 0) this.#shutdown();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#fromServer(data: string): void {
|
|
167
|
+
let frame: Frame;
|
|
168
|
+
try {
|
|
169
|
+
frame = decode(data);
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
switch (frame.type) {
|
|
174
|
+
case 'hello':
|
|
175
|
+
this.#hello = frame;
|
|
176
|
+
return;
|
|
177
|
+
case 'snapshot':
|
|
178
|
+
case 'patch':
|
|
179
|
+
case 'ack':
|
|
180
|
+
case 'records':
|
|
181
|
+
case 'replay-gap':
|
|
182
|
+
case 'events':
|
|
183
|
+
this.#router.route(frame, data);
|
|
184
|
+
return;
|
|
185
|
+
case 'update-available':
|
|
186
|
+
this.#update = frame.buildId;
|
|
187
|
+
for (const attached of this.#ports.values()) this.#post(attached, { t: 'frame', data });
|
|
188
|
+
return;
|
|
189
|
+
case 'reconnect':
|
|
190
|
+
// The node assigned this socket its slot in a drain spread: honour it, once, for everyone.
|
|
191
|
+
this.#lost(frame.afterMs);
|
|
192
|
+
return;
|
|
193
|
+
case 'subscribe':
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#dial(): void {
|
|
199
|
+
if (this.#socket !== null || this.#reconnect !== null || this.#target === null) return;
|
|
200
|
+
const target = this.#target;
|
|
201
|
+
let socket: ClientSocket;
|
|
202
|
+
try {
|
|
203
|
+
socket = this.#options.dial(target);
|
|
204
|
+
} catch {
|
|
205
|
+
this.#scheduleReconnect(null);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.#socket = socket;
|
|
209
|
+
socket.onOpen(() => {
|
|
210
|
+
if (this.#socket !== socket) return;
|
|
211
|
+
this.#up = true;
|
|
212
|
+
this.#attempt = 0;
|
|
213
|
+
socket.send(encode(this.#ownHello()));
|
|
214
|
+
this.#heartbeat.start(this.#now());
|
|
215
|
+
for (const attached of this.#ports.values()) {
|
|
216
|
+
if (attached.open) this.#post(attached, { t: 'open', target });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
socket.onMessage((data) => {
|
|
220
|
+
if (this.#socket !== socket) return;
|
|
221
|
+
this.#heartbeat.saw(this.#now());
|
|
222
|
+
this.#fromServer(data);
|
|
223
|
+
});
|
|
224
|
+
socket.onClose(() => {
|
|
225
|
+
if (this.#socket !== socket) return;
|
|
226
|
+
this.#lost();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The real socket is gone. Every tab's virtual socket closes with it, and each tab's own client
|
|
232
|
+
* re-opens and resubscribes FROM ITS CURSORS — the engine keeps no subscription state across a
|
|
233
|
+
* reconnect, so there is nothing here to go stale.
|
|
234
|
+
*/
|
|
235
|
+
#lost(afterMs: number | null = null): void {
|
|
236
|
+
const socket = this.#socket;
|
|
237
|
+
this.#socket = null;
|
|
238
|
+
this.#up = false;
|
|
239
|
+
this.#heartbeat.stop();
|
|
240
|
+
socket?.close(1000, 'engine reconnect');
|
|
241
|
+
this.#router.clear();
|
|
242
|
+
for (const attached of this.#ports.values()) {
|
|
243
|
+
if (attached.open) this.#post(attached, { t: 'close', code: 1006 });
|
|
244
|
+
attached.open = false;
|
|
245
|
+
}
|
|
246
|
+
if (this.#ports.size > 0) this.#scheduleReconnect(afterMs);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#scheduleReconnect(afterMs: number | null): void {
|
|
250
|
+
if (this.#reconnect !== null) return;
|
|
251
|
+
const rng = this.#options.rng ?? Math.random;
|
|
252
|
+
const delay =
|
|
253
|
+
// `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
|
|
254
|
+
afterMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
|
|
255
|
+
this.#attempt += 1;
|
|
256
|
+
this.#reconnect = this.#schedule(() => {
|
|
257
|
+
this.#reconnect = null;
|
|
258
|
+
// Dialled only for a tab that is asking: a tab re-asks through its own client's timer.
|
|
259
|
+
if ([...this.#ports.values()].some((attached) => attached.open)) this.#dial();
|
|
260
|
+
}, delay);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#restartCurve(): void {
|
|
264
|
+
this.#reconnect?.();
|
|
265
|
+
this.#reconnect = null;
|
|
266
|
+
this.#attempt = 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* No page left. A SharedWorker can outlive its last page for a moment and be handed the next
|
|
271
|
+
* one, so NOTHING a page brought survives here: not the curve, and not the target — a page of
|
|
272
|
+
* the next build would otherwise dial with the old build id and be told to update forever.
|
|
273
|
+
*/
|
|
274
|
+
#shutdown(): void {
|
|
275
|
+
this.#restartCurve();
|
|
276
|
+
this.#target = null;
|
|
277
|
+
this.#hello = null;
|
|
278
|
+
this.#update = null;
|
|
279
|
+
this.#reaper?.();
|
|
280
|
+
this.#reaper = null;
|
|
281
|
+
const socket = this.#socket;
|
|
282
|
+
this.#socket = null;
|
|
283
|
+
this.#up = false;
|
|
284
|
+
this.#heartbeat.stop();
|
|
285
|
+
socket?.close(1000, 'no tab left');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A silent port, released — and TOLD first. Silent is usually a closed tab, but a hidden tab the
|
|
290
|
+
* browser throttled is silent too, and it was released without a word: its virtual socket stayed
|
|
291
|
+
* "open" over a port nobody read, and that tab's realtime was dead until a reload. Told, its
|
|
292
|
+
* client goes offline and redials, and the page re-hosts (`socket-host.ts`'s `rehosting`).
|
|
293
|
+
*/
|
|
294
|
+
#reap(attached: AttachedPort): void {
|
|
295
|
+
if (attached.open) this.#post(attached, { t: 'close', code: 1006 });
|
|
296
|
+
this.#detach(attached);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A `MessagePort` has no close event: a port silent for three beats is a closed tab. */
|
|
300
|
+
#armReaper(): void {
|
|
301
|
+
if (this.#reaper !== null) return;
|
|
302
|
+
this.#reaper = this.#schedule(() => {
|
|
303
|
+
this.#reaper = null;
|
|
304
|
+
const cutoff = this.#now() - REAP_AFTER_BEATS * this.#beatMs;
|
|
305
|
+
for (const attached of [...this.#ports.values()]) {
|
|
306
|
+
if (attached.lastSeen < cutoff) this.#reap(attached);
|
|
307
|
+
}
|
|
308
|
+
if (this.#ports.size > 0) this.#armReaper();
|
|
309
|
+
}, this.#beatMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#beat(): void {
|
|
313
|
+
this.#send(this.#ownHello());
|
|
314
|
+
this.#router.announce();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** What this engine says to the node: the build every tab of it was rendered by. */
|
|
318
|
+
#ownHello(): Frame {
|
|
319
|
+
return {
|
|
320
|
+
type: 'hello',
|
|
321
|
+
v: PROTOCOL_VERSION,
|
|
322
|
+
buildId: this.#target?.buildId ?? '',
|
|
323
|
+
sessionId: null,
|
|
324
|
+
actorId: null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** What a tab's beat is answered with: the node's own hello once there is one. */
|
|
329
|
+
#helloReply(): Frame {
|
|
330
|
+
return this.#hello ?? this.#ownHello();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#send(frame: Frame): void {
|
|
334
|
+
if (this.#up) this.#socket?.send(encode(frame));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#post(attached: AttachedPort, message: PortMessage): void {
|
|
338
|
+
attached.port.postMessage(message);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#now(): number {
|
|
342
|
+
return this.#clock.now().getTime();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The tab side of the page's one socket (plan 101, slice 11): pick the host — a `SharedWorker`
|
|
2
|
+
// shared by every tab of this origin and principal, or the in-page engine when there is none —
|
|
3
|
+
// and hand the tab's `LiveClient` a socket that is really a `MessagePort`. One engine, one
|
|
4
|
+
// protocol, under both hosts: the fallback costs a socket per tab and nothing else.
|
|
5
|
+
|
|
6
|
+
import { browserSocket, dialUrl } from './browser-socket';
|
|
7
|
+
import type { ClientSocket } from './client-contract';
|
|
8
|
+
import type { SyncTarget } from './page-store';
|
|
9
|
+
import { messagePort, type PortLike, SocketEngine } from './socket-engine';
|
|
10
|
+
import { type Scheduler, timeoutScheduler } from './thundering-herd';
|
|
11
|
+
|
|
12
|
+
export interface SocketHostOptions {
|
|
13
|
+
/** The built worker script (`<meta name="ultimate-sync-worker">`); absent = in-page host. */
|
|
14
|
+
readonly workerUrl?: string | undefined;
|
|
15
|
+
/** The principal the page acts for: one worker per principal, never a shared socket across two. */
|
|
16
|
+
readonly scope: string | null;
|
|
17
|
+
/** The build the page was rendered by: one worker per build, too. See `workerName`. */
|
|
18
|
+
readonly buildId?: string | undefined;
|
|
19
|
+
/** Injected for tests; production reads the globals. */
|
|
20
|
+
readonly sharedWorker?: SharedWorkerLike | undefined;
|
|
21
|
+
readonly inPageEngine?: () => SocketEngine;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SharedWorkerLike = new (
|
|
25
|
+
url: string,
|
|
26
|
+
options: { name: string },
|
|
27
|
+
) => { port: MessagePort };
|
|
28
|
+
|
|
29
|
+
export interface SocketHost {
|
|
30
|
+
/** `'worker'` or `'in-page'` — which host this page ended up on. */
|
|
31
|
+
readonly kind: 'worker' | 'in-page';
|
|
32
|
+
/** A fresh virtual socket over the port — what `LiveClient`'s `connect` option returns. */
|
|
33
|
+
socket(target: SyncTarget): ClientSocket;
|
|
34
|
+
/** The tab is going away: release the port in the engine. */
|
|
35
|
+
bye(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The worker's name carries the scope, so two principals in two tabs get two workers — and the
|
|
40
|
+
* BUILD, so two builds do too. A SharedWorker keeps the first tab's build id for its whole life, so
|
|
41
|
+
* a tab of the new build joined the old engine and was told "update available" about itself.
|
|
42
|
+
*/
|
|
43
|
+
export function workerName(scope: string | null, buildId: string | undefined): string {
|
|
44
|
+
return `ultimate-sync:${encodeURIComponent(scope ?? '')}:${encodeURIComponent(buildId ?? '')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openHost(options: SocketHostOptions): SocketHost {
|
|
48
|
+
const worker = options.workerUrl === undefined ? undefined : workerPort(options);
|
|
49
|
+
if (worker !== undefined) return hostOver(worker, 'worker');
|
|
50
|
+
const channel = new MessageChannel();
|
|
51
|
+
const engine =
|
|
52
|
+
options.inPageEngine?.() ??
|
|
53
|
+
new SocketEngine({ dial: (target) => browserSocket(dialUrl(target)) });
|
|
54
|
+
engine.attach(messagePort(channel.port1));
|
|
55
|
+
return hostOver(messagePort(channel.port2), 'in-page');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `undefined` whenever a worker cannot be had — absent, sandboxed, or refused by the browser. */
|
|
59
|
+
function workerPort(options: SocketHostOptions): PortLike | undefined {
|
|
60
|
+
const Worker =
|
|
61
|
+
options.sharedWorker ??
|
|
62
|
+
(typeof SharedWorker === 'function' ? (SharedWorker as SharedWorkerLike) : undefined);
|
|
63
|
+
if (Worker === undefined || options.workerUrl === undefined) return undefined;
|
|
64
|
+
try {
|
|
65
|
+
return messagePort(
|
|
66
|
+
new Worker(options.workerUrl, { name: workerName(options.scope, options.buildId) }).port,
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hostOver(port: PortLike, kind: 'worker' | 'in-page'): SocketHost {
|
|
74
|
+
let current: VirtualSocket | null = null;
|
|
75
|
+
port.onmessage = (event) => current?.receive(event.data);
|
|
76
|
+
return {
|
|
77
|
+
kind,
|
|
78
|
+
socket: (target) => {
|
|
79
|
+
current = new VirtualSocket(port, target);
|
|
80
|
+
return current;
|
|
81
|
+
},
|
|
82
|
+
bye: () => {
|
|
83
|
+
current = null;
|
|
84
|
+
port.postMessage({ t: 'bye' });
|
|
85
|
+
port.close?.();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The tab's socket: one `open`, frames both ways, one `close` — over the host's port. */
|
|
91
|
+
class VirtualSocket implements ClientSocket {
|
|
92
|
+
readonly #port: PortLike;
|
|
93
|
+
#open: (() => void) | null = null;
|
|
94
|
+
#message: ((data: string) => void) | null = null;
|
|
95
|
+
#closed: ((code: number) => void) | null = null;
|
|
96
|
+
#live = true;
|
|
97
|
+
|
|
98
|
+
constructor(port: PortLike, target: SyncTarget) {
|
|
99
|
+
this.#port = port;
|
|
100
|
+
port.postMessage({ t: 'open', target });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
send(data: string): void {
|
|
104
|
+
if (this.#live) this.#port.postMessage({ t: 'frame', data });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close(): void {
|
|
108
|
+
if (!this.#live) return;
|
|
109
|
+
this.#live = false;
|
|
110
|
+
this.#port.postMessage({ t: 'close', code: 1000 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onOpen(handler: () => void): void {
|
|
114
|
+
this.#open = handler;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onMessage(handler: (data: string) => void): void {
|
|
118
|
+
this.#message = handler;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onClose(handler: (code: number) => void): void {
|
|
122
|
+
this.#closed = handler;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
receive(message: unknown): void {
|
|
126
|
+
if (!this.#live || typeof message !== 'object' || message === null) return;
|
|
127
|
+
const typed = message as { t?: unknown; data?: unknown; code?: unknown };
|
|
128
|
+
if (typed.t === 'open') this.#open?.();
|
|
129
|
+
else if (typed.t === 'frame' && typeof typed.data === 'string') this.#message?.(typed.data);
|
|
130
|
+
else if (typed.t === 'close') {
|
|
131
|
+
this.#live = false;
|
|
132
|
+
this.#closed?.(typeof typed.code === 'number' ? typed.code : 1006);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A host that can be replaced under the page's one client. */
|
|
138
|
+
export interface RehostingHost extends SocketHost {
|
|
139
|
+
/** Say bye to the current host and build a new one — the next `socket()` dials through it. */
|
|
140
|
+
rehost(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RehostingOptions {
|
|
144
|
+
/** How long a virtual socket may wait for `open` before its host is presumed dead. */
|
|
145
|
+
readonly openTimeoutMs: number;
|
|
146
|
+
readonly schedule?: Scheduler | undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The page's host, replaceable. A port the engine reaped (a throttled hidden tab), or one the tab
|
|
151
|
+
* itself said `bye` on at `pagehide` before a bfcache restore, is a port nobody reads: the virtual
|
|
152
|
+
* socket over it waited for `open` forever and the tab's realtime was dead until a reload. So an
|
|
153
|
+
* `open` unanswered by `openTimeoutMs` closes that socket (1006, which the client redials on) and
|
|
154
|
+
* re-hosts on a fresh port; `page-socket.ts` also re-hosts on a bfcache `pageshow`.
|
|
155
|
+
*/
|
|
156
|
+
export function rehosting(make: () => SocketHost, options: RehostingOptions): RehostingHost {
|
|
157
|
+
const schedule = options.schedule ?? timeoutScheduler;
|
|
158
|
+
let current = make();
|
|
159
|
+
const self: RehostingHost = {
|
|
160
|
+
get kind(): 'worker' | 'in-page' {
|
|
161
|
+
return current.kind;
|
|
162
|
+
},
|
|
163
|
+
socket: (target) => {
|
|
164
|
+
const inner = current.socket(target);
|
|
165
|
+
return watchOpen(inner, schedule, options.openTimeoutMs, () => self.rehost());
|
|
166
|
+
},
|
|
167
|
+
bye: () => current.bye(),
|
|
168
|
+
rehost: () => {
|
|
169
|
+
current.bye();
|
|
170
|
+
current = make();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return self;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `inner`, with a deadline on its `open`: missed, the host is re-made and the socket closes. */
|
|
177
|
+
function watchOpen(
|
|
178
|
+
inner: ClientSocket,
|
|
179
|
+
schedule: Scheduler,
|
|
180
|
+
ms: number,
|
|
181
|
+
rehost: () => void,
|
|
182
|
+
): ClientSocket {
|
|
183
|
+
let opened: (() => void) | null = null;
|
|
184
|
+
let closed: ((code: number) => void) | null = null;
|
|
185
|
+
let settled = false;
|
|
186
|
+
const disarm = schedule(() => {
|
|
187
|
+
if (settled) return;
|
|
188
|
+
settled = true;
|
|
189
|
+
rehost();
|
|
190
|
+
closed?.(1006);
|
|
191
|
+
}, ms);
|
|
192
|
+
inner.onOpen(() => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
disarm();
|
|
196
|
+
opened?.();
|
|
197
|
+
});
|
|
198
|
+
inner.onClose((code) => {
|
|
199
|
+
if (!settled) {
|
|
200
|
+
settled = true;
|
|
201
|
+
disarm();
|
|
202
|
+
}
|
|
203
|
+
closed?.(code);
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
get bufferedAmount(): number {
|
|
207
|
+
return inner.bufferedAmount ?? 0;
|
|
208
|
+
},
|
|
209
|
+
send: (data) => inner.send(data),
|
|
210
|
+
close: (code, reason) => {
|
|
211
|
+
if (!settled) {
|
|
212
|
+
settled = true;
|
|
213
|
+
disarm();
|
|
214
|
+
}
|
|
215
|
+
inner.close(code, reason);
|
|
216
|
+
},
|
|
217
|
+
onOpen: (handler) => {
|
|
218
|
+
opened = handler;
|
|
219
|
+
},
|
|
220
|
+
onMessage: (handler) => inner.onMessage(handler),
|
|
221
|
+
onClose: (handler) => {
|
|
222
|
+
closed = handler;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The application idle budget a sync node evicts a silent socket on, and how often it asks. Split
|
|
2
|
+
// from `socket.ts` at its line ceiling.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
|
|
6
|
+
* inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
|
|
7
|
+
* ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
|
|
8
|
+
* its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
|
|
9
|
+
* every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
|
|
15
|
+
* of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
|
|
16
|
+
* four times per window rather than once a second. Derived rather than configured — a second knob
|
|
17
|
+
* is a second number that can disagree with the one it is a fraction of.
|
|
18
|
+
*/
|
|
19
|
+
export function idleSweepPeriodMs(idleTimeoutMs: number): number {
|
|
20
|
+
return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
21
|
+
}
|