@rebasepro/client 0.9.0 → 0.9.1-canary.0de22e0
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/auth.d.ts +5 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.es.js +828 -96
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +243 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +97 -3
- package/package.json +11 -10
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/auth.ts +32 -0
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +112 -3
- package/src/realtime-channel.test.ts +542 -0
- package/src/realtime-channel.ts +539 -0
- package/src/realtime-optout.test.ts +245 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport-baseurl.test.ts +53 -0
- package/src/transport.ts +59 -3
- package/src/websocket.ts +516 -89
- 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
package/dist/index.es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath } from "@rebasepro/types";
|
|
2
|
-
import { QueryBuilder, and, cond, or, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
2
|
+
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, and, buildCompositeId, cond, or, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
4
|
//#region src/reviver.ts
|
|
5
5
|
function rebaseReviver(_key, value) {
|
|
@@ -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,
|
|
@@ -869,6 +916,32 @@ function createCron(transport, options) {
|
|
|
869
916
|
};
|
|
870
917
|
}
|
|
871
918
|
//#endregion
|
|
919
|
+
//#region src/backups.ts
|
|
920
|
+
function createBackups(transport, options) {
|
|
921
|
+
const backupsPath = options?.backupsPath || "/admin/backups";
|
|
922
|
+
async function list() {
|
|
923
|
+
return transport.request(backupsPath, { method: "GET" });
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Download a backup's bytes. Uses an authenticated fetch (not the JSON
|
|
927
|
+
* transport) so the octet-stream response comes back as a Blob.
|
|
928
|
+
*/
|
|
929
|
+
async function download(key) {
|
|
930
|
+
const token = await transport.resolveToken();
|
|
931
|
+
const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
|
|
932
|
+
const res = await fetch(url, {
|
|
933
|
+
method: "GET",
|
|
934
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
935
|
+
});
|
|
936
|
+
if (!res.ok) throw new Error(`Failed to download backup (${res.status})`);
|
|
937
|
+
return res.blob();
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
list,
|
|
941
|
+
download
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
872
945
|
//#region src/api-keys.ts
|
|
873
946
|
/**
|
|
874
947
|
* Creates a client for managing API keys via the admin routes.
|
|
@@ -1009,7 +1082,7 @@ var SDKQueryBuilder = class {
|
|
|
1009
1082
|
* Listen to realtime updates matching this query.
|
|
1010
1083
|
*/
|
|
1011
1084
|
listen(onUpdate, onError) {
|
|
1012
|
-
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
1085
|
+
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
|
|
1013
1086
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
1014
1087
|
}
|
|
1015
1088
|
};
|
|
@@ -1044,6 +1117,17 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1044
1117
|
body: JSON.stringify(body)
|
|
1045
1118
|
});
|
|
1046
1119
|
},
|
|
1120
|
+
async createMany(data, options) {
|
|
1121
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
1122
|
+
if (data.length === 0) return [];
|
|
1123
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
1124
|
+
method: "POST",
|
|
1125
|
+
body: JSON.stringify({
|
|
1126
|
+
rows: data,
|
|
1127
|
+
...options?.upsert ? { upsert: true } : {}
|
|
1128
|
+
})
|
|
1129
|
+
})).data || [];
|
|
1130
|
+
},
|
|
1047
1131
|
async update(id, data) {
|
|
1048
1132
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1049
1133
|
method: "PUT",
|
|
@@ -1360,18 +1444,35 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
1360
1444
|
function extractMessageError(message) {
|
|
1361
1445
|
const payload = message.payload;
|
|
1362
1446
|
const errPayload = payload?.error;
|
|
1447
|
+
const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
|
|
1448
|
+
const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
|
|
1363
1449
|
return {
|
|
1364
|
-
errorMessage: typeof
|
|
1365
|
-
errorCode
|
|
1450
|
+
errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
|
|
1451
|
+
errorCode
|
|
1366
1452
|
};
|
|
1367
1453
|
}
|
|
1368
1454
|
/**
|
|
1455
|
+
* Broadcast and presence frames.
|
|
1456
|
+
*
|
|
1457
|
+
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
1458
|
+
* client-side auth gate — a public channel is usable without an account.
|
|
1459
|
+
*/
|
|
1460
|
+
var CHANNEL_MESSAGE_TYPES = new Set([
|
|
1461
|
+
"join_channel",
|
|
1462
|
+
"leave_channel",
|
|
1463
|
+
"broadcast",
|
|
1464
|
+
"presence_track",
|
|
1465
|
+
"presence_untrack",
|
|
1466
|
+
"presence_state",
|
|
1467
|
+
"channel_history"
|
|
1468
|
+
]);
|
|
1469
|
+
/**
|
|
1369
1470
|
* Low-level realtime WebSocket client.
|
|
1370
1471
|
*
|
|
1371
1472
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
1372
1473
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
1373
1474
|
* `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
|
|
1374
|
-
* package root only because the `@rebasepro/client-
|
|
1475
|
+
* package root only because the `@rebasepro/client-postgres` driver
|
|
1375
1476
|
* instantiates it directly; its surface may change without a major bump.
|
|
1376
1477
|
*/
|
|
1377
1478
|
var RebaseWebSocketClient = class {
|
|
@@ -1380,6 +1481,36 @@ var RebaseWebSocketClient = class {
|
|
|
1380
1481
|
getAuthToken;
|
|
1381
1482
|
subscriptions = /* @__PURE__ */ new Map();
|
|
1382
1483
|
listeners = /* @__PURE__ */ new Map();
|
|
1484
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
1485
|
+
channelHandlers = /* @__PURE__ */ new Map();
|
|
1486
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
1487
|
+
closedByCaller = false;
|
|
1488
|
+
/**
|
|
1489
|
+
* Whether a socket exists at all (open or still opening).
|
|
1490
|
+
*
|
|
1491
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
1492
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
1493
|
+
*/
|
|
1494
|
+
get hasSocket() {
|
|
1495
|
+
return this.ws !== null;
|
|
1496
|
+
}
|
|
1497
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
1498
|
+
warnedNoWebSocket = false;
|
|
1499
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
1500
|
+
onChannelMessage(channel, handler) {
|
|
1501
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
1502
|
+
this.channelHandlers.get(channel).add(handler);
|
|
1503
|
+
return () => {
|
|
1504
|
+
const handlers = this.channelHandlers.get(channel);
|
|
1505
|
+
if (!handlers) return;
|
|
1506
|
+
handlers.delete(handler);
|
|
1507
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
1511
|
+
onReconnect(handler) {
|
|
1512
|
+
return this.on("reconnect", handler);
|
|
1513
|
+
}
|
|
1383
1514
|
on(event, cb) {
|
|
1384
1515
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
1385
1516
|
this.listeners.get(event).add(cb);
|
|
@@ -1398,6 +1529,7 @@ var RebaseWebSocketClient = class {
|
|
|
1398
1529
|
isConnected = false;
|
|
1399
1530
|
messageQueue = [];
|
|
1400
1531
|
requestTimeoutMs = 3e4;
|
|
1532
|
+
subscriptionTimeoutMs = 3e4;
|
|
1401
1533
|
reconnectTimeout = null;
|
|
1402
1534
|
isAuthenticated = false;
|
|
1403
1535
|
authPromise = null;
|
|
@@ -1409,8 +1541,25 @@ var RebaseWebSocketClient = class {
|
|
|
1409
1541
|
this.getAuthToken = config.getAuthToken;
|
|
1410
1542
|
this.onUnauthorized = config.onUnauthorized;
|
|
1411
1543
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
1412
|
-
|
|
1413
|
-
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Open the socket if it is not open (or opening) already.
|
|
1547
|
+
*
|
|
1548
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
1549
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
1550
|
+
* re-entrant, since the reconnect path has always called it.
|
|
1551
|
+
*/
|
|
1552
|
+
ensureConnected() {
|
|
1553
|
+
if (this.closedByCaller) return;
|
|
1554
|
+
if (!this.WebSocketConstructor) {
|
|
1555
|
+
if (!this.warnedNoWebSocket) {
|
|
1556
|
+
this.warnedNoWebSocket = true;
|
|
1557
|
+
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
1558
|
+
}
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (this.ws || this.reconnectTimeout) return;
|
|
1562
|
+
this.initWebSocket();
|
|
1414
1563
|
}
|
|
1415
1564
|
/**
|
|
1416
1565
|
* Authenticate the WebSocket connection
|
|
@@ -1460,7 +1609,16 @@ var RebaseWebSocketClient = class {
|
|
|
1460
1609
|
});
|
|
1461
1610
|
}
|
|
1462
1611
|
}
|
|
1463
|
-
|
|
1612
|
+
/**
|
|
1613
|
+
* Drop the socket.
|
|
1614
|
+
*
|
|
1615
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
1616
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
1617
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
1618
|
+
* must not be undone by a stray queued frame.
|
|
1619
|
+
*/
|
|
1620
|
+
disconnect(permanent = false) {
|
|
1621
|
+
if (permanent) this.closedByCaller = true;
|
|
1464
1622
|
this.isAuthenticated = false;
|
|
1465
1623
|
this.authPromise = null;
|
|
1466
1624
|
if (this.reconnectTimeout) {
|
|
@@ -1503,6 +1661,7 @@ var RebaseWebSocketClient = class {
|
|
|
1503
1661
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
1504
1662
|
this.processMessageQueue();
|
|
1505
1663
|
if (wasReconnect) this.resubscribeAll();
|
|
1664
|
+
this.armPendingSubscribeWatchdogs();
|
|
1506
1665
|
};
|
|
1507
1666
|
this.ws.onmessage = (event) => {
|
|
1508
1667
|
try {
|
|
@@ -1517,6 +1676,7 @@ var RebaseWebSocketClient = class {
|
|
|
1517
1676
|
this.isConnected = false;
|
|
1518
1677
|
this.isAuthenticated = false;
|
|
1519
1678
|
this.authPromise = null;
|
|
1679
|
+
this.suspendSubscribeWatchdogs();
|
|
1520
1680
|
this.emit("disconnect");
|
|
1521
1681
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
1522
1682
|
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
@@ -1548,6 +1708,7 @@ var RebaseWebSocketClient = class {
|
|
|
1548
1708
|
attemptReconnect() {
|
|
1549
1709
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
1550
1710
|
console.error("Max reconnection attempts reached");
|
|
1711
|
+
this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
|
|
1551
1712
|
return;
|
|
1552
1713
|
}
|
|
1553
1714
|
this.reconnectAttempts++;
|
|
@@ -1602,29 +1763,18 @@ var RebaseWebSocketClient = class {
|
|
|
1602
1763
|
subscription.backendSubscriptionId = newBackendId;
|
|
1603
1764
|
backendKeyMap.delete(oldBackendId);
|
|
1604
1765
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
1605
|
-
this.
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
...subscription.props,
|
|
1609
|
-
subscriptionId: newBackendId
|
|
1610
|
-
}
|
|
1611
|
-
}).catch((error) => {
|
|
1612
|
-
console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
|
|
1613
|
-
subscription.callbacks.forEach((callback) => {
|
|
1614
|
-
if (callback.onError) callback.onError(error);
|
|
1615
|
-
});
|
|
1616
|
-
});
|
|
1617
|
-
} else {
|
|
1618
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1619
|
-
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1620
|
-
subscription.callbacks.forEach((callback) => {
|
|
1621
|
-
if (callback.onError) callback.onError(error);
|
|
1622
|
-
});
|
|
1766
|
+
if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
|
|
1767
|
+
else this.sendEntitySubscribe(subscriptionKey);
|
|
1768
|
+
return;
|
|
1623
1769
|
}
|
|
1770
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1771
|
+
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1772
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
1773
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
1624
1774
|
}).catch((err) => {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1775
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1776
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
1777
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
1628
1778
|
});
|
|
1629
1779
|
}
|
|
1630
1780
|
handleWebSocketMessage(message) {
|
|
@@ -1653,16 +1803,30 @@ var RebaseWebSocketClient = class {
|
|
|
1653
1803
|
}
|
|
1654
1804
|
return;
|
|
1655
1805
|
}
|
|
1806
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
1807
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
1808
|
+
if (handlers) for (const handler of [...handlers]) try {
|
|
1809
|
+
handler(message);
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
console.error("Error in channel handler:", error);
|
|
1812
|
+
}
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1656
1815
|
if (subscriptionId && type === "collection_update") {
|
|
1657
1816
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
1658
1817
|
if (subscriptionKey) {
|
|
1659
1818
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1660
1819
|
if (collectionSub) {
|
|
1661
1820
|
const incomingRows = message.rows || [];
|
|
1662
|
-
const
|
|
1821
|
+
const updatePks = message.pks;
|
|
1822
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
1823
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
1663
1824
|
collectionSub.latestData = rows;
|
|
1664
1825
|
collectionSub.lastUpdated = Date.now();
|
|
1665
1826
|
collectionSub.isInitialDataReceived = true;
|
|
1827
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1828
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1829
|
+
collectionSub.subscribeInFlight = false;
|
|
1666
1830
|
collectionSub.callbacks.forEach((callback) => {
|
|
1667
1831
|
try {
|
|
1668
1832
|
callback.onUpdate(rows);
|
|
@@ -1681,12 +1845,14 @@ var RebaseWebSocketClient = class {
|
|
|
1681
1845
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1682
1846
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
1683
1847
|
const patchWireEntity = message.row ?? null;
|
|
1684
|
-
const
|
|
1848
|
+
const patchMessage = message;
|
|
1849
|
+
const patchEntityId = patchMessage.id;
|
|
1850
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
1685
1851
|
const patchRow = patchWireEntity ? patchWireEntity : null;
|
|
1686
1852
|
let updated;
|
|
1687
|
-
if (patchRow === null) updated = collectionSub.latestData.filter((e) =>
|
|
1853
|
+
if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
|
|
1688
1854
|
else {
|
|
1689
|
-
const idx = collectionSub.latestData.findIndex((e) =>
|
|
1855
|
+
const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
|
|
1690
1856
|
if (idx >= 0) {
|
|
1691
1857
|
updated = [...collectionSub.latestData];
|
|
1692
1858
|
updated[idx] = patchRow;
|
|
@@ -1716,6 +1882,9 @@ var RebaseWebSocketClient = class {
|
|
|
1716
1882
|
entitySub.latestData = row;
|
|
1717
1883
|
entitySub.lastUpdated = Date.now();
|
|
1718
1884
|
entitySub.isInitialDataReceived = true;
|
|
1885
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1886
|
+
entitySub.subscribeTimeout = void 0;
|
|
1887
|
+
entitySub.subscribeInFlight = false;
|
|
1719
1888
|
entitySub.callbacks.forEach((callback) => {
|
|
1720
1889
|
try {
|
|
1721
1890
|
callback.onUpdate(row);
|
|
@@ -1737,6 +1906,9 @@ var RebaseWebSocketClient = class {
|
|
|
1737
1906
|
this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
|
|
1738
1907
|
return;
|
|
1739
1908
|
}
|
|
1909
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1910
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1911
|
+
collectionSub.subscribeInFlight = false;
|
|
1740
1912
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1741
1913
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1742
1914
|
collectionSub.callbacks.forEach((callback) => {
|
|
@@ -1753,6 +1925,9 @@ var RebaseWebSocketClient = class {
|
|
|
1753
1925
|
this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
|
|
1754
1926
|
return;
|
|
1755
1927
|
}
|
|
1928
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1929
|
+
entitySub.subscribeTimeout = void 0;
|
|
1930
|
+
entitySub.subscribeInFlight = false;
|
|
1756
1931
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1757
1932
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1758
1933
|
entitySub.callbacks.forEach((callback) => {
|
|
@@ -1825,21 +2000,28 @@ var RebaseWebSocketClient = class {
|
|
|
1825
2000
|
throw error;
|
|
1826
2001
|
}
|
|
1827
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
2005
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
2006
|
+
*/
|
|
1828
2007
|
sendMessage(message) {
|
|
1829
2008
|
const queuedMsg = message;
|
|
1830
2009
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
1831
|
-
if (!this.isConnected || !this.ws)
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2010
|
+
if (!this.isConnected || !this.ws) {
|
|
2011
|
+
this.ensureConnected();
|
|
2012
|
+
return new Promise((resolve, reject) => {
|
|
2013
|
+
const queueable = message;
|
|
2014
|
+
queueable._queuedResolve = resolve;
|
|
2015
|
+
queueable._queuedReject = reject;
|
|
2016
|
+
this.messageQueue.push(message);
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
1837
2019
|
return new Promise((resolve, reject) => {
|
|
1838
2020
|
this.doSendMessage(message, resolve, reject);
|
|
1839
2021
|
});
|
|
1840
2022
|
}
|
|
1841
2023
|
async doSendMessage(message, resolve, reject) {
|
|
1842
|
-
if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
|
|
2024
|
+
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
1843
2025
|
await this.ensureAuthenticated();
|
|
1844
2026
|
} catch (error) {
|
|
1845
2027
|
reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
|
|
@@ -1847,17 +2029,7 @@ var RebaseWebSocketClient = class {
|
|
|
1847
2029
|
}
|
|
1848
2030
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1849
2031
|
message.requestId = requestId;
|
|
1850
|
-
const expectsResponse = !
|
|
1851
|
-
"subscribe_collection",
|
|
1852
|
-
"subscribe_one",
|
|
1853
|
-
"unsubscribe",
|
|
1854
|
-
"join_channel",
|
|
1855
|
-
"leave_channel",
|
|
1856
|
-
"broadcast",
|
|
1857
|
-
"presence_track",
|
|
1858
|
-
"presence_untrack",
|
|
1859
|
-
"presence_state"
|
|
1860
|
-
].includes(message.type);
|
|
2032
|
+
const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
1861
2033
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
1862
2034
|
const timeoutHandle = setTimeout(() => {
|
|
1863
2035
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -1927,6 +2099,9 @@ var RebaseWebSocketClient = class {
|
|
|
1927
2099
|
async fetchAvailableRoles() {
|
|
1928
2100
|
return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
|
|
1929
2101
|
}
|
|
2102
|
+
async fetchApplicationRoles() {
|
|
2103
|
+
return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
|
|
2104
|
+
}
|
|
1930
2105
|
async fetchCurrentDatabase() {
|
|
1931
2106
|
return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
|
|
1932
2107
|
}
|
|
@@ -2036,17 +2211,39 @@ var RebaseWebSocketClient = class {
|
|
|
2036
2211
|
return val;
|
|
2037
2212
|
}
|
|
2038
2213
|
/**
|
|
2214
|
+
* The address of a row, for matching it against another copy of itself.
|
|
2215
|
+
*
|
|
2216
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
2217
|
+
* from the key columns the server named — including the ordinary case where
|
|
2218
|
+
* that key is `id`, which the server reports like any other.
|
|
2219
|
+
*
|
|
2220
|
+
* Undefined when there are no keys, which means the server could not
|
|
2221
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
2222
|
+
* column called `id` would be inventing an identity for a table that has
|
|
2223
|
+
* none.
|
|
2224
|
+
*/
|
|
2225
|
+
rowAddress(row, pks) {
|
|
2226
|
+
if (!pks || pks.length === 0) return void 0;
|
|
2227
|
+
const address = buildCompositeId(row, pks);
|
|
2228
|
+
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every((part) => part === "")) return void 0;
|
|
2229
|
+
return address;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2039
2232
|
* Merge incoming rows with cached data, preserving cached references
|
|
2040
2233
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
2041
2234
|
* React re-renders when the server refetches all rows but most
|
|
2042
2235
|
* haven't actually changed.
|
|
2043
2236
|
*/
|
|
2044
|
-
mergeRows(cached, incoming) {
|
|
2237
|
+
mergeRows(cached, incoming, pks) {
|
|
2045
2238
|
if (!cached || cached.length === 0) return incoming;
|
|
2046
2239
|
const cachedById = /* @__PURE__ */ new Map();
|
|
2047
|
-
for (const row of cached)
|
|
2240
|
+
for (const row of cached) {
|
|
2241
|
+
const address = this.rowAddress(row, pks);
|
|
2242
|
+
if (address !== void 0) cachedById.set(address, row);
|
|
2243
|
+
}
|
|
2048
2244
|
return incoming.map((incomingRow) => {
|
|
2049
|
-
const
|
|
2245
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
2246
|
+
const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
|
|
2050
2247
|
if (!cachedRow) return incomingRow;
|
|
2051
2248
|
const normCached = this.normalizeForComparison(cachedRow);
|
|
2052
2249
|
const normIncoming = this.normalizeForComparison(incomingRow);
|
|
@@ -2058,12 +2255,13 @@ var RebaseWebSocketClient = class {
|
|
|
2058
2255
|
cached: normCached[key],
|
|
2059
2256
|
incoming: normIncoming[key]
|
|
2060
2257
|
};
|
|
2061
|
-
console.debug(`[RebaseWS] Row ${
|
|
2258
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
2062
2259
|
}
|
|
2063
2260
|
return incomingRow;
|
|
2064
2261
|
});
|
|
2065
2262
|
}
|
|
2066
2263
|
listenCollection(props, onUpdate, onError) {
|
|
2264
|
+
this.ensureConnected();
|
|
2067
2265
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
2068
2266
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
2069
2267
|
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
@@ -2079,9 +2277,12 @@ var RebaseWebSocketClient = class {
|
|
|
2079
2277
|
console.error("Error in collection subscription callback:", error);
|
|
2080
2278
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2081
2279
|
}
|
|
2280
|
+
else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
|
|
2082
2281
|
return () => {
|
|
2083
2282
|
callbackMap.delete(callbackId);
|
|
2084
2283
|
if (callbackMap.size === 0) {
|
|
2284
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2285
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2085
2286
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2086
2287
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
2087
2288
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2103,21 +2304,14 @@ var RebaseWebSocketClient = class {
|
|
|
2103
2304
|
props
|
|
2104
2305
|
});
|
|
2105
2306
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
2106
|
-
this.
|
|
2107
|
-
type: "subscribe_collection",
|
|
2108
|
-
payload: {
|
|
2109
|
-
...props,
|
|
2110
|
-
subscriptionId: backendSubscriptionId
|
|
2111
|
-
}
|
|
2112
|
-
}).catch((error) => {
|
|
2113
|
-
if (onError) onError(error);
|
|
2114
|
-
});
|
|
2307
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
2115
2308
|
return () => {
|
|
2116
2309
|
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2117
2310
|
if (subscription) {
|
|
2118
2311
|
const callbacks = subscription.callbacks;
|
|
2119
2312
|
callbacks.delete(callbackId);
|
|
2120
2313
|
if (callbacks.size === 0) {
|
|
2314
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2121
2315
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2122
2316
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
2123
2317
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2129,6 +2323,7 @@ var RebaseWebSocketClient = class {
|
|
|
2129
2323
|
};
|
|
2130
2324
|
}
|
|
2131
2325
|
listenOne(props, onUpdate, onError) {
|
|
2326
|
+
this.ensureConnected();
|
|
2132
2327
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
2133
2328
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
2134
2329
|
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
@@ -2144,9 +2339,12 @@ var RebaseWebSocketClient = class {
|
|
|
2144
2339
|
console.error("Error in row subscription callback:", error);
|
|
2145
2340
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2146
2341
|
}
|
|
2342
|
+
else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
|
|
2147
2343
|
return () => {
|
|
2148
2344
|
callbackMap.delete(callbackId);
|
|
2149
2345
|
if (callbackMap.size === 0) {
|
|
2346
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2347
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2150
2348
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
2151
2349
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
2152
2350
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2168,15 +2366,7 @@ var RebaseWebSocketClient = class {
|
|
|
2168
2366
|
props
|
|
2169
2367
|
});
|
|
2170
2368
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
2171
|
-
this.
|
|
2172
|
-
type: "subscribe_one",
|
|
2173
|
-
payload: {
|
|
2174
|
-
...props,
|
|
2175
|
-
subscriptionId: backendSubscriptionId
|
|
2176
|
-
}
|
|
2177
|
-
}).catch((error) => {
|
|
2178
|
-
if (onError) onError(error);
|
|
2179
|
-
});
|
|
2369
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
2180
2370
|
return () => {
|
|
2181
2371
|
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2182
2372
|
if (subscription) {
|
|
@@ -2194,6 +2384,157 @@ var RebaseWebSocketClient = class {
|
|
|
2194
2384
|
};
|
|
2195
2385
|
}
|
|
2196
2386
|
/**
|
|
2387
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
2388
|
+
* arm its watchdog.
|
|
2389
|
+
*
|
|
2390
|
+
* Every path that registers a collection subscription goes through here, so
|
|
2391
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
2392
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
2393
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
2394
|
+
*/
|
|
2395
|
+
sendCollectionSubscribe(subscriptionKey) {
|
|
2396
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2397
|
+
if (!subscription) return;
|
|
2398
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2399
|
+
subscription.subscribeInFlight = true;
|
|
2400
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2401
|
+
subscription.subscribeTimeout = void 0;
|
|
2402
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
2403
|
+
this.sendMessage({
|
|
2404
|
+
type: "subscribe_collection",
|
|
2405
|
+
payload: {
|
|
2406
|
+
...subscription.props,
|
|
2407
|
+
subscriptionId: backendSubscriptionId
|
|
2408
|
+
}
|
|
2409
|
+
}).catch((error) => {
|
|
2410
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
2411
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2412
|
+
this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
2416
|
+
sendEntitySubscribe(subscriptionKey) {
|
|
2417
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2418
|
+
if (!subscription) return;
|
|
2419
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2420
|
+
subscription.subscribeInFlight = true;
|
|
2421
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2422
|
+
subscription.subscribeTimeout = void 0;
|
|
2423
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
2424
|
+
this.sendMessage({
|
|
2425
|
+
type: "subscribe_one",
|
|
2426
|
+
payload: {
|
|
2427
|
+
...subscription.props,
|
|
2428
|
+
subscriptionId: backendSubscriptionId
|
|
2429
|
+
}
|
|
2430
|
+
}).catch((error) => {
|
|
2431
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
2432
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2433
|
+
this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
2438
|
+
*
|
|
2439
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
2440
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
2441
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
2442
|
+
* silently attaching to a dead one.
|
|
2443
|
+
*/
|
|
2444
|
+
failCollectionSubscription(subscriptionKey, error) {
|
|
2445
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2446
|
+
if (!subscription) return;
|
|
2447
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2448
|
+
subscription.subscribeInFlight = false;
|
|
2449
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2450
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
2451
|
+
subscription.callbacks.forEach((callback) => {
|
|
2452
|
+
if (callback.onError) try {
|
|
2453
|
+
callback.onError(error);
|
|
2454
|
+
} catch (callbackError) {
|
|
2455
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
2460
|
+
failEntitySubscription(subscriptionKey, error) {
|
|
2461
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2462
|
+
if (!subscription) return;
|
|
2463
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2464
|
+
subscription.subscribeInFlight = false;
|
|
2465
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
2466
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
2467
|
+
subscription.callbacks.forEach((callback) => {
|
|
2468
|
+
if (callback.onError) try {
|
|
2469
|
+
callback.onError(error);
|
|
2470
|
+
} catch (callbackError) {
|
|
2471
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
2477
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
2478
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
2479
|
+
*/
|
|
2480
|
+
suspendSubscribeWatchdogs() {
|
|
2481
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
2482
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
2483
|
+
sub.subscribeTimeout = void 0;
|
|
2484
|
+
sub.subscribeInFlight = false;
|
|
2485
|
+
}
|
|
2486
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
2487
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
2488
|
+
sub.subscribeTimeout = void 0;
|
|
2489
|
+
sub.subscribeInFlight = false;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
2494
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
2495
|
+
* request time, so without this they would have no timeout at all.
|
|
2496
|
+
*/
|
|
2497
|
+
armPendingSubscribeWatchdogs() {
|
|
2498
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
2499
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
2500
|
+
}
|
|
2501
|
+
sendCollectionSubscribeWatchdog(subscriptionKey) {
|
|
2502
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2503
|
+
if (!subscription) return;
|
|
2504
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2505
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
2506
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
2507
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2508
|
+
if (!current.subscribeInFlight) return;
|
|
2509
|
+
this.failCollectionSubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
2510
|
+
}, this.subscriptionTimeoutMs);
|
|
2511
|
+
}
|
|
2512
|
+
sendEntitySubscribeWatchdog(subscriptionKey) {
|
|
2513
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2514
|
+
if (!subscription) return;
|
|
2515
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2516
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
2517
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
2518
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2519
|
+
if (!current.subscribeInFlight) return;
|
|
2520
|
+
this.failEntitySubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
2521
|
+
}, this.subscriptionTimeoutMs);
|
|
2522
|
+
}
|
|
2523
|
+
/**
|
|
2524
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
2525
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
2526
|
+
*/
|
|
2527
|
+
failAllPendingSubscriptions(error) {
|
|
2528
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
2529
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
2530
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
2531
|
+
}
|
|
2532
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
2533
|
+
const sub = this.singleSubscriptions.get(key);
|
|
2534
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2197
2538
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
2198
2539
|
* The server wipes subscription state when a client disconnects, so
|
|
2199
2540
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -2206,15 +2547,7 @@ var RebaseWebSocketClient = class {
|
|
|
2206
2547
|
sub.backendSubscriptionId = newBackendId;
|
|
2207
2548
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
2208
2549
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
2209
|
-
this.
|
|
2210
|
-
type: "subscribe_collection",
|
|
2211
|
-
payload: {
|
|
2212
|
-
...sub.props,
|
|
2213
|
-
subscriptionId: newBackendId
|
|
2214
|
-
}
|
|
2215
|
-
}).catch((error) => {
|
|
2216
|
-
console.error("[WS] Failed to re-subscribe collection:", key, error);
|
|
2217
|
-
});
|
|
2550
|
+
this.sendCollectionSubscribe(key);
|
|
2218
2551
|
}
|
|
2219
2552
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
2220
2553
|
const oldBackendId = sub.backendSubscriptionId;
|
|
@@ -2222,15 +2555,7 @@ var RebaseWebSocketClient = class {
|
|
|
2222
2555
|
sub.backendSubscriptionId = newBackendId;
|
|
2223
2556
|
this.backendToEntityKey.delete(oldBackendId);
|
|
2224
2557
|
this.backendToEntityKey.set(newBackendId, key);
|
|
2225
|
-
this.
|
|
2226
|
-
type: "subscribe_one",
|
|
2227
|
-
payload: {
|
|
2228
|
-
...sub.props,
|
|
2229
|
-
subscriptionId: newBackendId
|
|
2230
|
-
}
|
|
2231
|
-
}).catch((error) => {
|
|
2232
|
-
console.error("[WS] Failed to re-subscribe row:", key, error);
|
|
2233
|
-
});
|
|
2558
|
+
this.sendEntitySubscribe(key);
|
|
2234
2559
|
}
|
|
2235
2560
|
}
|
|
2236
2561
|
createCollectionSubscriptionKey(props) {
|
|
@@ -2257,6 +2582,379 @@ var RebaseWebSocketClient = class {
|
|
|
2257
2582
|
}
|
|
2258
2583
|
};
|
|
2259
2584
|
//#endregion
|
|
2585
|
+
//#region src/realtime-channel.ts
|
|
2586
|
+
/**
|
|
2587
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
2588
|
+
*
|
|
2589
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
2590
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
2591
|
+
*/
|
|
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;
|
|
2601
|
+
var RebaseRealtimeChannel = class {
|
|
2602
|
+
name;
|
|
2603
|
+
transport;
|
|
2604
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
2605
|
+
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
2606
|
+
unsubscribers = [];
|
|
2607
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
2608
|
+
presences = {};
|
|
2609
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
2610
|
+
trackedState = null;
|
|
2611
|
+
heartbeat = null;
|
|
2612
|
+
joined = false;
|
|
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 = {}) {
|
|
2653
|
+
this.name = name;
|
|
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();
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Join the channel and ask for the current roster.
|
|
2673
|
+
*
|
|
2674
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
2675
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
2676
|
+
* before there is anything to send.
|
|
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
|
+
}
|
|
2699
|
+
async join() {
|
|
2700
|
+
if (this.joined) return;
|
|
2701
|
+
this.joined = true;
|
|
2702
|
+
this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
|
|
2703
|
+
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
2704
|
+
this.rejoin();
|
|
2705
|
+
}));
|
|
2706
|
+
await this.send("join_channel");
|
|
2707
|
+
await this.send("presence_state");
|
|
2708
|
+
if (this.wantsHistory) await this.requestHistory();
|
|
2709
|
+
}
|
|
2710
|
+
async rejoin() {
|
|
2711
|
+
try {
|
|
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();
|
|
2716
|
+
} catch {}
|
|
2717
|
+
}
|
|
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
|
+
/**
|
|
2760
|
+
* Publish this client's presence state, and keep publishing it.
|
|
2761
|
+
*
|
|
2762
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
2763
|
+
* which is how you update e.g. a cursor position.
|
|
2764
|
+
*/
|
|
2765
|
+
async track(state) {
|
|
2766
|
+
await this.join();
|
|
2767
|
+
this.trackedState = state;
|
|
2768
|
+
await this.send("presence_track", { state });
|
|
2769
|
+
if (!this.heartbeat) {
|
|
2770
|
+
this.heartbeat = setInterval(() => {
|
|
2771
|
+
if (!this.trackedState) return;
|
|
2772
|
+
this.send("presence_track", { state: this.trackedState }).catch(() => {});
|
|
2773
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
2774
|
+
this.heartbeat.unref?.();
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
2778
|
+
async untrack() {
|
|
2779
|
+
this.stopHeartbeat();
|
|
2780
|
+
this.trackedState = null;
|
|
2781
|
+
if (this.joined) await this.send("presence_untrack");
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
2785
|
+
* known, then on every change.
|
|
2786
|
+
*/
|
|
2787
|
+
onPresence(handler) {
|
|
2788
|
+
this.presenceHandlers.add(handler);
|
|
2789
|
+
this.join();
|
|
2790
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
2791
|
+
return () => this.presenceHandlers.delete(handler);
|
|
2792
|
+
}
|
|
2793
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
2794
|
+
async broadcast(event, payload) {
|
|
2795
|
+
await this.join();
|
|
2796
|
+
await this.send("broadcast", {
|
|
2797
|
+
event,
|
|
2798
|
+
payload
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
onBroadcast(eventOrHandler, maybeHandler) {
|
|
2802
|
+
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
2803
|
+
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
2804
|
+
} : eventOrHandler;
|
|
2805
|
+
this.broadcastHandlers.add(wrapped);
|
|
2806
|
+
this.join();
|
|
2807
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
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
|
+
}
|
|
2837
|
+
/** Leave the channel and release every listener and timer. */
|
|
2838
|
+
async leave() {
|
|
2839
|
+
this.stopHeartbeat();
|
|
2840
|
+
this.trackedState = null;
|
|
2841
|
+
this.presences = {};
|
|
2842
|
+
this.presenceHandlers.clear();
|
|
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
|
+
});
|
|
2855
|
+
for (const off of this.unsubscribers) off();
|
|
2856
|
+
this.unsubscribers = [];
|
|
2857
|
+
if (this.joined) {
|
|
2858
|
+
this.joined = false;
|
|
2859
|
+
await this.send("leave_channel");
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
stopHeartbeat() {
|
|
2863
|
+
if (this.heartbeat) {
|
|
2864
|
+
clearInterval(this.heartbeat);
|
|
2865
|
+
this.heartbeat = null;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
2869
|
+
handle(message) {
|
|
2870
|
+
switch (message.type) {
|
|
2871
|
+
case "presence_state":
|
|
2872
|
+
this.presences = message.presences ?? {};
|
|
2873
|
+
this.emitPresence();
|
|
2874
|
+
break;
|
|
2875
|
+
case "presence_diff": {
|
|
2876
|
+
const joins = message.joins ?? {};
|
|
2877
|
+
const leaves = message.leaves ?? {};
|
|
2878
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
2879
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
2880
|
+
this.emitPresence({
|
|
2881
|
+
joins,
|
|
2882
|
+
leaves
|
|
2883
|
+
});
|
|
2884
|
+
break;
|
|
2885
|
+
}
|
|
2886
|
+
case "broadcast": {
|
|
2887
|
+
const seq = typeof message.seq === "number" ? message.seq : void 0;
|
|
2888
|
+
const event = {
|
|
2889
|
+
event: message.event,
|
|
2890
|
+
payload: message.payload,
|
|
2891
|
+
...seq !== void 0 ? { seq } : {}
|
|
2892
|
+
};
|
|
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();
|
|
2931
|
+
break;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
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
|
+
}
|
|
2952
|
+
emitPresence(diff) {
|
|
2953
|
+
const snapshot = { ...this.presences };
|
|
2954
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
//#endregion
|
|
2260
2958
|
//#region src/index.ts
|
|
2261
2959
|
/**
|
|
2262
2960
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
@@ -2284,6 +2982,7 @@ function createRebaseClient(options) {
|
|
|
2284
2982
|
const auth = createAuth(transport, options.auth);
|
|
2285
2983
|
const admin = createAdmin(transport, options.admin);
|
|
2286
2984
|
const cron = createCron(transport, options.cron);
|
|
2985
|
+
const backups = createBackups(transport);
|
|
2287
2986
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
2288
2987
|
const storage = createStorage(transport);
|
|
2289
2988
|
const functions = createFunctionsClient(transport);
|
|
@@ -2304,8 +3003,10 @@ function createRebaseClient(options) {
|
|
|
2304
3003
|
});
|
|
2305
3004
|
return storageSourcesPromise;
|
|
2306
3005
|
};
|
|
2307
|
-
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
3006
|
+
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
2308
3007
|
let ws;
|
|
3008
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
3009
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
2309
3010
|
if (resolvedWsUrl) {
|
|
2310
3011
|
ws = new RebaseWebSocketClient({
|
|
2311
3012
|
websocketUrl: resolvedWsUrl,
|
|
@@ -2329,7 +3030,7 @@ function createRebaseClient(options) {
|
|
|
2329
3030
|
if (!ws) return;
|
|
2330
3031
|
if (event === "SIGNED_OUT") ws.disconnect();
|
|
2331
3032
|
else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
2332
|
-
if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
|
|
3033
|
+
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
2333
3034
|
}
|
|
2334
3035
|
});
|
|
2335
3036
|
}
|
|
@@ -2408,6 +3109,7 @@ function createRebaseClient(options) {
|
|
|
2408
3109
|
auth,
|
|
2409
3110
|
admin,
|
|
2410
3111
|
cron,
|
|
3112
|
+
backups,
|
|
2411
3113
|
apiKeys,
|
|
2412
3114
|
functions,
|
|
2413
3115
|
storage,
|
|
@@ -2415,6 +3117,36 @@ function createRebaseClient(options) {
|
|
|
2415
3117
|
createStorageSource,
|
|
2416
3118
|
fetchStorageSources,
|
|
2417
3119
|
ws,
|
|
3120
|
+
realtime: {
|
|
3121
|
+
/**
|
|
3122
|
+
* Join a broadcast/presence channel.
|
|
3123
|
+
*
|
|
3124
|
+
* Repeated calls with the same name return the same channel, so
|
|
3125
|
+
* separate components can attach handlers without each opening its
|
|
3126
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
3127
|
+
* cut off the others.
|
|
3128
|
+
*/
|
|
3129
|
+
channel: (name, options) => {
|
|
3130
|
+
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
3131
|
+
let existing = realtimeChannels.get(name);
|
|
3132
|
+
if (!existing) {
|
|
3133
|
+
existing = new RebaseRealtimeChannel(name, ws, options);
|
|
3134
|
+
realtimeChannels.set(name, existing);
|
|
3135
|
+
} else if (options?.history) existing.enableHistory();
|
|
3136
|
+
return existing;
|
|
3137
|
+
} },
|
|
3138
|
+
/**
|
|
3139
|
+
* Release the realtime socket and its reconnect timer.
|
|
3140
|
+
*
|
|
3141
|
+
* Until this returns, the open socket keeps the Node event loop alive
|
|
3142
|
+
* and the process will not exit on its own. Safe to call when realtime
|
|
3143
|
+
* was never started, and safe to call twice.
|
|
3144
|
+
*/
|
|
3145
|
+
close: () => {
|
|
3146
|
+
for (const channel of realtimeChannels.values()) channel.leave();
|
|
3147
|
+
realtimeChannels.clear();
|
|
3148
|
+
ws?.disconnect(true);
|
|
3149
|
+
},
|
|
2418
3150
|
setToken: transport.setToken,
|
|
2419
3151
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
2420
3152
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
@@ -2433,6 +3165,6 @@ function createRebaseClient(options) {
|
|
|
2433
3165
|
};
|
|
2434
3166
|
}
|
|
2435
3167
|
//#endregion
|
|
2436
|
-
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseWebSocketClient, and, cond, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
3168
|
+
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
2437
3169
|
|
|
2438
3170
|
//# sourceMappingURL=index.es.js.map
|