@rebasepro/client 0.9.0 → 0.9.1-canary.09aaf62
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/README.md +1 -1
- package/dist/admin.d.ts +1 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +516 -72
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +68 -2
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +66 -2
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport.ts +34 -0
- package/src/websocket.ts +403 -72
- 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 -2484
- package/dist/index.umd.js.map +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
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 ChannelTransport,
|
|
9
|
+
type PresenceState
|
|
10
|
+
} from "./realtime-channel";
|
|
11
|
+
|
|
12
|
+
/** Stand-in socket that records what was sent and can push frames back. */
|
|
13
|
+
function fakeTransport() {
|
|
14
|
+
const sent: Record<string, unknown>[] = [];
|
|
15
|
+
let channelHandler: ((m: Record<string, unknown>) => void) | undefined;
|
|
16
|
+
let reconnectHandler: (() => void) | undefined;
|
|
17
|
+
|
|
18
|
+
const transport: ChannelTransport = {
|
|
19
|
+
sendMessage: async (message) => { sent.push(message); return undefined; },
|
|
20
|
+
onChannelMessage: (_channel, handler) => {
|
|
21
|
+
channelHandler = handler;
|
|
22
|
+
return () => { channelHandler = undefined; };
|
|
23
|
+
},
|
|
24
|
+
onReconnect: (handler) => {
|
|
25
|
+
reconnectHandler = handler;
|
|
26
|
+
return () => { reconnectHandler = undefined; };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
transport,
|
|
32
|
+
sent,
|
|
33
|
+
types: () => sent.map((m) => m.type),
|
|
34
|
+
push: (message: Record<string, unknown>) => channelHandler?.(message),
|
|
35
|
+
reconnect: () => reconnectHandler?.(),
|
|
36
|
+
hasChannelHandler: () => channelHandler !== undefined
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("RebaseRealtimeChannel", () => {
|
|
41
|
+
let fake: ReturnType<typeof fakeTransport>;
|
|
42
|
+
let channel: RebaseRealtimeChannel;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.useFakeTimers();
|
|
46
|
+
fake = fakeTransport();
|
|
47
|
+
channel = new RebaseRealtimeChannel("doc:42", fake.transport);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
jest.useRealTimers();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("joining", () => {
|
|
55
|
+
it("asks for the roster, because joining does not push it", async () => {
|
|
56
|
+
// A joining client's presence_diff contains only itself, so
|
|
57
|
+
// without this request the channel believes it is alone until
|
|
58
|
+
// somebody else happens to move.
|
|
59
|
+
await channel.join();
|
|
60
|
+
|
|
61
|
+
expect(fake.types()).toEqual(["join_channel", "presence_state"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("joins only once across repeated calls", async () => {
|
|
65
|
+
await channel.join();
|
|
66
|
+
await channel.join();
|
|
67
|
+
await channel.broadcast("ping", {});
|
|
68
|
+
|
|
69
|
+
expect(fake.types().filter((t) => t === "join_channel")).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("presence", () => {
|
|
74
|
+
it("reports the roster from a presence_state frame", async () => {
|
|
75
|
+
const seen: PresenceState[] = [];
|
|
76
|
+
channel.onPresence((state) => seen.push(state));
|
|
77
|
+
await channel.join();
|
|
78
|
+
|
|
79
|
+
fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
|
|
80
|
+
|
|
81
|
+
expect(seen.at(-1)).toEqual({ a: { name: "Ana" } });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("maintains the roster across diffs so callers never reassemble it", async () => {
|
|
85
|
+
const seen: PresenceState[] = [];
|
|
86
|
+
channel.onPresence((state) => seen.push(state));
|
|
87
|
+
await channel.join();
|
|
88
|
+
|
|
89
|
+
fake.push({ type: "presence_state", channel: "doc:42", presences: { a: { name: "Ana" } } });
|
|
90
|
+
fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { name: "Bo" } }, leaves: {} });
|
|
91
|
+
|
|
92
|
+
expect(seen.at(-1)).toEqual({ a: { name: "Ana" }, b: { name: "Bo" } });
|
|
93
|
+
|
|
94
|
+
fake.push({ type: "presence_diff", channel: "doc:42", joins: {}, leaves: { a: { name: "Ana" } } });
|
|
95
|
+
|
|
96
|
+
expect(seen.at(-1)).toEqual({ b: { name: "Bo" } });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("passes the diff alongside the full state", async () => {
|
|
100
|
+
let lastDiff: unknown;
|
|
101
|
+
channel.onPresence((_state, diff) => { lastDiff = diff; });
|
|
102
|
+
await channel.join();
|
|
103
|
+
|
|
104
|
+
fake.push({ type: "presence_diff", channel: "doc:42", joins: { b: { x: 1 } }, leaves: {} });
|
|
105
|
+
|
|
106
|
+
expect(lastDiff).toEqual({ joins: { b: { x: 1 } }, leaves: {} });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("re-sends presence on a timer, because it expires after 30s", async () => {
|
|
110
|
+
// Server-side PRESENCE_TIMEOUT_MS is 30s. A client that tracks once
|
|
111
|
+
// and goes quiet vanishes from everyone else's roster while still
|
|
112
|
+
// sitting in the document.
|
|
113
|
+
await channel.track({ cursor: 1 });
|
|
114
|
+
expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(1);
|
|
115
|
+
|
|
116
|
+
await jest.advanceTimersByTimeAsync(21_000);
|
|
117
|
+
expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(2);
|
|
118
|
+
|
|
119
|
+
await jest.advanceTimersByTimeAsync(21_000);
|
|
120
|
+
expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("heartbeats within the expiry window", async () => {
|
|
124
|
+
await channel.track({ cursor: 1 });
|
|
125
|
+
const before = fake.types().filter((t) => t === "presence_track").length;
|
|
126
|
+
|
|
127
|
+
// One beat must land comfortably before 30s, and with enough margin
|
|
128
|
+
// that a single dropped frame is not a disappearance.
|
|
129
|
+
await jest.advanceTimersByTimeAsync(25_000);
|
|
130
|
+
|
|
131
|
+
expect(fake.types().filter((t) => t === "presence_track").length).toBeGreaterThan(before);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("heartbeats the latest state after a re-track", async () => {
|
|
135
|
+
await channel.track({ cursor: 1 });
|
|
136
|
+
await channel.track({ cursor: 99 });
|
|
137
|
+
|
|
138
|
+
await jest.advanceTimersByTimeAsync(21_000);
|
|
139
|
+
|
|
140
|
+
const beats = fake.sent.filter((m) => m.type === "presence_track");
|
|
141
|
+
expect(beats.at(-1)).toMatchObject({ state: { cursor: 99 } });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("stops the heartbeat on untrack", async () => {
|
|
145
|
+
await channel.track({ cursor: 1 });
|
|
146
|
+
await channel.untrack();
|
|
147
|
+
const after = fake.types().filter((t) => t === "presence_track").length;
|
|
148
|
+
|
|
149
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
150
|
+
|
|
151
|
+
expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(after);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("broadcast", () => {
|
|
156
|
+
it("delivers events to a handler", async () => {
|
|
157
|
+
const received: unknown[] = [];
|
|
158
|
+
channel.onBroadcast((e) => received.push(e));
|
|
159
|
+
await channel.join();
|
|
160
|
+
|
|
161
|
+
fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { at: 3 } });
|
|
162
|
+
|
|
163
|
+
expect(received).toEqual([{ event: "edit", payload: { at: 3 } }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("filters by event name when one is given", async () => {
|
|
167
|
+
const received: unknown[] = [];
|
|
168
|
+
channel.onBroadcast("edit", (payload) => received.push(payload));
|
|
169
|
+
await channel.join();
|
|
170
|
+
|
|
171
|
+
fake.push({ type: "broadcast", channel: "doc:42", event: "other", payload: { no: true } });
|
|
172
|
+
fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: { yes: true } });
|
|
173
|
+
|
|
174
|
+
expect(received).toEqual([{ yes: true }]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("stops delivering after the returned unsubscribe", async () => {
|
|
178
|
+
const received: unknown[] = [];
|
|
179
|
+
const off = channel.onBroadcast((e) => received.push(e));
|
|
180
|
+
await channel.join();
|
|
181
|
+
off();
|
|
182
|
+
|
|
183
|
+
fake.push({ type: "broadcast", channel: "doc:42", event: "edit", payload: {} });
|
|
184
|
+
|
|
185
|
+
expect(received).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("reconnect", () => {
|
|
190
|
+
it("re-joins, re-requests the roster and re-tracks", async () => {
|
|
191
|
+
// A reconnect drops server-side membership and presence. Nothing
|
|
192
|
+
// else notices: the socket returns and the client just stops
|
|
193
|
+
// receiving.
|
|
194
|
+
await channel.track({ cursor: 7 });
|
|
195
|
+
fake.sent.length = 0;
|
|
196
|
+
|
|
197
|
+
fake.reconnect();
|
|
198
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
199
|
+
|
|
200
|
+
expect(fake.types()).toEqual(["join_channel", "presence_state", "presence_track"]);
|
|
201
|
+
expect(fake.sent.at(-1)).toMatchObject({ state: { cursor: 7 } });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not re-track when the client never tracked", async () => {
|
|
205
|
+
await channel.join();
|
|
206
|
+
fake.sent.length = 0;
|
|
207
|
+
|
|
208
|
+
fake.reconnect();
|
|
209
|
+
await jest.advanceTimersByTimeAsync(0);
|
|
210
|
+
|
|
211
|
+
expect(fake.types()).toEqual(["join_channel", "presence_state"]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("leave", () => {
|
|
216
|
+
it("releases the socket handler, the timer and the listeners", async () => {
|
|
217
|
+
const received: unknown[] = [];
|
|
218
|
+
channel.onBroadcast((e) => received.push(e));
|
|
219
|
+
await channel.track({ cursor: 1 });
|
|
220
|
+
|
|
221
|
+
await channel.leave();
|
|
222
|
+
|
|
223
|
+
expect(fake.types().at(-1)).toBe("leave_channel");
|
|
224
|
+
expect(fake.hasChannelHandler()).toBe(false);
|
|
225
|
+
|
|
226
|
+
const beats = fake.types().filter((t) => t === "presence_track").length;
|
|
227
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
228
|
+
expect(fake.types().filter((t) => t === "presence_track")).toHaveLength(beats);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("can rejoin after leaving", async () => {
|
|
232
|
+
await channel.join();
|
|
233
|
+
await channel.leave();
|
|
234
|
+
fake.sent.length = 0;
|
|
235
|
+
|
|
236
|
+
await channel.join();
|
|
237
|
+
|
|
238
|
+
expect(fake.types()).toEqual(["join_channel", "presence_state"]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
|
|
40
|
+
/** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
|
|
41
|
+
export interface ChannelTransport {
|
|
42
|
+
sendMessage(message: Record<string, unknown>): Promise<unknown>;
|
|
43
|
+
onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
|
|
44
|
+
onReconnect(handler: () => void): () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
49
|
+
*
|
|
50
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
51
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
52
|
+
*/
|
|
53
|
+
const PRESENCE_HEARTBEAT_MS = 20_000;
|
|
54
|
+
|
|
55
|
+
export class RebaseRealtimeChannel {
|
|
56
|
+
private presenceHandlers = new Set<(state: PresenceState, diff?: PresenceDiff) => void>();
|
|
57
|
+
private broadcastHandlers = new Set<(event: BroadcastEvent) => void>();
|
|
58
|
+
private unsubscribers: (() => void)[] = [];
|
|
59
|
+
|
|
60
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
61
|
+
private presences: PresenceState = {};
|
|
62
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
63
|
+
private trackedState: Record<string, unknown> | null = null;
|
|
64
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
65
|
+
private joined = false;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
public readonly name: string,
|
|
69
|
+
private transport: ChannelTransport
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Join the channel and ask for the current roster.
|
|
74
|
+
*
|
|
75
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
76
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
77
|
+
* before there is anything to send.
|
|
78
|
+
*/
|
|
79
|
+
async join(): Promise<void> {
|
|
80
|
+
if (this.joined) return;
|
|
81
|
+
this.joined = true;
|
|
82
|
+
|
|
83
|
+
this.unsubscribers.push(
|
|
84
|
+
this.transport.onChannelMessage(this.name, (message) => this.handle(message))
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// A reconnect drops server-side channel membership and presence, so
|
|
88
|
+
// both have to be re-established. Nothing else notices this: the
|
|
89
|
+
// socket comes back, and the client just stops receiving.
|
|
90
|
+
this.unsubscribers.push(
|
|
91
|
+
this.transport.onReconnect(() => {
|
|
92
|
+
void this.rejoin();
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await this.transport.sendMessage({ type: "join_channel", channel: this.name });
|
|
97
|
+
// Not optional. Joining does not push the roster — without this the
|
|
98
|
+
// channel believes it is alone until somebody else happens to move.
|
|
99
|
+
await this.transport.sendMessage({ type: "presence_state", channel: this.name });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async rejoin(): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await this.transport.sendMessage({ type: "join_channel", channel: this.name });
|
|
105
|
+
await this.transport.sendMessage({ type: "presence_state", channel: this.name });
|
|
106
|
+
if (this.trackedState) {
|
|
107
|
+
await this.transport.sendMessage({
|
|
108
|
+
type: "presence_track",
|
|
109
|
+
channel: this.name,
|
|
110
|
+
state: this.trackedState
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// The socket is down again; the next reconnect will retry.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Publish this client's presence state, and keep publishing it.
|
|
120
|
+
*
|
|
121
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
122
|
+
* which is how you update e.g. a cursor position.
|
|
123
|
+
*/
|
|
124
|
+
async track(state: Record<string, unknown>): Promise<void> {
|
|
125
|
+
await this.join();
|
|
126
|
+
this.trackedState = state;
|
|
127
|
+
|
|
128
|
+
await this.transport.sendMessage({ type: "presence_track", channel: this.name, state });
|
|
129
|
+
|
|
130
|
+
if (!this.heartbeat) {
|
|
131
|
+
this.heartbeat = setInterval(() => {
|
|
132
|
+
if (!this.trackedState) return;
|
|
133
|
+
void this.transport
|
|
134
|
+
.sendMessage({ type: "presence_track", channel: this.name, state: this.trackedState })
|
|
135
|
+
.catch(() => { /* a dropped beat is recoverable; the next one carries the same state */ });
|
|
136
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
137
|
+
// Do not hold a Node process open just to say "still here".
|
|
138
|
+
(this.heartbeat as unknown as { unref?: () => void }).unref?.();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
143
|
+
async untrack(): Promise<void> {
|
|
144
|
+
this.stopHeartbeat();
|
|
145
|
+
this.trackedState = null;
|
|
146
|
+
if (this.joined) {
|
|
147
|
+
await this.transport.sendMessage({ type: "presence_untrack", channel: this.name });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
153
|
+
* known, then on every change.
|
|
154
|
+
*/
|
|
155
|
+
onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void {
|
|
156
|
+
this.presenceHandlers.add(handler);
|
|
157
|
+
void this.join();
|
|
158
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
159
|
+
return () => this.presenceHandlers.delete(handler);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
163
|
+
async broadcast(event: string, payload: unknown): Promise<void> {
|
|
164
|
+
await this.join();
|
|
165
|
+
await this.transport.sendMessage({ type: "broadcast", channel: this.name, event, payload });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Observe broadcasts. Pass an event name to filter. */
|
|
169
|
+
onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
|
|
170
|
+
onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
|
|
171
|
+
onBroadcast(
|
|
172
|
+
eventOrHandler: string | ((event: BroadcastEvent) => void),
|
|
173
|
+
maybeHandler?: (payload: unknown) => void
|
|
174
|
+
): () => void {
|
|
175
|
+
const wrapped: (event: BroadcastEvent) => void = typeof eventOrHandler === "string"
|
|
176
|
+
? (e) => { if (e.event === eventOrHandler) maybeHandler!(e.payload); }
|
|
177
|
+
: eventOrHandler;
|
|
178
|
+
|
|
179
|
+
this.broadcastHandlers.add(wrapped);
|
|
180
|
+
void this.join();
|
|
181
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Leave the channel and release every listener and timer. */
|
|
185
|
+
async leave(): Promise<void> {
|
|
186
|
+
this.stopHeartbeat();
|
|
187
|
+
this.trackedState = null;
|
|
188
|
+
this.presences = {};
|
|
189
|
+
this.presenceHandlers.clear();
|
|
190
|
+
this.broadcastHandlers.clear();
|
|
191
|
+
|
|
192
|
+
for (const off of this.unsubscribers) off();
|
|
193
|
+
this.unsubscribers = [];
|
|
194
|
+
|
|
195
|
+
if (this.joined) {
|
|
196
|
+
this.joined = false;
|
|
197
|
+
await this.transport.sendMessage({ type: "leave_channel", channel: this.name });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private stopHeartbeat(): void {
|
|
202
|
+
if (this.heartbeat) {
|
|
203
|
+
clearInterval(this.heartbeat);
|
|
204
|
+
this.heartbeat = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
209
|
+
private handle(message: Record<string, unknown>): void {
|
|
210
|
+
switch (message.type) {
|
|
211
|
+
case "presence_state": {
|
|
212
|
+
this.presences = (message.presences as PresenceState) ?? {};
|
|
213
|
+
this.emitPresence();
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "presence_diff": {
|
|
217
|
+
const joins = (message.joins as PresenceState) ?? {};
|
|
218
|
+
const leaves = (message.leaves as PresenceState) ?? {};
|
|
219
|
+
// A diff carries only what moved, so the roster is maintained
|
|
220
|
+
// here rather than handed to callers to reassemble.
|
|
221
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
222
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
223
|
+
this.emitPresence({ joins, leaves });
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "broadcast": {
|
|
227
|
+
const event = { event: message.event as string, payload: message.payload };
|
|
228
|
+
for (const handler of this.broadcastHandlers) handler(event);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private emitPresence(diff?: PresenceDiff): void {
|
|
235
|
+
const snapshot = { ...this.presences };
|
|
236
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createRebaseClient } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A WebSocket stand-in that records construction. The real socket keeps the Node
|
|
6
|
+
* event loop alive, which is what makes a one-shot script hang; here we only
|
|
7
|
+
* need to know whether one would have been opened at all.
|
|
8
|
+
*/
|
|
9
|
+
function trackingWebSocket() {
|
|
10
|
+
const opened: string[] = [];
|
|
11
|
+
const closed: string[] = [];
|
|
12
|
+
|
|
13
|
+
class FakeWebSocket {
|
|
14
|
+
static readonly OPEN = 1;
|
|
15
|
+
readyState = 0;
|
|
16
|
+
onopen: (() => void) | null = null;
|
|
17
|
+
onclose: (() => void) | null = null;
|
|
18
|
+
onerror: (() => void) | null = null;
|
|
19
|
+
onmessage: (() => void) | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(public url: string) {
|
|
22
|
+
opened.push(url);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
close() {
|
|
26
|
+
closed.push(this.url);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
send() { /* no-op */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened, closed };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("realtime opt-out", () => {
|
|
36
|
+
const original = globalThis.WebSocket;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.WebSocket = original;
|
|
40
|
+
jest.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("opens the socket by default", () => {
|
|
44
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
45
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
46
|
+
|
|
47
|
+
createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
48
|
+
|
|
49
|
+
expect(opened).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("opens no socket when realtime is disabled", () => {
|
|
53
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
54
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
55
|
+
|
|
56
|
+
const client = createRebaseClient({
|
|
57
|
+
baseUrl: "http://localhost:3000/api",
|
|
58
|
+
realtime: false
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The socket is what keeps a CLI / cron / ETL process alive past its work.
|
|
62
|
+
expect(opened).toHaveLength(0);
|
|
63
|
+
expect(client.ws).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
|
|
67
|
+
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
68
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
69
|
+
|
|
70
|
+
createRebaseClient({
|
|
71
|
+
baseUrl: "http://localhost:3000/api",
|
|
72
|
+
websocketUrl: "ws://localhost:3000",
|
|
73
|
+
realtime: false
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(opened).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("close() releases the socket", () => {
|
|
80
|
+
const { FakeWebSocket, opened, closed } = trackingWebSocket();
|
|
81
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
82
|
+
|
|
83
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
84
|
+
expect(opened).toHaveLength(1);
|
|
85
|
+
|
|
86
|
+
client.close();
|
|
87
|
+
|
|
88
|
+
expect(closed).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("close() is safe when realtime was never started, and when called twice", () => {
|
|
92
|
+
const { FakeWebSocket } = trackingWebSocket();
|
|
93
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
94
|
+
|
|
95
|
+
const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
|
|
96
|
+
expect(() => offline.close()).not.toThrow();
|
|
97
|
+
|
|
98
|
+
const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
|
|
99
|
+
live.close();
|
|
100
|
+
expect(() => live.close()).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
|
|
104
|
+
const { FakeWebSocket } = trackingWebSocket();
|
|
105
|
+
globalThis.WebSocket = FakeWebSocket;
|
|
106
|
+
|
|
107
|
+
const client = createRebaseClient({
|
|
108
|
+
baseUrl: "http://localhost:3000/api",
|
|
109
|
+
realtime: false
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// `listen` stays undefined rather than becoming a throwing stub: the
|
|
113
|
+
// optional type is what makes `if (client.listen)` work and what makes
|
|
114
|
+
// TypeScript reject a bare call.
|
|
115
|
+
expect(client.collection("posts").listen).toBeUndefined();
|
|
116
|
+
expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
|
|
117
|
+
.toThrow(/realtime: false/);
|
|
118
|
+
});
|
|
119
|
+
});
|