@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.
- package/dist/auth.d.ts +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.es.js +799 -95
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +243 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +96 -2
- package/package.json +11 -10
- package/src/auth.ts +32 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +105 -2
- package/src/realtime-channel.test.ts +542 -0
- package/src/realtime-channel.ts +539 -0
- package/src/realtime-optout.test.ts +245 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +59 -3
- package/src/websocket.ts +515 -88
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/data-proxy.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2513
- package/dist/index.umd.js.map +0 -1
|
@@ -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
|
+
});
|