@rebasepro/client 0.9.1-canary.ff338b5 → 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.
@@ -35,6 +35,61 @@ export interface PresenceDiff {
35
35
  export interface BroadcastEvent {
36
36
  event: string;
37
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;
38
93
  }
39
94
 
40
95
  /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
@@ -52,6 +107,15 @@ export interface ChannelTransport {
52
107
  */
53
108
  const PRESENCE_HEARTBEAT_MS = 20_000;
54
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
+
55
119
  export class RebaseRealtimeChannel {
56
120
  private presenceHandlers = new Set<(state: PresenceState, diff?: PresenceDiff) => void>();
57
121
  private broadcastHandlers = new Set<(event: BroadcastEvent) => void>();
@@ -64,10 +128,72 @@ export class RebaseRealtimeChannel {
64
128
  private heartbeat: ReturnType<typeof setInterval> | null = null;
65
129
  private joined = false;
66
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
+
67
175
  constructor(
68
176
  public readonly name: string,
69
- private transport: ChannelTransport
70
- ) {}
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
+ }
71
197
 
72
198
  /**
73
199
  * Join the channel and ask for the current roster.
@@ -113,6 +239,7 @@ export class RebaseRealtimeChannel {
113
239
  // Not optional. Joining does not push the roster — without this the
114
240
  // channel believes it is alone until somebody else happens to move.
115
241
  await this.send("presence_state");
242
+ if (this.wantsHistory) await this.requestHistory();
116
243
  }
117
244
 
118
245
  private async rejoin(): Promise<void> {
@@ -122,11 +249,61 @@ export class RebaseRealtimeChannel {
122
249
  if (this.trackedState) {
123
250
  await this.send("presence_track", { state: this.trackedState });
124
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();
125
257
  } catch {
126
258
  // The socket is down again; the next reconnect will retry.
127
259
  }
128
260
  }
129
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
+
130
307
  /**
131
308
  * Publish this client's presence state, and keep publishing it.
132
309
  *
@@ -192,6 +369,37 @@ export class RebaseRealtimeChannel {
192
369
  return () => this.broadcastHandlers.delete(wrapped);
193
370
  }
194
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
+
195
403
  /** Leave the channel and release every listener and timer. */
196
404
  async leave(): Promise<void> {
197
405
  this.stopHeartbeat();
@@ -199,6 +407,18 @@ export class RebaseRealtimeChannel {
199
407
  this.presences = {};
200
408
  this.presenceHandlers.clear();
201
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
+ }
202
422
 
203
423
  for (const off of this.unsubscribers) off();
204
424
  this.unsubscribers = [];
@@ -235,13 +455,83 @@ export class RebaseRealtimeChannel {
235
455
  break;
236
456
  }
237
457
  case "broadcast": {
238
- const event = { event: message.event as string, payload: message.payload };
239
- for (const handler of this.broadcastHandlers) handler(event);
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();
240
511
  break;
241
512
  }
242
513
  }
243
514
  }
244
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
+
245
535
  private emitPresence(diff?: PresenceDiff): void {
246
536
  const snapshot = { ...this.presences };
247
537
  for (const handler of this.presenceHandlers) handler(snapshot, diff);
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, afterEach } from "@jest/globals";
2
+ import { createTransport } from "./transport";
3
+
4
+ /**
5
+ * `baseUrl` is optional because the common production shape is a Rebase backend
6
+ * serving its own SPA: the API is the page's origin. These pin the resolution,
7
+ * because getting it wrong pushes apps into baking an absolute host — which
8
+ * breaks the moment a custom domain points at the same app, and which CORS
9
+ * cannot repair (a SameSite=Lax auth cookie is not sent cross-site either).
10
+ */
11
+ const setWindow = (origin?: string) => {
12
+ if (origin === undefined) { delete (globalThis as never as { window?: unknown }).window; return; }
13
+ (globalThis as never as { window: unknown }).window = { location: { origin, href: origin + "/" } };
14
+ };
15
+
16
+ afterEach(() => setWindow(undefined));
17
+
18
+ describe("transport baseUrl resolution", () => {
19
+ it("uses the page origin when unset in a browser", () => {
20
+ setWindow("https://dadaki.com");
21
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
22
+ });
23
+
24
+ it("gives callers something they can build an absolute URL from", () => {
25
+ setWindow("https://dadaki.com");
26
+ const t = createTransport({});
27
+ // The empty-string version of this threw, which is what drove apps to
28
+ // bake a hostname into the bundle.
29
+ expect(() => new URL(`${t.baseUrl}/api/functions/doc-content/get`)).not.toThrow();
30
+ });
31
+
32
+ it("follows the page, so a second hostname on the same app just works", () => {
33
+ setWindow("https://dadaki.apps.rebase.pro");
34
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.apps.rebase.pro");
35
+ setWindow("https://dadaki.com");
36
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
37
+ });
38
+
39
+ it("still honours an explicit baseUrl, which is what dev needs", () => {
40
+ setWindow("https://dadaki.com");
41
+ expect(createTransport({ baseUrl: "http://localhost:3001" }).baseUrl).toBe("http://localhost:3001");
42
+ });
43
+
44
+ it("strips a trailing slash so joins do not double up", () => {
45
+ setWindow(undefined);
46
+ expect(createTransport({ baseUrl: "https://api.example.com/" }).baseUrl).toBe("https://api.example.com");
47
+ });
48
+
49
+ it("stays relative off-browser, where there is no origin to borrow", () => {
50
+ setWindow(undefined);
51
+ expect(createTransport({}).baseUrl).toBe("");
52
+ });
53
+ });
package/src/transport.ts CHANGED
@@ -112,6 +112,29 @@ export interface Transport {
112
112
  resolveToken: () => Promise<string | null>;
113
113
  }
114
114
 
115
+ /**
116
+ * The base every request and every caller-built URL resolves against.
117
+ *
118
+ * `baseUrl` is optional because the common production shape is a Rebase
119
+ * backend serving its own SPA, where the API is simply the page's origin.
120
+ * Leaving it unset is therefore the *correct* configuration there — and the
121
+ * one that keeps working when a second hostname (a custom domain) points at
122
+ * the same app.
123
+ *
124
+ * When unset in a browser this resolves to the page origin rather than "".
125
+ * Requests behave identically either way, but the empty string is a trap for
126
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
127
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
128
+ * which is exactly what breaks the day a custom domain is added, and which no
129
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
130
+ * not sent cross-site either.
131
+ */
132
+ function resolveBaseUrl(configured?: string): string {
133
+ if (configured) return configured.replace(/\/$/, "");
134
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
135
+ return "";
136
+ }
137
+
115
138
  export function createTransport(config: RebaseClientConfig): Transport {
116
139
  const fetchFn = config.fetch || globalThis.fetch;
117
140
  const apiPath = config.apiPath || "/api";
@@ -128,8 +151,7 @@ export function createTransport(config: RebaseClientConfig): Transport {
128
151
  }
129
152
 
130
153
  async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
131
- const base = config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
132
- const url = base + apiPath + path;
154
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
133
155
 
134
156
  let activeToken = token;
135
157
  if (tokenGetter) {
@@ -242,7 +264,7 @@ headers: retryHeaders });
242
264
  setToken(newToken: string | null) { token = newToken || undefined; },
243
265
  setAuthTokenGetter(getter: () => Promise<string | null>) { tokenGetter = getter; },
244
266
  setOnUnauthorized(handler: () => Promise<boolean>) { onUnauthorizedHandler = handler; },
245
- get baseUrl() { return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : ""; },
267
+ get baseUrl() { return resolveBaseUrl(config.baseUrl); },
246
268
  get apiPath() { return apiPath; },
247
269
  get fetchFn() { return fetchFn; },
248
270
  getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record<string, string>,
package/src/websocket.ts CHANGED
@@ -64,7 +64,11 @@ const CHANNEL_MESSAGE_TYPES = new Set([
64
64
  "broadcast",
65
65
  "presence_track",
66
66
  "presence_untrack",
67
- "presence_state"
67
+ "presence_state",
68
+ // The catch-up request. Like `presence_state`, its answer comes back as a
69
+ // channel-addressed frame rather than a response envelope, so it must not
70
+ // be given a pending request to wait on.
71
+ "channel_history"
68
72
  ]);
69
73
 
70
74
  /**
@@ -599,7 +603,7 @@ export class RebaseWebSocketClient {
599
603
  // before the subscription paths — none of which would match it, and
600
604
  // the message would otherwise fall through and be dropped silently.
601
605
  if (typeof message.channel === "string" &&
602
- (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
606
+ (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
603
607
  const handlers = this.channelHandlers.get(message.channel);
604
608
  if (handlers) {
605
609
  for (const handler of [...handlers]) {
@@ -1062,6 +1066,13 @@ options }
1062
1066
  return response.roles || [];
1063
1067
  }
1064
1068
 
1069
+ async fetchApplicationRoles(): Promise<string[]> {
1070
+ const response = await this.sendMessage({
1071
+ type: "FETCH_APPLICATION_ROLES"
1072
+ }) as { roles?: string[] };
1073
+ return response.roles || [];
1074
+ }
1075
+
1065
1076
  async fetchCurrentDatabase(): Promise<string | undefined> {
1066
1077
  const response = await this.sendMessage({
1067
1078
  type: "FETCH_CURRENT_DATABASE"