@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,542 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
2
+ /**
3
+ * The channel surface, and specifically the two protocol details it exists to
4
+ * hide: the roster is not pushed on join, and presence expires after 30s.
5
+ */
6
+ import {
7
+ RebaseRealtimeChannel,
8
+ type BroadcastEvent,
9
+ type ChannelTransport,
10
+ type PresenceState
11
+ } from "./realtime-channel";
12
+
13
+ /** Stand-in socket that records what was sent and can push frames back. */
14
+ function fakeTransport() {
15
+ const sent: Record<string, unknown>[] = [];
16
+ let channelHandler: ((m: Record<string, unknown>) => void) | undefined;
17
+ let reconnectHandler: (() => void) | undefined;
18
+
19
+ const transport: ChannelTransport = {
20
+ sendMessage: async (message) => { sent.push(message); return undefined; },
21
+ onChannelMessage: (_channel, handler) => {
22
+ channelHandler = handler;
23
+ return () => { channelHandler = undefined; };
24
+ },
25
+ onReconnect: (handler) => {
26
+ reconnectHandler = handler;
27
+ return () => { reconnectHandler = undefined; };
28
+ }
29
+ };
30
+
31
+ return {
32
+ transport,
33
+ sent,
34
+ types: () => sent.map((m) => m.type),
35
+ push: (message: Record<string, unknown>) => channelHandler?.(message),
36
+ reconnect: () => reconnectHandler?.(),
37
+ hasChannelHandler: () => channelHandler !== undefined
38
+ };
39
+ }
40
+
41
+ describe("RebaseRealtimeChannel", () => {
42
+ let fake: ReturnType<typeof fakeTransport>;
43
+ let channel: RebaseRealtimeChannel;
44
+
45
+ beforeEach(() => {
46
+ jest.useFakeTimers();
47
+ fake = fakeTransport();
48
+ channel = new RebaseRealtimeChannel("doc:42", fake.transport);
49
+ });
50
+
51
+ afterEach(() => {
52
+ jest.useRealTimers();
53
+ });
54
+
55
+ // The server reads every channel message out of a `payload` envelope
56
+ // (`payload?.channel`, `payload?.state`, `payload?.event` — see
57
+ // server-postgres realtimeService). Sending those fields flat does not
58
+ // error: `payload?.channel` reads as undefined, so the client is
59
+ // registered into channel `undefined` with empty state and the echo comes
60
+ // back with no `channel` for `onChannelMessage` to match. Presence and
61
+ // broadcast then go silent with nothing logged anywhere.
62
+ //
63
+ // This shipped once. The tests below asserted only `m.type`, which is
64
+ // identical under both shapes — so they passed while the feature did
65
+ // nothing. These assert the envelope itself.
66
+ describe("wire contract", () => {
67
+ it("wraps every channel message in a payload envelope", async () => {
68
+ await channel.join();
69
+ await channel.track({ cursor: 1 });
70
+ await channel.broadcast("ping", { n: 2 });
71
+ await channel.untrack();
72
+ await channel.leave();
73
+
74
+ expect(fake.sent.length).toBeGreaterThan(0);
75
+ for (const message of fake.sent) {
76
+ expect(message).toHaveProperty("payload");
77
+ // The channel name must travel inside the envelope, never beside it.
78
+ expect((message.payload as Record<string, unknown>).channel).toBe("doc:42");
79
+ expect(message).not.toHaveProperty("channel");
80
+ }
81
+ });
82
+
83
+ it("carries presence state inside the envelope, not beside it", async () => {
84
+ await channel.track({ name: "Ada" });
85
+
86
+ const track = fake.sent.find((m) => m.type === "presence_track");
87
+ expect(track).toMatchObject({
88
+ type: "presence_track",
89
+ payload: { channel: "doc:42", state: { name: "Ada" } }
90
+ });
91
+ // Losing state is the quiet half of the bug: presence still
92
+ // "works", every peer just shows up empty.
93
+ expect(track).not.toHaveProperty("state");
94
+ });
95
+
96
+ it("carries broadcast event and payload inside the envelope", async () => {
97
+ await channel.broadcast("saved", { version: 3 });
98
+
99
+ expect(fake.sent.find((m) => m.type === "broadcast")).toMatchObject({
100
+ type: "broadcast",
101
+ payload: { channel: "doc:42", event: "saved", payload: { version: 3 } }
102
+ });
103
+ });
104
+
105
+ it("carries the catch-up cursor inside the envelope", async () => {
106
+ // Same failure mode as the rest: `payload?.sinceSeq` read flat is
107
+ // undefined, which the server would treat as "replay from zero" —
108
+ // a client asking to resume would silently get the whole history
109
+ // back and re-apply every operation it had already applied.
110
+ const retained = new RebaseRealtimeChannel("doc:42", fake.transport, { history: true });
111
+ await retained.join();
112
+
113
+ const request = fake.sent.find((m) => m.type === "channel_history");
114
+ expect(request).toMatchObject({
115
+ type: "channel_history",
116
+ payload: { channel: "doc:42", sinceSeq: 0 }
117
+ });
118
+ expect(request).not.toHaveProperty("sinceSeq");
119
+ });
120
+ });
121
+
122
+ describe("history", () => {
123
+ /**
124
+ * A joined channel that asks for catch-up.
125
+ *
126
+ * `settle` answers the join-time history request the way a server does.
127
+ * Without it the channel is legitimately still catching up, and holds
128
+ * live messages back — which is the behaviour one test below asserts on
129
+ * purpose.
130
+ */
131
+ async function retainedChannel({ settle = true } = {}) {
132
+ const c = new RebaseRealtimeChannel("doc:42", fake.transport, { history: true });
133
+ const received: BroadcastEvent[] = [];
134
+ c.onBroadcast((e) => received.push(e));
135
+ await c.join();
136
+ // `onBroadcast` already started a join, so the `await` above returns
137
+ // on the second (idempotent) call while the first is still working
138
+ // through its sends. Let it finish, or the history request has not
139
+ // been made yet and there is nothing for `settle` to answer.
140
+ await jest.advanceTimersByTimeAsync(0);
141
+ if (settle) {
142
+ fake.push({ type: "channel_history", channel: "doc:42", retained: true, messages: [] });
143
+ }
144
+ return { channel: c, received };
145
+ }
146
+
147
+ it("asks for history on join, and only when asked to", async () => {
148
+ await channel.join();
149
+ expect(fake.types()).not.toContain("channel_history");
150
+
151
+ fake.sent.length = 0;
152
+ const { channel: retained } = await retainedChannel();
153
+ expect(fake.types()).toContain("channel_history");
154
+ expect(retained.sequence).toBe(0);
155
+ });
156
+
157
+ it("tracks the sequence of live messages", async () => {
158
+ const { channel: retained, received } = await retainedChannel();
159
+
160
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
161
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 2 }, seq: 2 });
162
+
163
+ expect(received.map((e) => e.seq)).toEqual([1, 2]);
164
+ expect(retained.sequence).toBe(2);
165
+ });
166
+
167
+ it("delivers replayed messages through the same handlers, marked as replay", async () => {
168
+ const { channel: retained, received } = await retainedChannel();
169
+
170
+ fake.push({
171
+ type: "channel_history",
172
+ channel: "doc:42",
173
+ retained: true,
174
+ latestSeq: 2,
175
+ messages: [
176
+ { seq: 1, event: "op", payload: { n: 1 } },
177
+ { seq: 2, event: "op", payload: { n: 2 } }
178
+ ]
179
+ });
180
+
181
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }, { n: 2 }]);
182
+ expect(received.every((e) => e.replayed)).toBe(true);
183
+ expect(retained.sequence).toBe(2);
184
+ });
185
+
186
+ it("never re-delivers a message it already saw", async () => {
187
+ // Catch-up ranges overlap with what arrived live — the server
188
+ // cannot know exactly what landed before the socket dropped. The
189
+ // watermark is what makes replaying an overlap harmless.
190
+ const { received } = await retainedChannel();
191
+
192
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
193
+ fake.push({
194
+ type: "channel_history",
195
+ channel: "doc:42",
196
+ retained: true,
197
+ messages: [
198
+ { seq: 1, event: "op", payload: { n: 1 } },
199
+ { seq: 2, event: "op", payload: { n: 2 } }
200
+ ]
201
+ });
202
+
203
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }, { n: 2 }]);
204
+ });
205
+
206
+ it("holds live messages back until the catch-up lands, then orders them", async () => {
207
+ // The subtle one. A live message arriving mid-catch-up would
208
+ // otherwise be delivered first AND advance the watermark past the
209
+ // older messages still in flight — which the catch-up response
210
+ // would then discard as already-seen. Those messages would be lost
211
+ // silently, which is precisely the failure history exists to fix.
212
+ const { channel: retained, received } = await retainedChannel({ settle: false });
213
+
214
+ // seq 5 arrives while the replay of 1..4 is still on the wire.
215
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 5 }, seq: 5 });
216
+ expect(received).toHaveLength(0);
217
+
218
+ fake.push({
219
+ type: "channel_history",
220
+ channel: "doc:42",
221
+ retained: true,
222
+ messages: [
223
+ { seq: 3, event: "op", payload: { n: 3 } },
224
+ { seq: 4, event: "op", payload: { n: 4 } }
225
+ ]
226
+ });
227
+
228
+ expect(received.map((e) => e.seq)).toEqual([3, 4, 5]);
229
+ expect(retained.sequence).toBe(5);
230
+ });
231
+
232
+ it("leaves unsequenced channels completely untouched", async () => {
233
+ // An ephemeral channel retains nothing, so its broadcasts carry no
234
+ // seq. They must not be buffered, deduped or reordered.
235
+ const received: BroadcastEvent[] = [];
236
+ channel.onBroadcast((e) => received.push(e));
237
+ await channel.join();
238
+
239
+ fake.push({ type: "broadcast", channel: "doc:42", event: "cursor", payload: { x: 1 } });
240
+ fake.push({ type: "broadcast", channel: "doc:42", event: "cursor", payload: { x: 2 } });
241
+
242
+ expect(received.map((e) => e.payload)).toEqual([{ x: 1 }, { x: 2 }]);
243
+ expect(received.every((e) => e.seq === undefined)).toBe(true);
244
+ expect(channel.sequence).toBe(0);
245
+ });
246
+
247
+ it("resumes from the last sequence on reconnect", async () => {
248
+ const { channel: retained } = await retainedChannel();
249
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 7 });
250
+ fake.sent.length = 0;
251
+
252
+ fake.reconnect();
253
+ await jest.advanceTimersByTimeAsync(0);
254
+
255
+ expect(fake.types()).toEqual(["join_channel", "presence_state", "channel_history"]);
256
+ expect(fake.sent.at(-1)).toMatchObject({ payload: { channel: "doc:42", sinceSeq: 7 } });
257
+ expect(retained.sequence).toBe(7);
258
+ });
259
+
260
+ it("stops holding messages back if the catch-up never arrives", async () => {
261
+ // Buffering is only safe because the wait is bounded. A reply that
262
+ // never comes would otherwise leave the channel silently swallowing
263
+ // every edit from then on — worse than the problem replay solves.
264
+ const { received } = await retainedChannel({ settle: false });
265
+
266
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: { n: 1 }, seq: 1 });
267
+ expect(received).toHaveLength(0);
268
+
269
+ await jest.advanceTimersByTimeAsync(11_000);
270
+
271
+ expect(received.map((e) => e.payload)).toEqual([{ n: 1 }]);
272
+ });
273
+
274
+ it("reports a channel that keeps no history, rather than an empty one", async () => {
275
+ const { channel: retained } = await retainedChannel();
276
+
277
+ const pending = retained.history();
278
+ await jest.advanceTimersByTimeAsync(0);
279
+ fake.push({ type: "channel_history", channel: "doc:42", retained: false, messages: [] });
280
+
281
+ // The distinction matters: `retained: false` tells a client its
282
+ // reconnect strategy has to be a full resync.
283
+ await expect(pending).resolves.toEqual({
284
+ messages: [], retained: false, latestSeq: undefined
285
+ });
286
+ });
287
+
288
+ it("upgrades an existing channel when history is asked for later", async () => {
289
+ // The client hands back the same object per name, so a later
290
+ // `channel(name, { history: true })` has nothing new to configure.
291
+ await channel.join();
292
+ fake.sent.length = 0;
293
+
294
+ channel.enableHistory();
295
+ await jest.advanceTimersByTimeAsync(0);
296
+
297
+ expect(fake.types()).toContain("channel_history");
298
+ });
299
+
300
+ it("forgets its position on leave, so a rejoin does not skip the past", async () => {
301
+ const { channel: retained } = await retainedChannel();
302
+ fake.push({ type: "broadcast", channel: "doc:42", event: "op", payload: {}, seq: 9 });
303
+ expect(retained.sequence).toBe(9);
304
+
305
+ await retained.leave();
306
+
307
+ expect(retained.sequence).toBe(0);
308
+ });
309
+ });
310
+
311
+ describe("joining", () => {
312
+ it("asks for the roster, because joining does not push it", async () => {
313
+ // A joining client's presence_diff contains only itself, so
314
+ // without this request the channel believes it is alone until
315
+ // somebody else happens to move.
316
+ await channel.join();
317
+
318
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
319
+ });
320
+
321
+ it("joins only once across repeated calls", async () => {
322
+ await channel.join();
323
+ await channel.join();
324
+ await channel.broadcast("ping", {});
325
+
326
+ expect(fake.types().filter((t) => t === "join_channel")).toHaveLength(1);
327
+ });
328
+ });
329
+
330
+ describe("presence", () => {
331
+ it("reports the roster from a presence_state frame", async () => {
332
+ const seen: PresenceState[] = [];
333
+ channel.onPresence((state) => seen.push(state));
334
+ await channel.join();
335
+
336
+ fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
337
+
338
+ expect(seen.at(-1)).toEqual({ a: { name: "Ana" } });
339
+ });
340
+
341
+ it("maintains the roster across diffs so callers never reassemble it", async () => {
342
+ const seen: PresenceState[] = [];
343
+ channel.onPresence((state) => seen.push(state));
344
+ await channel.join();
345
+
346
+ fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
347
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { name: "Bo" } }, leaves: {} });
348
+
349
+ expect(seen.at(-1)).toEqual({ a: { name: "Ana" }, b: { name: "Bo" } });
350
+
351
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: {}, leaves: { a: { name: "Ana" } } });
352
+
353
+ expect(seen.at(-1)).toEqual({ b: { name: "Bo" } });
354
+ });
355
+
356
+ it("passes the diff alongside the full state", async () => {
357
+ let lastDiff: unknown;
358
+ channel.onPresence((_state, diff) => { lastDiff = diff; });
359
+ await channel.join();
360
+
361
+ fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { x: 1 } }, leaves: {} });
362
+
363
+ expect(lastDiff).toEqual({ joins: { b: { x: 1 } }, leaves: {} });
364
+ });
365
+
366
+ it("re-sends presence on a timer, because it expires after 30s", async () => {
367
+ // Server-side PRESENCE_TIMEOUT_MS is 30s. A client that tracks once
368
+ // and goes quiet vanishes from everyone else's roster while still
369
+ // sitting in the document.
370
+ await channel.track({ cursor: 1 });
371
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(1);
372
+
373
+ await jest.advanceTimersByTimeAsync(21_000);
374
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(2);
375
+
376
+ await jest.advanceTimersByTimeAsync(21_000);
377
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(3);
378
+ });
379
+
380
+ it("heartbeats within the expiry window", async () => {
381
+ await channel.track({ cursor: 1 });
382
+ const before = fake.types().filter((t) => t === "presence_track").length;
383
+
384
+ // One beat must land comfortably before 30s, and with enough margin
385
+ // that a single dropped frame is not a disappearance.
386
+ await jest.advanceTimersByTimeAsync(25_000);
387
+
388
+ expect(fake.types().filter((t) => t === "presence_track").length).toBeGreaterThan(before);
389
+ });
390
+
391
+ it("heartbeats the latest state after a re-track", async () => {
392
+ await channel.track({ cursor: 1 });
393
+ await channel.track({ cursor: 99 });
394
+
395
+ await jest.advanceTimersByTimeAsync(21_000);
396
+
397
+ const beats = fake.sent.filter((m) => m.type === "presence_track");
398
+ expect(beats.at(-1)).toMatchObject({ payload: { channel: "doc:42", state: { cursor: 99 } } });
399
+ });
400
+
401
+ it("stops the heartbeat on untrack", async () => {
402
+ await channel.track({ cursor: 1 });
403
+ await channel.untrack();
404
+ const after = fake.types().filter((t) => t === "presence_track").length;
405
+
406
+ await jest.advanceTimersByTimeAsync(60_000);
407
+
408
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(after);
409
+ });
410
+ });
411
+
412
+ describe("broadcast", () => {
413
+ it("delivers events to a handler", async () => {
414
+ const received: unknown[] = [];
415
+ channel.onBroadcast((e) => received.push(e));
416
+ await channel.join();
417
+
418
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { at: 3 } });
419
+
420
+ expect(received).toEqual([{ event: "edit", payload: { at: 3 } }]);
421
+ });
422
+
423
+ it("filters by event name when one is given", async () => {
424
+ const received: unknown[] = [];
425
+ channel.onBroadcast("edit", (payload) => received.push(payload));
426
+ await channel.join();
427
+
428
+ fake.push({ type: "broadcast", channel: "doc:42", event: "other", payload: { no: true } });
429
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { yes: true } });
430
+
431
+ expect(received).toEqual([{ yes: true }]);
432
+ });
433
+
434
+ it("stops delivering after the returned unsubscribe", async () => {
435
+ const received: unknown[] = [];
436
+ const off = channel.onBroadcast((e) => received.push(e));
437
+ await channel.join();
438
+ off();
439
+
440
+ fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: {} });
441
+
442
+ expect(received).toEqual([]);
443
+ });
444
+ });
445
+
446
+ describe("reconnect", () => {
447
+ it("re-joins, re-requests the roster and re-tracks", async () => {
448
+ // A reconnect drops server-side membership and presence. Nothing
449
+ // else notices: the socket returns and the client just stops
450
+ // receiving.
451
+ await channel.track({ cursor: 7 });
452
+ fake.sent.length = 0;
453
+
454
+ fake.reconnect();
455
+ await jest.advanceTimersByTimeAsync(0);
456
+
457
+ expect(fake.types()).toEqual(["join_channel", "presence_state", "presence_track"]);
458
+ expect(fake.sent.at(-1)).toMatchObject({ payload: { channel: "doc:42", state: { cursor: 7 } } });
459
+ });
460
+
461
+ it("does not re-track when the client never tracked", async () => {
462
+ await channel.join();
463
+ fake.sent.length = 0;
464
+
465
+ fake.reconnect();
466
+ await jest.advanceTimersByTimeAsync(0);
467
+
468
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
469
+ });
470
+ });
471
+
472
+ describe("anonymous callers", () => {
473
+ it("sends channel frames without an account", async () => {
474
+ // The motivating case, and one unit tests over a fake transport
475
+ // cannot see: `doSendMessage` gated every non-AUTHENTICATE frame on
476
+ // `ensureAuthenticated`, which throws "user not logged in" when
477
+ // there is no token — so on an anonymous-first app every channel
478
+ // operation failed client-side and the server never decided.
479
+ // Presence in a public room needs no account.
480
+ const { RebaseWebSocketClient } = await import("./websocket");
481
+
482
+ const sent: Record<string, unknown>[] = [];
483
+ class FakeWS {
484
+ static readonly OPEN = 1;
485
+ readyState = 1;
486
+ onopen: (() => void) | null = null;
487
+ onclose: (() => void) | null = null;
488
+ onerror: (() => void) | null = null;
489
+ onmessage: (() => void) | null = null;
490
+ constructor(public url: string) {
491
+ setTimeout(() => this.onopen?.(), 0);
492
+ }
493
+ send(raw: string) { sent.push(JSON.parse(raw)); }
494
+ close() { /* noop */ }
495
+ }
496
+
497
+ const ws = new RebaseWebSocketClient({
498
+ websocketUrl: "ws://localhost:1234",
499
+ WebSocket: FakeWS as unknown as typeof WebSocket,
500
+ // Signed out: exactly what an anonymous visitor has.
501
+ getAuthToken: async () => ""
502
+ });
503
+
504
+ const anonChannel = new RebaseRealtimeChannel("public:lobby", ws);
505
+ // The frames queue until the socket opens, and under fake timers
506
+ // the socket only opens when the clock is advanced — so awaiting
507
+ // join() first would deadlock against the open it is waiting for.
508
+ const joined = anonChannel.join();
509
+ await jest.advanceTimersByTimeAsync(10);
510
+ await joined;
511
+
512
+ expect(sent.map((m) => m.type)).toEqual(["join_channel", "presence_state"]);
513
+ });
514
+ });
515
+
516
+ describe("leave", () => {
517
+ it("releases the socket handler, the timer and the listeners", async () => {
518
+ const received: unknown[] = [];
519
+ channel.onBroadcast((e) => received.push(e));
520
+ await channel.track({ cursor: 1 });
521
+
522
+ await channel.leave();
523
+
524
+ expect(fake.types().at(-1)).toBe("leave_channel");
525
+ expect(fake.hasChannelHandler()).toBe(false);
526
+
527
+ const beats = fake.types().filter((t) => t === "presence_track").length;
528
+ await jest.advanceTimersByTimeAsync(60_000);
529
+ expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(beats);
530
+ });
531
+
532
+ it("can rejoin after leaving", async () => {
533
+ await channel.join();
534
+ await channel.leave();
535
+ fake.sent.length = 0;
536
+
537
+ await channel.join();
538
+
539
+ expect(fake.types()).toEqual(["join_channel", "presence_state"]);
540
+ });
541
+ });
542
+ });