@rebasepro/client 0.9.1-canary.a639738 → 0.9.1-canary.a8dbf1c
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 +20 -1
- package/dist/index.es.js +292 -52
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +155 -1
- package/dist/websocket.d.ts +1 -0
- package/package.json +4 -4
- package/src/auth.ts +32 -0
- package/src/index.ts +37 -4
- package/src/realtime-channel.test.ts +259 -2
- package/src/realtime-channel.ts +320 -19
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +25 -3
- package/src/websocket.ts +13 -2
package/dist/auth.d.ts
CHANGED
|
@@ -147,6 +147,11 @@ export declare function createAuth(transport: Transport, options?: CreateAuthOpt
|
|
|
147
147
|
success: boolean;
|
|
148
148
|
message: string;
|
|
149
149
|
}>;
|
|
150
|
+
linkProvider: (providerId: string, payload: Record<string, unknown>) => Promise<{
|
|
151
|
+
success: boolean;
|
|
152
|
+
provider: string;
|
|
153
|
+
alreadyLinked: boolean;
|
|
154
|
+
}>;
|
|
150
155
|
sendVerificationEmail: () => Promise<{
|
|
151
156
|
success: boolean;
|
|
152
157
|
message: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
|
|
|
7
7
|
import { CollectionClient } from "./collection";
|
|
8
8
|
import { createFunctionsClient } from "./functions";
|
|
9
9
|
import { RebaseWebSocketClient } from "./websocket";
|
|
10
|
+
import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
|
|
10
11
|
import { InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf } from "@rebasepro/types";
|
|
11
12
|
export { RebaseApiError } from "./transport";
|
|
12
13
|
export { RebaseClientError } from "./errors";
|
|
@@ -28,7 +29,7 @@ export type { ApiKeyMasked, ApiKeyPermission, ApiKeyWithSecret, CreateApiKeyRequ
|
|
|
28
29
|
export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
|
|
29
30
|
export { RebaseWebSocketClient } from "./websocket";
|
|
30
31
|
export { RebaseRealtimeChannel } from "./realtime-channel";
|
|
31
|
-
export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
|
|
32
|
+
export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport, ChannelOptions, ChannelHistoryEntry, ChannelHistoryResult } from "./realtime-channel";
|
|
32
33
|
export interface CreateRebaseClientOptions extends RebaseClientConfig {
|
|
33
34
|
auth?: CreateAuthOptions;
|
|
34
35
|
admin?: CreateAdminOptions;
|
|
@@ -76,6 +77,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
76
77
|
apiKeys: ReturnType<typeof createApiKeys>;
|
|
77
78
|
functions: ReturnType<typeof createFunctionsClient>;
|
|
78
79
|
ws?: RebaseWebSocketClient;
|
|
80
|
+
/**
|
|
81
|
+
* Broadcast and presence channels.
|
|
82
|
+
*
|
|
83
|
+
* Was missing from this type while present on the returned object, which
|
|
84
|
+
* made `client.realtime.channel(...)` a type error and forced every adopter
|
|
85
|
+
* to cast around the feature before they could reach it.
|
|
86
|
+
*/
|
|
87
|
+
realtime: {
|
|
88
|
+
/**
|
|
89
|
+
* Join a broadcast/presence channel. Repeated calls with the same name
|
|
90
|
+
* return the same channel object. Throws only when the client was
|
|
91
|
+
* created with `realtime: false`.
|
|
92
|
+
*
|
|
93
|
+
* Pass `{ history: true }` to have the channel replay what it missed on
|
|
94
|
+
* join and on every reconnect, for channels the server retains.
|
|
95
|
+
*/
|
|
96
|
+
channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
|
|
97
|
+
};
|
|
79
98
|
/**
|
|
80
99
|
* Release the realtime socket and its reconnect timer.
|
|
81
100
|
*
|
package/dist/index.es.js
CHANGED
|
@@ -54,6 +54,28 @@ function buildQueryString(params) {
|
|
|
54
54
|
}
|
|
55
55
|
return parts.length > 0 ? "?" + parts.join("&") : "";
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The base every request and every caller-built URL resolves against.
|
|
59
|
+
*
|
|
60
|
+
* `baseUrl` is optional because the common production shape is a Rebase
|
|
61
|
+
* backend serving its own SPA, where the API is simply the page's origin.
|
|
62
|
+
* Leaving it unset is therefore the *correct* configuration there — and the
|
|
63
|
+
* one that keeps working when a second hostname (a custom domain) points at
|
|
64
|
+
* the same app.
|
|
65
|
+
*
|
|
66
|
+
* When unset in a browser this resolves to the page origin rather than "".
|
|
67
|
+
* Requests behave identically either way, but the empty string is a trap for
|
|
68
|
+
* anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
|
|
69
|
+
* throws, so apps "fixed" it by baking an absolute host into their bundle —
|
|
70
|
+
* which is exactly what breaks the day a custom domain is added, and which no
|
|
71
|
+
* amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
|
|
72
|
+
* not sent cross-site either.
|
|
73
|
+
*/
|
|
74
|
+
function resolveBaseUrl(configured) {
|
|
75
|
+
if (configured) return configured.replace(/\/$/, "");
|
|
76
|
+
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
57
79
|
function createTransport(config) {
|
|
58
80
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
59
81
|
const apiPath = config.apiPath || "/api";
|
|
@@ -68,7 +90,7 @@ function createTransport(config) {
|
|
|
68
90
|
};
|
|
69
91
|
}
|
|
70
92
|
async function request(path, init) {
|
|
71
|
-
const url = (config.baseUrl
|
|
93
|
+
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
72
94
|
let activeToken = token;
|
|
73
95
|
if (tokenGetter) try {
|
|
74
96
|
const fetched = await tokenGetter();
|
|
@@ -143,7 +165,7 @@ function createTransport(config) {
|
|
|
143
165
|
onUnauthorizedHandler = handler;
|
|
144
166
|
},
|
|
145
167
|
get baseUrl() {
|
|
146
|
-
return config.baseUrl
|
|
168
|
+
return resolveBaseUrl(config.baseUrl);
|
|
147
169
|
},
|
|
148
170
|
get apiPath() {
|
|
149
171
|
return apiPath;
|
|
@@ -605,6 +627,30 @@ function createAuth(transport, options) {
|
|
|
605
627
|
})
|
|
606
628
|
});
|
|
607
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Link an OAuth provider to the **currently signed-in** account.
|
|
632
|
+
*
|
|
633
|
+
* Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
|
|
634
|
+
* with that email already exists under a different sign-in method — or to
|
|
635
|
+
* attach a provider whose email differs from the account's.
|
|
636
|
+
*
|
|
637
|
+
* The payload is the same one the provider's sign-in method takes, e.g.
|
|
638
|
+
* `linkProvider("google", { idToken })`.
|
|
639
|
+
*
|
|
640
|
+
* Unlike sign-in, this does not require the provider to have verified the
|
|
641
|
+
* email, and the emails need not match: the active session already proves
|
|
642
|
+
* account ownership.
|
|
643
|
+
*
|
|
644
|
+
* Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
|
|
645
|
+
* attached to a different user. Succeeds idempotently (`alreadyLinked:
|
|
646
|
+
* true`) if it is already attached to the current one.
|
|
647
|
+
*/
|
|
648
|
+
async function linkProvider(providerId, payload) {
|
|
649
|
+
return transport.request(authPath + "/link/" + providerId, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
body: JSON.stringify(payload)
|
|
652
|
+
});
|
|
653
|
+
}
|
|
608
654
|
async function sendVerificationEmail() {
|
|
609
655
|
return transport.request(authPath + "/send-verification", { method: "POST" });
|
|
610
656
|
}
|
|
@@ -726,6 +772,7 @@ function createAuth(transport, options) {
|
|
|
726
772
|
resetPasswordForEmail,
|
|
727
773
|
resetPassword,
|
|
728
774
|
changePassword,
|
|
775
|
+
linkProvider,
|
|
729
776
|
sendVerificationEmail,
|
|
730
777
|
verifyEmail,
|
|
731
778
|
sendMagicLink,
|
|
@@ -1416,7 +1463,8 @@ var CHANNEL_MESSAGE_TYPES = new Set([
|
|
|
1416
1463
|
"broadcast",
|
|
1417
1464
|
"presence_track",
|
|
1418
1465
|
"presence_untrack",
|
|
1419
|
-
"presence_state"
|
|
1466
|
+
"presence_state",
|
|
1467
|
+
"channel_history"
|
|
1420
1468
|
]);
|
|
1421
1469
|
/**
|
|
1422
1470
|
* Low-level realtime WebSocket client.
|
|
@@ -1755,7 +1803,7 @@ var RebaseWebSocketClient = class {
|
|
|
1755
1803
|
}
|
|
1756
1804
|
return;
|
|
1757
1805
|
}
|
|
1758
|
-
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
|
|
1806
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
1759
1807
|
const handlers = this.channelHandlers.get(message.channel);
|
|
1760
1808
|
if (handlers) for (const handler of [...handlers]) try {
|
|
1761
1809
|
handler(message);
|
|
@@ -2051,6 +2099,9 @@ var RebaseWebSocketClient = class {
|
|
|
2051
2099
|
async fetchAvailableRoles() {
|
|
2052
2100
|
return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
|
|
2053
2101
|
}
|
|
2102
|
+
async fetchApplicationRoles() {
|
|
2103
|
+
return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
|
|
2104
|
+
}
|
|
2054
2105
|
async fetchCurrentDatabase() {
|
|
2055
2106
|
return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
|
|
2056
2107
|
}
|
|
@@ -2539,6 +2590,14 @@ var RebaseWebSocketClient = class {
|
|
|
2539
2590
|
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
2540
2591
|
*/
|
|
2541
2592
|
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
2593
|
+
/**
|
|
2594
|
+
* How long live messages are held back waiting for a catch-up response.
|
|
2595
|
+
*
|
|
2596
|
+
* Short, because the cost of waiting is visible — on a collaborative document
|
|
2597
|
+
* this is a stall in everyone else's edits appearing. Long enough that a slow
|
|
2598
|
+
* replay of a busy channel is not abandoned needlessly.
|
|
2599
|
+
*/
|
|
2600
|
+
var CATCH_UP_TIMEOUT_MS = 1e4;
|
|
2542
2601
|
var RebaseRealtimeChannel = class {
|
|
2543
2602
|
name;
|
|
2544
2603
|
transport;
|
|
@@ -2551,9 +2610,63 @@ var RebaseRealtimeChannel = class {
|
|
|
2551
2610
|
trackedState = null;
|
|
2552
2611
|
heartbeat = null;
|
|
2553
2612
|
joined = false;
|
|
2554
|
-
|
|
2613
|
+
/** Whether this handle asks the server to replay missed messages. */
|
|
2614
|
+
wantsHistory;
|
|
2615
|
+
/**
|
|
2616
|
+
* Highest sequence number delivered to handlers so far.
|
|
2617
|
+
*
|
|
2618
|
+
* This is the resume point sent as `sinceSeq`, and the watermark that makes
|
|
2619
|
+
* replay idempotent: catch-up ranges overlap with what arrived live, and
|
|
2620
|
+
* anything at or below this has already been seen.
|
|
2621
|
+
*/
|
|
2622
|
+
lastSeq = 0;
|
|
2623
|
+
/**
|
|
2624
|
+
* Live messages that arrived while a catch-up was in flight.
|
|
2625
|
+
*
|
|
2626
|
+
* Without this they would be delivered ahead of the older messages being
|
|
2627
|
+
* fetched, and — worse — would advance {@link lastSeq} past them, so the
|
|
2628
|
+
* catch-up response would then be discarded as already-seen and those
|
|
2629
|
+
* messages would be lost for good. Held here and flushed, in order, once
|
|
2630
|
+
* the replay lands.
|
|
2631
|
+
*/
|
|
2632
|
+
pendingLive = [];
|
|
2633
|
+
catchUpInFlight = false;
|
|
2634
|
+
/**
|
|
2635
|
+
* Deadline for a catch-up response.
|
|
2636
|
+
*
|
|
2637
|
+
* Buffering live messages is only safe because the wait is bounded. A
|
|
2638
|
+
* catch-up frame that never arrives — a server that dropped it, a socket
|
|
2639
|
+
* that died between request and reply — would otherwise leave the channel
|
|
2640
|
+
* silently holding every subsequent edit forever, which is a worse failure
|
|
2641
|
+
* than the one replay was added to fix.
|
|
2642
|
+
*/
|
|
2643
|
+
catchUpTimeout = null;
|
|
2644
|
+
/**
|
|
2645
|
+
* Callers of {@link history} awaiting the next `channel_history` frame.
|
|
2646
|
+
*
|
|
2647
|
+
* These frames are addressed by channel rather than by request id, so they
|
|
2648
|
+
* are matched in arrival order. Requests on one channel are serialized by
|
|
2649
|
+
* the socket, so FIFO is the right correlation here.
|
|
2650
|
+
*/
|
|
2651
|
+
historyWaiters = [];
|
|
2652
|
+
constructor(name, transport, options = {}) {
|
|
2555
2653
|
this.name = name;
|
|
2556
2654
|
this.transport = transport;
|
|
2655
|
+
this.wantsHistory = options.history ?? false;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Turn on catch-up for a handle that was created without it.
|
|
2659
|
+
*
|
|
2660
|
+
* The client hands back the same channel object for a given name, so a
|
|
2661
|
+
* later `channel(name, { history: true })` has no new object to configure —
|
|
2662
|
+
* it upgrades this one instead. Idempotent, and never downgrades: one
|
|
2663
|
+
* caller asking for history must not be switched off by another that did
|
|
2664
|
+
* not ask.
|
|
2665
|
+
*/
|
|
2666
|
+
enableHistory() {
|
|
2667
|
+
if (this.wantsHistory) return;
|
|
2668
|
+
this.wantsHistory = true;
|
|
2669
|
+
if (this.joined) this.requestHistory();
|
|
2557
2670
|
}
|
|
2558
2671
|
/**
|
|
2559
2672
|
* Join the channel and ask for the current roster.
|
|
@@ -2562,6 +2675,27 @@ var RebaseRealtimeChannel = class {
|
|
|
2562
2675
|
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
2563
2676
|
* before there is anything to send.
|
|
2564
2677
|
*/
|
|
2678
|
+
/**
|
|
2679
|
+
* Send a channel message.
|
|
2680
|
+
*
|
|
2681
|
+
* Every channel message is read by the server out of a `payload` envelope
|
|
2682
|
+
* (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
|
|
2683
|
+
* fields flat does not error: `payload?.channel` simply reads as
|
|
2684
|
+
* `undefined`, so the client is registered into channel `undefined` with
|
|
2685
|
+
* empty state, and the echo comes back with no `channel` for
|
|
2686
|
+
* `onChannelMessage` to match — presence and broadcast both go quiet with
|
|
2687
|
+
* nothing logged. Funnelled through one place so a new message type cannot
|
|
2688
|
+
* reintroduce that.
|
|
2689
|
+
*/
|
|
2690
|
+
send(type, fields = {}) {
|
|
2691
|
+
return this.transport.sendMessage({
|
|
2692
|
+
type,
|
|
2693
|
+
payload: {
|
|
2694
|
+
channel: this.name,
|
|
2695
|
+
...fields
|
|
2696
|
+
}
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2565
2699
|
async join() {
|
|
2566
2700
|
if (this.joined) return;
|
|
2567
2701
|
this.joined = true;
|
|
@@ -2569,33 +2703,60 @@ var RebaseRealtimeChannel = class {
|
|
|
2569
2703
|
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
2570
2704
|
this.rejoin();
|
|
2571
2705
|
}));
|
|
2572
|
-
await this.
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
});
|
|
2576
|
-
await this.transport.sendMessage({
|
|
2577
|
-
type: "presence_state",
|
|
2578
|
-
channel: this.name
|
|
2579
|
-
});
|
|
2706
|
+
await this.send("join_channel");
|
|
2707
|
+
await this.send("presence_state");
|
|
2708
|
+
if (this.wantsHistory) await this.requestHistory();
|
|
2580
2709
|
}
|
|
2581
2710
|
async rejoin() {
|
|
2582
2711
|
try {
|
|
2583
|
-
await this.
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
await this.transport.sendMessage({
|
|
2588
|
-
type: "presence_state",
|
|
2589
|
-
channel: this.name
|
|
2590
|
-
});
|
|
2591
|
-
if (this.trackedState) await this.transport.sendMessage({
|
|
2592
|
-
type: "presence_track",
|
|
2593
|
-
channel: this.name,
|
|
2594
|
-
state: this.trackedState
|
|
2595
|
-
});
|
|
2712
|
+
await this.send("join_channel");
|
|
2713
|
+
await this.send("presence_state");
|
|
2714
|
+
if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
|
|
2715
|
+
if (this.wantsHistory) await this.requestHistory();
|
|
2596
2716
|
} catch {}
|
|
2597
2717
|
}
|
|
2598
2718
|
/**
|
|
2719
|
+
* Ask the server for everything after {@link lastSeq}.
|
|
2720
|
+
*
|
|
2721
|
+
* Live messages are buffered from here until the answer arrives — see
|
|
2722
|
+
* {@link pendingLive}.
|
|
2723
|
+
*/
|
|
2724
|
+
async requestHistory(limit) {
|
|
2725
|
+
this.catchUpInFlight = true;
|
|
2726
|
+
if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
|
|
2727
|
+
this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
|
|
2728
|
+
this.catchUpTimeout.unref?.();
|
|
2729
|
+
try {
|
|
2730
|
+
await this.send("channel_history", {
|
|
2731
|
+
sinceSeq: this.lastSeq,
|
|
2732
|
+
...limit !== void 0 ? { limit } : {}
|
|
2733
|
+
});
|
|
2734
|
+
} catch {
|
|
2735
|
+
this.abandonCatchUp();
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Give up waiting for a catch-up and release what was held back.
|
|
2740
|
+
*
|
|
2741
|
+
* The buffered messages are still the freshest thing this client has, so
|
|
2742
|
+
* they are delivered rather than dropped. Callers of {@link history} are
|
|
2743
|
+
* answered with `retained: false` — accurate in the sense that matters:
|
|
2744
|
+
* this client has no history to work from and has to resync.
|
|
2745
|
+
*/
|
|
2746
|
+
abandonCatchUp() {
|
|
2747
|
+
if (this.catchUpTimeout) {
|
|
2748
|
+
clearTimeout(this.catchUpTimeout);
|
|
2749
|
+
this.catchUpTimeout = null;
|
|
2750
|
+
}
|
|
2751
|
+
if (!this.catchUpInFlight) return;
|
|
2752
|
+
this.catchUpInFlight = false;
|
|
2753
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
2754
|
+
messages: [],
|
|
2755
|
+
retained: false
|
|
2756
|
+
});
|
|
2757
|
+
this.flushPendingLive();
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2599
2760
|
* Publish this client's presence state, and keep publishing it.
|
|
2600
2761
|
*
|
|
2601
2762
|
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
@@ -2604,19 +2765,11 @@ var RebaseRealtimeChannel = class {
|
|
|
2604
2765
|
async track(state) {
|
|
2605
2766
|
await this.join();
|
|
2606
2767
|
this.trackedState = state;
|
|
2607
|
-
await this.
|
|
2608
|
-
type: "presence_track",
|
|
2609
|
-
channel: this.name,
|
|
2610
|
-
state
|
|
2611
|
-
});
|
|
2768
|
+
await this.send("presence_track", { state });
|
|
2612
2769
|
if (!this.heartbeat) {
|
|
2613
2770
|
this.heartbeat = setInterval(() => {
|
|
2614
2771
|
if (!this.trackedState) return;
|
|
2615
|
-
this.
|
|
2616
|
-
type: "presence_track",
|
|
2617
|
-
channel: this.name,
|
|
2618
|
-
state: this.trackedState
|
|
2619
|
-
}).catch(() => {});
|
|
2772
|
+
this.send("presence_track", { state: this.trackedState }).catch(() => {});
|
|
2620
2773
|
}, PRESENCE_HEARTBEAT_MS);
|
|
2621
2774
|
this.heartbeat.unref?.();
|
|
2622
2775
|
}
|
|
@@ -2625,10 +2778,7 @@ var RebaseRealtimeChannel = class {
|
|
|
2625
2778
|
async untrack() {
|
|
2626
2779
|
this.stopHeartbeat();
|
|
2627
2780
|
this.trackedState = null;
|
|
2628
|
-
if (this.joined) await this.
|
|
2629
|
-
type: "presence_untrack",
|
|
2630
|
-
channel: this.name
|
|
2631
|
-
});
|
|
2781
|
+
if (this.joined) await this.send("presence_untrack");
|
|
2632
2782
|
}
|
|
2633
2783
|
/**
|
|
2634
2784
|
* Observe the roster. The handler fires immediately with what is already
|
|
@@ -2643,9 +2793,7 @@ var RebaseRealtimeChannel = class {
|
|
|
2643
2793
|
/** Send a broadcast. The sender does not receive its own message. */
|
|
2644
2794
|
async broadcast(event, payload) {
|
|
2645
2795
|
await this.join();
|
|
2646
|
-
await this.
|
|
2647
|
-
type: "broadcast",
|
|
2648
|
-
channel: this.name,
|
|
2796
|
+
await this.send("broadcast", {
|
|
2649
2797
|
event,
|
|
2650
2798
|
payload
|
|
2651
2799
|
});
|
|
@@ -2658,6 +2806,34 @@ var RebaseRealtimeChannel = class {
|
|
|
2658
2806
|
this.join();
|
|
2659
2807
|
return () => this.broadcastHandlers.delete(wrapped);
|
|
2660
2808
|
}
|
|
2809
|
+
/**
|
|
2810
|
+
* The last sequence number this channel has delivered.
|
|
2811
|
+
*
|
|
2812
|
+
* Zero on a channel that retains nothing. Persist it if you want catch-up
|
|
2813
|
+
* to survive a page reload as well as a reconnect, and pass it back via
|
|
2814
|
+
* {@link history}.
|
|
2815
|
+
*/
|
|
2816
|
+
get sequence() {
|
|
2817
|
+
return this.lastSeq;
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* Fetch retained messages explicitly, instead of waiting for join or
|
|
2821
|
+
* reconnect to do it.
|
|
2822
|
+
*
|
|
2823
|
+
* Defaults to resuming from {@link sequence}. Messages are delivered to
|
|
2824
|
+
* `onBroadcast` handlers as usual — the returned value is for callers that
|
|
2825
|
+
* want to inspect the batch, or to learn from `retained` that the channel
|
|
2826
|
+
* keeps no history at all.
|
|
2827
|
+
*/
|
|
2828
|
+
async history(options = {}) {
|
|
2829
|
+
await this.join();
|
|
2830
|
+
if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
|
|
2831
|
+
const result = new Promise((resolve) => {
|
|
2832
|
+
this.historyWaiters.push(resolve);
|
|
2833
|
+
});
|
|
2834
|
+
await this.requestHistory(options.limit);
|
|
2835
|
+
return result;
|
|
2836
|
+
}
|
|
2661
2837
|
/** Leave the channel and release every listener and timer. */
|
|
2662
2838
|
async leave() {
|
|
2663
2839
|
this.stopHeartbeat();
|
|
@@ -2665,14 +2841,22 @@ var RebaseRealtimeChannel = class {
|
|
|
2665
2841
|
this.presences = {};
|
|
2666
2842
|
this.presenceHandlers.clear();
|
|
2667
2843
|
this.broadcastHandlers.clear();
|
|
2844
|
+
this.lastSeq = 0;
|
|
2845
|
+
this.pendingLive = [];
|
|
2846
|
+
this.catchUpInFlight = false;
|
|
2847
|
+
if (this.catchUpTimeout) {
|
|
2848
|
+
clearTimeout(this.catchUpTimeout);
|
|
2849
|
+
this.catchUpTimeout = null;
|
|
2850
|
+
}
|
|
2851
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
2852
|
+
messages: [],
|
|
2853
|
+
retained: false
|
|
2854
|
+
});
|
|
2668
2855
|
for (const off of this.unsubscribers) off();
|
|
2669
2856
|
this.unsubscribers = [];
|
|
2670
2857
|
if (this.joined) {
|
|
2671
2858
|
this.joined = false;
|
|
2672
|
-
await this.
|
|
2673
|
-
type: "leave_channel",
|
|
2674
|
-
channel: this.name
|
|
2675
|
-
});
|
|
2859
|
+
await this.send("leave_channel");
|
|
2676
2860
|
}
|
|
2677
2861
|
}
|
|
2678
2862
|
stopHeartbeat() {
|
|
@@ -2700,15 +2884,71 @@ var RebaseRealtimeChannel = class {
|
|
|
2700
2884
|
break;
|
|
2701
2885
|
}
|
|
2702
2886
|
case "broadcast": {
|
|
2887
|
+
const seq = typeof message.seq === "number" ? message.seq : void 0;
|
|
2703
2888
|
const event = {
|
|
2704
2889
|
event: message.event,
|
|
2705
|
-
payload: message.payload
|
|
2890
|
+
payload: message.payload,
|
|
2891
|
+
...seq !== void 0 ? { seq } : {}
|
|
2706
2892
|
};
|
|
2707
|
-
|
|
2893
|
+
if (seq === void 0) {
|
|
2894
|
+
this.deliver(event);
|
|
2895
|
+
break;
|
|
2896
|
+
}
|
|
2897
|
+
if (this.catchUpInFlight) {
|
|
2898
|
+
this.pendingLive.push(event);
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
if (seq <= this.lastSeq) break;
|
|
2902
|
+
this.lastSeq = seq;
|
|
2903
|
+
this.deliver(event);
|
|
2904
|
+
break;
|
|
2905
|
+
}
|
|
2906
|
+
case "channel_history": {
|
|
2907
|
+
this.catchUpInFlight = false;
|
|
2908
|
+
if (this.catchUpTimeout) {
|
|
2909
|
+
clearTimeout(this.catchUpTimeout);
|
|
2910
|
+
this.catchUpTimeout = null;
|
|
2911
|
+
}
|
|
2912
|
+
const entries = message.messages ?? [];
|
|
2913
|
+
const retained = message.retained === true;
|
|
2914
|
+
const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
|
|
2915
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
2916
|
+
messages: entries,
|
|
2917
|
+
retained,
|
|
2918
|
+
latestSeq
|
|
2919
|
+
});
|
|
2920
|
+
for (const entry of entries) {
|
|
2921
|
+
if (entry.seq <= this.lastSeq) continue;
|
|
2922
|
+
this.lastSeq = entry.seq;
|
|
2923
|
+
this.deliver({
|
|
2924
|
+
event: entry.event,
|
|
2925
|
+
payload: entry.payload,
|
|
2926
|
+
seq: entry.seq,
|
|
2927
|
+
replayed: true
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
this.flushPendingLive();
|
|
2708
2931
|
break;
|
|
2709
2932
|
}
|
|
2710
2933
|
}
|
|
2711
2934
|
}
|
|
2935
|
+
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
2936
|
+
flushPendingLive() {
|
|
2937
|
+
if (this.pendingLive.length === 0) return;
|
|
2938
|
+
const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
2939
|
+
this.pendingLive = [];
|
|
2940
|
+
for (const event of buffered) {
|
|
2941
|
+
const seq = event.seq;
|
|
2942
|
+
if (seq !== void 0) {
|
|
2943
|
+
if (seq <= this.lastSeq) continue;
|
|
2944
|
+
this.lastSeq = seq;
|
|
2945
|
+
}
|
|
2946
|
+
this.deliver(event);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
deliver(event) {
|
|
2950
|
+
for (const handler of [...this.broadcastHandlers]) handler(event);
|
|
2951
|
+
}
|
|
2712
2952
|
emitPresence(diff) {
|
|
2713
2953
|
const snapshot = { ...this.presences };
|
|
2714
2954
|
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
@@ -2886,13 +3126,13 @@ function createRebaseClient(options) {
|
|
|
2886
3126
|
* own membership — and `leave()` from one would otherwise silently
|
|
2887
3127
|
* cut off the others.
|
|
2888
3128
|
*/
|
|
2889
|
-
channel: (name) => {
|
|
3129
|
+
channel: (name, options) => {
|
|
2890
3130
|
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
2891
3131
|
let existing = realtimeChannels.get(name);
|
|
2892
3132
|
if (!existing) {
|
|
2893
|
-
existing = new RebaseRealtimeChannel(name, ws);
|
|
3133
|
+
existing = new RebaseRealtimeChannel(name, ws, options);
|
|
2894
3134
|
realtimeChannels.set(name, existing);
|
|
2895
|
-
}
|
|
3135
|
+
} else if (options?.history) existing.enableHistory();
|
|
2896
3136
|
return existing;
|
|
2897
3137
|
} },
|
|
2898
3138
|
/**
|