@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,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
|
+
}
|