@abloatai/ablo 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- package/package.json +147 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncWebSocket - Manages WebSocket connection to Go sync engine
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - WebSocket lifecycle (connect, reconnect, disconnect)
|
|
6
|
+
* - Delta reception and processing
|
|
7
|
+
* - Multi-tab support
|
|
8
|
+
* - Automatic reconnection with exponential backoff
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import { getContext } from '../context.js';
|
|
12
|
+
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
|
+
import { CapabilityError, SyncSessionError } from '../errors.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
|
+
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
17
|
+
export class SyncWebSocket extends EventEmitter {
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe to events with automatic cleanup.
|
|
20
|
+
* Returns unsubscribe function for clean disposal.
|
|
21
|
+
*/
|
|
22
|
+
subscribe(event, handler) {
|
|
23
|
+
this.on(event, handler);
|
|
24
|
+
return () => this.off(event, handler);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Send a collaboration event (app-specific real-time message).
|
|
28
|
+
* The wire format is `{ type: messageType, payload: { ...payload, timestamp } }`.
|
|
29
|
+
*/
|
|
30
|
+
sendCollaborationEvent(messageType, payload) {
|
|
31
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
32
|
+
return;
|
|
33
|
+
this.send({
|
|
34
|
+
type: messageType.replace(/:/g, '_'), // 'sheet:selection' → 'sheet_selection' wire format
|
|
35
|
+
payload: { ...payload, timestamp: Date.now() },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
ws = null;
|
|
39
|
+
options;
|
|
40
|
+
reconnectAttempts = 0;
|
|
41
|
+
/** Stop retrying after this many consecutive failures (backoff caps at 30s, so ~7.5 min total) */
|
|
42
|
+
static MAX_RECONNECT_ATTEMPTS = 15;
|
|
43
|
+
reconnectTimer = null;
|
|
44
|
+
/** Periodic catchup interval — polls for missed deltas every 30s while connected */
|
|
45
|
+
catchupInterval = null;
|
|
46
|
+
/**
|
|
47
|
+
* Application-level heartbeat. The browser WebSocket API hides RFC 6455
|
|
48
|
+
* protocol-level ping/pong from JavaScript, so the server's `ws.ping()`
|
|
49
|
+
* keepalive can't be observed by client code — meaning the client cannot
|
|
50
|
+
* tell a healthy idle connection apart from a "zombie" socket where TCP
|
|
51
|
+
* silently broke (laptop sleep, NAT timeout, mobile handoff). We send an
|
|
52
|
+
* application-level `{ type: 'ping' }` every 30s and force-close the
|
|
53
|
+
* socket if no inbound traffic arrives within 10s. ANY inbound message
|
|
54
|
+
* counts as proof-of-life — the explicit `pong` is just a guarantee that
|
|
55
|
+
* something will arrive even on an idle stream.
|
|
56
|
+
*/
|
|
57
|
+
heartbeatTimer = null;
|
|
58
|
+
heartbeatTimeoutTimer = null;
|
|
59
|
+
static HEARTBEAT_INTERVAL_MS = 30_000;
|
|
60
|
+
static HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
61
|
+
isConnecting = false;
|
|
62
|
+
isManualClose = false;
|
|
63
|
+
/** When true, a session error has been detected (from any path — WS close or HTTP bootstrap).
|
|
64
|
+
* Suppresses reconnection and Sentry error capture to avoid cascading noise. */
|
|
65
|
+
_sessionErrorDetected = false;
|
|
66
|
+
/** True once `onopen` has fired at least once on the current socket. Reset each
|
|
67
|
+
* time a new socket is created in `connect()`. Used by `onclose` to detect
|
|
68
|
+
* handshake failures (close before open) — the one signal we have for "the
|
|
69
|
+
* server rejected the upgrade" since browsers hide the HTTP status (e.g.
|
|
70
|
+
* 401) behind the opaque 1006 close code. */
|
|
71
|
+
_everOpened = false;
|
|
72
|
+
/**
|
|
73
|
+
* Diagnostic snapshot of the last connection lifecycle. Persisted across
|
|
74
|
+
* the lifetime of the SyncWebSocket so that any subsequent "not connected"
|
|
75
|
+
* rejection can quote the actual root cause (close code + reason + when)
|
|
76
|
+
* instead of bottoming out at a generic error string. Browser WS code 1006
|
|
77
|
+
* hides the real reason, so we layer on our own signals: `forceCloseReason`
|
|
78
|
+
* captures heartbeat trips / send failures, `everOpened` distinguishes
|
|
79
|
+
* handshake reject from mid-session drop, and `sessionErrorAt` tells us
|
|
80
|
+
* whether reconnect is suppressed.
|
|
81
|
+
*/
|
|
82
|
+
lastOpenAt = null;
|
|
83
|
+
lastCloseAt = null;
|
|
84
|
+
lastCloseCode = null;
|
|
85
|
+
lastCloseReason = null;
|
|
86
|
+
lastForceCloseReason = null;
|
|
87
|
+
sessionErrorAt = null;
|
|
88
|
+
lastSyncId;
|
|
89
|
+
versionVector;
|
|
90
|
+
syncCursor = null;
|
|
91
|
+
/** Registered collaboration event keys (colon format) for dispatch in onmessage */
|
|
92
|
+
collaborationEventTypes;
|
|
93
|
+
/**
|
|
94
|
+
* In-flight `commit` mutation requests keyed by clientTxId. Resolved when
|
|
95
|
+
* a matching `mutation_result` frame arrives from the server, or rejected on
|
|
96
|
+
* timeout / disconnect. Lets consumers await a server ack for mutations
|
|
97
|
+
* sent over the same socket that streams deltas.
|
|
98
|
+
*/
|
|
99
|
+
pendingMutations = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* In-flight `claim` requests keyed by claimId. Resolved when the
|
|
102
|
+
* matching `claim_ack` arrives, or rejected on timeout/disconnect.
|
|
103
|
+
* Same shape as pendingMutations — Phoenix-style request/response
|
|
104
|
+
* over a multiplexed connection.
|
|
105
|
+
*/
|
|
106
|
+
pendingClaims = new Map();
|
|
107
|
+
constructor(options) {
|
|
108
|
+
super();
|
|
109
|
+
// Construct WebSocket URL from base Go server URL
|
|
110
|
+
const baseUrl = options.baseUrl || options.url || "http://localhost:8080";
|
|
111
|
+
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
|
|
112
|
+
const wsUrl = baseUrl.replace(/^https?/, wsProtocol) + '/api/sync/ws';
|
|
113
|
+
this.options = {
|
|
114
|
+
url: wsUrl,
|
|
115
|
+
reconnectDelay: 1000,
|
|
116
|
+
maxReconnectDelay: 30000,
|
|
117
|
+
collaborationEvents: ['sheet:selection', 'slide:selection', 'slide:cursor'],
|
|
118
|
+
syncGroups: [],
|
|
119
|
+
lastSyncId: 0,
|
|
120
|
+
versions: {
|
|
121
|
+
tasks: 0,
|
|
122
|
+
projects: 0,
|
|
123
|
+
users: 0,
|
|
124
|
+
events: 0,
|
|
125
|
+
inboxitems: 0,
|
|
126
|
+
teams: 0,
|
|
127
|
+
assignments: 0,
|
|
128
|
+
comments: 0,
|
|
129
|
+
threads: 0,
|
|
130
|
+
},
|
|
131
|
+
capabilities: {
|
|
132
|
+
partialBootstrap: true,
|
|
133
|
+
compressedDeltas: true,
|
|
134
|
+
streamingBootstrap: true,
|
|
135
|
+
batchedDeltas: true,
|
|
136
|
+
},
|
|
137
|
+
...options,
|
|
138
|
+
};
|
|
139
|
+
this.lastSyncId = this.options.lastSyncId;
|
|
140
|
+
this.versionVector = { ...this.options.versions };
|
|
141
|
+
this.syncCursor = null;
|
|
142
|
+
this.collaborationEventTypes = new Set(options.collaborationEvents ?? ['sheet:selection', 'slide:selection', 'slide:cursor']);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Mark that a session error has been detected (e.g. 401 from HTTP bootstrap).
|
|
146
|
+
* Suppresses further reconnection attempts and Sentry error capture.
|
|
147
|
+
*/
|
|
148
|
+
setSessionErrorDetected() {
|
|
149
|
+
this._sessionErrorDetected = true;
|
|
150
|
+
this.sessionErrorAt = Date.now();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Connect to the sync engine WebSocket
|
|
154
|
+
*/
|
|
155
|
+
connect() {
|
|
156
|
+
if (this._sessionErrorDetected) {
|
|
157
|
+
getContext().logger.debug('WebSocket connect suppressed — session error detected');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
|
161
|
+
getContext().logger.debug('WebSocket already connected or connecting');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Note: onlineStatus is advisory — we'll try to connect and let the WebSocket
|
|
165
|
+
// handle failures. The default browser implementation reads navigator.onLine,
|
|
166
|
+
// which is unreliable but the only signal available; in Node it returns true
|
|
167
|
+
// (assume online) so the sidecar/agent path doesn't short-circuit here.
|
|
168
|
+
if (!getContext().onlineStatus.isOnline()) {
|
|
169
|
+
getContext().logger.warn('onlineStatus reports offline, but attempting connection anyway');
|
|
170
|
+
}
|
|
171
|
+
this.isConnecting = true;
|
|
172
|
+
this.isManualClose = false;
|
|
173
|
+
// Pattern: one credential, server-resolved identity. The WS URL
|
|
174
|
+
// carries the credential (cap-token bearer in `?authorization=`
|
|
175
|
+
// for the cap path; session cookie in headers for the cookie
|
|
176
|
+
// path). The server's AuthProvider chain (`apiKeyProvider →
|
|
177
|
+
// agentTokenProvider → betterAuthProvider`) resolves identity
|
|
178
|
+
// from the verified credential — userId/organizationId are
|
|
179
|
+
// NEVER read from URL params in production. See
|
|
180
|
+
// `apps/sync-server/src/auth/provider.ts:148` (betterAuthProvider
|
|
181
|
+
// calls `auth.api.getSession({headers})`) and `agentTokenProvider`
|
|
182
|
+
// for the cap-token path.
|
|
183
|
+
const params = new URLSearchParams({
|
|
184
|
+
// Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
|
|
185
|
+
// and ack messages to avoid stale baselines on reconnect.
|
|
186
|
+
cursor: this.syncCursor || '',
|
|
187
|
+
});
|
|
188
|
+
// Participant kind — defaults to `user` for backward compatibility
|
|
189
|
+
// with web sessions. Agent runtimes pass `'agent'` so the server's
|
|
190
|
+
// capability-token path activates instead of session auth.
|
|
191
|
+
if (this.options.kind && this.options.kind !== 'user') {
|
|
192
|
+
params.set('kind', this.options.kind);
|
|
193
|
+
}
|
|
194
|
+
// Capability bearer (query-param form so it works in both Node's
|
|
195
|
+
// global WebSocket — which can't set headers — and browsers).
|
|
196
|
+
if (this.options.capabilityToken) {
|
|
197
|
+
params.set('authorization', `Bearer ${this.options.capabilityToken}`);
|
|
198
|
+
}
|
|
199
|
+
// Add sync groups if provided
|
|
200
|
+
this.options.syncGroups.forEach((group) => {
|
|
201
|
+
params.append('syncGroups', group);
|
|
202
|
+
});
|
|
203
|
+
const wsUrl = `${this.options.url}?${params.toString()}`;
|
|
204
|
+
try {
|
|
205
|
+
// Reset the handshake flag before wiring the new socket. Each connect()
|
|
206
|
+
// gets its own lifecycle — a prior successful open on a previous socket
|
|
207
|
+
// must not mask a handshake failure on the new one.
|
|
208
|
+
this._everOpened = false;
|
|
209
|
+
this.ws = new WebSocket(wsUrl);
|
|
210
|
+
this.setupEventHandlers();
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// WebSocket constructor can throw if URL is invalid
|
|
214
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
215
|
+
getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
216
|
+
this.isConnecting = false;
|
|
217
|
+
this.emit('error', new Error(errorMessage));
|
|
218
|
+
this.scheduleReconnect();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Setup WebSocket event handlers
|
|
223
|
+
*/
|
|
224
|
+
setupEventHandlers() {
|
|
225
|
+
if (!this.ws)
|
|
226
|
+
return;
|
|
227
|
+
this.ws.onopen = () => {
|
|
228
|
+
getContext().observability.breadcrumb('WebSocket connected', 'sync.websocket', 'info', {
|
|
229
|
+
lastSyncId: this.lastSyncId,
|
|
230
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
231
|
+
});
|
|
232
|
+
this.isConnecting = false;
|
|
233
|
+
this.reconnectAttempts = 0;
|
|
234
|
+
this._everOpened = true;
|
|
235
|
+
this.lastOpenAt = Date.now();
|
|
236
|
+
this.emit('connected');
|
|
237
|
+
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
238
|
+
// this improves localTime accuracy by providing the user's actual timezone)
|
|
239
|
+
this.sendPresenceUpdate('online');
|
|
240
|
+
// Flush any queued offline mutations now that we're online
|
|
241
|
+
// Fire-and-forget; emit events for UI if desired in the future
|
|
242
|
+
(async () => {
|
|
243
|
+
try {
|
|
244
|
+
const res = await flushOfflineQueueOnce();
|
|
245
|
+
if (res.processed > 0) {
|
|
246
|
+
getContext().logger.info('Flushed offline mutations', res);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
getContext().observability.captureOfflineFlushFailure({
|
|
251
|
+
error: e instanceof Error ? e.message : String(e),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
})();
|
|
255
|
+
// Immediately request incremental sync based on our stored cursor/versions
|
|
256
|
+
try {
|
|
257
|
+
if (this.lastSyncId && this.lastSyncId > 0) {
|
|
258
|
+
// Let server know where we left off before requesting deltas
|
|
259
|
+
this.sendAck(this.lastSyncId);
|
|
260
|
+
}
|
|
261
|
+
this.requestIncrementalSync();
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
getContext().observability.breadcrumb('Failed to request incremental sync on open', 'sync.websocket', 'warning', {
|
|
265
|
+
error: e instanceof Error ? e.message : String(e),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// Start periodic catchup — polls for missed deltas every 30s.
|
|
269
|
+
// Real-time WebSocket delivery is best-effort (fire-and-forget Redis pub/sub).
|
|
270
|
+
// This interval guarantees eventual consistency by fetching any deltas that
|
|
271
|
+
// were committed to the DB but whose broadcast was lost in transit.
|
|
272
|
+
this.stopCatchupInterval();
|
|
273
|
+
this.catchupInterval = setInterval(() => {
|
|
274
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
275
|
+
this.requestIncrementalSync();
|
|
276
|
+
}
|
|
277
|
+
}, 30_000);
|
|
278
|
+
// Start application-level heartbeat — see field declaration for rationale.
|
|
279
|
+
this.startHeartbeat();
|
|
280
|
+
};
|
|
281
|
+
this.ws.onmessage = (event) => {
|
|
282
|
+
try {
|
|
283
|
+
const message = JSON.parse(event.data);
|
|
284
|
+
// ANY inbound frame proves the socket is alive — clear the
|
|
285
|
+
// heartbeat-timeout timer so we don't false-trip force-close
|
|
286
|
+
// during normal traffic.
|
|
287
|
+
this.clearHeartbeatTimeout();
|
|
288
|
+
// Handle different message types
|
|
289
|
+
if (message.type === 'pong' || message.type === 'ping') {
|
|
290
|
+
// Ignore keepalive messages
|
|
291
|
+
getContext().logger.debug('Received keepalive', { type: message.type });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Handle different message types
|
|
295
|
+
switch (message.type) {
|
|
296
|
+
case 'sync_response':
|
|
297
|
+
this.handleSyncResponse(message.payload);
|
|
298
|
+
break;
|
|
299
|
+
case 'bootstrap_response':
|
|
300
|
+
this.handleBootstrapResponse(message.payload);
|
|
301
|
+
break;
|
|
302
|
+
case 'presence_update':
|
|
303
|
+
this.handlePresenceUpdate(message);
|
|
304
|
+
break;
|
|
305
|
+
case 'mutation_result': {
|
|
306
|
+
// Ack for a prior `commit` we sent. Wire format (mirrors
|
|
307
|
+
// apps/sync-server/src/hub/types.ts MutationResultMessage):
|
|
308
|
+
// { type: 'mutation_result',
|
|
309
|
+
// payload: { clientTxId, serverTxId, success,
|
|
310
|
+
// lastSyncId?, error? } }
|
|
311
|
+
const p = message.payload ?? message;
|
|
312
|
+
const { clientTxId, success, lastSyncId, error } = p ?? {};
|
|
313
|
+
const pending = typeof clientTxId === 'string'
|
|
314
|
+
? this.pendingMutations.get(clientTxId)
|
|
315
|
+
: undefined;
|
|
316
|
+
if (!pending)
|
|
317
|
+
break;
|
|
318
|
+
clearTimeout(pending.timeout);
|
|
319
|
+
this.pendingMutations.delete(clientTxId);
|
|
320
|
+
if (success) {
|
|
321
|
+
pending.resolve({
|
|
322
|
+
lastSyncId: typeof lastSyncId === 'number' ? lastSyncId : 0,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// Capture the FULL server error so the user can see what
|
|
327
|
+
// actually rejected the mutation. Without this, every
|
|
328
|
+
// rejection becomes the generic "mutation failed on
|
|
329
|
+
// server" — useless when debugging chart batches that
|
|
330
|
+
// tank 40+ ops at once. We stringify object errors so
|
|
331
|
+
// structured server payloads (e.g., Zod issues, schema
|
|
332
|
+
// violations) survive the trip through `new Error(...)`.
|
|
333
|
+
let errorMessage;
|
|
334
|
+
let errorCode;
|
|
335
|
+
let requiredCapability;
|
|
336
|
+
if (typeof error === 'string') {
|
|
337
|
+
errorMessage = error;
|
|
338
|
+
}
|
|
339
|
+
else if (error != null && typeof error === 'object') {
|
|
340
|
+
const obj = error;
|
|
341
|
+
if (typeof obj.code === 'string')
|
|
342
|
+
errorCode = obj.code;
|
|
343
|
+
if (typeof obj.message === 'string') {
|
|
344
|
+
errorMessage = obj.message;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
try {
|
|
348
|
+
errorMessage = JSON.stringify(error);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
errorMessage = String(error);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (obj.requiredCapability != null &&
|
|
355
|
+
typeof obj.requiredCapability === 'object' &&
|
|
356
|
+
typeof obj.requiredCapability.scope === 'string') {
|
|
357
|
+
requiredCapability = obj.requiredCapability;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
errorMessage = 'mutation failed on server';
|
|
362
|
+
}
|
|
363
|
+
// Capability denials route through the typed CapabilityError
|
|
364
|
+
// so callers can `instanceof CapabilityError` and read
|
|
365
|
+
// `.requiredCapability` to attenuate-and-retry without
|
|
366
|
+
// string-matching the error code.
|
|
367
|
+
if (errorCode === 'capability_scope_denied' ||
|
|
368
|
+
errorCode === 'capability_invalid') {
|
|
369
|
+
pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const rejection = new Error(errorMessage);
|
|
373
|
+
if (errorCode)
|
|
374
|
+
rejection.code = errorCode;
|
|
375
|
+
pending.reject(rejection);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'claim_ack': {
|
|
381
|
+
// Ack for a prior `claim` we sent. Wire format mirrors
|
|
382
|
+
// apps/sync-server/src/hub/types.ts ClaimAckMessage:
|
|
383
|
+
// { type: 'claim_ack',
|
|
384
|
+
// payload: { claimId, success, syncGroups?,
|
|
385
|
+
// ttlSeconds?, error? } }
|
|
386
|
+
const p = message.payload ?? {};
|
|
387
|
+
const { claimId, success, syncGroups, ttlSeconds, error } = p;
|
|
388
|
+
const pending = typeof claimId === 'string'
|
|
389
|
+
? this.pendingClaims.get(claimId)
|
|
390
|
+
: undefined;
|
|
391
|
+
if (!pending)
|
|
392
|
+
break;
|
|
393
|
+
clearTimeout(pending.timeout);
|
|
394
|
+
this.pendingClaims.delete(claimId);
|
|
395
|
+
if (success) {
|
|
396
|
+
pending.resolve({
|
|
397
|
+
syncGroups: Array.isArray(syncGroups) ? syncGroups : [],
|
|
398
|
+
ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
const code = error?.code && typeof error.code === 'string'
|
|
403
|
+
? error.code
|
|
404
|
+
: 'claim_rejected';
|
|
405
|
+
const msg = error?.message && typeof error.message === 'string'
|
|
406
|
+
? error.message
|
|
407
|
+
: 'claim rejected by server';
|
|
408
|
+
// Capability denials get the typed CapabilityError so
|
|
409
|
+
// callers can read `.requiredCapability` and attenuate-
|
|
410
|
+
// and-retry the claim with a narrower token.
|
|
411
|
+
if (code === 'capability_scope_denied' ||
|
|
412
|
+
code === 'capability_invalid') {
|
|
413
|
+
const rc = error
|
|
414
|
+
?.requiredCapability;
|
|
415
|
+
const requiredCapability = rc != null &&
|
|
416
|
+
typeof rc === 'object' &&
|
|
417
|
+
typeof rc.scope === 'string'
|
|
418
|
+
? rc
|
|
419
|
+
: undefined;
|
|
420
|
+
pending.reject(new CapabilityError(code, msg, requiredCapability));
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// Attach `code` as a property on the rejection so callers
|
|
424
|
+
// can discriminate (`scope_conflict`, `malformed_claim`,
|
|
425
|
+
// ...) without parsing the message.
|
|
426
|
+
const rejection = Object.assign(new Error(`${code}: ${msg}`), { code });
|
|
427
|
+
pending.reject(rejection);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case 'claim_expired': {
|
|
433
|
+
// Server-initiated expiry notification. Emit as a typed
|
|
434
|
+
// event so consumers can react (re-claim with a fresh
|
|
435
|
+
// capability, or accept the drop). The claim is already
|
|
436
|
+
// inactive server-side by the time this arrives.
|
|
437
|
+
const p = message.payload ?? {};
|
|
438
|
+
if (typeof p.claimId === 'string') {
|
|
439
|
+
this.emit('claim_expired', { claimId: p.claimId });
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 'intent_rejected': {
|
|
444
|
+
// Server denied an `intent_begin` because the target is
|
|
445
|
+
// already claimed by another participant. Forward the
|
|
446
|
+
// payload as-is — the IntentStream consumer interprets
|
|
447
|
+
// the conflict shape (peerId, target, etc.).
|
|
448
|
+
this.emit('intent_rejected', message.payload ?? {});
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case 'delta': {
|
|
452
|
+
const p = message.payload;
|
|
453
|
+
if (p?.actionType || p?.modelName) {
|
|
454
|
+
this.handleDelta(p);
|
|
455
|
+
}
|
|
456
|
+
else if (Array.isArray(p?.deltas)) {
|
|
457
|
+
for (const d of p.deltas) {
|
|
458
|
+
if (d?.actionType || d?.modelName)
|
|
459
|
+
this.handleDelta(d);
|
|
460
|
+
}
|
|
461
|
+
if (p?.newVersions) {
|
|
462
|
+
Object.assign(this.versionVector, p.newVersions);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
case undefined: // Legacy support: bare delta
|
|
468
|
+
if (message.actionType || message.modelName) {
|
|
469
|
+
this.handleDelta(message);
|
|
470
|
+
}
|
|
471
|
+
break;
|
|
472
|
+
default: {
|
|
473
|
+
// Collaboration events use underscore wire format (e.g., 'sheet_selection')
|
|
474
|
+
// Convert to colon format for the event map (e.g., 'sheet:selection')
|
|
475
|
+
const eventKey = message.type?.replace(/_/g, ':');
|
|
476
|
+
if (eventKey && this.collaborationEventTypes.has(eventKey)) {
|
|
477
|
+
this.emit(eventKey, message.payload);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
getContext().logger.debug('Received unknown message type', { message });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
getContext().observability.captureWebSocketError({
|
|
487
|
+
context: 'parse-message',
|
|
488
|
+
error: error instanceof Error ? error.message : String(error),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
this.ws.onerror = (_event) => {
|
|
493
|
+
// WebSocket errors are DOM Events, not Error objects
|
|
494
|
+
// Check if we're offline first
|
|
495
|
+
if (!getContext().onlineStatus.isOnline()) {
|
|
496
|
+
getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
|
|
497
|
+
this.emit('error', new Error('Network is offline'));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// After session error, suppress Sentry capture — the root cause is already reported.
|
|
501
|
+
// Still emit so SyncedStore can update UI state.
|
|
502
|
+
const error = new Error(`WebSocket connection failed`);
|
|
503
|
+
if (!this._sessionErrorDetected) {
|
|
504
|
+
getContext().observability.captureWebSocketError({
|
|
505
|
+
context: 'connection-error',
|
|
506
|
+
error: error.message,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
this.emit('error', error);
|
|
510
|
+
};
|
|
511
|
+
this.ws.onclose = (event) => {
|
|
512
|
+
const everOpened = this._everOpened;
|
|
513
|
+
this.lastCloseAt = Date.now();
|
|
514
|
+
this.lastCloseCode = event.code;
|
|
515
|
+
this.lastCloseReason = event.reason || null;
|
|
516
|
+
getContext().logger.info('WebSocket closed', {
|
|
517
|
+
code: event.code,
|
|
518
|
+
reason: event.reason,
|
|
519
|
+
everOpened,
|
|
520
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
521
|
+
forceCloseReason: this.lastForceCloseReason,
|
|
522
|
+
msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
523
|
+
isManualClose: this.isManualClose,
|
|
524
|
+
});
|
|
525
|
+
this.isConnecting = false;
|
|
526
|
+
this.ws = null;
|
|
527
|
+
this.stopCatchupInterval();
|
|
528
|
+
this.stopHeartbeat();
|
|
529
|
+
// Cancel in-flight mutations — the socket that was carrying them is
|
|
530
|
+
// gone, and the server-side state may or may not have accepted each
|
|
531
|
+
// one. Rejecting promptly is better than hanging the caller forever;
|
|
532
|
+
// higher-level retry belongs to TransactionQueue, not here.
|
|
533
|
+
if (this.pendingMutations.size > 0) {
|
|
534
|
+
for (const pending of this.pendingMutations.values()) {
|
|
535
|
+
clearTimeout(pending.timeout);
|
|
536
|
+
pending.reject(Object.assign(new Error(`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
537
|
+
(event.reason ? ` reason=${event.reason}` : '') +
|
|
538
|
+
(this.lastForceCloseReason
|
|
539
|
+
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
540
|
+
: '') +
|
|
541
|
+
')'), { diagnostics: this.getConnectionDiagnostics() }));
|
|
542
|
+
}
|
|
543
|
+
this.pendingMutations.clear();
|
|
544
|
+
}
|
|
545
|
+
// Cancel in-flight claims — same rationale. Server-side
|
|
546
|
+
// claims are bound to the connection; a reconnect will need
|
|
547
|
+
// to re-claim. Higher-level retry belongs to whoever holds
|
|
548
|
+
// the participant handle (typically the SDK's claim manager).
|
|
549
|
+
if (this.pendingClaims.size > 0) {
|
|
550
|
+
for (const pending of this.pendingClaims.values()) {
|
|
551
|
+
clearTimeout(pending.timeout);
|
|
552
|
+
pending.reject(new Error(`WebSocket closed while claim was in flight (code=${event.code})`));
|
|
553
|
+
}
|
|
554
|
+
this.pendingClaims.clear();
|
|
555
|
+
}
|
|
556
|
+
// Check for session-related close codes
|
|
557
|
+
// 1008 = Policy Violation (often auth)
|
|
558
|
+
// 4001 = Unauthorized (custom)
|
|
559
|
+
// 4003 = Forbidden (custom)
|
|
560
|
+
const isSessionClose = event.code === 1008 ||
|
|
561
|
+
event.code === 4001 ||
|
|
562
|
+
event.code === 4003 ||
|
|
563
|
+
SyncSessionError.isSessionError(event.reason || '');
|
|
564
|
+
if (isSessionClose) {
|
|
565
|
+
this._sessionErrorDetected = true;
|
|
566
|
+
this.sessionErrorAt = Date.now();
|
|
567
|
+
getContext().observability.captureWebSocketError({
|
|
568
|
+
context: 'session-error-close',
|
|
569
|
+
code: event.code,
|
|
570
|
+
reason: event.reason,
|
|
571
|
+
});
|
|
572
|
+
this.emit('session_error', new SyncSessionError(event.reason || 'Session expired', event.code));
|
|
573
|
+
// Don't reconnect for session errors - user needs to re-authenticate
|
|
574
|
+
this.emit('disconnected', event);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
// Handshake failure: `onclose` fired before `onopen` ever did, so the
|
|
578
|
+
// server rejected the upgrade (typically 401/403 on a bad cookie, but
|
|
579
|
+
// could also be a CORS/origin reject or an LB 5xx). The browser hides
|
|
580
|
+
// the HTTP status behind code 1006, so we can't tell which from here.
|
|
581
|
+
//
|
|
582
|
+
// Emit a dedicated event and SKIP the internal reconnect — the owner
|
|
583
|
+
// (SyncedStore / ConnectionStore) should run an auth-validating HTTP
|
|
584
|
+
// probe to distinguish session expiry from a transient network issue
|
|
585
|
+
// and transition the UI accordingly. Reconnecting blindly is what
|
|
586
|
+
// produced the infinite "offline → reconnecting → offline" loop on
|
|
587
|
+
// stale cookies.
|
|
588
|
+
if (!everOpened && !this.isManualClose) {
|
|
589
|
+
getContext().observability.captureWebSocketError({
|
|
590
|
+
context: 'handshake-failed-close',
|
|
591
|
+
code: event.code,
|
|
592
|
+
reason: event.reason,
|
|
593
|
+
});
|
|
594
|
+
this.emit('handshake_failed', event);
|
|
595
|
+
this.emit('disconnected', event);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.emit('disconnected', event);
|
|
599
|
+
// Reconnect if not manually closed
|
|
600
|
+
if (!this.isManualClose) {
|
|
601
|
+
this.scheduleReconnect();
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Handle incoming sync delta
|
|
607
|
+
*/
|
|
608
|
+
handleDelta(delta) {
|
|
609
|
+
getContext().logger.debug('Received delta', {
|
|
610
|
+
action: delta.actionType,
|
|
611
|
+
model: delta.modelName,
|
|
612
|
+
id: delta.modelId,
|
|
613
|
+
syncId: delta.id,
|
|
614
|
+
});
|
|
615
|
+
// DO NOT advance `this.lastSyncId` on receipt. The runtime cursor
|
|
616
|
+
// must stay consistent with what's persisted in IDB — otherwise the
|
|
617
|
+
// next `requestIncrementalSync()` (and the connect-time handshake)
|
|
618
|
+
// sends an optimistic cursor and the server skips deltas that never
|
|
619
|
+
// landed in IDB. `this.lastSyncId` is advanced only in `sendAck()`,
|
|
620
|
+
// which is gated on `BaseSyncedStore.flushPendingDeltas`'s
|
|
621
|
+
// `persistedSyncId` watermark. See Replicache's "lastMutationID
|
|
622
|
+
// read in the same transaction as the client view" rule.
|
|
623
|
+
//
|
|
624
|
+
// Version vector is also intentionally NOT updated here for the
|
|
625
|
+
// same reason — left to the persistence-gated path.
|
|
626
|
+
// Emit delta for processing. Ack will be sent by SyncedStore after persistence.
|
|
627
|
+
this.emit('delta', delta);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Send acknowledgment for received delta with version vector.
|
|
631
|
+
*
|
|
632
|
+
* This is the SOLE forward-mover of `this.lastSyncId` for live
|
|
633
|
+
* deltas. Called by `BaseSyncedStore.flushPendingDeltas` with the
|
|
634
|
+
* `persistedSyncId` watermark — i.e. only after the deltas have
|
|
635
|
+
* actually committed to IDB. Keeping the cursor advance here (rather
|
|
636
|
+
* than at receipt in `handleDelta`/`handleSyncResponse`) means the
|
|
637
|
+
* cursor never gets ahead of the persisted view, so reconnect/
|
|
638
|
+
* catch-up requests can't accidentally skip un-persisted deltas.
|
|
639
|
+
*/
|
|
640
|
+
sendAck(syncId) {
|
|
641
|
+
// Advance the local cursor *and* the version vector for this ack —
|
|
642
|
+
// these are what `requestIncrementalSync` and the connect handshake
|
|
643
|
+
// will send next, and what `getLastSyncId()` reports for clean-
|
|
644
|
+
// shutdown persistence.
|
|
645
|
+
if (syncId > this.lastSyncId) {
|
|
646
|
+
this.lastSyncId = syncId;
|
|
647
|
+
}
|
|
648
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
649
|
+
return;
|
|
650
|
+
this.send({
|
|
651
|
+
type: 'ack',
|
|
652
|
+
payload: {
|
|
653
|
+
lastSyncId: syncId,
|
|
654
|
+
versions: this.versionVector,
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Public wrapper for sending ack from outside the class
|
|
660
|
+
*/
|
|
661
|
+
acknowledge(syncId) {
|
|
662
|
+
this.sendAck(syncId);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Send message to server
|
|
666
|
+
*/
|
|
667
|
+
send(message) {
|
|
668
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
669
|
+
// Only log at debug level when offline - this is expected behavior, not an error
|
|
670
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
671
|
+
getContext().observability.breadcrumb('WebSocket not connected, cannot send message', 'sync.websocket', 'warning');
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
getContext().logger.debug('WebSocket send skipped - offline');
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
this.ws.send(JSON.stringify(message));
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// Only log as error if we're online - offline send failures are expected
|
|
683
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
684
|
+
getContext().observability.captureWebSocketError({
|
|
685
|
+
context: 'send-message',
|
|
686
|
+
error: error instanceof Error ? error.message : String(error),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
getContext().logger.debug('WebSocket send failed - offline');
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Send a `commit` mutation request over the existing WebSocket and
|
|
696
|
+
* resolve when the server's `mutation_result` frame comes back with
|
|
697
|
+
* the same `clientTxId`. The wire-level frame is `{ type: 'commit',
|
|
698
|
+
* payload: { operations, clientTxId } }` — matching the
|
|
699
|
+
* `handleCommit` path on `apps/sync-server/src/hub/Hub.ts` (see the
|
|
700
|
+
* dispatch at Hub.ts:737).
|
|
701
|
+
*
|
|
702
|
+
* Historical naming note: this was originally `sendBatchAck` back when
|
|
703
|
+
* the Go sync-engine used a GraphQL `batchAck` mutation. The TS
|
|
704
|
+
* sync-server uses `type: 'commit'` over WebSocket exclusively. The
|
|
705
|
+
* method name now matches the wire protocol so the ack/commit naming
|
|
706
|
+
* confusion stops here.
|
|
707
|
+
*
|
|
708
|
+
* Times out after 15s of silence from the server. The socket may close
|
|
709
|
+
* during an in-flight mutation (network flap, server restart); we do
|
|
710
|
+
* NOT auto-retry here — the caller's TransactionQueue owns retry +
|
|
711
|
+
* offline replay semantics and the SDK shouldn't duplicate that logic.
|
|
712
|
+
*/
|
|
713
|
+
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId) {
|
|
714
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
715
|
+
return Promise.reject(this.notConnectedError('commit'));
|
|
716
|
+
}
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
const timeout = setTimeout(() => {
|
|
719
|
+
this.pendingMutations.delete(clientTxId);
|
|
720
|
+
reject(new Error(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`));
|
|
721
|
+
}, timeoutMs);
|
|
722
|
+
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
723
|
+
try {
|
|
724
|
+
// `causedByTaskId` is included only when the agent SDK has
|
|
725
|
+
// an open turn — keeps the wire shape stable for sessions
|
|
726
|
+
// that don't use turns. Servers that don't know the field
|
|
727
|
+
// ignore it; newer servers stamp it onto every delta.
|
|
728
|
+
const payload = { operations, clientTxId };
|
|
729
|
+
if (causedByTaskId)
|
|
730
|
+
payload.causedByTaskId = causedByTaskId;
|
|
731
|
+
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
clearTimeout(timeout);
|
|
735
|
+
this.pendingMutations.delete(clientTxId);
|
|
736
|
+
reject(error instanceof Error
|
|
737
|
+
? error
|
|
738
|
+
: new Error(String(error)));
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Send a commit frame without waiting for `mutation_result`.
|
|
744
|
+
*
|
|
745
|
+
* This backs the public `wait: 'queued'` API: the socket accepted the
|
|
746
|
+
* frame for delivery, but the server has not confirmed it yet. The
|
|
747
|
+
* eventual `mutation_result` frame is intentionally ignored by this
|
|
748
|
+
* instance because no pending resolver is registered.
|
|
749
|
+
*/
|
|
750
|
+
sendCommitQueued(operations, clientTxId, causedByTaskId) {
|
|
751
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
752
|
+
throw this.notConnectedError('commit');
|
|
753
|
+
}
|
|
754
|
+
const payload = { operations, clientTxId };
|
|
755
|
+
if (causedByTaskId)
|
|
756
|
+
payload.causedByTaskId = causedByTaskId;
|
|
757
|
+
this.ws.send(JSON.stringify({ type: 'commit', payload }));
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Activate a participant claim on this connection. Multiplexed
|
|
761
|
+
* subscription pattern (Phoenix Channels / Pusher) — the same
|
|
762
|
+
* connection can hold N concurrent claims, each scoped to a
|
|
763
|
+
* different set of sync groups.
|
|
764
|
+
*
|
|
765
|
+
* Returns a promise that resolves with the server-canonicalized
|
|
766
|
+
* `syncGroups` and effective `ttlSeconds` once `claim_ack` arrives,
|
|
767
|
+
* or rejects with a typed error on `success: false` ack /
|
|
768
|
+
* timeout / disconnect.
|
|
769
|
+
*
|
|
770
|
+
* Why this exists: the old scoped-participant path opened a separate
|
|
771
|
+
* WS per scope. With claims, the SDK reuses the existing session/agent
|
|
772
|
+
* connection — one TCP, N logical participants. See
|
|
773
|
+
* `apps/sync-server/docs/PARTICIPANT_CLAIMS.md` for the migration
|
|
774
|
+
* framing (Phase A.1).
|
|
775
|
+
*/
|
|
776
|
+
sendClaim(claimId, syncGroups, options) {
|
|
777
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
778
|
+
return Promise.reject(this.notConnectedError('claim'));
|
|
779
|
+
}
|
|
780
|
+
const timeoutMs = options?.timeoutMs ?? 15_000;
|
|
781
|
+
return new Promise((resolve, reject) => {
|
|
782
|
+
const timeout = setTimeout(() => {
|
|
783
|
+
this.pendingClaims.delete(claimId);
|
|
784
|
+
reject(new Error(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`));
|
|
785
|
+
}, timeoutMs);
|
|
786
|
+
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
787
|
+
try {
|
|
788
|
+
this.ws.send(JSON.stringify({
|
|
789
|
+
type: 'claim',
|
|
790
|
+
payload: {
|
|
791
|
+
claimId,
|
|
792
|
+
syncGroups: [...syncGroups],
|
|
793
|
+
capabilityToken: options?.capabilityToken,
|
|
794
|
+
ttlSeconds: options?.ttlSeconds,
|
|
795
|
+
},
|
|
796
|
+
}));
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
clearTimeout(timeout);
|
|
800
|
+
this.pendingClaims.delete(claimId);
|
|
801
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Drop a previously-active claim. Idempotent — `release` is
|
|
807
|
+
* fire-and-forget per the wire contract; the server accepts
|
|
808
|
+
* unknown claimIds silently so disconnect-time release storms
|
|
809
|
+
* never error. No ack is expected.
|
|
810
|
+
*
|
|
811
|
+
* If a claim's send promise is still pending (no claim_ack yet),
|
|
812
|
+
* we reject it locally — the user explicitly chose to release.
|
|
813
|
+
*/
|
|
814
|
+
sendRelease(claimId) {
|
|
815
|
+
// Cancel any in-flight claim that hadn't acked yet — the user
|
|
816
|
+
// changed their mind. Without this the timer would eventually
|
|
817
|
+
// reject; doing it now matches the user's intent immediately.
|
|
818
|
+
const pending = this.pendingClaims.get(claimId);
|
|
819
|
+
if (pending) {
|
|
820
|
+
clearTimeout(pending.timeout);
|
|
821
|
+
this.pendingClaims.delete(claimId);
|
|
822
|
+
pending.reject(new Error(`claim ${claimId} released before ack`));
|
|
823
|
+
}
|
|
824
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
825
|
+
return;
|
|
826
|
+
try {
|
|
827
|
+
this.ws.send(JSON.stringify({ type: 'release', payload: { claimId } }));
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// Idempotent contract — silent failure is acceptable here.
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Replace the capability token used for authentication. The new
|
|
835
|
+
* value is read by the next URL-build (i.e., next connect / reconnect
|
|
836
|
+
* cycle). The currently-open WS is NOT torn down — servers keep
|
|
837
|
+
* connections alive past cap expiry until they decide to close, and
|
|
838
|
+
* a forced reconnect would interrupt in-flight deltas. The cap-mint
|
|
839
|
+
* scheduler in `Ablo.ts` calls this on each successful refresh so
|
|
840
|
+
* reconnects after server-initiated close pick up the fresh token.
|
|
841
|
+
*/
|
|
842
|
+
setCapabilityToken(token) {
|
|
843
|
+
this.options.capabilityToken = token;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Send spreadsheet selection presence
|
|
847
|
+
*/
|
|
848
|
+
sendSheetSelection(sheetId, selectedCells) {
|
|
849
|
+
this.sendCollaborationEvent('sheet:selection', {
|
|
850
|
+
sheetId,
|
|
851
|
+
selectedCells,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Send slide layer selection presence
|
|
856
|
+
*/
|
|
857
|
+
sendSlideSelection(deckId, slideId, selectedLayers) {
|
|
858
|
+
this.sendCollaborationEvent('slide:selection', {
|
|
859
|
+
deckId,
|
|
860
|
+
slideId,
|
|
861
|
+
selectedLayers,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Send slide cursor position for real-time collaboration
|
|
866
|
+
* Note: Throttling should be handled by the caller (e.g., useSlideCursorBroadcast hook)
|
|
867
|
+
*/
|
|
868
|
+
sendSlideCursor(deckId, slideId, x, y) {
|
|
869
|
+
this.sendCollaborationEvent('slide:cursor', {
|
|
870
|
+
deckId,
|
|
871
|
+
slideId,
|
|
872
|
+
x,
|
|
873
|
+
y,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Send presence update to server.
|
|
878
|
+
* Use this for:
|
|
879
|
+
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
880
|
+
* - Manual status changes (away, custom status)
|
|
881
|
+
*
|
|
882
|
+
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
883
|
+
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
884
|
+
*
|
|
885
|
+
* @param status - "online", "away", or custom status string
|
|
886
|
+
* @param customStatus - Optional custom status message
|
|
887
|
+
*/
|
|
888
|
+
sendPresenceUpdate(status = 'online', customStatus) {
|
|
889
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
890
|
+
return;
|
|
891
|
+
const timezone = (() => {
|
|
892
|
+
try {
|
|
893
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
return 'UTC';
|
|
897
|
+
}
|
|
898
|
+
})();
|
|
899
|
+
this.send({
|
|
900
|
+
type: 'presence_update',
|
|
901
|
+
payload: {
|
|
902
|
+
status,
|
|
903
|
+
timezone,
|
|
904
|
+
...(customStatus ? { customStatus } : {}),
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Schedule reconnection with exponential backoff
|
|
910
|
+
*/
|
|
911
|
+
scheduleReconnect() {
|
|
912
|
+
if (this.reconnectTimer) {
|
|
913
|
+
clearTimeout(this.reconnectTimer);
|
|
914
|
+
}
|
|
915
|
+
// Session error means the user needs to re-authenticate — don't reconnect.
|
|
916
|
+
if (this._sessionErrorDetected) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// Don't attempt reconnection while offline.
|
|
920
|
+
// SyncedStore.handleNetworkOnline() owns the offline→online transition:
|
|
921
|
+
// it bootstraps first, then calls syncWebSocket.connect() explicitly.
|
|
922
|
+
// Self-reconnecting here would bypass the bootstrap gate and cause stale data.
|
|
923
|
+
if (!getContext().onlineStatus.isOnline()) {
|
|
924
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay: 0 });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
// Give up after MAX_RECONNECT_ATTEMPTS consecutive failures.
|
|
928
|
+
// The user can recover by refreshing or when network comes back online
|
|
929
|
+
// (handleNetworkOnline resets attempts and reconnects).
|
|
930
|
+
if (this.reconnectAttempts >= SyncWebSocket.MAX_RECONNECT_ATTEMPTS) {
|
|
931
|
+
this.emit('reconnect_failed', { attempts: this.reconnectAttempts });
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// Exponential backoff with ±15% jitter to prevent thundering herd
|
|
935
|
+
const baseDelay = Math.min(this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.options.maxReconnectDelay);
|
|
936
|
+
const jitter = baseDelay * (0.85 + Math.random() * 0.3);
|
|
937
|
+
const delay = Math.round(jitter);
|
|
938
|
+
// Emit reconnecting event so UI can show reconnection status
|
|
939
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts + 1, delay });
|
|
940
|
+
this.reconnectTimer = setTimeout(() => {
|
|
941
|
+
this.reconnectAttempts++;
|
|
942
|
+
this.connect();
|
|
943
|
+
}, delay);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Reset reconnect attempt counter. Called when network comes back online
|
|
947
|
+
* to allow a fresh reconnect cycle after the max was previously reached.
|
|
948
|
+
*/
|
|
949
|
+
resetReconnectAttempts() {
|
|
950
|
+
this.reconnectAttempts = 0;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Stop the periodic catchup interval
|
|
954
|
+
*/
|
|
955
|
+
stopCatchupInterval() {
|
|
956
|
+
if (this.catchupInterval) {
|
|
957
|
+
clearInterval(this.catchupInterval);
|
|
958
|
+
this.catchupInterval = null;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Disconnect from WebSocket
|
|
963
|
+
*/
|
|
964
|
+
disconnect() {
|
|
965
|
+
this.isManualClose = true;
|
|
966
|
+
this.stopCatchupInterval();
|
|
967
|
+
this.stopHeartbeat();
|
|
968
|
+
if (this.reconnectTimer) {
|
|
969
|
+
clearTimeout(this.reconnectTimer);
|
|
970
|
+
this.reconnectTimer = null;
|
|
971
|
+
}
|
|
972
|
+
if (this.ws) {
|
|
973
|
+
this.ws.close(1000, 'Manual disconnect');
|
|
974
|
+
this.ws = null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Application-level heartbeat. Every `HEARTBEAT_INTERVAL_MS` while
|
|
979
|
+
* `OPEN`, send `{ type: 'ping' }` and arm a `HEARTBEAT_TIMEOUT_MS`
|
|
980
|
+
* watchdog. Any inbound frame (handled in `onmessage`) clears the
|
|
981
|
+
* watchdog. If the watchdog fires, we treat the connection as
|
|
982
|
+
* zombie and force-close it — `onclose` then triggers the existing
|
|
983
|
+
* reconnect path.
|
|
984
|
+
*
|
|
985
|
+
* Why both sides need this:
|
|
986
|
+
* - The server sends RFC 6455 protocol pings via `ws.ping()` every
|
|
987
|
+
* 30s. Browsers auto-respond with a pong but DO NOT expose either
|
|
988
|
+
* frame to JavaScript, so the client is blind to its own keepalive.
|
|
989
|
+
* - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
|
|
990
|
+
* the browser may keep `readyState === OPEN` for minutes before
|
|
991
|
+
* the OS surfaces the broken connection. App-level traffic is
|
|
992
|
+
* the only signal we can observe.
|
|
993
|
+
*/
|
|
994
|
+
startHeartbeat() {
|
|
995
|
+
this.stopHeartbeat();
|
|
996
|
+
this.heartbeatTimer = setInterval(() => {
|
|
997
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
998
|
+
return;
|
|
999
|
+
// Send the ping. If `send` throws, the socket is already dead —
|
|
1000
|
+
// force-close so onclose triggers the reconnect cycle.
|
|
1001
|
+
try {
|
|
1002
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
getContext().observability.captureWebSocketError({
|
|
1006
|
+
context: 'heartbeat-send-failed',
|
|
1007
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1008
|
+
});
|
|
1009
|
+
this.forceClose('heartbeat-send-failed');
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// Arm the timeout. ANY inbound message clears it (see onmessage).
|
|
1013
|
+
// We don't require an explicit `pong` — a delta or any other frame
|
|
1014
|
+
// is equally good proof-of-life.
|
|
1015
|
+
if (this.heartbeatTimeoutTimer)
|
|
1016
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
1017
|
+
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
1018
|
+
getContext().observability.captureWebSocketError({
|
|
1019
|
+
context: 'heartbeat-timeout',
|
|
1020
|
+
});
|
|
1021
|
+
this.forceClose('heartbeat-timeout');
|
|
1022
|
+
}, SyncWebSocket.HEARTBEAT_TIMEOUT_MS);
|
|
1023
|
+
}, SyncWebSocket.HEARTBEAT_INTERVAL_MS);
|
|
1024
|
+
}
|
|
1025
|
+
stopHeartbeat() {
|
|
1026
|
+
if (this.heartbeatTimer) {
|
|
1027
|
+
clearInterval(this.heartbeatTimer);
|
|
1028
|
+
this.heartbeatTimer = null;
|
|
1029
|
+
}
|
|
1030
|
+
this.clearHeartbeatTimeout();
|
|
1031
|
+
}
|
|
1032
|
+
clearHeartbeatTimeout() {
|
|
1033
|
+
if (this.heartbeatTimeoutTimer) {
|
|
1034
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
1035
|
+
this.heartbeatTimeoutTimer = null;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Force-close the socket from the client side using a private 4xxx
|
|
1040
|
+
* code. Callers expect `onclose` to fire; that handler runs the
|
|
1041
|
+
* existing reconnect / handshake-failed dispatch. Wrapped in
|
|
1042
|
+
* try/catch because `close()` on a CLOSING/CLOSED socket throws on
|
|
1043
|
+
* some browsers.
|
|
1044
|
+
*/
|
|
1045
|
+
forceClose(reason) {
|
|
1046
|
+
if (!this.ws)
|
|
1047
|
+
return;
|
|
1048
|
+
this.lastForceCloseReason = reason;
|
|
1049
|
+
getContext().logger.warn('[SyncWebSocket] forceClose', {
|
|
1050
|
+
reason,
|
|
1051
|
+
readyState: this.ws.readyState,
|
|
1052
|
+
msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
|
|
1053
|
+
});
|
|
1054
|
+
try {
|
|
1055
|
+
this.ws.close(4000, reason);
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
// Already closing / closed — onclose will still fire.
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Get connection state
|
|
1063
|
+
*/
|
|
1064
|
+
isConnected() {
|
|
1065
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Snapshot of recent connection lifecycle state, for diagnostic logs
|
|
1069
|
+
* and error messages. Cheap to call (no I/O); safe to log every time
|
|
1070
|
+
* a send is rejected so we can attribute "not connected" rejections
|
|
1071
|
+
* to the actual root cause (handshake reject vs heartbeat zombie vs
|
|
1072
|
+
* session expiry vs explicit close).
|
|
1073
|
+
*/
|
|
1074
|
+
getConnectionDiagnostics() {
|
|
1075
|
+
const now = Date.now();
|
|
1076
|
+
return {
|
|
1077
|
+
readyState: this.ws?.readyState ?? null,
|
|
1078
|
+
isConnecting: this.isConnecting,
|
|
1079
|
+
isManualClose: this.isManualClose,
|
|
1080
|
+
sessionErrorDetected: this._sessionErrorDetected,
|
|
1081
|
+
everOpened: this._everOpened,
|
|
1082
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
1083
|
+
maxReconnectAttempts: SyncWebSocket.MAX_RECONNECT_ATTEMPTS,
|
|
1084
|
+
lastOpenAt: this.lastOpenAt,
|
|
1085
|
+
lastCloseAt: this.lastCloseAt,
|
|
1086
|
+
lastCloseCode: this.lastCloseCode,
|
|
1087
|
+
lastCloseReason: this.lastCloseReason,
|
|
1088
|
+
lastForceCloseReason: this.lastForceCloseReason,
|
|
1089
|
+
sessionErrorAt: this.sessionErrorAt,
|
|
1090
|
+
msSinceLastOpen: this.lastOpenAt != null ? now - this.lastOpenAt : null,
|
|
1091
|
+
msSinceLastClose: this.lastCloseAt != null ? now - this.lastCloseAt : null,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Build a richly-diagnosed "not connected" error so callers (and the
|
|
1096
|
+
* logs they emit) can attribute the rejection. The message embeds the
|
|
1097
|
+
* dominant signal in human-readable form; the structured detail is
|
|
1098
|
+
* also attached as `error.diagnostics` for log scrapers.
|
|
1099
|
+
*/
|
|
1100
|
+
notConnectedError(action) {
|
|
1101
|
+
const d = this.getConnectionDiagnostics();
|
|
1102
|
+
let detail;
|
|
1103
|
+
if (d.sessionErrorDetected) {
|
|
1104
|
+
detail = 'session_error_suppressed_reconnect';
|
|
1105
|
+
}
|
|
1106
|
+
else if (d.isManualClose) {
|
|
1107
|
+
detail = 'manual_close';
|
|
1108
|
+
}
|
|
1109
|
+
else if (d.isConnecting) {
|
|
1110
|
+
detail = 'still_connecting';
|
|
1111
|
+
}
|
|
1112
|
+
else if (!d.everOpened && d.lastCloseAt != null) {
|
|
1113
|
+
detail = `handshake_failed code=${d.lastCloseCode}`;
|
|
1114
|
+
}
|
|
1115
|
+
else if (d.lastForceCloseReason) {
|
|
1116
|
+
detail = `force_closed reason=${d.lastForceCloseReason}`;
|
|
1117
|
+
}
|
|
1118
|
+
else if (d.lastCloseAt != null) {
|
|
1119
|
+
detail =
|
|
1120
|
+
`closed code=${d.lastCloseCode}` +
|
|
1121
|
+
(d.lastCloseReason ? ` reason=${d.lastCloseReason}` : '') +
|
|
1122
|
+
(d.msSinceLastClose != null ? ` ${d.msSinceLastClose}ms ago` : '') +
|
|
1123
|
+
(d.reconnectAttempts > 0
|
|
1124
|
+
? ` reconnectAttempts=${d.reconnectAttempts}/${d.maxReconnectAttempts}`
|
|
1125
|
+
: '');
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
detail = 'never_connected';
|
|
1129
|
+
}
|
|
1130
|
+
const err = new Error(`SyncWebSocket not connected — cannot send ${action} (${detail})`);
|
|
1131
|
+
err.diagnostics = d;
|
|
1132
|
+
return err;
|
|
1133
|
+
}
|
|
1134
|
+
/** Returns the sync groups this connection is subscribed to. */
|
|
1135
|
+
getSyncGroups() {
|
|
1136
|
+
return this.options.syncGroups;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Update last sync ID (for persistence)
|
|
1140
|
+
*/
|
|
1141
|
+
setLastSyncId(syncId) {
|
|
1142
|
+
this.lastSyncId = syncId;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Get current version vector
|
|
1146
|
+
*/
|
|
1147
|
+
getVersionVector() {
|
|
1148
|
+
return { ...this.versionVector };
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Update version vector for specific entity type
|
|
1152
|
+
*/
|
|
1153
|
+
updateVersionVector(entityType, version) {
|
|
1154
|
+
this.versionVector[entityType] = Math.max(this.versionVector[entityType] || 0, version);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Set version vector (for initialization)
|
|
1158
|
+
*/
|
|
1159
|
+
setVersionVector(versions) {
|
|
1160
|
+
this.versionVector = { ...versions };
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Update sync cursor (for incremental sync)
|
|
1164
|
+
*/
|
|
1165
|
+
setSyncCursor(cursor) {
|
|
1166
|
+
this.syncCursor = cursor;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Get current sync cursor
|
|
1170
|
+
*/
|
|
1171
|
+
getSyncCursor() {
|
|
1172
|
+
return this.syncCursor;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Get the highest syncId seen this session (for persistence on clean shutdown)
|
|
1176
|
+
*/
|
|
1177
|
+
getLastSyncId() {
|
|
1178
|
+
return this.lastSyncId || 0;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Linear-style incremental sync request
|
|
1182
|
+
*/
|
|
1183
|
+
async requestIncrementalSync() {
|
|
1184
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1185
|
+
// Silent when offline - not an error condition
|
|
1186
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
1187
|
+
getContext().observability.breadcrumb('WebSocket not connected, cannot request sync', 'sync.websocket', 'warning');
|
|
1188
|
+
}
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
// Normalize capabilities to an array of strings for server compatibility
|
|
1192
|
+
const capsObj = this.options.capabilities || {};
|
|
1193
|
+
const capsArr = Object.entries(capsObj)
|
|
1194
|
+
.filter(([, v]) => !!v)
|
|
1195
|
+
.map(([k]) => k);
|
|
1196
|
+
this.send({
|
|
1197
|
+
type: 'sync_request',
|
|
1198
|
+
payload: {
|
|
1199
|
+
cursor: this.syncCursor,
|
|
1200
|
+
versions: this.versionVector,
|
|
1201
|
+
// Always send lastSyncId to ensure server uses client's current position
|
|
1202
|
+
lastSyncId: this.lastSyncId,
|
|
1203
|
+
capabilities: capsArr,
|
|
1204
|
+
},
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Request bootstrap for specific entities
|
|
1209
|
+
*/
|
|
1210
|
+
async requestBootstrap(entities) {
|
|
1211
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
1212
|
+
// Silent when offline - not an error condition
|
|
1213
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
1214
|
+
getContext().observability.breadcrumb('WebSocket not connected, cannot request bootstrap', 'sync.websocket', 'warning');
|
|
1215
|
+
}
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
this.send({
|
|
1219
|
+
type: 'bootstrap_request',
|
|
1220
|
+
payload: {
|
|
1221
|
+
entities: entities || [],
|
|
1222
|
+
versions: this.versionVector,
|
|
1223
|
+
capabilities: this.options.capabilities,
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Handle sync response from server
|
|
1229
|
+
*/
|
|
1230
|
+
handleSyncResponse(payload) {
|
|
1231
|
+
// Cursor reconciliation — Linear-style handshake. The server stamps
|
|
1232
|
+
// its authoritative `currentSyncId` on every sync_response. If our
|
|
1233
|
+
// local cursor is AHEAD of the server, our local view has somehow
|
|
1234
|
+
// diverged (corrupted metadata, future regression reintroducing an
|
|
1235
|
+
// eager-advance, IDB lying about a successful commit). Trust the
|
|
1236
|
+
// server, reset the cursor, and request another sync so any deltas
|
|
1237
|
+
// we *should* have applied get re-delivered. Backward-compatible
|
|
1238
|
+
// when the field is absent (older server build) — we just skip the
|
|
1239
|
+
// reconciliation step.
|
|
1240
|
+
//
|
|
1241
|
+
// We only reconcile when the response carries NO deltas. If deltas
|
|
1242
|
+
// are present, they'll advance our cursor through the normal
|
|
1243
|
+
// persistence-gated path anyway — and the in-flight request/response
|
|
1244
|
+
// round-trip means the snapshot's `currentSyncId` is naturally a
|
|
1245
|
+
// few syncIds behind our locally-advanced cursor at receive time
|
|
1246
|
+
// (live deltas may have landed in the meantime). Restricting to
|
|
1247
|
+
// empty-delta responses eliminates this benign false positive while
|
|
1248
|
+
// still catching the real corruption case (server head < local AND
|
|
1249
|
+
// server has nothing new to send).
|
|
1250
|
+
const hasDeltas = Array.isArray(payload.deltas) && payload.deltas.length > 0;
|
|
1251
|
+
if (!hasDeltas && typeof payload.currentSyncId === 'number') {
|
|
1252
|
+
const serverHead = payload.currentSyncId;
|
|
1253
|
+
if (serverHead < this.lastSyncId) {
|
|
1254
|
+
getContext().logger.warn('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
|
|
1255
|
+
local: this.lastSyncId,
|
|
1256
|
+
server: serverHead,
|
|
1257
|
+
drift: this.lastSyncId - serverHead,
|
|
1258
|
+
});
|
|
1259
|
+
getContext().observability.breadcrumb('Local sync cursor diverged from server — reset', 'sync.websocket', 'warning', { local: this.lastSyncId, server: serverHead });
|
|
1260
|
+
this.lastSyncId = serverHead;
|
|
1261
|
+
// Fire a follow-up incremental sync to re-deliver anything we
|
|
1262
|
+
// were missing. Fire-and-forget — the next response will go
|
|
1263
|
+
// through this same path. The infinite-loop concern is bounded
|
|
1264
|
+
// by the `serverHead < this.lastSyncId` strict-less check: once
|
|
1265
|
+
// we've reset to `serverHead`, the next response with the same
|
|
1266
|
+
// (or higher) `currentSyncId` won't re-enter this branch.
|
|
1267
|
+
void this.requestIncrementalSync();
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (payload.requiresBootstrap) {
|
|
1271
|
+
this.emit('bootstrap_required', payload.bootstrapHint);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
// Process incremental deltas
|
|
1275
|
+
if (payload.deltas && Array.isArray(payload.deltas)) {
|
|
1276
|
+
// Process all deltas from sync response - store handles idempotency
|
|
1277
|
+
const newDeltas = payload.deltas;
|
|
1278
|
+
if (newDeltas.length > 0) {
|
|
1279
|
+
// DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
|
|
1280
|
+
// `handleDelta`: the runtime cursor must stay consistent with
|
|
1281
|
+
// IDB. The delta_batch event routes through
|
|
1282
|
+
// `BaseSyncedStore.processDeltaWithBatching` →
|
|
1283
|
+
// `flushPendingDeltas`, which calls `acknowledge()` with the
|
|
1284
|
+
// honest `persistedSyncId` once IDB commits. That ack is what
|
|
1285
|
+
// moves `this.lastSyncId` forward.
|
|
1286
|
+
// Emit ALL deltas as a single batch event
|
|
1287
|
+
this.emit('delta_batch', newDeltas);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
// Update cursors and versions
|
|
1291
|
+
if (payload.newCursor) {
|
|
1292
|
+
this.syncCursor = payload.newCursor;
|
|
1293
|
+
}
|
|
1294
|
+
else if (payload.cursor) {
|
|
1295
|
+
this.syncCursor = payload.cursor;
|
|
1296
|
+
}
|
|
1297
|
+
if (payload.newVersions) {
|
|
1298
|
+
Object.assign(this.versionVector, payload.newVersions);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Handle bootstrap response from server
|
|
1303
|
+
*/
|
|
1304
|
+
handleBootstrapResponse(payload) {
|
|
1305
|
+
// Emit bootstrap data for processing
|
|
1306
|
+
this.emit('bootstrap_data', {
|
|
1307
|
+
entityType: payload.entityType,
|
|
1308
|
+
data: payload.data,
|
|
1309
|
+
isComplete: payload.isComplete,
|
|
1310
|
+
cursor: payload.cursor,
|
|
1311
|
+
});
|
|
1312
|
+
// Update version vector if provided
|
|
1313
|
+
if (payload.version && payload.entityType) {
|
|
1314
|
+
this.updateVersionVector(payload.entityType.toLowerCase(), payload.version);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Handle presence update from server. The wire frame's payload is
|
|
1319
|
+
* forwarded as-is so every consumer (web entity cache,
|
|
1320
|
+
* PresenceStream, agent runtime) reads from the same shape.
|
|
1321
|
+
* Stripping fields here was a prior bug — it silently dropped
|
|
1322
|
+
* `kind`, `activity`, `syncGroups`, `isAgent` for rich consumers.
|
|
1323
|
+
*
|
|
1324
|
+
* Wire frame (apps/sync-server/src/hub/types.ts PresenceUpdateMessage):
|
|
1325
|
+
* { type: 'presence_update', payload: { kind, userId, status,
|
|
1326
|
+
* syncGroups, activity, isAgent, timestamp, activeIntents } }
|
|
1327
|
+
*/
|
|
1328
|
+
handlePresenceUpdate(message) {
|
|
1329
|
+
const event =
|
|
1330
|
+
// Server canonical path: `{ payload: {...} }`. Some legacy
|
|
1331
|
+
// pathways emit fields at the top level (test fixtures) — fall
|
|
1332
|
+
// back to reading from the message itself.
|
|
1333
|
+
message.payload ?? message;
|
|
1334
|
+
this.emit('presence_update', event);
|
|
1335
|
+
}
|
|
1336
|
+
}
|