deepspace 0.5.1 → 0.5.3
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/cli.js +1306 -2004
- package/dist/cli.js.map +1 -1
- package/dist/index.js +446 -539
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +17 -8
- package/dist/server.js +16 -3
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +13 -6
- package/dist/worker.js +15 -2
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1278,33 +1278,21 @@ var RecordStore = class {
|
|
|
1278
1278
|
}
|
|
1279
1279
|
};
|
|
1280
1280
|
|
|
1281
|
-
// src/client/storage/
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1281
|
+
// src/client/storage/record-matching.ts
|
|
1282
|
+
function recordMatchesWhere(record, where) {
|
|
1283
|
+
if (!where) return true;
|
|
1284
|
+
const r = record;
|
|
1285
|
+
const data = r.data;
|
|
1286
|
+
if (!data) return false;
|
|
1287
|
+
return Object.entries(where).every(([key, value]) => {
|
|
1288
|
+
if (key in data) return data[key] === value;
|
|
1289
|
+
if (key === "recordId") return r.recordId === value;
|
|
1290
|
+
if (key === "createdBy") return r.createdBy === value;
|
|
1291
|
+
return false;
|
|
1292
|
+
});
|
|
1287
1293
|
}
|
|
1288
|
-
function
|
|
1289
|
-
|
|
1290
|
-
const register = useCallback((scopeId, collections, entry) => {
|
|
1291
|
-
for (const name of collections) {
|
|
1292
|
-
mapRef.current.set(name, { scopeId, entry });
|
|
1293
|
-
}
|
|
1294
|
-
}, []);
|
|
1295
|
-
const unregister = useCallback((scopeId, collections) => {
|
|
1296
|
-
for (const name of collections) {
|
|
1297
|
-
const existing = mapRef.current.get(name);
|
|
1298
|
-
if (existing?.scopeId === scopeId) {
|
|
1299
|
-
mapRef.current.delete(name);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
}, []);
|
|
1303
|
-
const resolve = useCallback((collection) => {
|
|
1304
|
-
return mapRef.current.get(collection)?.entry ?? null;
|
|
1305
|
-
}, []);
|
|
1306
|
-
const value = useMemo(() => ({ register, unregister, resolve }), [register, unregister, resolve]);
|
|
1307
|
-
return /* @__PURE__ */ jsx5(ScopeRegistryContext.Provider, { value, children });
|
|
1294
|
+
function reconnectDelayMs(attempt) {
|
|
1295
|
+
return Math.min(1e3 * Math.pow(2, attempt), 3e4);
|
|
1308
1296
|
}
|
|
1309
1297
|
|
|
1310
1298
|
// src/client/storage/serverErrors.ts
|
|
@@ -1402,6 +1390,327 @@ function parseServerError(error) {
|
|
|
1402
1390
|
};
|
|
1403
1391
|
}
|
|
1404
1392
|
|
|
1393
|
+
// src/client/storage/record-socket.ts
|
|
1394
|
+
var WS_CONNECTING = 0;
|
|
1395
|
+
var WS_OPEN = 1;
|
|
1396
|
+
var RecordSocket = class {
|
|
1397
|
+
constructor(config) {
|
|
1398
|
+
this.config = config;
|
|
1399
|
+
this.subscriptions = config.subscriptions ?? /* @__PURE__ */ new Map();
|
|
1400
|
+
this.binaryHandlers = config.binaryHandlers ?? /* @__PURE__ */ new Set();
|
|
1401
|
+
this.yjsJoinHandlers = config.yjsJoinHandlers ?? /* @__PURE__ */ new Map();
|
|
1402
|
+
}
|
|
1403
|
+
ws = null;
|
|
1404
|
+
subscriptions;
|
|
1405
|
+
// subscriptionId → queryKey
|
|
1406
|
+
binaryHandlers;
|
|
1407
|
+
yjsJoinHandlers;
|
|
1408
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
1409
|
+
reconnectAttempt = 0;
|
|
1410
|
+
reconnectTimeout = null;
|
|
1411
|
+
// Bumped by connect() (on entry) and disconnect()/destroy(). connect()
|
|
1412
|
+
// captures it before awaiting the auth token and re-checks after; a
|
|
1413
|
+
// mismatch means a teardown (or a newer connect) raced in during the
|
|
1414
|
+
// await, so the resolved connect must bail instead of leaking a socket.
|
|
1415
|
+
connectToken = 0;
|
|
1416
|
+
// Opaque identity the current socket was opened with (e.g. '' for
|
|
1417
|
+
// anonymous, the profile id once signed in). A connect() with a DIFFERENT
|
|
1418
|
+
// tag while OPEN forces a reconnect so the server sees the new identity.
|
|
1419
|
+
identityTag;
|
|
1420
|
+
destroyed = false;
|
|
1421
|
+
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
1422
|
+
async connect(identityTag) {
|
|
1423
|
+
if (this.destroyed) return;
|
|
1424
|
+
const { config } = this;
|
|
1425
|
+
if (this.ws?.readyState === WS_OPEN) {
|
|
1426
|
+
if (identityTag !== void 0 && identityTag !== this.identityTag) {
|
|
1427
|
+
config.log?.("reconnecting with new identity", config.roomId);
|
|
1428
|
+
this.teardownSocket();
|
|
1429
|
+
} else {
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (this.ws?.readyState === WS_CONNECTING) return;
|
|
1434
|
+
if (this.reconnectTimeout) {
|
|
1435
|
+
clearTimeout(this.reconnectTimeout);
|
|
1436
|
+
this.reconnectTimeout = null;
|
|
1437
|
+
}
|
|
1438
|
+
const myToken = ++this.connectToken;
|
|
1439
|
+
const params = new URLSearchParams();
|
|
1440
|
+
try {
|
|
1441
|
+
const token = await config.getToken();
|
|
1442
|
+
if (token) params.set("token", token);
|
|
1443
|
+
} catch {
|
|
1444
|
+
}
|
|
1445
|
+
if (this.connectToken !== myToken || this.destroyed) return;
|
|
1446
|
+
for (const [key, value] of Object.entries(config.extraParams ?? {})) {
|
|
1447
|
+
params.set(key, value);
|
|
1448
|
+
}
|
|
1449
|
+
if (identityTag !== void 0) this.identityTag = identityTag;
|
|
1450
|
+
const protocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
1451
|
+
const baseUrl = config.wsUrl?.replace(/^http/, "ws") ?? `${protocol}//${typeof window !== "undefined" ? window.location.host : ""}`;
|
|
1452
|
+
const pathPrefix = config.wsPathPrefix ?? "/ws";
|
|
1453
|
+
const url = `${baseUrl}${pathPrefix}/${config.roomId}?${params.toString()}`;
|
|
1454
|
+
config.log?.("connecting", config.roomId);
|
|
1455
|
+
const WS = config.WebSocketImpl ?? WebSocket;
|
|
1456
|
+
const ws = new WS(url);
|
|
1457
|
+
ws.binaryType = "arraybuffer";
|
|
1458
|
+
this.ws = ws;
|
|
1459
|
+
ws.onopen = () => {
|
|
1460
|
+
config.log?.("connected", config.roomId);
|
|
1461
|
+
config.listeners.onStatus("connected");
|
|
1462
|
+
this.resubscribeAll(ws);
|
|
1463
|
+
};
|
|
1464
|
+
ws.onmessage = (event) => this.handleMessage(event);
|
|
1465
|
+
ws.onclose = () => {
|
|
1466
|
+
config.log?.("disconnected", config.roomId);
|
|
1467
|
+
config.listeners.onStatus("disconnected");
|
|
1468
|
+
config.listeners.onReady(false);
|
|
1469
|
+
this.ws = null;
|
|
1470
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1471
|
+
clearTimeout(pending.timer);
|
|
1472
|
+
pending.reject(new Error("WebSocket disconnected"));
|
|
1473
|
+
}
|
|
1474
|
+
this.pendingRequests.clear();
|
|
1475
|
+
for (const queryKey of this.subscriptions.values()) {
|
|
1476
|
+
config.store.resetToLoading(queryKey);
|
|
1477
|
+
}
|
|
1478
|
+
const attempt = this.reconnectAttempt;
|
|
1479
|
+
const delay = reconnectDelayMs(attempt);
|
|
1480
|
+
config.log?.("scheduling reconnect", { roomId: config.roomId, attempt, delayMs: delay });
|
|
1481
|
+
this.reconnectAttempt = attempt + 1;
|
|
1482
|
+
this.reconnectTimeout = setTimeout(() => void this.connect(this.identityTag), delay);
|
|
1483
|
+
};
|
|
1484
|
+
ws.onerror = () => {
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Tear down and reset to a connectable state (auth identity change, scope
|
|
1489
|
+
* unmount-and-remount). Emits status 'connecting' so the UI shows progress
|
|
1490
|
+
* rather than a dead 'disconnected'.
|
|
1491
|
+
*/
|
|
1492
|
+
disconnect() {
|
|
1493
|
+
this.connectToken++;
|
|
1494
|
+
this.clearReconnect();
|
|
1495
|
+
this.teardownSocket();
|
|
1496
|
+
this.config.listeners.onStatus("connecting");
|
|
1497
|
+
this.config.listeners.onReady(false);
|
|
1498
|
+
this.reconnectAttempt = 0;
|
|
1499
|
+
}
|
|
1500
|
+
/** Final teardown (unmount): no listener calls — the owner is gone. */
|
|
1501
|
+
destroy() {
|
|
1502
|
+
this.destroyed = true;
|
|
1503
|
+
this.connectToken++;
|
|
1504
|
+
this.clearReconnect();
|
|
1505
|
+
this.teardownSocket();
|
|
1506
|
+
}
|
|
1507
|
+
/** Zero the backoff (tab became visible, user asked to retry). */
|
|
1508
|
+
resetBackoff() {
|
|
1509
|
+
this.reconnectAttempt = 0;
|
|
1510
|
+
}
|
|
1511
|
+
get isOpen() {
|
|
1512
|
+
return this.ws?.readyState === WS_OPEN;
|
|
1513
|
+
}
|
|
1514
|
+
clearReconnect() {
|
|
1515
|
+
if (this.reconnectTimeout) {
|
|
1516
|
+
clearTimeout(this.reconnectTimeout);
|
|
1517
|
+
this.reconnectTimeout = null;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
/** Close without firing onclose — prevents the zombie-reconnect. */
|
|
1521
|
+
teardownSocket() {
|
|
1522
|
+
const ws = this.ws;
|
|
1523
|
+
if (!ws) return;
|
|
1524
|
+
this.config.log?.("closing", this.config.roomId);
|
|
1525
|
+
ws.onclose = null;
|
|
1526
|
+
ws.onmessage = null;
|
|
1527
|
+
ws.onerror = null;
|
|
1528
|
+
ws.onopen = null;
|
|
1529
|
+
ws.close();
|
|
1530
|
+
this.ws = null;
|
|
1531
|
+
}
|
|
1532
|
+
// ── sending ───────────────────────────────────────────────────────────────
|
|
1533
|
+
sendMessage(message) {
|
|
1534
|
+
if (this.ws?.readyState === WS_OPEN) this.ws.send(JSON.stringify(message));
|
|
1535
|
+
else this.config.log?.("send dropped (not connected)", message.type);
|
|
1536
|
+
}
|
|
1537
|
+
sendBinary(data) {
|
|
1538
|
+
if (this.ws?.readyState === WS_OPEN) this.ws.send(data);
|
|
1539
|
+
}
|
|
1540
|
+
sendConfirmed(message, timeoutMs = 1e4) {
|
|
1541
|
+
const ws = this.ws;
|
|
1542
|
+
if (!ws || ws.readyState !== WS_OPEN) {
|
|
1543
|
+
return Promise.reject(new Error("WebSocket not connected"));
|
|
1544
|
+
}
|
|
1545
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
1546
|
+
return new Promise((resolve, reject) => {
|
|
1547
|
+
const timer = setTimeout(() => {
|
|
1548
|
+
this.pendingRequests.delete(requestId);
|
|
1549
|
+
reject(new Error("Mutation confirmation timed out"));
|
|
1550
|
+
}, timeoutMs);
|
|
1551
|
+
this.pendingRequests.set(requestId, { resolve, reject, timer });
|
|
1552
|
+
ws.send(JSON.stringify({ ...message, payload: { ...message.payload, requestId } }));
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
// ── registrations ─────────────────────────────────────────────────────────
|
|
1556
|
+
registerSubscription(subscriptionId, queryKey) {
|
|
1557
|
+
this.subscriptions.set(subscriptionId, queryKey);
|
|
1558
|
+
}
|
|
1559
|
+
unregisterSubscription(subscriptionId) {
|
|
1560
|
+
this.subscriptions.delete(subscriptionId);
|
|
1561
|
+
}
|
|
1562
|
+
onBinaryMessage(handler) {
|
|
1563
|
+
this.binaryHandlers.add(handler);
|
|
1564
|
+
return () => {
|
|
1565
|
+
this.binaryHandlers.delete(handler);
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
registerYjsJoinHandler(docKey, handler) {
|
|
1569
|
+
if (!this.yjsJoinHandlers.has(docKey)) this.yjsJoinHandlers.set(docKey, /* @__PURE__ */ new Set());
|
|
1570
|
+
this.yjsJoinHandlers.get(docKey).add(handler);
|
|
1571
|
+
return () => {
|
|
1572
|
+
const handlers = this.yjsJoinHandlers.get(docKey);
|
|
1573
|
+
if (handlers) {
|
|
1574
|
+
handlers.delete(handler);
|
|
1575
|
+
if (handlers.size === 0) this.yjsJoinHandlers.delete(docKey);
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
// ── message dispatch ──────────────────────────────────────────────────────
|
|
1580
|
+
resubscribeAll(ws) {
|
|
1581
|
+
for (const [subscriptionId, queryKey] of this.subscriptions) {
|
|
1582
|
+
try {
|
|
1583
|
+
const query = JSON.parse(queryKey);
|
|
1584
|
+
ws.send(JSON.stringify({ type: MSG.SUBSCRIBE, payload: { subscriptionId, query } }));
|
|
1585
|
+
} catch {
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
handleMessage(event) {
|
|
1590
|
+
if (event.data instanceof ArrayBuffer) {
|
|
1591
|
+
this.binaryHandlers.forEach((h) => h(event.data));
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
let msg;
|
|
1595
|
+
try {
|
|
1596
|
+
msg = JSON.parse(event.data);
|
|
1597
|
+
} catch {
|
|
1598
|
+
console.error("[RecordSocket] Failed to parse message");
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const { type, payload } = msg;
|
|
1602
|
+
const { store, listeners } = this.config;
|
|
1603
|
+
switch (type) {
|
|
1604
|
+
case MSG.USER_INFO: {
|
|
1605
|
+
const { role } = payload;
|
|
1606
|
+
listeners.onRole(role);
|
|
1607
|
+
listeners.onReady(true);
|
|
1608
|
+
this.reconnectAttempt = 0;
|
|
1609
|
+
this.sendMessage({ type: MSG.USER_LIST, payload: {} });
|
|
1610
|
+
break;
|
|
1611
|
+
}
|
|
1612
|
+
case MSG.USER_LIST:
|
|
1613
|
+
listeners.onUsers(payload.users);
|
|
1614
|
+
break;
|
|
1615
|
+
case MSG.QUERY_RESULT: {
|
|
1616
|
+
const { subscriptionId, records } = payload;
|
|
1617
|
+
const queryKey = this.subscriptions.get(subscriptionId);
|
|
1618
|
+
if (queryKey !== void 0) store.setQueryResult(queryKey, records);
|
|
1619
|
+
break;
|
|
1620
|
+
}
|
|
1621
|
+
case MSG.RECORD_CHANGE: {
|
|
1622
|
+
const { collection, record, changeType } = payload;
|
|
1623
|
+
for (const queryKey of this.subscriptions.values()) {
|
|
1624
|
+
try {
|
|
1625
|
+
const query = JSON.parse(queryKey);
|
|
1626
|
+
if (query.collection !== collection) continue;
|
|
1627
|
+
const matches = recordMatchesWhere(record, query.where);
|
|
1628
|
+
const exists = store.hasRecord(queryKey, record.recordId);
|
|
1629
|
+
if (changeType === "delete") {
|
|
1630
|
+
if (exists) store.applyChange(queryKey, record, "delete");
|
|
1631
|
+
} else if (changeType === "create") {
|
|
1632
|
+
if (matches) store.applyChange(queryKey, record, "create");
|
|
1633
|
+
} else {
|
|
1634
|
+
if (matches && exists) store.applyChange(queryKey, record, "update");
|
|
1635
|
+
else if (matches && !exists) store.applyChange(queryKey, record, "create");
|
|
1636
|
+
else if (!matches && exists) store.applyChange(queryKey, record, "delete");
|
|
1637
|
+
}
|
|
1638
|
+
} catch {
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
break;
|
|
1642
|
+
}
|
|
1643
|
+
case MSG.ERROR: {
|
|
1644
|
+
const { subscriptionId, error } = payload;
|
|
1645
|
+
if (subscriptionId) {
|
|
1646
|
+
const queryKey = this.subscriptions.get(subscriptionId);
|
|
1647
|
+
if (queryKey !== void 0) store.setError(queryKey, error);
|
|
1648
|
+
} else {
|
|
1649
|
+
const parsed = parseServerError(error);
|
|
1650
|
+
if (parsed.isPermissionError) listeners.onPermissionError?.(parsed.title, parsed.detail);
|
|
1651
|
+
else listeners.onValidationError?.(parsed.title, parsed.detail);
|
|
1652
|
+
}
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
case MSG.YJS_JOIN: {
|
|
1656
|
+
const { collection, recordId, fieldName, canWrite } = payload;
|
|
1657
|
+
const docKey = `${collection}:${recordId}:${fieldName}`;
|
|
1658
|
+
this.yjsJoinHandlers.get(docKey)?.forEach((h) => h(canWrite));
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
case MSG.ACK: {
|
|
1662
|
+
const { requestId, success, error, ...rest } = payload;
|
|
1663
|
+
const pending = this.pendingRequests.get(requestId);
|
|
1664
|
+
if (pending) {
|
|
1665
|
+
clearTimeout(pending.timer);
|
|
1666
|
+
this.pendingRequests.delete(requestId);
|
|
1667
|
+
if (success) pending.resolve(rest);
|
|
1668
|
+
else pending.reject(new Error(error || "Mutation rejected"));
|
|
1669
|
+
}
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
case MSG.LIST_SCHEMAS: {
|
|
1673
|
+
const { schemas } = payload;
|
|
1674
|
+
listeners.onSchemas?.(schemas ?? []);
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
case MSG.RESUBSCRIBE: {
|
|
1678
|
+
if (this.ws?.readyState === WS_OPEN) this.resubscribeAll(this.ws);
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
// src/client/storage/ScopeRegistry.tsx
|
|
1686
|
+
import { createContext, useContext, useCallback, useRef as useRef3, useMemo } from "react";
|
|
1687
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
1688
|
+
var ScopeRegistryContext = createContext(null);
|
|
1689
|
+
function useScopeRegistry() {
|
|
1690
|
+
return useContext(ScopeRegistryContext);
|
|
1691
|
+
}
|
|
1692
|
+
function ScopeRegistryProvider({ children }) {
|
|
1693
|
+
const mapRef = useRef3(/* @__PURE__ */ new Map());
|
|
1694
|
+
const register = useCallback((scopeId, collections, entry) => {
|
|
1695
|
+
for (const name of collections) {
|
|
1696
|
+
mapRef.current.set(name, { scopeId, entry });
|
|
1697
|
+
}
|
|
1698
|
+
}, []);
|
|
1699
|
+
const unregister = useCallback((scopeId, collections) => {
|
|
1700
|
+
for (const name of collections) {
|
|
1701
|
+
const existing = mapRef.current.get(name);
|
|
1702
|
+
if (existing?.scopeId === scopeId) {
|
|
1703
|
+
mapRef.current.delete(name);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}, []);
|
|
1707
|
+
const resolve = useCallback((collection) => {
|
|
1708
|
+
return mapRef.current.get(collection)?.entry ?? null;
|
|
1709
|
+
}, []);
|
|
1710
|
+
const value = useMemo(() => ({ register, unregister, resolve }), [register, unregister, resolve]);
|
|
1711
|
+
return /* @__PURE__ */ jsx5(ScopeRegistryContext.Provider, { value, children });
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1405
1714
|
// src/client/storage/context.tsx
|
|
1406
1715
|
import { Fragment as Fragment5, jsx as jsx6 } from "react/jsx-runtime";
|
|
1407
1716
|
var RecordContext = createContext2(null);
|
|
@@ -1416,18 +1725,6 @@ var RecordAuthContext = createContext2(null);
|
|
|
1416
1725
|
function useRecordAuth() {
|
|
1417
1726
|
return useContext2(RecordAuthContext);
|
|
1418
1727
|
}
|
|
1419
|
-
function recordMatchesWhere(record, where) {
|
|
1420
|
-
if (!where) return true;
|
|
1421
|
-
const r = record;
|
|
1422
|
-
const data = r.data;
|
|
1423
|
-
if (!data) return false;
|
|
1424
|
-
return Object.entries(where).every(([key, value]) => {
|
|
1425
|
-
if (key in data) return data[key] === value;
|
|
1426
|
-
if (key === "recordId") return r.recordId === value;
|
|
1427
|
-
if (key === "createdBy") return r.createdBy === value;
|
|
1428
|
-
return false;
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
1728
|
function RecordProviderCore({
|
|
1432
1729
|
roomId,
|
|
1433
1730
|
wsUrl,
|
|
@@ -1450,17 +1747,14 @@ function RecordProviderCore({
|
|
|
1450
1747
|
void 0
|
|
1451
1748
|
);
|
|
1452
1749
|
const storeRef = useRef4(new RecordStore());
|
|
1453
|
-
const
|
|
1454
|
-
const
|
|
1750
|
+
const socketRef = useRef4(null);
|
|
1751
|
+
const subscriptionsRef = useRef4(/* @__PURE__ */ new Map());
|
|
1455
1752
|
const binaryHandlersRef = useRef4(/* @__PURE__ */ new Set());
|
|
1456
1753
|
const yjsJoinHandlersRef = useRef4(/* @__PURE__ */ new Map());
|
|
1457
|
-
const pendingRequestsRef = useRef4(/* @__PURE__ */ new Map());
|
|
1458
|
-
const reconnectAttemptRef = useRef4(0);
|
|
1459
|
-
const reconnectTimeoutRef = useRef4(null);
|
|
1460
|
-
const connectTokenRef = useRef4(0);
|
|
1461
|
-
const connectedWithProfileRef = useRef4(false);
|
|
1462
1754
|
const userProfileRef = useRef4(null);
|
|
1463
1755
|
userProfileRef.current = userProfile;
|
|
1756
|
+
const getAuthTokenRef = useRef4(getAuthTokenProp);
|
|
1757
|
+
getAuthTokenRef.current = getAuthTokenProp;
|
|
1464
1758
|
const refetchUserProfile = useCallback2(async () => {
|
|
1465
1759
|
try {
|
|
1466
1760
|
const profile = await fetchUser();
|
|
@@ -1481,6 +1775,7 @@ function RecordProviderCore({
|
|
|
1481
1775
|
if (mounted)
|
|
1482
1776
|
fetchUser().then((p) => {
|
|
1483
1777
|
if (mounted) setUserProfile(p);
|
|
1778
|
+
}).catch(() => {
|
|
1484
1779
|
});
|
|
1485
1780
|
}, 3e4);
|
|
1486
1781
|
return () => {
|
|
@@ -1488,228 +1783,54 @@ function RecordProviderCore({
|
|
|
1488
1783
|
clearInterval(interval);
|
|
1489
1784
|
};
|
|
1490
1785
|
}, [fetchUser]);
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
}
|
|
1510
|
-
break;
|
|
1511
|
-
}
|
|
1512
|
-
case MSG.USER_LIST:
|
|
1513
|
-
setAllUsers(payload.users);
|
|
1786
|
+
const userProfileId = userProfile?.id ?? null;
|
|
1787
|
+
useEffect4(() => {
|
|
1788
|
+
if (userProfileLoading) return;
|
|
1789
|
+
const socket = new RecordSocket({
|
|
1790
|
+
roomId,
|
|
1791
|
+
store: storeRef.current,
|
|
1792
|
+
subscriptions: subscriptionsRef.current,
|
|
1793
|
+
binaryHandlers: binaryHandlersRef.current,
|
|
1794
|
+
yjsJoinHandlers: yjsJoinHandlersRef.current,
|
|
1795
|
+
getToken: () => (getAuthTokenRef.current ?? getAuthToken)(),
|
|
1796
|
+
wsUrl,
|
|
1797
|
+
log: (event, detail) => debugLog(`[WS] ${event}`, detail),
|
|
1798
|
+
listeners: {
|
|
1799
|
+
onStatus: setStatus,
|
|
1800
|
+
onReady: setReady,
|
|
1801
|
+
onRole: setRoomRole,
|
|
1802
|
+
onUsers: (users) => {
|
|
1803
|
+
setAllUsers(users);
|
|
1514
1804
|
setUsersLoaded(true);
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
for (const [subId, queryKey] of subscriptionMapRef.current) {
|
|
1519
|
-
if (subId === subscriptionId) {
|
|
1520
|
-
storeRef.current.setQueryResult(queryKey, records);
|
|
1521
|
-
break;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
break;
|
|
1525
|
-
}
|
|
1526
|
-
case MSG.RECORD_CHANGE: {
|
|
1527
|
-
const { collection, record, changeType } = payload;
|
|
1528
|
-
for (const queryKey of subscriptionMapRef.current.values()) {
|
|
1529
|
-
try {
|
|
1530
|
-
const query = JSON.parse(queryKey);
|
|
1531
|
-
if (query.collection !== collection) continue;
|
|
1532
|
-
const matches = recordMatchesWhere(record, query.where);
|
|
1533
|
-
const exists = storeRef.current.hasRecord(queryKey, record.recordId);
|
|
1534
|
-
if (changeType === "delete") {
|
|
1535
|
-
if (exists) storeRef.current.applyChange(queryKey, record, "delete");
|
|
1536
|
-
} else if (changeType === "create") {
|
|
1537
|
-
if (matches) storeRef.current.applyChange(queryKey, record, "create");
|
|
1538
|
-
} else {
|
|
1539
|
-
if (matches && exists) storeRef.current.applyChange(queryKey, record, "update");
|
|
1540
|
-
else if (matches && !exists)
|
|
1541
|
-
storeRef.current.applyChange(queryKey, record, "create");
|
|
1542
|
-
else if (!matches && exists)
|
|
1543
|
-
storeRef.current.applyChange(queryKey, record, "delete");
|
|
1544
|
-
}
|
|
1545
|
-
} catch {
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
break;
|
|
1549
|
-
}
|
|
1550
|
-
case MSG.ERROR: {
|
|
1551
|
-
const { subscriptionId, error } = payload;
|
|
1552
|
-
if (subscriptionId) {
|
|
1553
|
-
for (const [subId, queryKey] of subscriptionMapRef.current) {
|
|
1554
|
-
if (subId === subscriptionId) {
|
|
1555
|
-
storeRef.current.setError(queryKey, error);
|
|
1556
|
-
break;
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
} else {
|
|
1560
|
-
const parsed = parseServerError(error);
|
|
1561
|
-
console.error("[RecordProvider] Error:", error);
|
|
1562
|
-
if (parsed.isPermissionError) {
|
|
1563
|
-
onPermissionErrorRef.current?.(parsed.title, parsed.detail);
|
|
1564
|
-
} else {
|
|
1565
|
-
onValidationErrorRef.current?.(parsed.title, parsed.detail);
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
break;
|
|
1569
|
-
}
|
|
1570
|
-
case MSG.YJS_JOIN: {
|
|
1571
|
-
const { collection, recordId, fieldName, canWrite } = payload;
|
|
1572
|
-
const docKey = `${collection}:${recordId}:${fieldName}`;
|
|
1573
|
-
yjsJoinHandlersRef.current.get(docKey)?.forEach((handler) => handler(canWrite));
|
|
1574
|
-
break;
|
|
1575
|
-
}
|
|
1576
|
-
case MSG.ACK: {
|
|
1577
|
-
const { requestId, success, error, ...rest } = payload;
|
|
1578
|
-
const pending = pendingRequestsRef.current.get(requestId);
|
|
1579
|
-
if (pending) {
|
|
1580
|
-
clearTimeout(pending.timer);
|
|
1581
|
-
pendingRequestsRef.current.delete(requestId);
|
|
1582
|
-
if (success) pending.resolve(rest);
|
|
1583
|
-
else pending.reject(new Error(error || "Mutation rejected"));
|
|
1584
|
-
}
|
|
1585
|
-
break;
|
|
1586
|
-
}
|
|
1587
|
-
case MSG.RESUBSCRIBE: {
|
|
1588
|
-
const ws = wsRef.current;
|
|
1589
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1590
|
-
for (const [subscriptionId, queryKey] of subscriptionMapRef.current) {
|
|
1591
|
-
try {
|
|
1592
|
-
const query = JSON.parse(queryKey);
|
|
1593
|
-
ws.send(JSON.stringify({ type: MSG.SUBSCRIBE, payload: { subscriptionId, query } }));
|
|
1594
|
-
} catch {
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
break;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
} catch (e) {
|
|
1602
|
-
console.error("[RecordProvider] Failed to parse message:", e);
|
|
1603
|
-
}
|
|
1604
|
-
}, [roomId]);
|
|
1605
|
-
const connect = useCallback2(async () => {
|
|
1606
|
-
const profile = userProfileRef.current;
|
|
1607
|
-
if (!profile && !allowAnonymous) return;
|
|
1608
|
-
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
1609
|
-
if (profile && !connectedWithProfileRef.current) {
|
|
1610
|
-
debugLog(`[WS] Reconnecting with profile data`, { roomId });
|
|
1611
|
-
wsRef.current.onclose = null;
|
|
1612
|
-
wsRef.current.close();
|
|
1613
|
-
wsRef.current = null;
|
|
1614
|
-
} else {
|
|
1615
|
-
return;
|
|
1805
|
+
},
|
|
1806
|
+
onPermissionError: (title, detail) => onPermissionErrorRef.current?.(title, detail),
|
|
1807
|
+
onValidationError: (title, detail) => onValidationErrorRef.current?.(title, detail)
|
|
1616
1808
|
}
|
|
1809
|
+
});
|
|
1810
|
+
socketRef.current = socket;
|
|
1811
|
+
if (userProfileId || allowAnonymous) {
|
|
1812
|
+
void socket.connect(userProfileId ?? "");
|
|
1617
1813
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
}
|
|
1623
|
-
const myToken = ++connectTokenRef.current;
|
|
1624
|
-
let baseUrl;
|
|
1625
|
-
if (wsUrl) {
|
|
1626
|
-
baseUrl = wsUrl.replace(/^http/, "ws");
|
|
1627
|
-
} else {
|
|
1628
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
1629
|
-
baseUrl = `${protocol}//${window.location.host}`;
|
|
1630
|
-
}
|
|
1631
|
-
const params = new URLSearchParams();
|
|
1632
|
-
try {
|
|
1633
|
-
const tokenFn = getAuthTokenProp ?? getAuthToken;
|
|
1634
|
-
const token = await tokenFn();
|
|
1635
|
-
if (token) params.set("token", token);
|
|
1636
|
-
} catch {
|
|
1637
|
-
}
|
|
1638
|
-
if (connectTokenRef.current !== myToken) return;
|
|
1639
|
-
connectedWithProfileRef.current = !!profile;
|
|
1640
|
-
const wsPath = `/ws/${roomId}`;
|
|
1641
|
-
const url = `${baseUrl}${wsPath}?${params.toString()}`;
|
|
1642
|
-
debugLog("[WS] Connecting:", { roomId, attempt: reconnectAttemptRef.current });
|
|
1643
|
-
const ws = new WebSocket(url);
|
|
1644
|
-
ws.binaryType = "arraybuffer";
|
|
1645
|
-
ws.onopen = () => {
|
|
1646
|
-
debugLog("[WS] Connected:", { roomId });
|
|
1647
|
-
setStatus("connected");
|
|
1648
|
-
};
|
|
1649
|
-
ws.onmessage = handleMessage;
|
|
1650
|
-
ws.onclose = (event) => {
|
|
1651
|
-
debugLog("[WS] Closed:", {
|
|
1652
|
-
roomId,
|
|
1653
|
-
code: event.code,
|
|
1654
|
-
reason: event.reason,
|
|
1655
|
-
wasClean: event.wasClean
|
|
1656
|
-
});
|
|
1657
|
-
setStatus("disconnected");
|
|
1814
|
+
return () => {
|
|
1815
|
+
socketRef.current = null;
|
|
1816
|
+
socket.destroy();
|
|
1817
|
+
setStatus("connecting");
|
|
1658
1818
|
setReady(false);
|
|
1659
|
-
wsRef.current = null;
|
|
1660
|
-
for (const pending of pendingRequestsRef.current.values()) {
|
|
1661
|
-
clearTimeout(pending.timer);
|
|
1662
|
-
pending.reject(new Error("WebSocket disconnected"));
|
|
1663
|
-
}
|
|
1664
|
-
pendingRequestsRef.current.clear();
|
|
1665
|
-
const attempt = reconnectAttemptRef.current;
|
|
1666
|
-
const delay = Math.min(1e3 * Math.pow(2, attempt), 3e4);
|
|
1667
|
-
debugLog("[WS] Scheduling reconnect:", { roomId, attempt, delayMs: delay });
|
|
1668
|
-
reconnectAttemptRef.current = attempt + 1;
|
|
1669
|
-
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
|
1670
|
-
};
|
|
1671
|
-
ws.onerror = (e) => {
|
|
1672
|
-
console.error("[WS] Error:", { roomId, error: e });
|
|
1673
|
-
};
|
|
1674
|
-
wsRef.current = ws;
|
|
1675
|
-
}, [roomId, wsUrl, handleMessage, allowAnonymous, getAuthTokenProp]);
|
|
1676
|
-
useEffect4(() => {
|
|
1677
|
-
if (userProfile?.id) connect();
|
|
1678
|
-
else if (allowAnonymous && !userProfileLoading) connect();
|
|
1679
|
-
}, [userProfile?.id, userProfileLoading, allowAnonymous, connect]);
|
|
1680
|
-
useEffect4(() => {
|
|
1681
|
-
const handleVisibilityChange = () => {
|
|
1682
|
-
if (document.visibilityState === "visible") {
|
|
1683
|
-
const ws = wsRef.current;
|
|
1684
|
-
if (ws?.readyState !== WebSocket.OPEN && (userProfileRef.current || allowAnonymous)) {
|
|
1685
|
-
debugLog("[WS] Reconnecting after visibility change");
|
|
1686
|
-
reconnectAttemptRef.current = 0;
|
|
1687
|
-
connect();
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
1819
|
};
|
|
1691
|
-
|
|
1692
|
-
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1693
|
-
}, [roomId, connect, allowAnonymous]);
|
|
1820
|
+
}, [roomId, wsUrl, userProfileId, userProfileLoading, allowAnonymous]);
|
|
1694
1821
|
useEffect4(() => {
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
if (ws) {
|
|
1701
|
-
ws.onclose = null;
|
|
1702
|
-
ws.onmessage = null;
|
|
1703
|
-
ws.onerror = null;
|
|
1704
|
-
ws.close();
|
|
1705
|
-
wsRef.current = null;
|
|
1822
|
+
const onVisible = () => {
|
|
1823
|
+
if (document.visibilityState === "visible" && !socketRef.current?.isOpen) {
|
|
1824
|
+
debugLog("[WS] Reconnecting after visibility change");
|
|
1825
|
+
socketRef.current?.resetBackoff();
|
|
1826
|
+
void socketRef.current?.connect();
|
|
1706
1827
|
}
|
|
1707
1828
|
};
|
|
1708
|
-
|
|
1829
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
1830
|
+
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
1831
|
+
}, []);
|
|
1709
1832
|
const sendMessage = useCallback2((message) => {
|
|
1710
|
-
|
|
1711
|
-
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
|
1712
|
-
else console.warn("[RecordProvider] Cannot send message, WebSocket not connected");
|
|
1833
|
+
socketRef.current?.sendMessage(message);
|
|
1713
1834
|
}, []);
|
|
1714
1835
|
const setUserRole = useCallback2(
|
|
1715
1836
|
(userId, role) => {
|
|
@@ -1721,14 +1842,13 @@ function RecordProviderCore({
|
|
|
1721
1842
|
sendMessage({ type: MSG.USER_LIST, payload: {} });
|
|
1722
1843
|
}, [sendMessage]);
|
|
1723
1844
|
const registerSubscription = useCallback2((subscriptionId, queryKey) => {
|
|
1724
|
-
|
|
1845
|
+
subscriptionsRef.current.set(subscriptionId, queryKey);
|
|
1725
1846
|
}, []);
|
|
1726
1847
|
const unregisterSubscription = useCallback2((subscriptionId) => {
|
|
1727
|
-
|
|
1848
|
+
subscriptionsRef.current.delete(subscriptionId);
|
|
1728
1849
|
}, []);
|
|
1729
1850
|
const sendBinary = useCallback2((data) => {
|
|
1730
|
-
|
|
1731
|
-
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
1851
|
+
socketRef.current?.sendBinary(data);
|
|
1732
1852
|
}, []);
|
|
1733
1853
|
const onBinaryMessage = useCallback2((handler) => {
|
|
1734
1854
|
binaryHandlersRef.current.add(handler);
|
|
@@ -1738,15 +1858,14 @@ function RecordProviderCore({
|
|
|
1738
1858
|
}, []);
|
|
1739
1859
|
const registerYjsJoinHandler = useCallback2(
|
|
1740
1860
|
(docKey, handler) => {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
yjsJoinHandlersRef.current.get(docKey).add(handler);
|
|
1861
|
+
const handlers = yjsJoinHandlersRef.current;
|
|
1862
|
+
if (!handlers.has(docKey)) handlers.set(docKey, /* @__PURE__ */ new Set());
|
|
1863
|
+
handlers.get(docKey).add(handler);
|
|
1745
1864
|
return () => {
|
|
1746
|
-
const
|
|
1747
|
-
if (
|
|
1748
|
-
|
|
1749
|
-
if (
|
|
1865
|
+
const set = handlers.get(docKey);
|
|
1866
|
+
if (set) {
|
|
1867
|
+
set.delete(handler);
|
|
1868
|
+
if (set.size === 0) handlers.delete(docKey);
|
|
1750
1869
|
}
|
|
1751
1870
|
};
|
|
1752
1871
|
},
|
|
@@ -1754,19 +1873,9 @@ function RecordProviderCore({
|
|
|
1754
1873
|
);
|
|
1755
1874
|
const sendConfirmed = useCallback2(
|
|
1756
1875
|
(message, timeoutMs = 1e4) => {
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
return Promise.reject(new Error("WebSocket not connected"));
|
|
1761
|
-
}
|
|
1762
|
-
return new Promise((resolve, reject) => {
|
|
1763
|
-
const timer = setTimeout(() => {
|
|
1764
|
-
pendingRequestsRef.current.delete(requestId);
|
|
1765
|
-
reject(new Error("Mutation confirmation timed out"));
|
|
1766
|
-
}, timeoutMs);
|
|
1767
|
-
pendingRequestsRef.current.set(requestId, { resolve, reject, timer });
|
|
1768
|
-
ws.send(JSON.stringify({ ...message, payload: { ...message.payload, requestId } }));
|
|
1769
|
-
});
|
|
1876
|
+
const socket = socketRef.current;
|
|
1877
|
+
if (!socket) return Promise.reject(new Error("WebSocket not connected"));
|
|
1878
|
+
return socket.sendConfirmed(message, timeoutMs);
|
|
1770
1879
|
},
|
|
1771
1880
|
[]
|
|
1772
1881
|
);
|
|
@@ -1869,6 +1978,7 @@ function RecordProviderAuthOnly({
|
|
|
1869
1978
|
if (mounted) {
|
|
1870
1979
|
fetchUser().then((p) => {
|
|
1871
1980
|
if (mounted) setUserProfile(p);
|
|
1981
|
+
}).catch(() => {
|
|
1872
1982
|
});
|
|
1873
1983
|
}
|
|
1874
1984
|
}, 3e4);
|
|
@@ -1962,18 +2072,6 @@ function wsLog(event, label) {
|
|
|
1962
2072
|
|
|
1963
2073
|
// src/client/storage/RecordScope.tsx
|
|
1964
2074
|
import { Fragment as Fragment6, jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1965
|
-
function recordMatchesWhere2(record, where) {
|
|
1966
|
-
if (!where) return true;
|
|
1967
|
-
const r = record;
|
|
1968
|
-
const data = r.data;
|
|
1969
|
-
if (!data) return false;
|
|
1970
|
-
return Object.entries(where).every(([key, value]) => {
|
|
1971
|
-
if (key in data) return data[key] === value;
|
|
1972
|
-
if (key === "recordId") return r.recordId === value;
|
|
1973
|
-
if (key === "createdBy") return r.createdBy === value;
|
|
1974
|
-
return false;
|
|
1975
|
-
});
|
|
1976
|
-
}
|
|
1977
2075
|
function HeadlessScope({
|
|
1978
2076
|
roomId,
|
|
1979
2077
|
schemas,
|
|
@@ -2011,14 +2109,10 @@ function ScopeConnection({
|
|
|
2011
2109
|
const [ready, setReady] = useState4(false);
|
|
2012
2110
|
const [discoveredSchemas, setDiscoveredSchemas] = useState4([]);
|
|
2013
2111
|
const storeRef = useRef5(new RecordStore());
|
|
2014
|
-
const
|
|
2015
|
-
const
|
|
2112
|
+
const socketRef = useRef5(null);
|
|
2113
|
+
const subscriptionsRef = useRef5(/* @__PURE__ */ new Map());
|
|
2016
2114
|
const binaryHandlersRef = useRef5(/* @__PURE__ */ new Set());
|
|
2017
2115
|
const yjsJoinHandlersRef = useRef5(/* @__PURE__ */ new Map());
|
|
2018
|
-
const pendingRequestsRef = useRef5(/* @__PURE__ */ new Map());
|
|
2019
|
-
const reconnectAttemptRef = useRef5(0);
|
|
2020
|
-
const reconnectTimeoutRef = useRef5(null);
|
|
2021
|
-
const connectTokenRef = useRef5(0);
|
|
2022
2116
|
const userProfileRef = useRef5(auth?.userProfile ?? null);
|
|
2023
2117
|
userProfileRef.current = auth?.userProfile ?? null;
|
|
2024
2118
|
const getAuthTokenRef = useRef5(auth?.getAuthToken ?? null);
|
|
@@ -2030,239 +2124,60 @@ function ScopeConnection({
|
|
|
2030
2124
|
authCallbacksRef.current.onPermissionError = auth?.onPermissionError;
|
|
2031
2125
|
authCallbacksRef.current.onValidationError = auth?.onValidationError;
|
|
2032
2126
|
const allowAnonymous = auth?.allowAnonymous ?? false;
|
|
2033
|
-
const handleMessage = useCallback3(
|
|
2034
|
-
(event) => {
|
|
2035
|
-
if (event.data instanceof ArrayBuffer) {
|
|
2036
|
-
binaryHandlersRef.current.forEach((h) => h(event.data));
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
let msg;
|
|
2040
|
-
try {
|
|
2041
|
-
msg = JSON.parse(event.data);
|
|
2042
|
-
} catch {
|
|
2043
|
-
console.error("[RecordScope] Failed to parse message");
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
const { type, payload } = msg;
|
|
2047
|
-
switch (type) {
|
|
2048
|
-
case MSG.USER_INFO: {
|
|
2049
|
-
const { role } = payload;
|
|
2050
|
-
setRoomRole(role);
|
|
2051
|
-
setReady(true);
|
|
2052
|
-
reconnectAttemptRef.current = 0;
|
|
2053
|
-
const ws = wsRef.current;
|
|
2054
|
-
if (ws?.readyState === WebSocket.OPEN) {
|
|
2055
|
-
ws.send(JSON.stringify({ type: MSG.USER_LIST, payload: {} }));
|
|
2056
|
-
}
|
|
2057
|
-
break;
|
|
2058
|
-
}
|
|
2059
|
-
case MSG.USER_LIST:
|
|
2060
|
-
setAllUsers(payload.users);
|
|
2061
|
-
setUsersLoaded(true);
|
|
2062
|
-
break;
|
|
2063
|
-
case MSG.QUERY_RESULT: {
|
|
2064
|
-
const { subscriptionId, records } = payload;
|
|
2065
|
-
for (const [subId, queryKey] of subscriptionMapRef.current) {
|
|
2066
|
-
if (subId === subscriptionId) {
|
|
2067
|
-
storeRef.current.setQueryResult(queryKey, records);
|
|
2068
|
-
break;
|
|
2069
|
-
}
|
|
2070
|
-
}
|
|
2071
|
-
break;
|
|
2072
|
-
}
|
|
2073
|
-
case MSG.RECORD_CHANGE: {
|
|
2074
|
-
const { collection, record, changeType } = payload;
|
|
2075
|
-
for (const [, queryKey] of subscriptionMapRef.current) {
|
|
2076
|
-
try {
|
|
2077
|
-
const query = JSON.parse(queryKey);
|
|
2078
|
-
if (query.collection !== collection) continue;
|
|
2079
|
-
const matches = recordMatchesWhere2(record, query.where);
|
|
2080
|
-
const exists = storeRef.current.hasRecord(queryKey, record.recordId);
|
|
2081
|
-
if (changeType === "delete") {
|
|
2082
|
-
if (exists) storeRef.current.applyChange(queryKey, record, "delete");
|
|
2083
|
-
} else if (changeType === "create") {
|
|
2084
|
-
if (matches) storeRef.current.applyChange(queryKey, record, "create");
|
|
2085
|
-
} else {
|
|
2086
|
-
if (matches && exists) storeRef.current.applyChange(queryKey, record, "update");
|
|
2087
|
-
else if (matches && !exists)
|
|
2088
|
-
storeRef.current.applyChange(queryKey, record, "create");
|
|
2089
|
-
else if (!matches && exists)
|
|
2090
|
-
storeRef.current.applyChange(queryKey, record, "delete");
|
|
2091
|
-
}
|
|
2092
|
-
} catch {
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
break;
|
|
2096
|
-
}
|
|
2097
|
-
case MSG.ERROR: {
|
|
2098
|
-
const { subscriptionId, error } = payload;
|
|
2099
|
-
if (subscriptionId) {
|
|
2100
|
-
for (const [subId, queryKey] of subscriptionMapRef.current) {
|
|
2101
|
-
if (subId === subscriptionId) {
|
|
2102
|
-
storeRef.current.setError(queryKey, error);
|
|
2103
|
-
break;
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
} else {
|
|
2107
|
-
const parsed = parseServerError(error);
|
|
2108
|
-
if (parsed.isPermissionError) {
|
|
2109
|
-
authCallbacksRef.current.onPermissionError?.(parsed.title, parsed.detail);
|
|
2110
|
-
} else {
|
|
2111
|
-
authCallbacksRef.current.onValidationError?.(parsed.title, parsed.detail);
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
break;
|
|
2115
|
-
}
|
|
2116
|
-
case MSG.YJS_JOIN: {
|
|
2117
|
-
const { collection, recordId, fieldName, canWrite } = payload;
|
|
2118
|
-
const docKey = `${collection}:${recordId}:${fieldName}`;
|
|
2119
|
-
yjsJoinHandlersRef.current.get(docKey)?.forEach((h) => h(canWrite));
|
|
2120
|
-
break;
|
|
2121
|
-
}
|
|
2122
|
-
case MSG.ACK: {
|
|
2123
|
-
const { requestId, success, error, ...rest } = payload;
|
|
2124
|
-
const pending = pendingRequestsRef.current.get(requestId);
|
|
2125
|
-
if (pending) {
|
|
2126
|
-
clearTimeout(pending.timer);
|
|
2127
|
-
pendingRequestsRef.current.delete(requestId);
|
|
2128
|
-
if (success) {
|
|
2129
|
-
pending.resolve(rest);
|
|
2130
|
-
} else {
|
|
2131
|
-
pending.reject(new Error(error || "Mutation rejected"));
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
break;
|
|
2135
|
-
}
|
|
2136
|
-
case MSG.LIST_SCHEMAS: {
|
|
2137
|
-
const { schemas: list } = payload;
|
|
2138
|
-
setDiscoveredSchemas(list ?? []);
|
|
2139
|
-
break;
|
|
2140
|
-
}
|
|
2141
|
-
case MSG.RESUBSCRIBE: {
|
|
2142
|
-
const ws = wsRef.current;
|
|
2143
|
-
if (ws?.readyState === WebSocket.OPEN) {
|
|
2144
|
-
for (const [subscriptionId, queryKey] of subscriptionMapRef.current) {
|
|
2145
|
-
try {
|
|
2146
|
-
const query = JSON.parse(queryKey);
|
|
2147
|
-
ws.send(JSON.stringify({ type: MSG.SUBSCRIBE, payload: { subscriptionId, query } }));
|
|
2148
|
-
} catch {
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
}
|
|
2152
|
-
break;
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
},
|
|
2156
|
-
// Body reads only refs/setters, never roomId; keeping deps empty makes the
|
|
2157
|
-
// handler stable and avoids needlessly re-triggering the connect effect
|
|
2158
|
-
// (which lists roomId itself).
|
|
2159
|
-
[]
|
|
2160
|
-
);
|
|
2161
|
-
const connect = useCallback3(async () => {
|
|
2162
|
-
const profile = userProfileRef.current;
|
|
2163
|
-
const hasTokenProvider = !!getAuthTokenRef.current;
|
|
2164
|
-
if (!profile && !hasTokenProvider && !allowAnonymous) return;
|
|
2165
|
-
if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING)
|
|
2166
|
-
return;
|
|
2167
|
-
if (reconnectTimeoutRef.current) {
|
|
2168
|
-
clearTimeout(reconnectTimeoutRef.current);
|
|
2169
|
-
reconnectTimeoutRef.current = null;
|
|
2170
|
-
}
|
|
2171
|
-
const myToken = ++connectTokenRef.current;
|
|
2172
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
2173
|
-
const baseUrl = wsUrl?.replace(/^http/, "ws") ?? `${protocol}//${window.location.host}`;
|
|
2174
|
-
const params = new URLSearchParams();
|
|
2175
|
-
try {
|
|
2176
|
-
const tokenFn = getAuthTokenRef.current ?? getAuthToken;
|
|
2177
|
-
const token = await tokenFn();
|
|
2178
|
-
if (token) params.set("token", token);
|
|
2179
|
-
} catch {
|
|
2180
|
-
}
|
|
2181
|
-
if (connectTokenRef.current !== myToken) return;
|
|
2182
|
-
params.set("appId", appId);
|
|
2183
|
-
const url = `${baseUrl}${wsPathPrefix}/${roomId}?${params.toString()}`;
|
|
2184
|
-
wsLog("connecting", roomId);
|
|
2185
|
-
const ws = new WebSocket(url);
|
|
2186
|
-
ws.binaryType = "arraybuffer";
|
|
2187
|
-
wsRef.current = ws;
|
|
2188
|
-
ws.onopen = () => {
|
|
2189
|
-
wsLog("connected", roomId);
|
|
2190
|
-
setStatus("connected");
|
|
2191
|
-
for (const [subscriptionId, queryKey] of subscriptionMapRef.current) {
|
|
2192
|
-
try {
|
|
2193
|
-
const query = JSON.parse(queryKey);
|
|
2194
|
-
ws.send(JSON.stringify({ type: MSG.SUBSCRIBE, payload: { subscriptionId, query } }));
|
|
2195
|
-
} catch {
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
};
|
|
2199
|
-
ws.onmessage = handleMessage;
|
|
2200
|
-
ws.onclose = () => {
|
|
2201
|
-
wsLog("disconnected", roomId);
|
|
2202
|
-
setStatus("disconnected");
|
|
2203
|
-
setReady(false);
|
|
2204
|
-
wsRef.current = null;
|
|
2205
|
-
for (const [, queryKey] of subscriptionMapRef.current)
|
|
2206
|
-
storeRef.current.resetToLoading(queryKey);
|
|
2207
|
-
for (const [, pending] of pendingRequestsRef.current) {
|
|
2208
|
-
clearTimeout(pending.timer);
|
|
2209
|
-
pending.reject(new Error("WebSocket disconnected"));
|
|
2210
|
-
}
|
|
2211
|
-
pendingRequestsRef.current.clear();
|
|
2212
|
-
const attempt = reconnectAttemptRef.current;
|
|
2213
|
-
const delay = Math.min(1e3 * Math.pow(2, attempt), 3e4);
|
|
2214
|
-
reconnectAttemptRef.current = attempt + 1;
|
|
2215
|
-
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
|
2216
|
-
};
|
|
2217
|
-
ws.onerror = () => {
|
|
2218
|
-
};
|
|
2219
|
-
}, [roomId, wsUrl, wsPathPrefix, handleMessage, allowAnonymous, appId]);
|
|
2220
|
-
const disconnect = useCallback3(() => {
|
|
2221
|
-
connectTokenRef.current++;
|
|
2222
|
-
if (reconnectTimeoutRef.current) {
|
|
2223
|
-
clearTimeout(reconnectTimeoutRef.current);
|
|
2224
|
-
reconnectTimeoutRef.current = null;
|
|
2225
|
-
}
|
|
2226
|
-
const ws = wsRef.current;
|
|
2227
|
-
if (ws) {
|
|
2228
|
-
wsLog("closing", roomId);
|
|
2229
|
-
ws.onclose = null;
|
|
2230
|
-
ws.onmessage = null;
|
|
2231
|
-
ws.onerror = null;
|
|
2232
|
-
ws.close();
|
|
2233
|
-
wsRef.current = null;
|
|
2234
|
-
}
|
|
2235
|
-
setStatus("connecting");
|
|
2236
|
-
setReady(false);
|
|
2237
|
-
reconnectAttemptRef.current = 0;
|
|
2238
|
-
}, [roomId]);
|
|
2239
2127
|
const userProfileId = auth?.userProfile?.id ?? null;
|
|
2240
2128
|
const userProfileLoading = auth?.userProfileLoading ?? false;
|
|
2241
2129
|
useEffect5(() => {
|
|
2242
2130
|
if (userProfileLoading) return;
|
|
2243
|
-
|
|
2131
|
+
const socket = new RecordSocket({
|
|
2132
|
+
roomId,
|
|
2133
|
+
store: storeRef.current,
|
|
2134
|
+
subscriptions: subscriptionsRef.current,
|
|
2135
|
+
binaryHandlers: binaryHandlersRef.current,
|
|
2136
|
+
yjsJoinHandlers: yjsJoinHandlersRef.current,
|
|
2137
|
+
getToken: () => (getAuthTokenRef.current ?? getAuthToken)(),
|
|
2138
|
+
wsUrl,
|
|
2139
|
+
wsPathPrefix,
|
|
2140
|
+
extraParams: { appId },
|
|
2141
|
+
log: (event) => wsLog(event, roomId),
|
|
2142
|
+
listeners: {
|
|
2143
|
+
onStatus: setStatus,
|
|
2144
|
+
onReady: setReady,
|
|
2145
|
+
onRole: setRoomRole,
|
|
2146
|
+
onUsers: (users) => {
|
|
2147
|
+
setAllUsers(users);
|
|
2148
|
+
setUsersLoaded(true);
|
|
2149
|
+
},
|
|
2150
|
+
onSchemas: setDiscoveredSchemas,
|
|
2151
|
+
onPermissionError: (title, detail) => authCallbacksRef.current.onPermissionError?.(title, detail),
|
|
2152
|
+
onValidationError: (title, detail) => authCallbacksRef.current.onValidationError?.(title, detail)
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
socketRef.current = socket;
|
|
2244
2156
|
if (userProfileId || allowAnonymous) {
|
|
2245
|
-
connect();
|
|
2157
|
+
void socket.connect(userProfileId ?? "");
|
|
2246
2158
|
}
|
|
2247
|
-
return
|
|
2248
|
-
|
|
2159
|
+
return () => {
|
|
2160
|
+
socketRef.current = null;
|
|
2161
|
+
socket.destroy();
|
|
2162
|
+
setStatus("connecting");
|
|
2163
|
+
setReady(false);
|
|
2164
|
+
};
|
|
2165
|
+
}, [roomId, wsUrl, wsPathPrefix, appId, userProfileId, userProfileLoading, allowAnonymous]);
|
|
2249
2166
|
useEffect5(() => {
|
|
2250
2167
|
const onVisible = () => {
|
|
2251
|
-
if (document.visibilityState === "visible" &&
|
|
2252
|
-
|
|
2253
|
-
connect();
|
|
2168
|
+
if (document.visibilityState === "visible" && !socketRef.current?.isOpen) {
|
|
2169
|
+
socketRef.current?.resetBackoff();
|
|
2170
|
+
void socketRef.current?.connect();
|
|
2254
2171
|
}
|
|
2255
2172
|
};
|
|
2256
2173
|
document.addEventListener("visibilitychange", onVisible);
|
|
2257
2174
|
return () => document.removeEventListener("visibilitychange", onVisible);
|
|
2258
|
-
}, [
|
|
2175
|
+
}, []);
|
|
2259
2176
|
const sendMessage = useCallback3((message) => {
|
|
2260
|
-
|
|
2261
|
-
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
|
2177
|
+
socketRef.current?.sendMessage(message);
|
|
2262
2178
|
}, []);
|
|
2263
2179
|
const sendBinary = useCallback3((data) => {
|
|
2264
|
-
|
|
2265
|
-
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
|
2180
|
+
socketRef.current?.sendBinary(data);
|
|
2266
2181
|
}, []);
|
|
2267
2182
|
const onBinaryMessage = useCallback3((handler) => {
|
|
2268
2183
|
binaryHandlersRef.current.add(handler);
|
|
@@ -2272,13 +2187,14 @@ function ScopeConnection({
|
|
|
2272
2187
|
}, []);
|
|
2273
2188
|
const registerYjsJoinHandler = useCallback3(
|
|
2274
2189
|
(docKey, handler) => {
|
|
2275
|
-
|
|
2276
|
-
|
|
2190
|
+
const handlers = yjsJoinHandlersRef.current;
|
|
2191
|
+
if (!handlers.has(docKey)) handlers.set(docKey, /* @__PURE__ */ new Set());
|
|
2192
|
+
handlers.get(docKey).add(handler);
|
|
2277
2193
|
return () => {
|
|
2278
|
-
const
|
|
2279
|
-
if (
|
|
2280
|
-
|
|
2281
|
-
if (
|
|
2194
|
+
const set = handlers.get(docKey);
|
|
2195
|
+
if (set) {
|
|
2196
|
+
set.delete(handler);
|
|
2197
|
+
if (set.size === 0) handlers.delete(docKey);
|
|
2282
2198
|
}
|
|
2283
2199
|
};
|
|
2284
2200
|
},
|
|
@@ -2286,18 +2202,9 @@ function ScopeConnection({
|
|
|
2286
2202
|
);
|
|
2287
2203
|
const sendConfirmed = useCallback3(
|
|
2288
2204
|
(message, timeoutMs = 1e4) => {
|
|
2289
|
-
const
|
|
2290
|
-
if (!
|
|
2291
|
-
|
|
2292
|
-
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
2293
|
-
return new Promise((resolve, reject) => {
|
|
2294
|
-
const timer = setTimeout(() => {
|
|
2295
|
-
pendingRequestsRef.current.delete(requestId);
|
|
2296
|
-
reject(new Error("Mutation confirmation timed out"));
|
|
2297
|
-
}, timeoutMs);
|
|
2298
|
-
pendingRequestsRef.current.set(requestId, { resolve, reject, timer });
|
|
2299
|
-
ws.send(JSON.stringify({ ...message, payload: { ...message.payload, requestId } }));
|
|
2300
|
-
});
|
|
2205
|
+
const socket = socketRef.current;
|
|
2206
|
+
if (!socket) return Promise.reject(new Error("WebSocket not connected"));
|
|
2207
|
+
return socket.sendConfirmed(message, timeoutMs);
|
|
2301
2208
|
},
|
|
2302
2209
|
[]
|
|
2303
2210
|
);
|
|
@@ -2311,10 +2218,10 @@ function ScopeConnection({
|
|
|
2311
2218
|
sendMessage({ type: MSG.USER_LIST, payload: {} });
|
|
2312
2219
|
}, [sendMessage]);
|
|
2313
2220
|
const registerSubscription = useCallback3((subscriptionId, queryKey) => {
|
|
2314
|
-
|
|
2221
|
+
subscriptionsRef.current.set(subscriptionId, queryKey);
|
|
2315
2222
|
}, []);
|
|
2316
2223
|
const unregisterSubscription = useCallback3((subscriptionId) => {
|
|
2317
|
-
|
|
2224
|
+
subscriptionsRef.current.delete(subscriptionId);
|
|
2318
2225
|
}, []);
|
|
2319
2226
|
useEffect5(() => {
|
|
2320
2227
|
if (ready) sendMessage({ type: MSG.LIST_SCHEMAS, payload: {} });
|