@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
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
backoffDelay,
|
|
17
|
+
browserBackoff,
|
|
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
|
+
afterMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? browserBackoff, rng);
|
|
254
|
+
this.#attempt += 1;
|
|
255
|
+
this.#reconnect = this.#schedule(() => {
|
|
256
|
+
this.#reconnect = null;
|
|
257
|
+
// Dialled only for a tab that is asking: a tab re-asks through its own client's timer.
|
|
258
|
+
if ([...this.#ports.values()].some((attached) => attached.open)) this.#dial();
|
|
259
|
+
}, delay);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#restartCurve(): void {
|
|
263
|
+
this.#reconnect?.();
|
|
264
|
+
this.#reconnect = null;
|
|
265
|
+
this.#attempt = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* No page left. A SharedWorker can outlive its last page for a moment and be handed the next
|
|
270
|
+
* one, so NOTHING a page brought survives here: not the curve, and not the target — a page of
|
|
271
|
+
* the next build would otherwise dial with the old build id and be told to update forever.
|
|
272
|
+
*/
|
|
273
|
+
#shutdown(): void {
|
|
274
|
+
this.#restartCurve();
|
|
275
|
+
this.#target = null;
|
|
276
|
+
this.#hello = null;
|
|
277
|
+
this.#update = null;
|
|
278
|
+
this.#reaper?.();
|
|
279
|
+
this.#reaper = null;
|
|
280
|
+
const socket = this.#socket;
|
|
281
|
+
this.#socket = null;
|
|
282
|
+
this.#up = false;
|
|
283
|
+
this.#heartbeat.stop();
|
|
284
|
+
socket?.close(1000, 'no tab left');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A `MessagePort` has no close event: a port silent for three beats is a closed tab. */
|
|
288
|
+
#armReaper(): void {
|
|
289
|
+
if (this.#reaper !== null) return;
|
|
290
|
+
this.#reaper = this.#schedule(() => {
|
|
291
|
+
this.#reaper = null;
|
|
292
|
+
const cutoff = this.#now() - REAP_AFTER_BEATS * this.#beatMs;
|
|
293
|
+
for (const attached of [...this.#ports.values()]) {
|
|
294
|
+
if (attached.lastSeen < cutoff) this.#detach(attached);
|
|
295
|
+
}
|
|
296
|
+
if (this.#ports.size > 0) this.#armReaper();
|
|
297
|
+
}, this.#beatMs);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#beat(): void {
|
|
301
|
+
this.#send(this.#ownHello());
|
|
302
|
+
this.#router.announce();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** What this engine says to the node: the build every tab of it was rendered by. */
|
|
306
|
+
#ownHello(): Frame {
|
|
307
|
+
return {
|
|
308
|
+
type: 'hello',
|
|
309
|
+
v: PROTOCOL_VERSION,
|
|
310
|
+
buildId: this.#target?.buildId ?? '',
|
|
311
|
+
sessionId: null,
|
|
312
|
+
actorId: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** What a tab's beat is answered with: the node's own hello once there is one. */
|
|
317
|
+
#helloReply(): Frame {
|
|
318
|
+
return this.#hello ?? this.#ownHello();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#send(frame: Frame): void {
|
|
322
|
+
if (this.#up) this.#socket?.send(encode(frame));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#post(attached: AttachedPort, message: PortMessage): void {
|
|
326
|
+
attached.port.postMessage(message);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#now(): number {
|
|
330
|
+
return this.#clock.now().getTime();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
|
|
11
|
+
export interface SocketHostOptions {
|
|
12
|
+
/** The built worker script (`<meta name="ultimate-sync-worker">`); absent = in-page host. */
|
|
13
|
+
readonly workerUrl?: string | undefined;
|
|
14
|
+
/** The principal the page acts for: one worker per principal, never a shared socket across two. */
|
|
15
|
+
readonly scope: string | null;
|
|
16
|
+
/** Injected for tests; production reads the globals. */
|
|
17
|
+
readonly sharedWorker?: SharedWorkerLike | undefined;
|
|
18
|
+
readonly inPageEngine?: () => SocketEngine;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SharedWorkerLike = new (
|
|
22
|
+
url: string,
|
|
23
|
+
options: { name: string },
|
|
24
|
+
) => { port: MessagePort };
|
|
25
|
+
|
|
26
|
+
export interface SocketHost {
|
|
27
|
+
/** `'worker'` or `'in-page'` — which host this page ended up on. */
|
|
28
|
+
readonly kind: 'worker' | 'in-page';
|
|
29
|
+
/** A fresh virtual socket over the port — what `LiveClient`'s `connect` option returns. */
|
|
30
|
+
socket(target: SyncTarget): ClientSocket;
|
|
31
|
+
/** The tab is going away: release the port in the engine. */
|
|
32
|
+
bye(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The worker's name carries the scope, so two principals in two tabs get two workers. */
|
|
36
|
+
export function workerName(scope: string | null): string {
|
|
37
|
+
return `ultimate-sync:${encodeURIComponent(scope ?? '')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function openHost(options: SocketHostOptions): SocketHost {
|
|
41
|
+
const worker = options.workerUrl === undefined ? undefined : workerPort(options);
|
|
42
|
+
if (worker !== undefined) return hostOver(worker, 'worker');
|
|
43
|
+
const channel = new MessageChannel();
|
|
44
|
+
const engine =
|
|
45
|
+
options.inPageEngine?.() ??
|
|
46
|
+
new SocketEngine({ dial: (target) => browserSocket(dialUrl(target)) });
|
|
47
|
+
engine.attach(messagePort(channel.port1));
|
|
48
|
+
return hostOver(messagePort(channel.port2), 'in-page');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `undefined` whenever a worker cannot be had — absent, sandboxed, or refused by the browser. */
|
|
52
|
+
function workerPort(options: SocketHostOptions): PortLike | undefined {
|
|
53
|
+
const Worker =
|
|
54
|
+
options.sharedWorker ??
|
|
55
|
+
(typeof SharedWorker === 'function' ? (SharedWorker as SharedWorkerLike) : undefined);
|
|
56
|
+
if (Worker === undefined || options.workerUrl === undefined) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return messagePort(new Worker(options.workerUrl, { name: workerName(options.scope) }).port);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hostOver(port: PortLike, kind: 'worker' | 'in-page'): SocketHost {
|
|
65
|
+
let current: VirtualSocket | null = null;
|
|
66
|
+
port.onmessage = (event) => current?.receive(event.data);
|
|
67
|
+
return {
|
|
68
|
+
kind,
|
|
69
|
+
socket: (target) => {
|
|
70
|
+
current = new VirtualSocket(port, target);
|
|
71
|
+
return current;
|
|
72
|
+
},
|
|
73
|
+
bye: () => {
|
|
74
|
+
current = null;
|
|
75
|
+
port.postMessage({ t: 'bye' });
|
|
76
|
+
port.close?.();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The tab's socket: one `open`, frames both ways, one `close` — over the host's port. */
|
|
82
|
+
class VirtualSocket implements ClientSocket {
|
|
83
|
+
readonly #port: PortLike;
|
|
84
|
+
#open: (() => void) | null = null;
|
|
85
|
+
#message: ((data: string) => void) | null = null;
|
|
86
|
+
#closed: ((code: number) => void) | null = null;
|
|
87
|
+
#live = true;
|
|
88
|
+
|
|
89
|
+
constructor(port: PortLike, target: SyncTarget) {
|
|
90
|
+
this.#port = port;
|
|
91
|
+
port.postMessage({ t: 'open', target });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
send(data: string): void {
|
|
95
|
+
if (this.#live) this.#port.postMessage({ t: 'frame', data });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
close(): void {
|
|
99
|
+
if (!this.#live) return;
|
|
100
|
+
this.#live = false;
|
|
101
|
+
this.#port.postMessage({ t: 'close', code: 1000 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onOpen(handler: () => void): void {
|
|
105
|
+
this.#open = handler;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onMessage(handler: (data: string) => void): void {
|
|
109
|
+
this.#message = handler;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onClose(handler: (code: number) => void): void {
|
|
113
|
+
this.#closed = handler;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
receive(message: unknown): void {
|
|
117
|
+
if (!this.#live || typeof message !== 'object' || message === null) return;
|
|
118
|
+
const typed = message as { t?: unknown; data?: unknown; code?: unknown };
|
|
119
|
+
if (typed.t === 'open') this.#open?.();
|
|
120
|
+
else if (typed.t === 'frame' && typeof typed.data === 'string') this.#message?.(typed.data);
|
|
121
|
+
else if (typed.t === 'close') {
|
|
122
|
+
this.#live = false;
|
|
123
|
+
this.#closed?.(typeof typed.code === 'number' ? typed.code : 1006);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The engine ⇄ tab seam: the messages a `MessagePort` carries, the part of a port the engine uses,
|
|
2
|
+
// and what the engine remembers about each attached one. Shared by the engine and its router.
|
|
3
|
+
|
|
4
|
+
import type { SyncTarget } from './page-store';
|
|
5
|
+
import type { SubscribeFrame } from './sync-protocol';
|
|
6
|
+
|
|
7
|
+
/** The engine ⇄ tab messages. `frame.data` is a wire frame, encoded exactly as a socket carries it. */
|
|
8
|
+
export type PortMessage =
|
|
9
|
+
| { readonly t: 'open'; readonly target: SyncTarget }
|
|
10
|
+
| { readonly t: 'frame'; readonly data: string }
|
|
11
|
+
| { readonly t: 'close'; readonly code: number }
|
|
12
|
+
/** The tab is going away (`pagehide`, a principal change): release the port itself. */
|
|
13
|
+
| { readonly t: 'bye' };
|
|
14
|
+
|
|
15
|
+
/** The part of a `MessagePort` the engine uses — so a test hands in a `MessageChannel` port. */
|
|
16
|
+
export interface PortLike {
|
|
17
|
+
postMessage(message: PortMessage): void;
|
|
18
|
+
onmessage: ((event: { readonly data: unknown }) => void) | null;
|
|
19
|
+
close?(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One tab, as the engine knows it. */
|
|
23
|
+
export interface AttachedPort {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly port: PortLike;
|
|
26
|
+
/** The tab asked for its virtual socket to be open. */
|
|
27
|
+
open: boolean;
|
|
28
|
+
/** It has asked before: a later `open` is the tab's own retry, not a page arriving. */
|
|
29
|
+
asked: boolean;
|
|
30
|
+
lastSeen: number;
|
|
31
|
+
/** Channel topics this port wants. */
|
|
32
|
+
readonly topics: Set<string>;
|
|
33
|
+
/** Engine sid → the add frame, for the live queries this port holds. */
|
|
34
|
+
readonly lives: Map<string, SubscribeFrame>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A real `MessagePort` as a `PortLike`. Setting `onmessage` on a `MessagePort` starts it, which is
|
|
39
|
+
* what both hosts rely on; the wrapper exists only because the DOM types a handler over
|
|
40
|
+
* `MessageEvent` and the engine reads nothing of one but `data`.
|
|
41
|
+
*/
|
|
42
|
+
export function messagePort(port: MessagePort): PortLike {
|
|
43
|
+
let handler: PortLike['onmessage'] = null;
|
|
44
|
+
return {
|
|
45
|
+
postMessage: (message) => port.postMessage(message),
|
|
46
|
+
get onmessage() {
|
|
47
|
+
return handler;
|
|
48
|
+
},
|
|
49
|
+
set onmessage(next) {
|
|
50
|
+
handler = next;
|
|
51
|
+
port.onmessage = next === null ? null : (event: MessageEvent) => next({ data: event.data });
|
|
52
|
+
},
|
|
53
|
+
close: () => port.close(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The engine's multiplexing: which port wants which channel topic and which live query, so the
|
|
2
|
+
// one socket carries ONE membership per topic and every server frame is ROUTED only to the ports
|
|
3
|
+
// that want it — never broadcast. It holds no socket; the engine hands it `send` and `post`.
|
|
4
|
+
|
|
5
|
+
import { CHANNEL_SID_PREFIX } from './client-channels';
|
|
6
|
+
import type { AttachedPort, PortMessage } from './socket-port';
|
|
7
|
+
import { encode, type Frame, PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
|
|
8
|
+
|
|
9
|
+
export interface RouterDeps {
|
|
10
|
+
/** To the node, when the socket is up. */
|
|
11
|
+
send(frame: Frame): void;
|
|
12
|
+
post(attached: AttachedPort, message: PortMessage): void;
|
|
13
|
+
port(id: number): AttachedPort | undefined;
|
|
14
|
+
ports(): Iterable<AttachedPort>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The frames the router routes; the engine keeps `hello`, `update-available` and `reconnect`. */
|
|
18
|
+
type RoutedFrame = Extract<
|
|
19
|
+
Frame,
|
|
20
|
+
{ readonly type: 'snapshot' | 'patch' | 'ack' | 'records' | 'replay-gap' | 'events' }
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
export class PortRouter {
|
|
24
|
+
readonly #deps: RouterDeps;
|
|
25
|
+
/**
|
|
26
|
+
* Topic → the ports that want it, the add frame that joined it, and the epoch the node last
|
|
27
|
+
* named for it — what a later port is told to re-read in.
|
|
28
|
+
*/
|
|
29
|
+
readonly #topics = new Map<
|
|
30
|
+
string,
|
|
31
|
+
{ readonly ports: Set<number>; readonly add: SubscribeFrame; epoch: string | null }
|
|
32
|
+
>();
|
|
33
|
+
|
|
34
|
+
constructor(deps: RouterDeps) {
|
|
35
|
+
this.#deps = deps;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A tab's `subscribe`: a channel membership, or a live query under a port-scoped sid. */
|
|
39
|
+
subscribe(attached: AttachedPort, frame: SubscribeFrame): void {
|
|
40
|
+
if (frame.target.kind === 'channel') {
|
|
41
|
+
this.#channel(attached, frame);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (frame.target.kind !== 'query') return;
|
|
45
|
+
// Every tab mints its own sids; the engine's sid carries the port so two tabs never collide.
|
|
46
|
+
const sid = `${attached.id}|${frame.sid}`;
|
|
47
|
+
const rewritten: SubscribeFrame = { ...frame, sid };
|
|
48
|
+
if (frame.op === 'add') attached.lives.set(sid, rewritten);
|
|
49
|
+
else attached.lives.delete(sid);
|
|
50
|
+
this.#deps.send(rewritten);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Release what a port held: its channel wants and its live registrations. */
|
|
54
|
+
release(attached: AttachedPort): void {
|
|
55
|
+
for (const topic of attached.topics) {
|
|
56
|
+
const add = this.#topics.get(topic)?.add;
|
|
57
|
+
if (add !== undefined) this.#leave(attached.id, topic, { ...add, op: 'drop' });
|
|
58
|
+
}
|
|
59
|
+
attached.topics.clear();
|
|
60
|
+
for (const add of attached.lives.values()) this.#deps.send({ ...add, op: 'drop' });
|
|
61
|
+
attached.lives.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One server frame to the ports it belongs to. `data` is the frame as the socket carried it. */
|
|
65
|
+
route(frame: RoutedFrame, data: string): void {
|
|
66
|
+
switch (frame.type) {
|
|
67
|
+
case 'snapshot':
|
|
68
|
+
case 'patch':
|
|
69
|
+
this.#toLive(frame.sid, (sid) => ({ ...frame, sid }));
|
|
70
|
+
return;
|
|
71
|
+
case 'ack': {
|
|
72
|
+
const ref = frame.ref;
|
|
73
|
+
if (ref.includes('|')) this.#toLive(ref, (local) => ({ ...frame, ref: local }));
|
|
74
|
+
else if (this.#topics.has(ref)) this.#toTopic(ref, data);
|
|
75
|
+
else for (const attached of this.#deps.ports()) this.#frame(attached, data);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
case 'records':
|
|
79
|
+
case 'replay-gap': {
|
|
80
|
+
const held = this.#topics.get(`${CHANNEL_SID_PREFIX}${frame.channel}`);
|
|
81
|
+
if (held !== undefined) held.epoch = frame.epoch;
|
|
82
|
+
this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
case 'events':
|
|
86
|
+
this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Re-announcing each channel IS the node's presence heartbeat — with no `since`, so it replays
|
|
93
|
+
* nothing.
|
|
94
|
+
*/
|
|
95
|
+
announce(): void {
|
|
96
|
+
for (const { add } of this.#topics.values()) this.#deps.send(withoutSince(add));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The socket is gone: every membership goes with it; each tab resubscribes from its cursors. */
|
|
100
|
+
clear(): void {
|
|
101
|
+
this.#topics.clear();
|
|
102
|
+
for (const attached of this.#deps.ports()) {
|
|
103
|
+
attached.topics.clear();
|
|
104
|
+
attached.lives.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One membership per topic across every port; the first `add` joins, the last leave drops. */
|
|
109
|
+
#channel(attached: AttachedPort, frame: SubscribeFrame): void {
|
|
110
|
+
const topic = frame.sid;
|
|
111
|
+
const held = this.#topics.get(topic);
|
|
112
|
+
if (frame.op !== 'add') {
|
|
113
|
+
attached.topics.delete(topic);
|
|
114
|
+
this.#leave(attached.id, topic, frame);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
attached.topics.add(topic);
|
|
118
|
+
if (held === undefined) {
|
|
119
|
+
this.#topics.set(topic, { ports: new Set([attached.id]), add: frame, epoch: null });
|
|
120
|
+
this.#deps.send(frame);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (held.ports.has(attached.id)) return;
|
|
124
|
+
held.ports.add(attached.id);
|
|
125
|
+
// The node never hears this add, so it cannot answer it with the `replay-gap` a fresh seat
|
|
126
|
+
// gets; the engine does. With no epoch yet, the node's own answer is still on its way and now
|
|
127
|
+
// reaches this port too.
|
|
128
|
+
if (held.epoch === null) return;
|
|
129
|
+
const gap: Frame = {
|
|
130
|
+
type: 'replay-gap',
|
|
131
|
+
v: PROTOCOL_VERSION,
|
|
132
|
+
channel: topic.slice(CHANNEL_SID_PREFIX.length),
|
|
133
|
+
epoch: held.epoch,
|
|
134
|
+
};
|
|
135
|
+
this.#frame(attached, encode(gap));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#leave(id: number, topic: string, drop: SubscribeFrame): void {
|
|
139
|
+
const held = this.#topics.get(topic);
|
|
140
|
+
if (held === undefined) return;
|
|
141
|
+
held.ports.delete(id);
|
|
142
|
+
if (held.ports.size > 0) return;
|
|
143
|
+
this.#topics.delete(topic);
|
|
144
|
+
this.#deps.send(drop);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#toLive(engineSid: string, rewrite: (localSid: string) => Frame): void {
|
|
148
|
+
const bar = engineSid.indexOf('|');
|
|
149
|
+
const attached = this.#deps.port(Number(engineSid.slice(0, bar)));
|
|
150
|
+
if (attached !== undefined) this.#frame(attached, encode(rewrite(engineSid.slice(bar + 1))));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#toTopic(topic: string, data: string): void {
|
|
154
|
+
for (const id of this.#topics.get(topic)?.ports ?? []) {
|
|
155
|
+
const attached = this.#deps.port(id);
|
|
156
|
+
if (attached !== undefined) this.#frame(attached, data);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#frame(attached: AttachedPort, data: string): void {
|
|
161
|
+
this.#deps.post(attached, { t: 'frame', data });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A channel's `add` without its resume point — the beat repeats a membership, it resumes nothing. */
|
|
166
|
+
function withoutSince(add: SubscribeFrame): SubscribeFrame {
|
|
167
|
+
if (add.target.kind !== 'channel') return add;
|
|
168
|
+
const { since: _since, ...target } = add.target;
|
|
169
|
+
return { ...add, target };
|
|
170
|
+
}
|