@rebasepro/client 0.9.0 → 0.9.1-canary.09aaf62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/admin.d.ts +1 -0
- package/dist/backups.d.ts +13 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +516 -72
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +89 -0
- package/dist/transport.d.ts +34 -0
- package/dist/websocket.d.ts +68 -2
- package/package.json +8 -9
- package/src/admin.ts +1 -1
- package/src/api-keys.ts +1 -1
- package/src/backups.ts +40 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +66 -2
- package/src/realtime-channel.test.ts +241 -0
- package/src/realtime-channel.ts +238 -0
- package/src/realtime-optout.test.ts +119 -0
- package/src/realtime-row-identity.test.ts +254 -0
- package/src/sdk_query_builder.ts +4 -1
- package/src/transport.ts +34 -0
- package/src/websocket.ts +403 -72
- package/dist/collection.test.d.ts +0 -1
- package/dist/cron.test.d.ts +0 -1
- package/dist/data-proxy.test.d.ts +0 -1
- package/dist/index.umd.js +0 -2484
- package/dist/index.umd.js.map +0 -1
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) {
|
|
@@ -869,6 +869,32 @@ function createCron(transport, options) {
|
|
|
869
869
|
};
|
|
870
870
|
}
|
|
871
871
|
//#endregion
|
|
872
|
+
//#region src/backups.ts
|
|
873
|
+
function createBackups(transport, options) {
|
|
874
|
+
const backupsPath = options?.backupsPath || "/admin/backups";
|
|
875
|
+
async function list() {
|
|
876
|
+
return transport.request(backupsPath, { method: "GET" });
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Download a backup's bytes. Uses an authenticated fetch (not the JSON
|
|
880
|
+
* transport) so the octet-stream response comes back as a Blob.
|
|
881
|
+
*/
|
|
882
|
+
async function download(key) {
|
|
883
|
+
const token = await transport.resolveToken();
|
|
884
|
+
const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
|
|
885
|
+
const res = await fetch(url, {
|
|
886
|
+
method: "GET",
|
|
887
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
888
|
+
});
|
|
889
|
+
if (!res.ok) throw new Error(`Failed to download backup (${res.status})`);
|
|
890
|
+
return res.blob();
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
list,
|
|
894
|
+
download
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
872
898
|
//#region src/api-keys.ts
|
|
873
899
|
/**
|
|
874
900
|
* Creates a client for managing API keys via the admin routes.
|
|
@@ -1009,7 +1035,7 @@ var SDKQueryBuilder = class {
|
|
|
1009
1035
|
* Listen to realtime updates matching this query.
|
|
1010
1036
|
*/
|
|
1011
1037
|
listen(onUpdate, onError) {
|
|
1012
|
-
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
1038
|
+
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
1039
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
1014
1040
|
}
|
|
1015
1041
|
};
|
|
@@ -1044,6 +1070,17 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1044
1070
|
body: JSON.stringify(body)
|
|
1045
1071
|
});
|
|
1046
1072
|
},
|
|
1073
|
+
async createMany(data, options) {
|
|
1074
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
1075
|
+
if (data.length === 0) return [];
|
|
1076
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
body: JSON.stringify({
|
|
1079
|
+
rows: data,
|
|
1080
|
+
...options?.upsert ? { upsert: true } : {}
|
|
1081
|
+
})
|
|
1082
|
+
})).data || [];
|
|
1083
|
+
},
|
|
1047
1084
|
async update(id, data) {
|
|
1048
1085
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1049
1086
|
method: "PUT",
|
|
@@ -1360,9 +1397,11 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
1360
1397
|
function extractMessageError(message) {
|
|
1361
1398
|
const payload = message.payload;
|
|
1362
1399
|
const errPayload = payload?.error;
|
|
1400
|
+
const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
|
|
1401
|
+
const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
|
|
1363
1402
|
return {
|
|
1364
|
-
errorMessage: typeof
|
|
1365
|
-
errorCode
|
|
1403
|
+
errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
|
|
1404
|
+
errorCode
|
|
1366
1405
|
};
|
|
1367
1406
|
}
|
|
1368
1407
|
/**
|
|
@@ -1371,7 +1410,7 @@ function extractMessageError(message) {
|
|
|
1371
1410
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
1372
1411
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
1373
1412
|
* `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
|
|
1374
|
-
* package root only because the `@rebasepro/client-
|
|
1413
|
+
* package root only because the `@rebasepro/client-postgres` driver
|
|
1375
1414
|
* instantiates it directly; its surface may change without a major bump.
|
|
1376
1415
|
*/
|
|
1377
1416
|
var RebaseWebSocketClient = class {
|
|
@@ -1380,6 +1419,23 @@ var RebaseWebSocketClient = class {
|
|
|
1380
1419
|
getAuthToken;
|
|
1381
1420
|
subscriptions = /* @__PURE__ */ new Map();
|
|
1382
1421
|
listeners = /* @__PURE__ */ new Map();
|
|
1422
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
1423
|
+
channelHandlers = /* @__PURE__ */ new Map();
|
|
1424
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
1425
|
+
onChannelMessage(channel, handler) {
|
|
1426
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
1427
|
+
this.channelHandlers.get(channel).add(handler);
|
|
1428
|
+
return () => {
|
|
1429
|
+
const handlers = this.channelHandlers.get(channel);
|
|
1430
|
+
if (!handlers) return;
|
|
1431
|
+
handlers.delete(handler);
|
|
1432
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
1436
|
+
onReconnect(handler) {
|
|
1437
|
+
return this.on("reconnect", handler);
|
|
1438
|
+
}
|
|
1383
1439
|
on(event, cb) {
|
|
1384
1440
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
1385
1441
|
this.listeners.get(event).add(cb);
|
|
@@ -1398,6 +1454,7 @@ var RebaseWebSocketClient = class {
|
|
|
1398
1454
|
isConnected = false;
|
|
1399
1455
|
messageQueue = [];
|
|
1400
1456
|
requestTimeoutMs = 3e4;
|
|
1457
|
+
subscriptionTimeoutMs = 3e4;
|
|
1401
1458
|
reconnectTimeout = null;
|
|
1402
1459
|
isAuthenticated = false;
|
|
1403
1460
|
authPromise = null;
|
|
@@ -1503,6 +1560,7 @@ var RebaseWebSocketClient = class {
|
|
|
1503
1560
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
1504
1561
|
this.processMessageQueue();
|
|
1505
1562
|
if (wasReconnect) this.resubscribeAll();
|
|
1563
|
+
this.armPendingSubscribeWatchdogs();
|
|
1506
1564
|
};
|
|
1507
1565
|
this.ws.onmessage = (event) => {
|
|
1508
1566
|
try {
|
|
@@ -1517,6 +1575,7 @@ var RebaseWebSocketClient = class {
|
|
|
1517
1575
|
this.isConnected = false;
|
|
1518
1576
|
this.isAuthenticated = false;
|
|
1519
1577
|
this.authPromise = null;
|
|
1578
|
+
this.suspendSubscribeWatchdogs();
|
|
1520
1579
|
this.emit("disconnect");
|
|
1521
1580
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
1522
1581
|
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
@@ -1548,6 +1607,7 @@ var RebaseWebSocketClient = class {
|
|
|
1548
1607
|
attemptReconnect() {
|
|
1549
1608
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
1550
1609
|
console.error("Max reconnection attempts reached");
|
|
1610
|
+
this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
|
|
1551
1611
|
return;
|
|
1552
1612
|
}
|
|
1553
1613
|
this.reconnectAttempts++;
|
|
@@ -1602,29 +1662,18 @@ var RebaseWebSocketClient = class {
|
|
|
1602
1662
|
subscription.backendSubscriptionId = newBackendId;
|
|
1603
1663
|
backendKeyMap.delete(oldBackendId);
|
|
1604
1664
|
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
|
-
});
|
|
1665
|
+
if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
|
|
1666
|
+
else this.sendEntitySubscribe(subscriptionKey);
|
|
1667
|
+
return;
|
|
1623
1668
|
}
|
|
1669
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1670
|
+
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1671
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
1672
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
1624
1673
|
}).catch((err) => {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1674
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1675
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
1676
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
1628
1677
|
});
|
|
1629
1678
|
}
|
|
1630
1679
|
handleWebSocketMessage(message) {
|
|
@@ -1653,16 +1702,30 @@ var RebaseWebSocketClient = class {
|
|
|
1653
1702
|
}
|
|
1654
1703
|
return;
|
|
1655
1704
|
}
|
|
1705
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
|
|
1706
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
1707
|
+
if (handlers) for (const handler of [...handlers]) try {
|
|
1708
|
+
handler(message);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
console.error("Error in channel handler:", error);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1656
1714
|
if (subscriptionId && type === "collection_update") {
|
|
1657
1715
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
1658
1716
|
if (subscriptionKey) {
|
|
1659
1717
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1660
1718
|
if (collectionSub) {
|
|
1661
1719
|
const incomingRows = message.rows || [];
|
|
1662
|
-
const
|
|
1720
|
+
const updatePks = message.pks;
|
|
1721
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
1722
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
1663
1723
|
collectionSub.latestData = rows;
|
|
1664
1724
|
collectionSub.lastUpdated = Date.now();
|
|
1665
1725
|
collectionSub.isInitialDataReceived = true;
|
|
1726
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1727
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1728
|
+
collectionSub.subscribeInFlight = false;
|
|
1666
1729
|
collectionSub.callbacks.forEach((callback) => {
|
|
1667
1730
|
try {
|
|
1668
1731
|
callback.onUpdate(rows);
|
|
@@ -1681,12 +1744,14 @@ var RebaseWebSocketClient = class {
|
|
|
1681
1744
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1682
1745
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
1683
1746
|
const patchWireEntity = message.row ?? null;
|
|
1684
|
-
const
|
|
1747
|
+
const patchMessage = message;
|
|
1748
|
+
const patchEntityId = patchMessage.id;
|
|
1749
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
1685
1750
|
const patchRow = patchWireEntity ? patchWireEntity : null;
|
|
1686
1751
|
let updated;
|
|
1687
|
-
if (patchRow === null) updated = collectionSub.latestData.filter((e) =>
|
|
1752
|
+
if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
|
|
1688
1753
|
else {
|
|
1689
|
-
const idx = collectionSub.latestData.findIndex((e) =>
|
|
1754
|
+
const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
|
|
1690
1755
|
if (idx >= 0) {
|
|
1691
1756
|
updated = [...collectionSub.latestData];
|
|
1692
1757
|
updated[idx] = patchRow;
|
|
@@ -1716,6 +1781,9 @@ var RebaseWebSocketClient = class {
|
|
|
1716
1781
|
entitySub.latestData = row;
|
|
1717
1782
|
entitySub.lastUpdated = Date.now();
|
|
1718
1783
|
entitySub.isInitialDataReceived = true;
|
|
1784
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1785
|
+
entitySub.subscribeTimeout = void 0;
|
|
1786
|
+
entitySub.subscribeInFlight = false;
|
|
1719
1787
|
entitySub.callbacks.forEach((callback) => {
|
|
1720
1788
|
try {
|
|
1721
1789
|
callback.onUpdate(row);
|
|
@@ -1737,6 +1805,9 @@ var RebaseWebSocketClient = class {
|
|
|
1737
1805
|
this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
|
|
1738
1806
|
return;
|
|
1739
1807
|
}
|
|
1808
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1809
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1810
|
+
collectionSub.subscribeInFlight = false;
|
|
1740
1811
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1741
1812
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1742
1813
|
collectionSub.callbacks.forEach((callback) => {
|
|
@@ -1753,6 +1824,9 @@ var RebaseWebSocketClient = class {
|
|
|
1753
1824
|
this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
|
|
1754
1825
|
return;
|
|
1755
1826
|
}
|
|
1827
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1828
|
+
entitySub.subscribeTimeout = void 0;
|
|
1829
|
+
entitySub.subscribeInFlight = false;
|
|
1756
1830
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1757
1831
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1758
1832
|
entitySub.callbacks.forEach((callback) => {
|
|
@@ -1825,6 +1899,10 @@ var RebaseWebSocketClient = class {
|
|
|
1825
1899
|
throw error;
|
|
1826
1900
|
}
|
|
1827
1901
|
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
1904
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
1905
|
+
*/
|
|
1828
1906
|
sendMessage(message) {
|
|
1829
1907
|
const queuedMsg = message;
|
|
1830
1908
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
@@ -2036,17 +2114,39 @@ var RebaseWebSocketClient = class {
|
|
|
2036
2114
|
return val;
|
|
2037
2115
|
}
|
|
2038
2116
|
/**
|
|
2117
|
+
* The address of a row, for matching it against another copy of itself.
|
|
2118
|
+
*
|
|
2119
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
2120
|
+
* from the key columns the server named — including the ordinary case where
|
|
2121
|
+
* that key is `id`, which the server reports like any other.
|
|
2122
|
+
*
|
|
2123
|
+
* Undefined when there are no keys, which means the server could not
|
|
2124
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
2125
|
+
* column called `id` would be inventing an identity for a table that has
|
|
2126
|
+
* none.
|
|
2127
|
+
*/
|
|
2128
|
+
rowAddress(row, pks) {
|
|
2129
|
+
if (!pks || pks.length === 0) return void 0;
|
|
2130
|
+
const address = buildCompositeId(row, pks);
|
|
2131
|
+
if (!address || address.split(COMPOSITE_ID_SEPARATOR).every((part) => part === "")) return void 0;
|
|
2132
|
+
return address;
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2039
2135
|
* Merge incoming rows with cached data, preserving cached references
|
|
2040
2136
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
2041
2137
|
* React re-renders when the server refetches all rows but most
|
|
2042
2138
|
* haven't actually changed.
|
|
2043
2139
|
*/
|
|
2044
|
-
mergeRows(cached, incoming) {
|
|
2140
|
+
mergeRows(cached, incoming, pks) {
|
|
2045
2141
|
if (!cached || cached.length === 0) return incoming;
|
|
2046
2142
|
const cachedById = /* @__PURE__ */ new Map();
|
|
2047
|
-
for (const row of cached)
|
|
2143
|
+
for (const row of cached) {
|
|
2144
|
+
const address = this.rowAddress(row, pks);
|
|
2145
|
+
if (address !== void 0) cachedById.set(address, row);
|
|
2146
|
+
}
|
|
2048
2147
|
return incoming.map((incomingRow) => {
|
|
2049
|
-
const
|
|
2148
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
2149
|
+
const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
|
|
2050
2150
|
if (!cachedRow) return incomingRow;
|
|
2051
2151
|
const normCached = this.normalizeForComparison(cachedRow);
|
|
2052
2152
|
const normIncoming = this.normalizeForComparison(incomingRow);
|
|
@@ -2058,7 +2158,7 @@ var RebaseWebSocketClient = class {
|
|
|
2058
2158
|
cached: normCached[key],
|
|
2059
2159
|
incoming: normIncoming[key]
|
|
2060
2160
|
};
|
|
2061
|
-
console.debug(`[RebaseWS] Row ${
|
|
2161
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
2062
2162
|
}
|
|
2063
2163
|
return incomingRow;
|
|
2064
2164
|
});
|
|
@@ -2079,9 +2179,12 @@ var RebaseWebSocketClient = class {
|
|
|
2079
2179
|
console.error("Error in collection subscription callback:", error);
|
|
2080
2180
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2081
2181
|
}
|
|
2182
|
+
else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
|
|
2082
2183
|
return () => {
|
|
2083
2184
|
callbackMap.delete(callbackId);
|
|
2084
2185
|
if (callbackMap.size === 0) {
|
|
2186
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2187
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2085
2188
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2086
2189
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
2087
2190
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2103,21 +2206,14 @@ var RebaseWebSocketClient = class {
|
|
|
2103
2206
|
props
|
|
2104
2207
|
});
|
|
2105
2208
|
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
|
-
});
|
|
2209
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
2115
2210
|
return () => {
|
|
2116
2211
|
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2117
2212
|
if (subscription) {
|
|
2118
2213
|
const callbacks = subscription.callbacks;
|
|
2119
2214
|
callbacks.delete(callbackId);
|
|
2120
2215
|
if (callbacks.size === 0) {
|
|
2216
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2121
2217
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2122
2218
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
2123
2219
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2144,9 +2240,12 @@ var RebaseWebSocketClient = class {
|
|
|
2144
2240
|
console.error("Error in row subscription callback:", error);
|
|
2145
2241
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2146
2242
|
}
|
|
2243
|
+
else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
|
|
2147
2244
|
return () => {
|
|
2148
2245
|
callbackMap.delete(callbackId);
|
|
2149
2246
|
if (callbackMap.size === 0) {
|
|
2247
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2248
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2150
2249
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
2151
2250
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
2152
2251
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2168,15 +2267,7 @@ var RebaseWebSocketClient = class {
|
|
|
2168
2267
|
props
|
|
2169
2268
|
});
|
|
2170
2269
|
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
|
-
});
|
|
2270
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
2180
2271
|
return () => {
|
|
2181
2272
|
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2182
2273
|
if (subscription) {
|
|
@@ -2194,6 +2285,157 @@ var RebaseWebSocketClient = class {
|
|
|
2194
2285
|
};
|
|
2195
2286
|
}
|
|
2196
2287
|
/**
|
|
2288
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
2289
|
+
* arm its watchdog.
|
|
2290
|
+
*
|
|
2291
|
+
* Every path that registers a collection subscription goes through here, so
|
|
2292
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
2293
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
2294
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
2295
|
+
*/
|
|
2296
|
+
sendCollectionSubscribe(subscriptionKey) {
|
|
2297
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2298
|
+
if (!subscription) return;
|
|
2299
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2300
|
+
subscription.subscribeInFlight = true;
|
|
2301
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2302
|
+
subscription.subscribeTimeout = void 0;
|
|
2303
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
2304
|
+
this.sendMessage({
|
|
2305
|
+
type: "subscribe_collection",
|
|
2306
|
+
payload: {
|
|
2307
|
+
...subscription.props,
|
|
2308
|
+
subscriptionId: backendSubscriptionId
|
|
2309
|
+
}
|
|
2310
|
+
}).catch((error) => {
|
|
2311
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
2312
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2313
|
+
this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
2317
|
+
sendEntitySubscribe(subscriptionKey) {
|
|
2318
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2319
|
+
if (!subscription) return;
|
|
2320
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2321
|
+
subscription.subscribeInFlight = true;
|
|
2322
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2323
|
+
subscription.subscribeTimeout = void 0;
|
|
2324
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
2325
|
+
this.sendMessage({
|
|
2326
|
+
type: "subscribe_one",
|
|
2327
|
+
payload: {
|
|
2328
|
+
...subscription.props,
|
|
2329
|
+
subscriptionId: backendSubscriptionId
|
|
2330
|
+
}
|
|
2331
|
+
}).catch((error) => {
|
|
2332
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
2333
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2334
|
+
this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
2339
|
+
*
|
|
2340
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
2341
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
2342
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
2343
|
+
* silently attaching to a dead one.
|
|
2344
|
+
*/
|
|
2345
|
+
failCollectionSubscription(subscriptionKey, error) {
|
|
2346
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2347
|
+
if (!subscription) return;
|
|
2348
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2349
|
+
subscription.subscribeInFlight = false;
|
|
2350
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2351
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
2352
|
+
subscription.callbacks.forEach((callback) => {
|
|
2353
|
+
if (callback.onError) try {
|
|
2354
|
+
callback.onError(error);
|
|
2355
|
+
} catch (callbackError) {
|
|
2356
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
2361
|
+
failEntitySubscription(subscriptionKey, error) {
|
|
2362
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2363
|
+
if (!subscription) return;
|
|
2364
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2365
|
+
subscription.subscribeInFlight = false;
|
|
2366
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
2367
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
2368
|
+
subscription.callbacks.forEach((callback) => {
|
|
2369
|
+
if (callback.onError) try {
|
|
2370
|
+
callback.onError(error);
|
|
2371
|
+
} catch (callbackError) {
|
|
2372
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
2378
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
2379
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
2380
|
+
*/
|
|
2381
|
+
suspendSubscribeWatchdogs() {
|
|
2382
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
2383
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
2384
|
+
sub.subscribeTimeout = void 0;
|
|
2385
|
+
sub.subscribeInFlight = false;
|
|
2386
|
+
}
|
|
2387
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
2388
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
2389
|
+
sub.subscribeTimeout = void 0;
|
|
2390
|
+
sub.subscribeInFlight = false;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
2395
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
2396
|
+
* request time, so without this they would have no timeout at all.
|
|
2397
|
+
*/
|
|
2398
|
+
armPendingSubscribeWatchdogs() {
|
|
2399
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
2400
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
2401
|
+
}
|
|
2402
|
+
sendCollectionSubscribeWatchdog(subscriptionKey) {
|
|
2403
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2404
|
+
if (!subscription) return;
|
|
2405
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2406
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
2407
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
2408
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2409
|
+
if (!current.subscribeInFlight) return;
|
|
2410
|
+
this.failCollectionSubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
2411
|
+
}, this.subscriptionTimeoutMs);
|
|
2412
|
+
}
|
|
2413
|
+
sendEntitySubscribeWatchdog(subscriptionKey) {
|
|
2414
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2415
|
+
if (!subscription) return;
|
|
2416
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
2417
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
2418
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
2419
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
2420
|
+
if (!current.subscribeInFlight) return;
|
|
2421
|
+
this.failEntitySubscription(subscriptionKey, new RebaseApiError$1("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
2422
|
+
}, this.subscriptionTimeoutMs);
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
2426
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
2427
|
+
*/
|
|
2428
|
+
failAllPendingSubscriptions(error) {
|
|
2429
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
2430
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
2431
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
2432
|
+
}
|
|
2433
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
2434
|
+
const sub = this.singleSubscriptions.get(key);
|
|
2435
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2197
2439
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
2198
2440
|
* The server wipes subscription state when a client disconnects, so
|
|
2199
2441
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -2206,15 +2448,7 @@ var RebaseWebSocketClient = class {
|
|
|
2206
2448
|
sub.backendSubscriptionId = newBackendId;
|
|
2207
2449
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
2208
2450
|
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
|
-
});
|
|
2451
|
+
this.sendCollectionSubscribe(key);
|
|
2218
2452
|
}
|
|
2219
2453
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
2220
2454
|
const oldBackendId = sub.backendSubscriptionId;
|
|
@@ -2222,15 +2456,7 @@ var RebaseWebSocketClient = class {
|
|
|
2222
2456
|
sub.backendSubscriptionId = newBackendId;
|
|
2223
2457
|
this.backendToEntityKey.delete(oldBackendId);
|
|
2224
2458
|
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
|
-
});
|
|
2459
|
+
this.sendEntitySubscribe(key);
|
|
2234
2460
|
}
|
|
2235
2461
|
}
|
|
2236
2462
|
createCollectionSubscriptionKey(props) {
|
|
@@ -2257,6 +2483,190 @@ var RebaseWebSocketClient = class {
|
|
|
2257
2483
|
}
|
|
2258
2484
|
};
|
|
2259
2485
|
//#endregion
|
|
2486
|
+
//#region src/realtime-channel.ts
|
|
2487
|
+
/**
|
|
2488
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
2489
|
+
*
|
|
2490
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
2491
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
2492
|
+
*/
|
|
2493
|
+
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
2494
|
+
var RebaseRealtimeChannel = class {
|
|
2495
|
+
name;
|
|
2496
|
+
transport;
|
|
2497
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
2498
|
+
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
2499
|
+
unsubscribers = [];
|
|
2500
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
2501
|
+
presences = {};
|
|
2502
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
2503
|
+
trackedState = null;
|
|
2504
|
+
heartbeat = null;
|
|
2505
|
+
joined = false;
|
|
2506
|
+
constructor(name, transport) {
|
|
2507
|
+
this.name = name;
|
|
2508
|
+
this.transport = transport;
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* Join the channel and ask for the current roster.
|
|
2512
|
+
*
|
|
2513
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
2514
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
2515
|
+
* before there is anything to send.
|
|
2516
|
+
*/
|
|
2517
|
+
async join() {
|
|
2518
|
+
if (this.joined) return;
|
|
2519
|
+
this.joined = true;
|
|
2520
|
+
this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
|
|
2521
|
+
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
2522
|
+
this.rejoin();
|
|
2523
|
+
}));
|
|
2524
|
+
await this.transport.sendMessage({
|
|
2525
|
+
type: "join_channel",
|
|
2526
|
+
channel: this.name
|
|
2527
|
+
});
|
|
2528
|
+
await this.transport.sendMessage({
|
|
2529
|
+
type: "presence_state",
|
|
2530
|
+
channel: this.name
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
async rejoin() {
|
|
2534
|
+
try {
|
|
2535
|
+
await this.transport.sendMessage({
|
|
2536
|
+
type: "join_channel",
|
|
2537
|
+
channel: this.name
|
|
2538
|
+
});
|
|
2539
|
+
await this.transport.sendMessage({
|
|
2540
|
+
type: "presence_state",
|
|
2541
|
+
channel: this.name
|
|
2542
|
+
});
|
|
2543
|
+
if (this.trackedState) await this.transport.sendMessage({
|
|
2544
|
+
type: "presence_track",
|
|
2545
|
+
channel: this.name,
|
|
2546
|
+
state: this.trackedState
|
|
2547
|
+
});
|
|
2548
|
+
} catch {}
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Publish this client's presence state, and keep publishing it.
|
|
2552
|
+
*
|
|
2553
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
2554
|
+
* which is how you update e.g. a cursor position.
|
|
2555
|
+
*/
|
|
2556
|
+
async track(state) {
|
|
2557
|
+
await this.join();
|
|
2558
|
+
this.trackedState = state;
|
|
2559
|
+
await this.transport.sendMessage({
|
|
2560
|
+
type: "presence_track",
|
|
2561
|
+
channel: this.name,
|
|
2562
|
+
state
|
|
2563
|
+
});
|
|
2564
|
+
if (!this.heartbeat) {
|
|
2565
|
+
this.heartbeat = setInterval(() => {
|
|
2566
|
+
if (!this.trackedState) return;
|
|
2567
|
+
this.transport.sendMessage({
|
|
2568
|
+
type: "presence_track",
|
|
2569
|
+
channel: this.name,
|
|
2570
|
+
state: this.trackedState
|
|
2571
|
+
}).catch(() => {});
|
|
2572
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
2573
|
+
this.heartbeat.unref?.();
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
2577
|
+
async untrack() {
|
|
2578
|
+
this.stopHeartbeat();
|
|
2579
|
+
this.trackedState = null;
|
|
2580
|
+
if (this.joined) await this.transport.sendMessage({
|
|
2581
|
+
type: "presence_untrack",
|
|
2582
|
+
channel: this.name
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
2587
|
+
* known, then on every change.
|
|
2588
|
+
*/
|
|
2589
|
+
onPresence(handler) {
|
|
2590
|
+
this.presenceHandlers.add(handler);
|
|
2591
|
+
this.join();
|
|
2592
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
2593
|
+
return () => this.presenceHandlers.delete(handler);
|
|
2594
|
+
}
|
|
2595
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
2596
|
+
async broadcast(event, payload) {
|
|
2597
|
+
await this.join();
|
|
2598
|
+
await this.transport.sendMessage({
|
|
2599
|
+
type: "broadcast",
|
|
2600
|
+
channel: this.name,
|
|
2601
|
+
event,
|
|
2602
|
+
payload
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
onBroadcast(eventOrHandler, maybeHandler) {
|
|
2606
|
+
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
2607
|
+
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
2608
|
+
} : eventOrHandler;
|
|
2609
|
+
this.broadcastHandlers.add(wrapped);
|
|
2610
|
+
this.join();
|
|
2611
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
2612
|
+
}
|
|
2613
|
+
/** Leave the channel and release every listener and timer. */
|
|
2614
|
+
async leave() {
|
|
2615
|
+
this.stopHeartbeat();
|
|
2616
|
+
this.trackedState = null;
|
|
2617
|
+
this.presences = {};
|
|
2618
|
+
this.presenceHandlers.clear();
|
|
2619
|
+
this.broadcastHandlers.clear();
|
|
2620
|
+
for (const off of this.unsubscribers) off();
|
|
2621
|
+
this.unsubscribers = [];
|
|
2622
|
+
if (this.joined) {
|
|
2623
|
+
this.joined = false;
|
|
2624
|
+
await this.transport.sendMessage({
|
|
2625
|
+
type: "leave_channel",
|
|
2626
|
+
channel: this.name
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
stopHeartbeat() {
|
|
2631
|
+
if (this.heartbeat) {
|
|
2632
|
+
clearInterval(this.heartbeat);
|
|
2633
|
+
this.heartbeat = null;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
2637
|
+
handle(message) {
|
|
2638
|
+
switch (message.type) {
|
|
2639
|
+
case "presence_state":
|
|
2640
|
+
this.presences = message.presences ?? {};
|
|
2641
|
+
this.emitPresence();
|
|
2642
|
+
break;
|
|
2643
|
+
case "presence_diff": {
|
|
2644
|
+
const joins = message.joins ?? {};
|
|
2645
|
+
const leaves = message.leaves ?? {};
|
|
2646
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
2647
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
2648
|
+
this.emitPresence({
|
|
2649
|
+
joins,
|
|
2650
|
+
leaves
|
|
2651
|
+
});
|
|
2652
|
+
break;
|
|
2653
|
+
}
|
|
2654
|
+
case "broadcast": {
|
|
2655
|
+
const event = {
|
|
2656
|
+
event: message.event,
|
|
2657
|
+
payload: message.payload
|
|
2658
|
+
};
|
|
2659
|
+
for (const handler of this.broadcastHandlers) handler(event);
|
|
2660
|
+
break;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
emitPresence(diff) {
|
|
2665
|
+
const snapshot = { ...this.presences };
|
|
2666
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
//#endregion
|
|
2260
2670
|
//#region src/index.ts
|
|
2261
2671
|
/**
|
|
2262
2672
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
@@ -2284,6 +2694,7 @@ function createRebaseClient(options) {
|
|
|
2284
2694
|
const auth = createAuth(transport, options.auth);
|
|
2285
2695
|
const admin = createAdmin(transport, options.admin);
|
|
2286
2696
|
const cron = createCron(transport, options.cron);
|
|
2697
|
+
const backups = createBackups(transport);
|
|
2287
2698
|
const apiKeys = createApiKeys(transport, options.apiKeys);
|
|
2288
2699
|
const storage = createStorage(transport);
|
|
2289
2700
|
const functions = createFunctionsClient(transport);
|
|
@@ -2304,8 +2715,10 @@ function createRebaseClient(options) {
|
|
|
2304
2715
|
});
|
|
2305
2716
|
return storageSourcesPromise;
|
|
2306
2717
|
};
|
|
2307
|
-
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
2718
|
+
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
2308
2719
|
let ws;
|
|
2720
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
2721
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
2309
2722
|
if (resolvedWsUrl) {
|
|
2310
2723
|
ws = new RebaseWebSocketClient({
|
|
2311
2724
|
websocketUrl: resolvedWsUrl,
|
|
@@ -2408,6 +2821,7 @@ function createRebaseClient(options) {
|
|
|
2408
2821
|
auth,
|
|
2409
2822
|
admin,
|
|
2410
2823
|
cron,
|
|
2824
|
+
backups,
|
|
2411
2825
|
apiKeys,
|
|
2412
2826
|
functions,
|
|
2413
2827
|
storage,
|
|
@@ -2415,6 +2829,36 @@ function createRebaseClient(options) {
|
|
|
2415
2829
|
createStorageSource,
|
|
2416
2830
|
fetchStorageSources,
|
|
2417
2831
|
ws,
|
|
2832
|
+
realtime: {
|
|
2833
|
+
/**
|
|
2834
|
+
* Join a broadcast/presence channel.
|
|
2835
|
+
*
|
|
2836
|
+
* Repeated calls with the same name return the same channel, so
|
|
2837
|
+
* separate components can attach handlers without each opening its
|
|
2838
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
2839
|
+
* cut off the others.
|
|
2840
|
+
*/
|
|
2841
|
+
channel: (name) => {
|
|
2842
|
+
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
2843
|
+
let existing = realtimeChannels.get(name);
|
|
2844
|
+
if (!existing) {
|
|
2845
|
+
existing = new RebaseRealtimeChannel(name, ws);
|
|
2846
|
+
realtimeChannels.set(name, existing);
|
|
2847
|
+
}
|
|
2848
|
+
return existing;
|
|
2849
|
+
} },
|
|
2850
|
+
/**
|
|
2851
|
+
* Release the realtime socket and its reconnect timer.
|
|
2852
|
+
*
|
|
2853
|
+
* Until this returns, the open socket keeps the Node event loop alive
|
|
2854
|
+
* and the process will not exit on its own. Safe to call when realtime
|
|
2855
|
+
* was never started, and safe to call twice.
|
|
2856
|
+
*/
|
|
2857
|
+
close: () => {
|
|
2858
|
+
for (const channel of realtimeChannels.values()) channel.leave();
|
|
2859
|
+
realtimeChannels.clear();
|
|
2860
|
+
ws?.disconnect();
|
|
2861
|
+
},
|
|
2418
2862
|
setToken: transport.setToken,
|
|
2419
2863
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
2420
2864
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
@@ -2433,6 +2877,6 @@ function createRebaseClient(options) {
|
|
|
2433
2877
|
};
|
|
2434
2878
|
}
|
|
2435
2879
|
//#endregion
|
|
2436
|
-
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseWebSocketClient, and, cond, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
2880
|
+
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
2437
2881
|
|
|
2438
2882
|
//# sourceMappingURL=index.es.js.map
|