@rebasepro/client 0.9.1-canary.fd3754b → 0.10.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.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Broadcast channels and presence, as an SDK surface.
3
+ *
4
+ * The realtime engine has supported `join_channel`, `broadcast`,
5
+ * `presence_track`, `presence_untrack` and `presence_state` for a while, but
6
+ * the client only recognised those types well enough to send them
7
+ * fire-and-forget: there were no methods to call and no way to receive channel
8
+ * or broadcast events, since `on()` handles only connect / disconnect /
9
+ * reconnect / error. Anything wanting presence therefore opened a *second*
10
+ * socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the
11
+ * reconnect backoff, and the presence heartbeat — a couple of hundred lines
12
+ * per app, all of it duplicating this package.
13
+ *
14
+ * Two protocol details this hides, because both are easy to get wrong and
15
+ * neither is discoverable from the message list:
16
+ *
17
+ * - **A joining client is told only about its own join.** The `presence_diff`
18
+ * it receives after `presence_track` contains just itself. The existing
19
+ * roster arrives only in response to an explicit `presence_state` request,
20
+ * so `join()` sends one.
21
+ * - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A
22
+ * client that tracks once and goes quiet silently vanishes from everyone
23
+ * else's roster while still sitting in the document, so `track()` starts a
24
+ * heartbeat and `leave()` stops it.
25
+ */
26
+
27
+ /** Presence state keyed by the server's client id. */
28
+ export type PresenceState = Record<string, Record<string, unknown>>;
29
+
30
+ export interface PresenceDiff {
31
+ joins: PresenceState;
32
+ leaves: PresenceState;
33
+ }
34
+
35
+ export interface BroadcastEvent {
36
+ event: string;
37
+ payload: unknown;
38
+ /**
39
+ * Per-channel sequence number, present only on retained channels.
40
+ *
41
+ * Monotonically increasing and dense, so a consumer that remembers the last
42
+ * one it applied can tell the server exactly where to resume from.
43
+ */
44
+ seq?: number;
45
+ /**
46
+ * True when this arrived through catch-up rather than live.
47
+ *
48
+ * Handlers do not have to care — replayed messages are delivered to the
49
+ * same `onBroadcast` handlers, in sequence order, so an operation stream
50
+ * needs no second code path. It is exposed for consumers that want to,
51
+ * for example, skip an animation while fast-forwarding.
52
+ */
53
+ replayed?: boolean;
54
+ }
55
+
56
+ /** One retained message, as returned by {@link RebaseRealtimeChannel.history}. */
57
+ export interface ChannelHistoryEntry {
58
+ seq: number;
59
+ event: string;
60
+ payload: unknown;
61
+ senderId?: string;
62
+ at?: string;
63
+ }
64
+
65
+ /** The answer to a catch-up request. */
66
+ export interface ChannelHistoryResult {
67
+ messages: ChannelHistoryEntry[];
68
+ /**
69
+ * Whether the server retains anything for this channel.
70
+ *
71
+ * False means there is no retention rule configured for it, so the empty
72
+ * list means "never keeps history" rather than "you missed nothing" — a
73
+ * client that needs to converge has to fall back to a full resync.
74
+ */
75
+ retained: boolean;
76
+ /** Highest sequence the server holds, even if this batch was capped. */
77
+ latestSeq?: number;
78
+ }
79
+
80
+ /** Options for a channel handle. */
81
+ export interface ChannelOptions {
82
+ /**
83
+ * Ask the server to replay what this client missed, on join and on every
84
+ * reconnect.
85
+ *
86
+ * Only meaningful for a channel the *server* has a retention rule for —
87
+ * retention is configured on the backend, since a channel is created by
88
+ * whoever names it and a client-chosen history depth would let any visitor
89
+ * commit the backend to unbounded storage. On a channel with no rule the
90
+ * server answers `retained: false` and this is inert.
91
+ */
92
+ history?: boolean;
93
+ }
94
+
95
+ /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
96
+ export interface ChannelTransport {
97
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
98
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
99
+ onReconnect(handler: () => void): () => void;
100
+ }
101
+
102
+ /**
103
+ * Re-send presence comfortably inside the server's 30s expiry.
104
+ *
105
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
106
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
107
+ */
108
+ const PRESENCE_HEARTBEAT_MS = 20_000;
109
+
110
+ /**
111
+ * How long live messages are held back waiting for a catch-up response.
112
+ *
113
+ * Short, because the cost of waiting is visible — on a collaborative document
114
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
115
+ * replay of a busy channel is not abandoned needlessly.
116
+ */
117
+ const CATCH_UP_TIMEOUT_MS = 10_000;
118
+
119
+ export class RebaseRealtimeChannel {
120
+ private presenceHandlers = new Set<(state: PresenceState, diff?: PresenceDiff) => void>();
121
+ private broadcastHandlers = new Set<(event: BroadcastEvent) => void>();
122
+ private unsubscribers: (() => void)[] = [];
123
+
124
+ /** Last known roster, kept so handlers always get a full picture. */
125
+ private presences: PresenceState = {};
126
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
127
+ private trackedState: Record<string, unknown> | null = null;
128
+ private heartbeat: ReturnType<typeof setInterval> | null = null;
129
+ private joined = false;
130
+
131
+ /** Whether this handle asks the server to replay missed messages. */
132
+ private wantsHistory: boolean;
133
+
134
+ /**
135
+ * Highest sequence number delivered to handlers so far.
136
+ *
137
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
138
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
139
+ * anything at or below this has already been seen.
140
+ */
141
+ private lastSeq = 0;
142
+
143
+ /**
144
+ * Live messages that arrived while a catch-up was in flight.
145
+ *
146
+ * Without this they would be delivered ahead of the older messages being
147
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
148
+ * catch-up response would then be discarded as already-seen and those
149
+ * messages would be lost for good. Held here and flushed, in order, once
150
+ * the replay lands.
151
+ */
152
+ private pendingLive: BroadcastEvent[] = [];
153
+ private catchUpInFlight = false;
154
+
155
+ /**
156
+ * Deadline for a catch-up response.
157
+ *
158
+ * Buffering live messages is only safe because the wait is bounded. A
159
+ * catch-up frame that never arrives — a server that dropped it, a socket
160
+ * that died between request and reply — would otherwise leave the channel
161
+ * silently holding every subsequent edit forever, which is a worse failure
162
+ * than the one replay was added to fix.
163
+ */
164
+ private catchUpTimeout: ReturnType<typeof setTimeout> | null = null;
165
+
166
+ /**
167
+ * Callers of {@link history} awaiting the next `channel_history` frame.
168
+ *
169
+ * These frames are addressed by channel rather than by request id, so they
170
+ * are matched in arrival order. Requests on one channel are serialized by
171
+ * the socket, so FIFO is the right correlation here.
172
+ */
173
+ private historyWaiters: Array<(result: ChannelHistoryResult) => void> = [];
174
+
175
+ constructor(
176
+ public readonly name: string,
177
+ private transport: ChannelTransport,
178
+ options: ChannelOptions = {}
179
+ ) {
180
+ this.wantsHistory = options.history ?? false;
181
+ }
182
+
183
+ /**
184
+ * Turn on catch-up for a handle that was created without it.
185
+ *
186
+ * The client hands back the same channel object for a given name, so a
187
+ * later `channel(name, { history: true })` has no new object to configure —
188
+ * it upgrades this one instead. Idempotent, and never downgrades: one
189
+ * caller asking for history must not be switched off by another that did
190
+ * not ask.
191
+ */
192
+ enableHistory(): void {
193
+ if (this.wantsHistory) return;
194
+ this.wantsHistory = true;
195
+ if (this.joined) void this.requestHistory();
196
+ }
197
+
198
+ /**
199
+ * Join the channel and ask for the current roster.
200
+ *
201
+ * Called automatically by `track`, `broadcast`, `onPresence` and
202
+ * `onBroadcast`; calling it directly is only needed to start receiving
203
+ * before there is anything to send.
204
+ */
205
+ /**
206
+ * Send a channel message.
207
+ *
208
+ * Every channel message is read by the server out of a `payload` envelope
209
+ * (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
210
+ * fields flat does not error: `payload?.channel` simply reads as
211
+ * `undefined`, so the client is registered into channel `undefined` with
212
+ * empty state, and the echo comes back with no `channel` for
213
+ * `onChannelMessage` to match — presence and broadcast both go quiet with
214
+ * nothing logged. Funnelled through one place so a new message type cannot
215
+ * reintroduce that.
216
+ */
217
+ private send(type: string, fields: Record<string, unknown> = {}): Promise<unknown> {
218
+ return this.transport.sendMessage({ type, payload: { channel: this.name, ...fields } });
219
+ }
220
+
221
+ async join(): Promise<void> {
222
+ if (this.joined) return;
223
+ this.joined = true;
224
+
225
+ this.unsubscribers.push(
226
+ this.transport.onChannelMessage(this.name, (message) => this.handle(message))
227
+ );
228
+
229
+ // A reconnect drops server-side channel membership and presence, so
230
+ // both have to be re-established. Nothing else notices this: the
231
+ // socket comes back, and the client just stops receiving.
232
+ this.unsubscribers.push(
233
+ this.transport.onReconnect(() => {
234
+ void this.rejoin();
235
+ })
236
+ );
237
+
238
+ await this.send("join_channel");
239
+ // Not optional. Joining does not push the roster — without this the
240
+ // channel believes it is alone until somebody else happens to move.
241
+ await this.send("presence_state");
242
+ if (this.wantsHistory) await this.requestHistory();
243
+ }
244
+
245
+ private async rejoin(): Promise<void> {
246
+ try {
247
+ await this.send("join_channel");
248
+ await this.send("presence_state");
249
+ if (this.trackedState) {
250
+ await this.send("presence_track", { state: this.trackedState });
251
+ }
252
+ // The reason this class tracks a sequence number at all: whatever
253
+ // was broadcast while the socket was down was delivered to everyone
254
+ // else and never to us. Asking from `lastSeq` is the difference
255
+ // between resuming and resyncing the whole document.
256
+ if (this.wantsHistory) await this.requestHistory();
257
+ } catch {
258
+ // The socket is down again; the next reconnect will retry.
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Ask the server for everything after {@link lastSeq}.
264
+ *
265
+ * Live messages are buffered from here until the answer arrives — see
266
+ * {@link pendingLive}.
267
+ */
268
+ private async requestHistory(limit?: number): Promise<void> {
269
+ this.catchUpInFlight = true;
270
+
271
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
272
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
273
+ (this.catchUpTimeout as unknown as { unref?: () => void }).unref?.();
274
+
275
+ try {
276
+ await this.send("channel_history", {
277
+ sinceSeq: this.lastSeq,
278
+ ...(limit !== undefined ? { limit } : {})
279
+ });
280
+ } catch {
281
+ // The frame never went out, so nothing will answer it.
282
+ this.abandonCatchUp();
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Give up waiting for a catch-up and release what was held back.
288
+ *
289
+ * The buffered messages are still the freshest thing this client has, so
290
+ * they are delivered rather than dropped. Callers of {@link history} are
291
+ * answered with `retained: false` — accurate in the sense that matters:
292
+ * this client has no history to work from and has to resync.
293
+ */
294
+ private abandonCatchUp(): void {
295
+ if (this.catchUpTimeout) {
296
+ clearTimeout(this.catchUpTimeout);
297
+ this.catchUpTimeout = null;
298
+ }
299
+ if (!this.catchUpInFlight) return;
300
+ this.catchUpInFlight = false;
301
+ for (const resolve of this.historyWaiters.splice(0)) {
302
+ resolve({ messages: [], retained: false });
303
+ }
304
+ this.flushPendingLive();
305
+ }
306
+
307
+ /**
308
+ * Publish this client's presence state, and keep publishing it.
309
+ *
310
+ * Calling `track` again replaces the state (and restarts the heartbeat),
311
+ * which is how you update e.g. a cursor position.
312
+ */
313
+ async track(state: Record<string, unknown>): Promise<void> {
314
+ await this.join();
315
+ this.trackedState = state;
316
+
317
+ await this.send("presence_track", { state });
318
+
319
+ if (!this.heartbeat) {
320
+ this.heartbeat = setInterval(() => {
321
+ if (!this.trackedState) return;
322
+ void this.send("presence_track", { state: this.trackedState })
323
+ .catch(() => { /* a dropped beat is recoverable; the next one carries the same state */ });
324
+ }, PRESENCE_HEARTBEAT_MS);
325
+ // Do not hold a Node process open just to say "still here".
326
+ (this.heartbeat as unknown as { unref?: () => void }).unref?.();
327
+ }
328
+ }
329
+
330
+ /** Stop publishing presence, without leaving the channel. */
331
+ async untrack(): Promise<void> {
332
+ this.stopHeartbeat();
333
+ this.trackedState = null;
334
+ if (this.joined) {
335
+ await this.send("presence_untrack");
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Observe the roster. The handler fires immediately with what is already
341
+ * known, then on every change.
342
+ */
343
+ onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void {
344
+ this.presenceHandlers.add(handler);
345
+ void this.join();
346
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
347
+ return () => this.presenceHandlers.delete(handler);
348
+ }
349
+
350
+ /** Send a broadcast. The sender does not receive its own message. */
351
+ async broadcast(event: string, payload: unknown): Promise<void> {
352
+ await this.join();
353
+ await this.send("broadcast", { event, payload });
354
+ }
355
+
356
+ /** Observe broadcasts. Pass an event name to filter. */
357
+ onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
358
+ onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
359
+ onBroadcast(
360
+ eventOrHandler: string | ((event: BroadcastEvent) => void),
361
+ maybeHandler?: (payload: unknown) => void
362
+ ): () => void {
363
+ const wrapped: (event: BroadcastEvent) => void = typeof eventOrHandler === "string"
364
+ ? (e) => { if (e.event === eventOrHandler) maybeHandler!(e.payload); }
365
+ : eventOrHandler;
366
+
367
+ this.broadcastHandlers.add(wrapped);
368
+ void this.join();
369
+ return () => this.broadcastHandlers.delete(wrapped);
370
+ }
371
+
372
+ /**
373
+ * The last sequence number this channel has delivered.
374
+ *
375
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
376
+ * to survive a page reload as well as a reconnect, and pass it back via
377
+ * {@link history}.
378
+ */
379
+ get sequence(): number {
380
+ return this.lastSeq;
381
+ }
382
+
383
+ /**
384
+ * Fetch retained messages explicitly, instead of waiting for join or
385
+ * reconnect to do it.
386
+ *
387
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
388
+ * `onBroadcast` handlers as usual — the returned value is for callers that
389
+ * want to inspect the batch, or to learn from `retained` that the channel
390
+ * keeps no history at all.
391
+ */
392
+ async history(options: { sinceSeq?: number; limit?: number } = {}): Promise<ChannelHistoryResult> {
393
+ await this.join();
394
+ if (options.sinceSeq !== undefined) this.lastSeq = options.sinceSeq;
395
+
396
+ const result = new Promise<ChannelHistoryResult>((resolve) => {
397
+ this.historyWaiters.push(resolve);
398
+ });
399
+ await this.requestHistory(options.limit);
400
+ return result;
401
+ }
402
+
403
+ /** Leave the channel and release every listener and timer. */
404
+ async leave(): Promise<void> {
405
+ this.stopHeartbeat();
406
+ this.trackedState = null;
407
+ this.presences = {};
408
+ this.presenceHandlers.clear();
409
+ this.broadcastHandlers.clear();
410
+ // A rejoin is a fresh start: replaying from a watermark left over from
411
+ // the previous membership would silently skip everything before it.
412
+ this.lastSeq = 0;
413
+ this.pendingLive = [];
414
+ this.catchUpInFlight = false;
415
+ if (this.catchUpTimeout) {
416
+ clearTimeout(this.catchUpTimeout);
417
+ this.catchUpTimeout = null;
418
+ }
419
+ for (const resolve of this.historyWaiters.splice(0)) {
420
+ resolve({ messages: [], retained: false });
421
+ }
422
+
423
+ for (const off of this.unsubscribers) off();
424
+ this.unsubscribers = [];
425
+
426
+ if (this.joined) {
427
+ this.joined = false;
428
+ await this.send("leave_channel");
429
+ }
430
+ }
431
+
432
+ private stopHeartbeat(): void {
433
+ if (this.heartbeat) {
434
+ clearInterval(this.heartbeat);
435
+ this.heartbeat = null;
436
+ }
437
+ }
438
+
439
+ /** Fold an incoming frame into the roster and fan it out. */
440
+ private handle(message: Record<string, unknown>): void {
441
+ switch (message.type) {
442
+ case "presence_state": {
443
+ this.presences = (message.presences as PresenceState) ?? {};
444
+ this.emitPresence();
445
+ break;
446
+ }
447
+ case "presence_diff": {
448
+ const joins = (message.joins as PresenceState) ?? {};
449
+ const leaves = (message.leaves as PresenceState) ?? {};
450
+ // A diff carries only what moved, so the roster is maintained
451
+ // here rather than handed to callers to reassemble.
452
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
453
+ for (const id of Object.keys(leaves)) delete this.presences[id];
454
+ this.emitPresence({ joins, leaves });
455
+ break;
456
+ }
457
+ case "broadcast": {
458
+ const seq = typeof message.seq === "number" ? message.seq : undefined;
459
+ const event: BroadcastEvent = {
460
+ event: message.event as string,
461
+ payload: message.payload,
462
+ ...(seq !== undefined ? { seq } : {})
463
+ };
464
+
465
+ // Unsequenced channels keep the original behaviour exactly:
466
+ // straight through, no buffering, no watermark.
467
+ if (seq === undefined) {
468
+ this.deliver(event);
469
+ break;
470
+ }
471
+
472
+ if (this.catchUpInFlight) {
473
+ this.pendingLive.push(event);
474
+ break;
475
+ }
476
+ if (seq <= this.lastSeq) break; // already delivered
477
+ this.lastSeq = seq;
478
+ this.deliver(event);
479
+ break;
480
+ }
481
+ case "channel_history": {
482
+ this.catchUpInFlight = false;
483
+ if (this.catchUpTimeout) {
484
+ clearTimeout(this.catchUpTimeout);
485
+ this.catchUpTimeout = null;
486
+ }
487
+
488
+ const entries = (message.messages as ChannelHistoryEntry[] | undefined) ?? [];
489
+ const retained = message.retained === true;
490
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : undefined;
491
+
492
+ for (const resolve of this.historyWaiters.splice(0)) {
493
+ resolve({ messages: entries, retained, latestSeq });
494
+ }
495
+
496
+ // Server-ordered ascending; the watermark check makes the
497
+ // overlap with anything already seen a no-op rather than a
498
+ // double-apply.
499
+ for (const entry of entries) {
500
+ if (entry.seq <= this.lastSeq) continue;
501
+ this.lastSeq = entry.seq;
502
+ this.deliver({
503
+ event: entry.event,
504
+ payload: entry.payload,
505
+ seq: entry.seq,
506
+ replayed: true
507
+ });
508
+ }
509
+
510
+ this.flushPendingLive();
511
+ break;
512
+ }
513
+ }
514
+ }
515
+
516
+ /** Deliver everything held back during a catch-up, in sequence order. */
517
+ private flushPendingLive(): void {
518
+ if (this.pendingLive.length === 0) return;
519
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
520
+ this.pendingLive = [];
521
+ for (const event of buffered) {
522
+ const seq = event.seq;
523
+ if (seq !== undefined) {
524
+ if (seq <= this.lastSeq) continue;
525
+ this.lastSeq = seq;
526
+ }
527
+ this.deliver(event);
528
+ }
529
+ }
530
+
531
+ private deliver(event: BroadcastEvent): void {
532
+ for (const handler of [...this.broadcastHandlers]) handler(event);
533
+ }
534
+
535
+ private emitPresence(diff?: PresenceDiff): void {
536
+ const snapshot = { ...this.presences };
537
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
538
+ }
539
+ }