@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.
- package/dist/auth.d.ts +5 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.es.js +261 -10
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +142 -1
- package/dist/websocket.d.ts +1 -0
- package/package.json +4 -4
- package/src/auth.ts +32 -0
- package/src/index.ts +23 -5
- package/src/realtime-channel.test.ts +206 -0
- package/src/realtime-channel.ts +294 -4
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +25 -3
- package/src/websocket.ts +13 -2
package/src/realtime-channel.ts
CHANGED
|
@@ -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
|
|
239
|
-
|
|
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
|
|
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
|
|
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"
|