@rebasepro/client 0.9.1-canary.fd3754b → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.es.js +799 -95
- 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 +96 -2
- package/package.json +11 -10
- package/src/auth.ts +32 -0
- package/src/collection.ts +16 -0
- package/src/index.ts +105 -2
- 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 +515 -88
- 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 -2513
- 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,
|
|
@@ -1035,7 +1082,7 @@ var SDKQueryBuilder = class {
|
|
|
1035
1082
|
* Listen to realtime updates matching this query.
|
|
1036
1083
|
*/
|
|
1037
1084
|
listen(onUpdate, onError) {
|
|
1038
|
-
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.");
|
|
1039
1086
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
1040
1087
|
}
|
|
1041
1088
|
};
|
|
@@ -1070,6 +1117,17 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1070
1117
|
body: JSON.stringify(body)
|
|
1071
1118
|
});
|
|
1072
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
|
+
},
|
|
1073
1131
|
async update(id, data) {
|
|
1074
1132
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1075
1133
|
method: "PUT",
|
|
@@ -1386,12 +1444,29 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
1386
1444
|
function extractMessageError(message) {
|
|
1387
1445
|
const payload = message.payload;
|
|
1388
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;
|
|
1389
1449
|
return {
|
|
1390
|
-
errorMessage: typeof
|
|
1391
|
-
errorCode
|
|
1450
|
+
errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
|
|
1451
|
+
errorCode
|
|
1392
1452
|
};
|
|
1393
1453
|
}
|
|
1394
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
|
+
/**
|
|
1395
1470
|
* Low-level realtime WebSocket client.
|
|
1396
1471
|
*
|
|
1397
1472
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
@@ -1406,6 +1481,36 @@ var RebaseWebSocketClient = class {
|
|
|
1406
1481
|
getAuthToken;
|
|
1407
1482
|
subscriptions = /* @__PURE__ */ new Map();
|
|
1408
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
|
+
}
|
|
1409
1514
|
on(event, cb) {
|
|
1410
1515
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
1411
1516
|
this.listeners.get(event).add(cb);
|
|
@@ -1424,6 +1529,7 @@ var RebaseWebSocketClient = class {
|
|
|
1424
1529
|
isConnected = false;
|
|
1425
1530
|
messageQueue = [];
|
|
1426
1531
|
requestTimeoutMs = 3e4;
|
|
1532
|
+
subscriptionTimeoutMs = 3e4;
|
|
1427
1533
|
reconnectTimeout = null;
|
|
1428
1534
|
isAuthenticated = false;
|
|
1429
1535
|
authPromise = null;
|
|
@@ -1435,8 +1541,25 @@ var RebaseWebSocketClient = class {
|
|
|
1435
1541
|
this.getAuthToken = config.getAuthToken;
|
|
1436
1542
|
this.onUnauthorized = config.onUnauthorized;
|
|
1437
1543
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
1438
|
-
|
|
1439
|
-
|
|
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();
|
|
1440
1563
|
}
|
|
1441
1564
|
/**
|
|
1442
1565
|
* Authenticate the WebSocket connection
|
|
@@ -1486,7 +1609,16 @@ var RebaseWebSocketClient = class {
|
|
|
1486
1609
|
});
|
|
1487
1610
|
}
|
|
1488
1611
|
}
|
|
1489
|
-
|
|
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;
|
|
1490
1622
|
this.isAuthenticated = false;
|
|
1491
1623
|
this.authPromise = null;
|
|
1492
1624
|
if (this.reconnectTimeout) {
|
|
@@ -1529,6 +1661,7 @@ var RebaseWebSocketClient = class {
|
|
|
1529
1661
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
1530
1662
|
this.processMessageQueue();
|
|
1531
1663
|
if (wasReconnect) this.resubscribeAll();
|
|
1664
|
+
this.armPendingSubscribeWatchdogs();
|
|
1532
1665
|
};
|
|
1533
1666
|
this.ws.onmessage = (event) => {
|
|
1534
1667
|
try {
|
|
@@ -1543,6 +1676,7 @@ var RebaseWebSocketClient = class {
|
|
|
1543
1676
|
this.isConnected = false;
|
|
1544
1677
|
this.isAuthenticated = false;
|
|
1545
1678
|
this.authPromise = null;
|
|
1679
|
+
this.suspendSubscribeWatchdogs();
|
|
1546
1680
|
this.emit("disconnect");
|
|
1547
1681
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
1548
1682
|
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
@@ -1574,6 +1708,7 @@ var RebaseWebSocketClient = class {
|
|
|
1574
1708
|
attemptReconnect() {
|
|
1575
1709
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
1576
1710
|
console.error("Max reconnection attempts reached");
|
|
1711
|
+
this.failAllPendingSubscriptions(new RebaseApiError$1("Connection lost", { code: "CONNECTION_LOST" }));
|
|
1577
1712
|
return;
|
|
1578
1713
|
}
|
|
1579
1714
|
this.reconnectAttempts++;
|
|
@@ -1628,29 +1763,18 @@ var RebaseWebSocketClient = class {
|
|
|
1628
1763
|
subscription.backendSubscriptionId = newBackendId;
|
|
1629
1764
|
backendKeyMap.delete(oldBackendId);
|
|
1630
1765
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
1631
|
-
this.
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
...subscription.props,
|
|
1635
|
-
subscriptionId: newBackendId
|
|
1636
|
-
}
|
|
1637
|
-
}).catch((error) => {
|
|
1638
|
-
console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
|
|
1639
|
-
subscription.callbacks.forEach((callback) => {
|
|
1640
|
-
if (callback.onError) callback.onError(error);
|
|
1641
|
-
});
|
|
1642
|
-
});
|
|
1643
|
-
} else {
|
|
1644
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1645
|
-
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1646
|
-
subscription.callbacks.forEach((callback) => {
|
|
1647
|
-
if (callback.onError) callback.onError(error);
|
|
1648
|
-
});
|
|
1766
|
+
if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
|
|
1767
|
+
else this.sendEntitySubscribe(subscriptionKey);
|
|
1768
|
+
return;
|
|
1649
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);
|
|
1650
1774
|
}).catch((err) => {
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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);
|
|
1654
1778
|
});
|
|
1655
1779
|
}
|
|
1656
1780
|
handleWebSocketMessage(message) {
|
|
@@ -1679,16 +1803,30 @@ var RebaseWebSocketClient = class {
|
|
|
1679
1803
|
}
|
|
1680
1804
|
return;
|
|
1681
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
|
+
}
|
|
1682
1815
|
if (subscriptionId && type === "collection_update") {
|
|
1683
1816
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
1684
1817
|
if (subscriptionKey) {
|
|
1685
1818
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1686
1819
|
if (collectionSub) {
|
|
1687
1820
|
const incomingRows = message.rows || [];
|
|
1688
|
-
const
|
|
1821
|
+
const updatePks = message.pks;
|
|
1822
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
1823
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
1689
1824
|
collectionSub.latestData = rows;
|
|
1690
1825
|
collectionSub.lastUpdated = Date.now();
|
|
1691
1826
|
collectionSub.isInitialDataReceived = true;
|
|
1827
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1828
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1829
|
+
collectionSub.subscribeInFlight = false;
|
|
1692
1830
|
collectionSub.callbacks.forEach((callback) => {
|
|
1693
1831
|
try {
|
|
1694
1832
|
callback.onUpdate(rows);
|
|
@@ -1707,12 +1845,14 @@ var RebaseWebSocketClient = class {
|
|
|
1707
1845
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
1708
1846
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
1709
1847
|
const patchWireEntity = message.row ?? null;
|
|
1710
|
-
const
|
|
1848
|
+
const patchMessage = message;
|
|
1849
|
+
const patchEntityId = patchMessage.id;
|
|
1850
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
1711
1851
|
const patchRow = patchWireEntity ? patchWireEntity : null;
|
|
1712
1852
|
let updated;
|
|
1713
|
-
if (patchRow === null) updated = collectionSub.latestData.filter((e) =>
|
|
1853
|
+
if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
|
|
1714
1854
|
else {
|
|
1715
|
-
const idx = collectionSub.latestData.findIndex((e) =>
|
|
1855
|
+
const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
|
|
1716
1856
|
if (idx >= 0) {
|
|
1717
1857
|
updated = [...collectionSub.latestData];
|
|
1718
1858
|
updated[idx] = patchRow;
|
|
@@ -1742,6 +1882,9 @@ var RebaseWebSocketClient = class {
|
|
|
1742
1882
|
entitySub.latestData = row;
|
|
1743
1883
|
entitySub.lastUpdated = Date.now();
|
|
1744
1884
|
entitySub.isInitialDataReceived = true;
|
|
1885
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1886
|
+
entitySub.subscribeTimeout = void 0;
|
|
1887
|
+
entitySub.subscribeInFlight = false;
|
|
1745
1888
|
entitySub.callbacks.forEach((callback) => {
|
|
1746
1889
|
try {
|
|
1747
1890
|
callback.onUpdate(row);
|
|
@@ -1763,6 +1906,9 @@ var RebaseWebSocketClient = class {
|
|
|
1763
1906
|
this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
|
|
1764
1907
|
return;
|
|
1765
1908
|
}
|
|
1909
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
1910
|
+
collectionSub.subscribeTimeout = void 0;
|
|
1911
|
+
collectionSub.subscribeInFlight = false;
|
|
1766
1912
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1767
1913
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1768
1914
|
collectionSub.callbacks.forEach((callback) => {
|
|
@@ -1779,6 +1925,9 @@ var RebaseWebSocketClient = class {
|
|
|
1779
1925
|
this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
|
|
1780
1926
|
return;
|
|
1781
1927
|
}
|
|
1928
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
1929
|
+
entitySub.subscribeTimeout = void 0;
|
|
1930
|
+
entitySub.subscribeInFlight = false;
|
|
1782
1931
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
1783
1932
|
const error = new RebaseApiError$1(errorMessage, { code: errorCode });
|
|
1784
1933
|
entitySub.callbacks.forEach((callback) => {
|
|
@@ -1851,21 +2000,28 @@ var RebaseWebSocketClient = class {
|
|
|
1851
2000
|
throw error;
|
|
1852
2001
|
}
|
|
1853
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
2005
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
2006
|
+
*/
|
|
1854
2007
|
sendMessage(message) {
|
|
1855
2008
|
const queuedMsg = message;
|
|
1856
2009
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
1857
|
-
if (!this.isConnected || !this.ws)
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
+
}
|
|
1863
2019
|
return new Promise((resolve, reject) => {
|
|
1864
2020
|
this.doSendMessage(message, resolve, reject);
|
|
1865
2021
|
});
|
|
1866
2022
|
}
|
|
1867
2023
|
async doSendMessage(message, resolve, reject) {
|
|
1868
|
-
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 {
|
|
1869
2025
|
await this.ensureAuthenticated();
|
|
1870
2026
|
} catch (error) {
|
|
1871
2027
|
reject(new RebaseApiError$1(error instanceof Error ? error.message : "Authentication required"));
|
|
@@ -1873,17 +2029,7 @@ var RebaseWebSocketClient = class {
|
|
|
1873
2029
|
}
|
|
1874
2030
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1875
2031
|
message.requestId = requestId;
|
|
1876
|
-
const expectsResponse = !
|
|
1877
|
-
"subscribe_collection",
|
|
1878
|
-
"subscribe_one",
|
|
1879
|
-
"unsubscribe",
|
|
1880
|
-
"join_channel",
|
|
1881
|
-
"leave_channel",
|
|
1882
|
-
"broadcast",
|
|
1883
|
-
"presence_track",
|
|
1884
|
-
"presence_untrack",
|
|
1885
|
-
"presence_state"
|
|
1886
|
-
].includes(message.type);
|
|
2032
|
+
const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
1887
2033
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
1888
2034
|
const timeoutHandle = setTimeout(() => {
|
|
1889
2035
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -1953,6 +2099,9 @@ var RebaseWebSocketClient = class {
|
|
|
1953
2099
|
async fetchAvailableRoles() {
|
|
1954
2100
|
return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
|
|
1955
2101
|
}
|
|
2102
|
+
async fetchApplicationRoles() {
|
|
2103
|
+
return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
|
|
2104
|
+
}
|
|
1956
2105
|
async fetchCurrentDatabase() {
|
|
1957
2106
|
return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
|
|
1958
2107
|
}
|
|
@@ -2062,17 +2211,39 @@ var RebaseWebSocketClient = class {
|
|
|
2062
2211
|
return val;
|
|
2063
2212
|
}
|
|
2064
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
|
+
/**
|
|
2065
2232
|
* Merge incoming rows with cached data, preserving cached references
|
|
2066
2233
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
2067
2234
|
* React re-renders when the server refetches all rows but most
|
|
2068
2235
|
* haven't actually changed.
|
|
2069
2236
|
*/
|
|
2070
|
-
mergeRows(cached, incoming) {
|
|
2237
|
+
mergeRows(cached, incoming, pks) {
|
|
2071
2238
|
if (!cached || cached.length === 0) return incoming;
|
|
2072
2239
|
const cachedById = /* @__PURE__ */ new Map();
|
|
2073
|
-
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
|
+
}
|
|
2074
2244
|
return incoming.map((incomingRow) => {
|
|
2075
|
-
const
|
|
2245
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
2246
|
+
const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
|
|
2076
2247
|
if (!cachedRow) return incomingRow;
|
|
2077
2248
|
const normCached = this.normalizeForComparison(cachedRow);
|
|
2078
2249
|
const normIncoming = this.normalizeForComparison(incomingRow);
|
|
@@ -2084,12 +2255,13 @@ var RebaseWebSocketClient = class {
|
|
|
2084
2255
|
cached: normCached[key],
|
|
2085
2256
|
incoming: normIncoming[key]
|
|
2086
2257
|
};
|
|
2087
|
-
console.debug(`[RebaseWS] Row ${
|
|
2258
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
2088
2259
|
}
|
|
2089
2260
|
return incomingRow;
|
|
2090
2261
|
});
|
|
2091
2262
|
}
|
|
2092
2263
|
listenCollection(props, onUpdate, onError) {
|
|
2264
|
+
this.ensureConnected();
|
|
2093
2265
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
2094
2266
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
2095
2267
|
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
@@ -2105,9 +2277,12 @@ var RebaseWebSocketClient = class {
|
|
|
2105
2277
|
console.error("Error in collection subscription callback:", error);
|
|
2106
2278
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2107
2279
|
}
|
|
2280
|
+
else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
|
|
2108
2281
|
return () => {
|
|
2109
2282
|
callbackMap.delete(callbackId);
|
|
2110
2283
|
if (callbackMap.size === 0) {
|
|
2284
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2285
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2111
2286
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2112
2287
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
2113
2288
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2129,21 +2304,14 @@ var RebaseWebSocketClient = class {
|
|
|
2129
2304
|
props
|
|
2130
2305
|
});
|
|
2131
2306
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
2132
|
-
this.
|
|
2133
|
-
type: "subscribe_collection",
|
|
2134
|
-
payload: {
|
|
2135
|
-
...props,
|
|
2136
|
-
subscriptionId: backendSubscriptionId
|
|
2137
|
-
}
|
|
2138
|
-
}).catch((error) => {
|
|
2139
|
-
if (onError) onError(error);
|
|
2140
|
-
});
|
|
2307
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
2141
2308
|
return () => {
|
|
2142
2309
|
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
2143
2310
|
if (subscription) {
|
|
2144
2311
|
const callbacks = subscription.callbacks;
|
|
2145
2312
|
callbacks.delete(callbackId);
|
|
2146
2313
|
if (callbacks.size === 0) {
|
|
2314
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
2147
2315
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
2148
2316
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
2149
2317
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2155,6 +2323,7 @@ var RebaseWebSocketClient = class {
|
|
|
2155
2323
|
};
|
|
2156
2324
|
}
|
|
2157
2325
|
listenOne(props, onUpdate, onError) {
|
|
2326
|
+
this.ensureConnected();
|
|
2158
2327
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
2159
2328
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
2160
2329
|
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
@@ -2170,9 +2339,12 @@ var RebaseWebSocketClient = class {
|
|
|
2170
2339
|
console.error("Error in row subscription callback:", error);
|
|
2171
2340
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
2172
2341
|
}
|
|
2342
|
+
else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
|
|
2173
2343
|
return () => {
|
|
2174
2344
|
callbackMap.delete(callbackId);
|
|
2175
2345
|
if (callbackMap.size === 0) {
|
|
2346
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
2347
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
2176
2348
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
2177
2349
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
2178
2350
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -2194,15 +2366,7 @@ var RebaseWebSocketClient = class {
|
|
|
2194
2366
|
props
|
|
2195
2367
|
});
|
|
2196
2368
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
2197
|
-
this.
|
|
2198
|
-
type: "subscribe_one",
|
|
2199
|
-
payload: {
|
|
2200
|
-
...props,
|
|
2201
|
-
subscriptionId: backendSubscriptionId
|
|
2202
|
-
}
|
|
2203
|
-
}).catch((error) => {
|
|
2204
|
-
if (onError) onError(error);
|
|
2205
|
-
});
|
|
2369
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
2206
2370
|
return () => {
|
|
2207
2371
|
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
2208
2372
|
if (subscription) {
|
|
@@ -2220,6 +2384,157 @@ var RebaseWebSocketClient = class {
|
|
|
2220
2384
|
};
|
|
2221
2385
|
}
|
|
2222
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
|
+
/**
|
|
2223
2538
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
2224
2539
|
* The server wipes subscription state when a client disconnects, so
|
|
2225
2540
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -2232,15 +2547,7 @@ var RebaseWebSocketClient = class {
|
|
|
2232
2547
|
sub.backendSubscriptionId = newBackendId;
|
|
2233
2548
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
2234
2549
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
2235
|
-
this.
|
|
2236
|
-
type: "subscribe_collection",
|
|
2237
|
-
payload: {
|
|
2238
|
-
...sub.props,
|
|
2239
|
-
subscriptionId: newBackendId
|
|
2240
|
-
}
|
|
2241
|
-
}).catch((error) => {
|
|
2242
|
-
console.error("[WS] Failed to re-subscribe collection:", key, error);
|
|
2243
|
-
});
|
|
2550
|
+
this.sendCollectionSubscribe(key);
|
|
2244
2551
|
}
|
|
2245
2552
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
2246
2553
|
const oldBackendId = sub.backendSubscriptionId;
|
|
@@ -2248,15 +2555,7 @@ var RebaseWebSocketClient = class {
|
|
|
2248
2555
|
sub.backendSubscriptionId = newBackendId;
|
|
2249
2556
|
this.backendToEntityKey.delete(oldBackendId);
|
|
2250
2557
|
this.backendToEntityKey.set(newBackendId, key);
|
|
2251
|
-
this.
|
|
2252
|
-
type: "subscribe_one",
|
|
2253
|
-
payload: {
|
|
2254
|
-
...sub.props,
|
|
2255
|
-
subscriptionId: newBackendId
|
|
2256
|
-
}
|
|
2257
|
-
}).catch((error) => {
|
|
2258
|
-
console.error("[WS] Failed to re-subscribe row:", key, error);
|
|
2259
|
-
});
|
|
2558
|
+
this.sendEntitySubscribe(key);
|
|
2260
2559
|
}
|
|
2261
2560
|
}
|
|
2262
2561
|
createCollectionSubscriptionKey(props) {
|
|
@@ -2283,6 +2582,379 @@ var RebaseWebSocketClient = class {
|
|
|
2283
2582
|
}
|
|
2284
2583
|
};
|
|
2285
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
|
|
2286
2958
|
//#region src/index.ts
|
|
2287
2959
|
/**
|
|
2288
2960
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
@@ -2331,8 +3003,10 @@ function createRebaseClient(options) {
|
|
|
2331
3003
|
});
|
|
2332
3004
|
return storageSourcesPromise;
|
|
2333
3005
|
};
|
|
2334
|
-
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
3006
|
+
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
2335
3007
|
let ws;
|
|
3008
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
3009
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
2336
3010
|
if (resolvedWsUrl) {
|
|
2337
3011
|
ws = new RebaseWebSocketClient({
|
|
2338
3012
|
websocketUrl: resolvedWsUrl,
|
|
@@ -2356,7 +3030,7 @@ function createRebaseClient(options) {
|
|
|
2356
3030
|
if (!ws) return;
|
|
2357
3031
|
if (event === "SIGNED_OUT") ws.disconnect();
|
|
2358
3032
|
else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
2359
|
-
if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
|
|
3033
|
+
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
2360
3034
|
}
|
|
2361
3035
|
});
|
|
2362
3036
|
}
|
|
@@ -2443,6 +3117,36 @@ function createRebaseClient(options) {
|
|
|
2443
3117
|
createStorageSource,
|
|
2444
3118
|
fetchStorageSources,
|
|
2445
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
|
+
},
|
|
2446
3150
|
setToken: transport.setToken,
|
|
2447
3151
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
2448
3152
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
@@ -2461,6 +3165,6 @@ function createRebaseClient(options) {
|
|
|
2461
3165
|
};
|
|
2462
3166
|
}
|
|
2463
3167
|
//#endregion
|
|
2464
|
-
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
3168
|
+
export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
|
|
2465
3169
|
|
|
2466
3170
|
//# sourceMappingURL=index.es.js.map
|