deepspace 0.5.1 → 0.5.2

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/index.js CHANGED
@@ -1278,33 +1278,21 @@ var RecordStore = class {
1278
1278
  }
1279
1279
  };
1280
1280
 
1281
- // src/client/storage/ScopeRegistry.tsx
1282
- import { createContext, useContext, useCallback, useRef as useRef3, useMemo } from "react";
1283
- import { jsx as jsx5 } from "react/jsx-runtime";
1284
- var ScopeRegistryContext = createContext(null);
1285
- function useScopeRegistry() {
1286
- return useContext(ScopeRegistryContext);
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 ScopeRegistryProvider({ children }) {
1289
- const mapRef = useRef3(/* @__PURE__ */ new Map());
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 wsRef = useRef4(null);
1454
- const subscriptionMapRef = useRef4(/* @__PURE__ */ new Map());
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 handleMessage = useCallback2((event) => {
1492
- if (event.data instanceof ArrayBuffer) {
1493
- binaryHandlersRef.current.forEach((handler) => handler(event.data));
1494
- return;
1495
- }
1496
- try {
1497
- const msg = JSON.parse(event.data);
1498
- const { type, payload } = msg;
1499
- switch (type) {
1500
- case MSG.USER_INFO: {
1501
- const serverUser = payload;
1502
- debugLog("[WS] Ready:", { roomId, role: serverUser.role });
1503
- setRoomRole(serverUser.role);
1504
- setReady(true);
1505
- reconnectAttemptRef.current = 0;
1506
- const ws = wsRef.current;
1507
- if (ws && ws.readyState === WebSocket.OPEN) {
1508
- ws.send(JSON.stringify({ type: MSG.USER_LIST, payload: {} }));
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
- break;
1516
- case MSG.QUERY_RESULT: {
1517
- const { subscriptionId, records } = payload;
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
- if (wsRef.current?.readyState === WebSocket.CONNECTING) return;
1619
- if (reconnectTimeoutRef.current) {
1620
- clearTimeout(reconnectTimeoutRef.current);
1621
- reconnectTimeoutRef.current = null;
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
- document.addEventListener("visibilitychange", handleVisibilityChange);
1692
- return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
1693
- }, [roomId, connect, allowAnonymous]);
1820
+ }, [roomId, wsUrl, userProfileId, userProfileLoading, allowAnonymous]);
1694
1821
  useEffect4(() => {
1695
- return () => {
1696
- debugLog("[WS] Unmounting provider:", { roomId });
1697
- if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
1698
- connectTokenRef.current++;
1699
- const ws = wsRef.current;
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
- }, [roomId]);
1829
+ document.addEventListener("visibilitychange", onVisible);
1830
+ return () => document.removeEventListener("visibilitychange", onVisible);
1831
+ }, []);
1709
1832
  const sendMessage = useCallback2((message) => {
1710
- const ws = wsRef.current;
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
- subscriptionMapRef.current.set(subscriptionId, queryKey);
1845
+ subscriptionsRef.current.set(subscriptionId, queryKey);
1725
1846
  }, []);
1726
1847
  const unregisterSubscription = useCallback2((subscriptionId) => {
1727
- subscriptionMapRef.current.delete(subscriptionId);
1848
+ subscriptionsRef.current.delete(subscriptionId);
1728
1849
  }, []);
1729
1850
  const sendBinary = useCallback2((data) => {
1730
- const ws = wsRef.current;
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
- if (!yjsJoinHandlersRef.current.has(docKey)) {
1742
- yjsJoinHandlersRef.current.set(docKey, /* @__PURE__ */ new Set());
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 handlers = yjsJoinHandlersRef.current.get(docKey);
1747
- if (handlers) {
1748
- handlers.delete(handler);
1749
- if (handlers.size === 0) yjsJoinHandlersRef.current.delete(docKey);
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 requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
1758
- const ws = wsRef.current;
1759
- if (!ws || ws.readyState !== WebSocket.OPEN) {
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 wsRef = useRef5(null);
2015
- const subscriptionMapRef = useRef5(/* @__PURE__ */ new Map());
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
- disconnect();
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 disconnect;
2248
- }, [userProfileId, userProfileLoading, allowAnonymous, connect, disconnect]);
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" && wsRef.current?.readyState !== WebSocket.OPEN) {
2252
- reconnectAttemptRef.current = 0;
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
- }, [connect]);
2175
+ }, []);
2259
2176
  const sendMessage = useCallback3((message) => {
2260
- const ws = wsRef.current;
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
- const ws = wsRef.current;
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
- if (!yjsJoinHandlersRef.current.has(docKey)) yjsJoinHandlersRef.current.set(docKey, /* @__PURE__ */ new Set());
2276
- yjsJoinHandlersRef.current.get(docKey).add(handler);
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 handlers = yjsJoinHandlersRef.current.get(docKey);
2279
- if (handlers) {
2280
- handlers.delete(handler);
2281
- if (handlers.size === 0) yjsJoinHandlersRef.current.delete(docKey);
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 ws = wsRef.current;
2290
- if (!ws || ws.readyState !== WebSocket.OPEN)
2291
- return Promise.reject(new Error("WebSocket not connected"));
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
- subscriptionMapRef.current.set(subscriptionId, queryKey);
2221
+ subscriptionsRef.current.set(subscriptionId, queryKey);
2315
2222
  }, []);
2316
2223
  const unregisterSubscription = useCallback3((subscriptionId) => {
2317
- subscriptionMapRef.current.delete(subscriptionId);
2224
+ subscriptionsRef.current.delete(subscriptionId);
2318
2225
  }, []);
2319
2226
  useEffect5(() => {
2320
2227
  if (ready) sendMessage({ type: MSG.LIST_SCHEMAS, payload: {} });