@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010
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-cjs/index.d.ts +605 -75
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js +3 -0
- package/dist-cjs/lib/RoomSession.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
- package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
- package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +280 -56
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +45 -2
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +161 -16
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +30 -0
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/findMin.js.map +2 -2
- package/dist-cjs/lib/interval.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-cjs/lib/server-types.js.map +1 -1
- package/dist-esm/index.d.mts +605 -75
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs +3 -0
- package/dist-esm/lib/RoomSession.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
- package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +280 -56
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +45 -2
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +161 -16
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +30 -0
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/findMin.mjs.map +2 -2
- package/dist-esm/lib/interval.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
- package/src/lib/ClientWebSocketAdapter.ts +240 -9
- package/src/lib/RoomSession.test.ts +97 -0
- package/src/lib/RoomSession.ts +105 -3
- package/src/lib/ServerSocketAdapter.test.ts +228 -0
- package/src/lib/ServerSocketAdapter.ts +124 -5
- package/src/lib/TLRemoteSyncError.ts +50 -1
- package/src/lib/TLSocketRoom.ts +377 -60
- package/src/lib/TLSyncClient.test.ts +828 -0
- package/src/lib/TLSyncClient.ts +251 -26
- package/src/lib/TLSyncRoom.ts +284 -24
- package/src/lib/chunk.ts +72 -1
- package/src/lib/diff.ts +128 -14
- package/src/lib/findMin.ts +6 -0
- package/src/lib/interval.ts +40 -0
- package/src/lib/protocol.ts +185 -7
- package/src/lib/server-types.test.ts +44 -0
- package/src/lib/server-types.ts +45 -1
- package/src/test/TLSocketRoom.test.ts +438 -29
- package/src/test/chunk.test.ts +200 -3
- package/src/test/diff.test.ts +396 -1
package/dist-cjs/index.js
CHANGED
|
@@ -50,7 +50,7 @@ var import_TLSyncClient = require("./lib/TLSyncClient");
|
|
|
50
50
|
var import_TLSyncRoom = require("./lib/TLSyncRoom");
|
|
51
51
|
(0, import_utils.registerTldrawLibraryVersion)(
|
|
52
52
|
"@tldraw/sync-core",
|
|
53
|
-
"4.1.0-next.
|
|
53
|
+
"4.1.0-next.b9999db71010",
|
|
54
54
|
"cjs"
|
|
55
55
|
);
|
|
56
56
|
//# sourceMappingURL=index.js.map
|
|
@@ -53,6 +53,11 @@ class ClientWebSocketAdapter {
|
|
|
53
53
|
isDisposed = false;
|
|
54
54
|
/** @internal */
|
|
55
55
|
_reconnectManager;
|
|
56
|
+
/**
|
|
57
|
+
* Permanently closes the WebSocket adapter and disposes of all resources.
|
|
58
|
+
* Once closed, the adapter cannot be reused and should be discarded.
|
|
59
|
+
* This method is idempotent - calling it multiple times has no additional effect.
|
|
60
|
+
*/
|
|
56
61
|
// TODO: .close should be a project-wide interface with a common contract (.close()d thing
|
|
57
62
|
// can only be garbage collected, and can't be used anymore)
|
|
58
63
|
close() {
|
|
@@ -60,6 +65,14 @@ class ClientWebSocketAdapter {
|
|
|
60
65
|
this._reconnectManager.close();
|
|
61
66
|
this._ws?.close();
|
|
62
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Creates a new ClientWebSocketAdapter instance.
|
|
70
|
+
*
|
|
71
|
+
* @param getUri - Function that returns the WebSocket URI to connect to.
|
|
72
|
+
* Can return a string directly or a Promise that resolves to a string.
|
|
73
|
+
* This function is called each time a connection attempt is made,
|
|
74
|
+
* allowing for dynamic URI generation (e.g., for authentication tokens).
|
|
75
|
+
*/
|
|
63
76
|
constructor(getUri) {
|
|
64
77
|
this._reconnectManager = new ReconnectManager(this, getUri);
|
|
65
78
|
}
|
|
@@ -159,11 +172,30 @@ class ClientWebSocketAdapter {
|
|
|
159
172
|
"websocket connection status",
|
|
160
173
|
"initial"
|
|
161
174
|
);
|
|
175
|
+
/**
|
|
176
|
+
* Gets the current connection status of the WebSocket.
|
|
177
|
+
*
|
|
178
|
+
* @returns The current connection status: 'online', 'offline', or 'error'
|
|
179
|
+
*/
|
|
162
180
|
// eslint-disable-next-line no-restricted-syntax
|
|
163
181
|
get connectionStatus() {
|
|
164
182
|
const status = this._connectionStatus.get();
|
|
165
183
|
return status === "initial" ? "offline" : status;
|
|
166
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Sends a message to the server through the WebSocket connection.
|
|
187
|
+
* Messages are automatically chunked if they exceed size limits.
|
|
188
|
+
*
|
|
189
|
+
* @param msg - The message to send to the server
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```ts
|
|
193
|
+
* adapter.sendMessage({
|
|
194
|
+
* type: 'push',
|
|
195
|
+
* diff: { 'shape:abc123': [2, { x: [1, 150] }] }
|
|
196
|
+
* })
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
167
199
|
sendMessage(msg) {
|
|
168
200
|
(0, import_utils.assert)(!this.isDisposed, "Tried to send message on a disposed socket");
|
|
169
201
|
if (!this._ws) return;
|
|
@@ -177,6 +209,29 @@ class ClientWebSocketAdapter {
|
|
|
177
209
|
}
|
|
178
210
|
}
|
|
179
211
|
messageListeners = /* @__PURE__ */ new Set();
|
|
212
|
+
/**
|
|
213
|
+
* Registers a callback to handle incoming messages from the server.
|
|
214
|
+
*
|
|
215
|
+
* @param cb - Callback function that will be called with each received message
|
|
216
|
+
* @returns A cleanup function to remove the message listener
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* const unsubscribe = adapter.onReceiveMessage((message) => {
|
|
221
|
+
* switch (message.type) {
|
|
222
|
+
* case 'connect':
|
|
223
|
+
* console.log('Connected to room')
|
|
224
|
+
* break
|
|
225
|
+
* case 'data':
|
|
226
|
+
* console.log('Received data:', message.diff)
|
|
227
|
+
* break
|
|
228
|
+
* }
|
|
229
|
+
* })
|
|
230
|
+
*
|
|
231
|
+
* // Later, remove the listener
|
|
232
|
+
* unsubscribe()
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
180
235
|
onReceiveMessage(cb) {
|
|
181
236
|
(0, import_utils.assert)(!this.isDisposed, "Tried to add message listener on a disposed socket");
|
|
182
237
|
this.messageListeners.add(cb);
|
|
@@ -185,6 +240,26 @@ class ClientWebSocketAdapter {
|
|
|
185
240
|
};
|
|
186
241
|
}
|
|
187
242
|
statusListeners = /* @__PURE__ */ new Set();
|
|
243
|
+
/**
|
|
244
|
+
* Registers a callback to handle connection status changes.
|
|
245
|
+
*
|
|
246
|
+
* @param cb - Callback function that will be called when the connection status changes
|
|
247
|
+
* @returns A cleanup function to remove the status listener
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* const unsubscribe = adapter.onStatusChange((status) => {
|
|
252
|
+
* if (status.status === 'error') {
|
|
253
|
+
* console.error('Connection error:', status.reason)
|
|
254
|
+
* } else {
|
|
255
|
+
* console.log('Status changed to:', status.status)
|
|
256
|
+
* }
|
|
257
|
+
* })
|
|
258
|
+
*
|
|
259
|
+
* // Later, remove the listener
|
|
260
|
+
* unsubscribe()
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
188
263
|
onStatusChange(cb) {
|
|
189
264
|
(0, import_utils.assert)(!this.isDisposed, "Tried to add status listener on a disposed socket");
|
|
190
265
|
this.statusListeners.add(cb);
|
|
@@ -192,6 +267,19 @@ class ClientWebSocketAdapter {
|
|
|
192
267
|
this.statusListeners.delete(cb);
|
|
193
268
|
};
|
|
194
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Manually restarts the WebSocket connection.
|
|
272
|
+
* This closes the current connection (if any) and attempts to establish a new one.
|
|
273
|
+
* Useful for implementing connection loss detection and recovery.
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* ```ts
|
|
277
|
+
* // Restart connection after detecting it's stale
|
|
278
|
+
* if (lastPongTime < Date.now() - 30000) {
|
|
279
|
+
* adapter.restart()
|
|
280
|
+
* }
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
195
283
|
restart() {
|
|
196
284
|
(0, import_utils.assert)(!this.isDisposed, "Tried to restart a disposed socket");
|
|
197
285
|
debug("restarting");
|
|
@@ -206,6 +294,12 @@ const INACTIVE_MAX_DELAY = 1e3 * 60 * 5;
|
|
|
206
294
|
const DELAY_EXPONENT = 1.5;
|
|
207
295
|
const ATTEMPT_TIMEOUT = 1e3;
|
|
208
296
|
class ReconnectManager {
|
|
297
|
+
/**
|
|
298
|
+
* Creates a new ReconnectManager instance.
|
|
299
|
+
*
|
|
300
|
+
* socketAdapter - The ClientWebSocketAdapter instance to manage
|
|
301
|
+
* getUri - Function that returns the WebSocket URI for connection attempts
|
|
302
|
+
*/
|
|
209
303
|
constructor(socketAdapter, getUri) {
|
|
210
304
|
this.socketAdapter = socketAdapter;
|
|
211
305
|
this.getUri = getUri;
|
|
@@ -287,6 +381,22 @@ class ReconnectManager {
|
|
|
287
381
|
this.recheckConnectingTimeout = null;
|
|
288
382
|
}
|
|
289
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Checks if reconnection should be attempted and initiates it if appropriate.
|
|
386
|
+
* This method is called in response to network events, tab visibility changes,
|
|
387
|
+
* and other hints that connectivity may have been restored.
|
|
388
|
+
*
|
|
389
|
+
* The method intelligently handles various connection states:
|
|
390
|
+
* - Already connected: no action needed
|
|
391
|
+
* - Currently connecting: waits or retries based on attempt age
|
|
392
|
+
* - Disconnected: initiates immediate reconnection attempt
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* // Called automatically on network/visibility events, but can be called manually
|
|
397
|
+
* manager.maybeReconnected()
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
290
400
|
maybeReconnected() {
|
|
291
401
|
debug("ReconnectManager.maybeReconnected");
|
|
292
402
|
this.clearRecheckConnectingTimeout();
|
|
@@ -318,6 +428,22 @@ class ReconnectManager {
|
|
|
318
428
|
this.intendedDelay = ACTIVE_MIN_DELAY;
|
|
319
429
|
this.disconnected();
|
|
320
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Handles disconnection events and schedules reconnection attempts with exponential backoff.
|
|
433
|
+
* This method is called when the WebSocket connection is lost or fails to establish.
|
|
434
|
+
*
|
|
435
|
+
* It implements intelligent delay calculation based on:
|
|
436
|
+
* - Previous attempt timing
|
|
437
|
+
* - Current tab visibility (active vs inactive delays)
|
|
438
|
+
* - Exponential backoff for repeated failures
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* ```ts
|
|
442
|
+
* // Called automatically when connection is lost
|
|
443
|
+
* // Schedules reconnection with appropriate delay
|
|
444
|
+
* manager.disconnected()
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
321
447
|
disconnected() {
|
|
322
448
|
debug("ReconnectManager.disconnected");
|
|
323
449
|
if (this.socketAdapter._ws?.readyState !== WebSocket.OPEN && this.socketAdapter._ws?.readyState !== WebSocket.CONNECTING) {
|
|
@@ -348,6 +474,19 @@ class ReconnectManager {
|
|
|
348
474
|
}
|
|
349
475
|
}
|
|
350
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Handles successful connection events and resets reconnection state.
|
|
479
|
+
* This method is called when the WebSocket successfully connects to the server.
|
|
480
|
+
*
|
|
481
|
+
* It clears any pending reconnection attempts and resets the delay back to minimum
|
|
482
|
+
* for future connection attempts.
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```ts
|
|
486
|
+
* // Called automatically when WebSocket opens successfully
|
|
487
|
+
* manager.connected()
|
|
488
|
+
* ```
|
|
489
|
+
*/
|
|
351
490
|
connected() {
|
|
352
491
|
debug("ReconnectManager.connected");
|
|
353
492
|
if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {
|
|
@@ -357,6 +496,11 @@ class ReconnectManager {
|
|
|
357
496
|
this.intendedDelay = ACTIVE_MIN_DELAY;
|
|
358
497
|
}
|
|
359
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Permanently closes the reconnection manager and cleans up all resources.
|
|
501
|
+
* This stops all pending reconnection attempts and removes event listeners.
|
|
502
|
+
* Once closed, the manager cannot be reused.
|
|
503
|
+
*/
|
|
360
504
|
close() {
|
|
361
505
|
this.disposables.forEach((d) => d());
|
|
362
506
|
this.isDisposed = true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/ClientWebSocketAdapter.ts"],
|
|
4
|
-
"sourcesContent": ["import { atom, Atom } from '@tldraw/state'\nimport { TLRecord } from '@tldraw/tlschema'\nimport { assert, warnOnce } from '@tldraw/utils'\nimport { chunk } from './chunk'\nimport { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'\nimport {\n\tTLPersistentClientSocket,\n\tTLPersistentClientSocketStatus,\n\tTLSocketStatusListener,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n} from './TLSyncClient'\n\nfunction listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {\n\ttarget.addEventListener(event, handler)\n\treturn () => {\n\t\ttarget.removeEventListener(event, handler)\n\t}\n}\n\nfunction debug(...args: any[]) {\n\t// @ts-ignore\n\tif (typeof window !== 'undefined' && window.__tldraw_socket_debug) {\n\t\tconst now = new Date()\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(\n\t\t\t`${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`,\n\t\t\t...args\n\t\t\t//, new Error().stack\n\t\t)\n\t}\n}\n\n// NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss\n// detection, for example by regularly pinging the server and .restart()ing\n// the connection when a number of pings goes unanswered. Without this mechanism,\n// we might not be able to detect the websocket connection going down in a timely manner\n// (it will probably time out on outgoing data packets at some point).\n//\n// This is by design. While the Websocket protocol specifies protocol-level pings,\n// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,\n// pings need to be implemented one level up, on the application API side, which for our\n// codebase means whatever code that uses ClientWebSocketAdapter.\n/** @internal */\nexport class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord> {\n\t_ws: WebSocket | null = null\n\n\tisDisposed = false\n\n\t/** @internal */\n\treadonly _reconnectManager: ReconnectManager\n\n\t// TODO: .close should be a project-wide interface with a common contract (.close()d thing\n\t// can only be garbage collected, and can't be used anymore)\n\tclose() {\n\t\tthis.isDisposed = true\n\t\tthis._reconnectManager.close()\n\t\t// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\n\t}\n\n\tconstructor(getUri: () => Promise<string> | string) {\n\t\tthis._reconnectManager = new ReconnectManager(this, getUri)\n\t}\n\n\tprivate _handleConnect() {\n\t\tdebug('handleConnect')\n\n\t\tthis._connectionStatus.set('online')\n\t\tthis.statusListeners.forEach((cb) => cb({ status: 'online' }))\n\n\t\tthis._reconnectManager.connected()\n\t}\n\n\tprivate _handleDisconnect(\n\t\treason: 'closed' | 'manual',\n\t\tcloseCode?: number,\n\t\tdidOpen?: boolean,\n\t\tcloseReason?: string\n\t) {\n\t\tcloseReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR\n\n\t\tdebug('handleDisconnect', {\n\t\t\tcurrentStatus: this.connectionStatus,\n\t\t\tcloseCode,\n\t\t\treason,\n\t\t})\n\n\t\tlet newStatus: 'offline' | 'error'\n\t\tswitch (reason) {\n\t\t\tcase 'closed':\n\t\t\t\tif (closeCode === TLSyncErrorCloseEventCode) {\n\t\t\t\t\tnewStatus = 'error'\n\t\t\t\t} else {\n\t\t\t\t\tnewStatus = 'offline'\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tcase 'manual':\n\t\t\t\tnewStatus = 'offline'\n\t\t\t\tbreak\n\t\t}\n\n\t\tif (closeCode === 1006 && !didOpen) {\n\t\t\twarnOnce(\n\t\t\t\t\"Could not open WebSocket connection. This might be because you're trying to load a URL that doesn't support websockets. Check the URL you're trying to connect to.\"\n\t\t\t)\n\t\t}\n\n\t\tif (\n\t\t\t// it the status changed\n\t\t\tthis.connectionStatus !== newStatus &&\n\t\t\t// ignore errors if we're already in the offline state\n\t\t\t!(newStatus === 'error' && this.connectionStatus === 'offline')\n\t\t) {\n\t\t\tthis._connectionStatus.set(newStatus)\n\t\t\tthis.statusListeners.forEach((cb) =>\n\t\t\t\tcb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus })\n\t\t\t)\n\t\t}\n\n\t\tthis._reconnectManager.disconnected()\n\t}\n\n\t_setNewSocket(ws: WebSocket) {\n\t\tassert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')\n\t\tassert(\n\t\t\tthis._ws === null ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSED ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSING,\n\t\t\t`Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`\n\t\t)\n\n\t\tlet didOpen = false\n\n\t\t// NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition\n\t\t// between CLOSING and CLOSED happens either after the closing handshake, or after a\n\t\t// timeout, but in either case those sockets don't need any special handling, the browser\n\t\t// will close them eventually. We just \"orphan\" such sockets and ignore their onclose/onerror.\n\t\tws.onopen = () => {\n\t\t\tdebug('ws.onopen')\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open\"\n\t\t\t)\n\t\t\tdidOpen = true\n\t\t\tthis._handleConnect()\n\t\t}\n\t\tws.onclose = (event: CloseEvent) => {\n\t\t\tdebug('ws.onclose', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed', event.code, didOpen, event.reason)\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onclose for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onerror = (event) => {\n\t\t\tdebug('ws.onerror', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed')\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onerror for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onmessage = (ev) => {\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages\"\n\t\t\t)\n\t\t\tconst parsed = JSON.parse(ev.data.toString())\n\t\t\tthis.messageListeners.forEach((cb) => cb(parsed))\n\t\t}\n\n\t\tthis._ws = ws\n\t}\n\n\t_closeSocket() {\n\t\tif (this._ws === null) return\n\n\t\tthis._ws.close()\n\t\t// explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed\n\t\tthis._ws = null\n\t\tthis._handleDisconnect('manual')\n\t}\n\n\t// TLPersistentClientSocket stuff\n\n\t_connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(\n\t\t'websocket connection status',\n\t\t'initial'\n\t)\n\n\t// eslint-disable-next-line no-restricted-syntax\n\tget connectionStatus(): TLPersistentClientSocketStatus {\n\t\tconst status = this._connectionStatus.get()\n\t\treturn status === 'initial' ? 'offline' : status\n\t}\n\n\tsendMessage(msg: TLSocketClientSentEvent<TLRecord>) {\n\t\tassert(!this.isDisposed, 'Tried to send message on a disposed socket')\n\n\t\tif (!this._ws) return\n\t\tif (this.connectionStatus === 'online') {\n\t\t\tconst chunks = chunk(JSON.stringify(msg))\n\t\t\tfor (const part of chunks) {\n\t\t\t\tthis._ws.send(part)\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn('Tried to send message while ' + this.connectionStatus)\n\t\t}\n\t}\n\n\tprivate messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()\n\tonReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {\n\t\tassert(!this.isDisposed, 'Tried to add message listener on a disposed socket')\n\n\t\tthis.messageListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.messageListeners.delete(cb)\n\t\t}\n\t}\n\n\tprivate statusListeners = new Set<TLSocketStatusListener>()\n\tonStatusChange(cb: TLSocketStatusListener) {\n\t\tassert(!this.isDisposed, 'Tried to add status listener on a disposed socket')\n\n\t\tthis.statusListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.statusListeners.delete(cb)\n\t\t}\n\t}\n\n\trestart() {\n\t\tassert(!this.isDisposed, 'Tried to restart a disposed socket')\n\t\tdebug('restarting')\n\n\t\tthis._closeSocket()\n\t\tthis._reconnectManager.maybeReconnected()\n\t}\n}\n\n// Those constants are exported primarily for tests\n// ACTIVE_ means the tab is active, document.hidden is false\nexport const ACTIVE_MIN_DELAY = 500\nexport const ACTIVE_MAX_DELAY = 2000\n// Correspondingly, here document.hidden is true. It's intended to reduce the load and battery drain\n// on client devices somewhat when they aren't looking at the tab. We don't disconnect completely\n// to minimise issues with reconnection/sync when the tab becomes visible again\nexport const INACTIVE_MIN_DELAY = 1000\nexport const INACTIVE_MAX_DELAY = 1000 * 60 * 5\nexport const DELAY_EXPONENT = 1.5\n// this is a tradeoff between quickly detecting connections stuck in the CONNECTING state and\n// not needlessly reconnecting if the connection is just slow to establish\nexport const ATTEMPT_TIMEOUT = 1000\n\n/** @internal */\nexport class ReconnectManager {\n\tprivate isDisposed = false\n\tprivate disposables: (() => void)[] = [\n\t\t() => {\n\t\t\tif (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)\n\t\t\tif (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout)\n\t\t},\n\t]\n\tprivate reconnectTimeout: ReturnType<typeof setTimeout> | null = null\n\tprivate recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null\n\n\tprivate lastAttemptStart: number | null = null\n\tintendedDelay: number = ACTIVE_MIN_DELAY\n\tprivate state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'\n\n\tconstructor(\n\t\tprivate socketAdapter: ClientWebSocketAdapter,\n\t\tprivate getUri: () => Promise<string> | string\n\t) {\n\t\tthis.subscribeToReconnectHints()\n\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'offline', () => {\n\t\t\t\tdebug('window went offline')\n\t\t\t\t// On the one hand, 'offline' event is not really reliable; on the other, the only\n\t\t\t\t// alternative is to wait for pings not being delivered, which takes more than 20 seconds,\n\t\t\t\t// which means we won't see the ClientWebSocketAdapter status change for more than\n\t\t\t\t// 20 seconds after the tab goes offline. Our application layer must be resistent to\n\t\t\t\t// connection restart anyway, so we can just try to reconnect and see if\n\t\t\t\t// we're truly offline.\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t})\n\t\t)\n\n\t\tthis.state = 'pendingAttempt'\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.scheduleAttempt()\n\t}\n\n\tprivate subscribeToReconnectHints() {\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'online', () => {\n\t\t\t\tdebug('window went online')\n\t\t\t\tthis.maybeReconnected()\n\t\t\t}),\n\t\t\tlistenTo(document, 'visibilitychange', () => {\n\t\t\t\tif (!document.hidden) {\n\t\t\t\t\tdebug('document became visible')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t}\n\t\t\t})\n\t\t)\n\n\t\tif (Object.prototype.hasOwnProperty.call(navigator, 'connection')) {\n\t\t\tconst connection = (navigator as any)['connection'] as EventTarget\n\t\t\tthis.disposables.push(\n\t\t\t\tlistenTo(connection, 'change', () => {\n\t\t\t\t\tdebug('navigator.connection change')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\n\t}\n\n\tprivate getMaxDelay() {\n\t\treturn document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY\n\t}\n\n\tprivate getMinDelay() {\n\t\treturn document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY\n\t}\n\n\tprivate clearReconnectTimeout() {\n\t\tif (this.reconnectTimeout) {\n\t\t\tclearTimeout(this.reconnectTimeout)\n\t\t\tthis.reconnectTimeout = null\n\t\t}\n\t}\n\n\tprivate clearRecheckConnectingTimeout() {\n\t\tif (this.recheckConnectingTimeout) {\n\t\t\tclearTimeout(this.recheckConnectingTimeout)\n\t\t\tthis.recheckConnectingTimeout = null\n\t\t}\n\t}\n\n\tmaybeReconnected() {\n\t\tdebug('ReconnectManager.maybeReconnected')\n\t\t// It doesn't make sense to have another check scheduled if we're already checking it now.\n\t\t// If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway\n\t\tthis.clearRecheckConnectingTimeout()\n\n\t\t// readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: already connected')\n\t\t\t// nothing to do, we're already OK\n\t\t\treturn\n\t\t}\n\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: connecting')\n\t\t\t// We might be waiting for a TCP connection that sent SYN out and will never get it back,\n\t\t\t// while a new connection appeared. On the other hand, we might have just started connecting\n\t\t\t// and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew\n\t\t\t// if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent\n\t\t\t// new connections as long as `maybeReconnected` is not looped itself\n\t\t\tassert(\n\t\t\t\tthis.lastAttemptStart,\n\t\t\t\t'ReadyState=CONNECTING without lastAttemptStart should be impossible'\n\t\t\t)\n\t\t\tconst sinceLastStart = Date.now() - this.lastAttemptStart\n\t\t\tif (sinceLastStart < ATTEMPT_TIMEOUT) {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, rechecking later')\n\t\t\t\tthis.recheckConnectingTimeout = setTimeout(\n\t\t\t\t\t() => this.maybeReconnected(),\n\t\t\t\t\tATTEMPT_TIMEOUT - sinceLastStart\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now')\n\t\t\t\t// Last connection attempt was started a while ago, it's possible that network conditions\n\t\t\t\t// changed, and it's worth retrying to connect. `disconnected` will handle reconnection\n\t\t\t\t//\n\t\t\t\t// NOTE: The danger here is looping in connection attemps if connections are slow.\n\t\t\t\t// Make sure that `maybeReconnected` is not called in the `disconnected` codepath!\n\t\t\t\tthis.clearRecheckConnectingTimeout()\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t}\n\n\t\t\treturn\n\t\t}\n\n\t\tdebug('ReconnectManager.maybeReconnected: closing/closed/null, retry now')\n\t\t// readyState is CLOSING or CLOSED, or the websocket is null\n\t\t// Restart the backoff and retry ASAP (honouring the min delay)\n\t\t// this.state doesn't really matter, because disconnected() will handle any state correctly\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.disconnected()\n\t}\n\n\tdisconnected() {\n\t\tdebug('ReconnectManager.disconnected')\n\t\t// This either means we're freshly disconnected, or the last connection attempt failed;\n\t\t// either way, time to try again.\n\n\t\t// Guard against delayed notifications and recheck synchronously\n\t\tif (\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN &&\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.CONNECTING\n\t\t) {\n\t\t\tdebug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')\n\t\t\tthis.clearReconnectTimeout()\n\n\t\t\tlet delayLeft\n\t\t\tif (this.state === 'connected') {\n\t\t\t\t// it's the first sign that we got disconnected; the state will be updated below,\n\t\t\t\t// just set the appropriate delay for now\n\t\t\t\tthis.intendedDelay = this.getMinDelay()\n\t\t\t\tdelayLeft = this.intendedDelay\n\t\t\t} else {\n\t\t\t\tdelayLeft =\n\t\t\t\t\tthis.lastAttemptStart !== null\n\t\t\t\t\t\t? this.lastAttemptStart + this.intendedDelay - Date.now()\n\t\t\t\t\t\t: 0\n\t\t\t}\n\n\t\t\tif (delayLeft > 0) {\n\t\t\t\tdebug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft)\n\t\t\t\t// try again later\n\t\t\t\tthis.state = 'delay'\n\n\t\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft)\n\t\t\t} else {\n\t\t\t\t// not connected and not delayed, time to retry\n\t\t\t\tthis.state = 'pendingAttempt'\n\n\t\t\t\tthis.intendedDelay = Math.min(\n\t\t\t\t\tthis.getMaxDelay(),\n\t\t\t\t\tMath.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT\n\t\t\t\t)\n\t\t\t\tdebug(\n\t\t\t\t\t'ReconnectManager.disconnected: attempting a connection, next delay',\n\t\t\t\t\tthis.intendedDelay\n\t\t\t\t)\n\t\t\t\tthis.scheduleAttempt()\n\t\t\t}\n\t\t}\n\t}\n\n\tconnected() {\n\t\tdebug('ReconnectManager.connected')\n\t\t// this notification could've been delayed, recheck synchronously\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.connected: websocket is OPEN')\n\t\t\tthis.state = 'connected'\n\t\t\tthis.clearReconnectTimeout()\n\t\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\t}\n\t}\n\n\tclose() {\n\t\tthis.disposables.forEach((d) => d())\n\t\tthis.isDisposed = true\n\t}\n}\n\nfunction httpToWs(url: string) {\n\treturn url.replace(/^http(s)?:/, 'ws$1:')\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA2B;AAE3B,mBAAiC;AACjC,mBAAsB;AAEtB,0BAMO;AAEP,SAAS,SAAgC,QAAW,OAAe,SAAqB;AACvF,SAAO,iBAAiB,OAAO,OAAO;AACtC,SAAO,MAAM;AACZ,WAAO,oBAAoB,OAAO,OAAO;AAAA,EAC1C;AACD;AAEA,SAAS,SAAS,MAAa;AAE9B,MAAI,OAAO,WAAW,eAAe,OAAO,uBAAuB;AAClE,UAAM,MAAM,oBAAI,KAAK;AAErB,YAAQ;AAAA,MACP,GAAG,IAAI,SAAS,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,gBAAgB,CAAC;AAAA,MAClF,GAAG;AAAA;AAAA,IAEJ;AAAA,EACD;AACD;
|
|
4
|
+
"sourcesContent": ["import { atom, Atom } from '@tldraw/state'\nimport { TLRecord } from '@tldraw/tlschema'\nimport { assert, warnOnce } from '@tldraw/utils'\nimport { chunk } from './chunk'\nimport { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'\nimport {\n\tTLPersistentClientSocket,\n\tTLPersistentClientSocketStatus,\n\tTLSocketStatusListener,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n} from './TLSyncClient'\n\nfunction listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {\n\ttarget.addEventListener(event, handler)\n\treturn () => {\n\t\ttarget.removeEventListener(event, handler)\n\t}\n}\n\nfunction debug(...args: any[]) {\n\t// @ts-ignore\n\tif (typeof window !== 'undefined' && window.__tldraw_socket_debug) {\n\t\tconst now = new Date()\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(\n\t\t\t`${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`,\n\t\t\t...args\n\t\t\t//, new Error().stack\n\t\t)\n\t}\n}\n\n// NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss\n// detection, for example by regularly pinging the server and .restart()ing\n// the connection when a number of pings goes unanswered. Without this mechanism,\n// we might not be able to detect the websocket connection going down in a timely manner\n// (it will probably time out on outgoing data packets at some point).\n//\n// This is by design. While the Websocket protocol specifies protocol-level pings,\n// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,\n// pings need to be implemented one level up, on the application API side, which for our\n// codebase means whatever code that uses ClientWebSocketAdapter.\n/**\n * A WebSocket adapter that provides persistent connection management for tldraw synchronization.\n * This adapter handles connection establishment, reconnection logic, and message routing between\n * the sync client and server. It implements automatic reconnection with exponential backoff\n * and supports connection loss detection.\n *\n * Note: This adapter requires users to implement their own connection loss detection (e.g., pings)\n * as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames.\n *\n * @internal\n * @example\n * ```ts\n * // Create a WebSocket adapter with connection URI\n * const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync')\n *\n * // Listen for connection status changes\n * adapter.onStatusChange((status) => {\n * console.log('Connection status:', status)\n * })\n *\n * // Listen for incoming messages\n * adapter.onReceiveMessage((message) => {\n * console.log('Received:', message)\n * })\n *\n * // Send a message when connected\n * if (adapter.connectionStatus === 'online') {\n * adapter.sendMessage({ type: 'ping' })\n * }\n * ```\n */\nexport class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord> {\n\t_ws: WebSocket | null = null\n\n\tisDisposed = false\n\n\t/** @internal */\n\treadonly _reconnectManager: ReconnectManager\n\n\t/**\n\t * Permanently closes the WebSocket adapter and disposes of all resources.\n\t * Once closed, the adapter cannot be reused and should be discarded.\n\t * This method is idempotent - calling it multiple times has no additional effect.\n\t */\n\t// TODO: .close should be a project-wide interface with a common contract (.close()d thing\n\t// can only be garbage collected, and can't be used anymore)\n\tclose() {\n\t\tthis.isDisposed = true\n\t\tthis._reconnectManager.close()\n\t\t// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\n\t}\n\n\t/**\n\t * Creates a new ClientWebSocketAdapter instance.\n\t *\n\t * @param getUri - Function that returns the WebSocket URI to connect to.\n\t * Can return a string directly or a Promise that resolves to a string.\n\t * This function is called each time a connection attempt is made,\n\t * allowing for dynamic URI generation (e.g., for authentication tokens).\n\t */\n\tconstructor(getUri: () => Promise<string> | string) {\n\t\tthis._reconnectManager = new ReconnectManager(this, getUri)\n\t}\n\n\tprivate _handleConnect() {\n\t\tdebug('handleConnect')\n\n\t\tthis._connectionStatus.set('online')\n\t\tthis.statusListeners.forEach((cb) => cb({ status: 'online' }))\n\n\t\tthis._reconnectManager.connected()\n\t}\n\n\tprivate _handleDisconnect(\n\t\treason: 'closed' | 'manual',\n\t\tcloseCode?: number,\n\t\tdidOpen?: boolean,\n\t\tcloseReason?: string\n\t) {\n\t\tcloseReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR\n\n\t\tdebug('handleDisconnect', {\n\t\t\tcurrentStatus: this.connectionStatus,\n\t\t\tcloseCode,\n\t\t\treason,\n\t\t})\n\n\t\tlet newStatus: 'offline' | 'error'\n\t\tswitch (reason) {\n\t\t\tcase 'closed':\n\t\t\t\tif (closeCode === TLSyncErrorCloseEventCode) {\n\t\t\t\t\tnewStatus = 'error'\n\t\t\t\t} else {\n\t\t\t\t\tnewStatus = 'offline'\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tcase 'manual':\n\t\t\t\tnewStatus = 'offline'\n\t\t\t\tbreak\n\t\t}\n\n\t\tif (closeCode === 1006 && !didOpen) {\n\t\t\twarnOnce(\n\t\t\t\t\"Could not open WebSocket connection. This might be because you're trying to load a URL that doesn't support websockets. Check the URL you're trying to connect to.\"\n\t\t\t)\n\t\t}\n\n\t\tif (\n\t\t\t// it the status changed\n\t\t\tthis.connectionStatus !== newStatus &&\n\t\t\t// ignore errors if we're already in the offline state\n\t\t\t!(newStatus === 'error' && this.connectionStatus === 'offline')\n\t\t) {\n\t\t\tthis._connectionStatus.set(newStatus)\n\t\t\tthis.statusListeners.forEach((cb) =>\n\t\t\t\tcb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus })\n\t\t\t)\n\t\t}\n\n\t\tthis._reconnectManager.disconnected()\n\t}\n\n\t_setNewSocket(ws: WebSocket) {\n\t\tassert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')\n\t\tassert(\n\t\t\tthis._ws === null ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSED ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSING,\n\t\t\t`Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`\n\t\t)\n\n\t\tlet didOpen = false\n\n\t\t// NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition\n\t\t// between CLOSING and CLOSED happens either after the closing handshake, or after a\n\t\t// timeout, but in either case those sockets don't need any special handling, the browser\n\t\t// will close them eventually. We just \"orphan\" such sockets and ignore their onclose/onerror.\n\t\tws.onopen = () => {\n\t\t\tdebug('ws.onopen')\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open\"\n\t\t\t)\n\t\t\tdidOpen = true\n\t\t\tthis._handleConnect()\n\t\t}\n\t\tws.onclose = (event: CloseEvent) => {\n\t\t\tdebug('ws.onclose', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed', event.code, didOpen, event.reason)\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onclose for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onerror = (event) => {\n\t\t\tdebug('ws.onerror', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed')\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onerror for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onmessage = (ev) => {\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages\"\n\t\t\t)\n\t\t\tconst parsed = JSON.parse(ev.data.toString())\n\t\t\tthis.messageListeners.forEach((cb) => cb(parsed))\n\t\t}\n\n\t\tthis._ws = ws\n\t}\n\n\t_closeSocket() {\n\t\tif (this._ws === null) return\n\n\t\tthis._ws.close()\n\t\t// explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed\n\t\tthis._ws = null\n\t\tthis._handleDisconnect('manual')\n\t}\n\n\t// TLPersistentClientSocket stuff\n\n\t_connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(\n\t\t'websocket connection status',\n\t\t'initial'\n\t)\n\n\t/**\n\t * Gets the current connection status of the WebSocket.\n\t *\n\t * @returns The current connection status: 'online', 'offline', or 'error'\n\t */\n\t// eslint-disable-next-line no-restricted-syntax\n\tget connectionStatus(): TLPersistentClientSocketStatus {\n\t\tconst status = this._connectionStatus.get()\n\t\treturn status === 'initial' ? 'offline' : status\n\t}\n\n\t/**\n\t * Sends a message to the server through the WebSocket connection.\n\t * Messages are automatically chunked if they exceed size limits.\n\t *\n\t * @param msg - The message to send to the server\n\t *\n\t * @example\n\t * ```ts\n\t * adapter.sendMessage({\n\t * type: 'push',\n\t * diff: { 'shape:abc123': [2, { x: [1, 150] }] }\n\t * })\n\t * ```\n\t */\n\tsendMessage(msg: TLSocketClientSentEvent<TLRecord>) {\n\t\tassert(!this.isDisposed, 'Tried to send message on a disposed socket')\n\n\t\tif (!this._ws) return\n\t\tif (this.connectionStatus === 'online') {\n\t\t\tconst chunks = chunk(JSON.stringify(msg))\n\t\t\tfor (const part of chunks) {\n\t\t\t\tthis._ws.send(part)\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn('Tried to send message while ' + this.connectionStatus)\n\t\t}\n\t}\n\n\tprivate messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()\n\t/**\n\t * Registers a callback to handle incoming messages from the server.\n\t *\n\t * @param cb - Callback function that will be called with each received message\n\t * @returns A cleanup function to remove the message listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onReceiveMessage((message) => {\n\t * switch (message.type) {\n\t * case 'connect':\n\t * console.log('Connected to room')\n\t * break\n\t * case 'data':\n\t * console.log('Received data:', message.diff)\n\t * break\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {\n\t\tassert(!this.isDisposed, 'Tried to add message listener on a disposed socket')\n\n\t\tthis.messageListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.messageListeners.delete(cb)\n\t\t}\n\t}\n\n\tprivate statusListeners = new Set<TLSocketStatusListener>()\n\t/**\n\t * Registers a callback to handle connection status changes.\n\t *\n\t * @param cb - Callback function that will be called when the connection status changes\n\t * @returns A cleanup function to remove the status listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onStatusChange((status) => {\n\t * if (status.status === 'error') {\n\t * console.error('Connection error:', status.reason)\n\t * } else {\n\t * console.log('Status changed to:', status.status)\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonStatusChange(cb: TLSocketStatusListener) {\n\t\tassert(!this.isDisposed, 'Tried to add status listener on a disposed socket')\n\n\t\tthis.statusListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.statusListeners.delete(cb)\n\t\t}\n\t}\n\n\t/**\n\t * Manually restarts the WebSocket connection.\n\t * This closes the current connection (if any) and attempts to establish a new one.\n\t * Useful for implementing connection loss detection and recovery.\n\t *\n\t * @example\n\t * ```ts\n\t * // Restart connection after detecting it's stale\n\t * if (lastPongTime < Date.now() - 30000) {\n\t * adapter.restart()\n\t * }\n\t * ```\n\t */\n\trestart() {\n\t\tassert(!this.isDisposed, 'Tried to restart a disposed socket')\n\t\tdebug('restarting')\n\n\t\tthis._closeSocket()\n\t\tthis._reconnectManager.maybeReconnected()\n\t}\n}\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MIN_DELAY = 500\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MAX_DELAY = 2000\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab.\n *\n * @internal\n */\nexport const INACTIVE_MIN_DELAY = 1000\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * Set to 5 minutes to balance between maintaining sync and conserving resources.\n *\n * @internal\n */\nexport const INACTIVE_MAX_DELAY = 1000 * 60 * 5\n\n/**\n * Exponential backoff multiplier for calculating reconnection delays.\n * Each failed connection attempt increases the delay by this factor until max delay is reached.\n *\n * @internal\n */\nexport const DELAY_EXPONENT = 1.5\n\n/**\n * Maximum time in milliseconds to wait for a connection attempt before considering it failed.\n * This helps detect connections stuck in the CONNECTING state and retry with fresh attempts.\n *\n * @internal\n */\nexport const ATTEMPT_TIMEOUT = 1000\n\n/**\n * Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies.\n * This class handles connection attempts, tracks connection state, and implements exponential backoff\n * with different delays based on whether the browser tab is active or inactive.\n *\n * The ReconnectManager responds to various browser events like network status changes,\n * tab visibility changes, and connection events to optimize reconnection timing and\n * minimize unnecessary connection attempts.\n *\n * @internal\n *\n * @example\n * ```ts\n * const manager = new ReconnectManager(\n * socketAdapter,\n * () => 'ws://localhost:3000/sync'\n * )\n *\n * // Manager automatically handles:\n * // - Initial connection\n * // - Reconnection on disconnect\n * // - Exponential backoff on failures\n * // - Tab visibility-aware delays\n * // - Network status change responses\n * ```\n */\nexport class ReconnectManager {\n\tprivate isDisposed = false\n\tprivate disposables: (() => void)[] = [\n\t\t() => {\n\t\t\tif (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)\n\t\t\tif (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout)\n\t\t},\n\t]\n\tprivate reconnectTimeout: ReturnType<typeof setTimeout> | null = null\n\tprivate recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null\n\n\tprivate lastAttemptStart: number | null = null\n\tintendedDelay: number = ACTIVE_MIN_DELAY\n\tprivate state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'\n\n\t/**\n\t * Creates a new ReconnectManager instance.\n\t *\n\t * socketAdapter - The ClientWebSocketAdapter instance to manage\n\t * getUri - Function that returns the WebSocket URI for connection attempts\n\t */\n\tconstructor(\n\t\tprivate socketAdapter: ClientWebSocketAdapter,\n\t\tprivate getUri: () => Promise<string> | string\n\t) {\n\t\tthis.subscribeToReconnectHints()\n\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'offline', () => {\n\t\t\t\tdebug('window went offline')\n\t\t\t\t// On the one hand, 'offline' event is not really reliable; on the other, the only\n\t\t\t\t// alternative is to wait for pings not being delivered, which takes more than 20 seconds,\n\t\t\t\t// which means we won't see the ClientWebSocketAdapter status change for more than\n\t\t\t\t// 20 seconds after the tab goes offline. Our application layer must be resistent to\n\t\t\t\t// connection restart anyway, so we can just try to reconnect and see if\n\t\t\t\t// we're truly offline.\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t})\n\t\t)\n\n\t\tthis.state = 'pendingAttempt'\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.scheduleAttempt()\n\t}\n\n\tprivate subscribeToReconnectHints() {\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'online', () => {\n\t\t\t\tdebug('window went online')\n\t\t\t\tthis.maybeReconnected()\n\t\t\t}),\n\t\t\tlistenTo(document, 'visibilitychange', () => {\n\t\t\t\tif (!document.hidden) {\n\t\t\t\t\tdebug('document became visible')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t}\n\t\t\t})\n\t\t)\n\n\t\tif (Object.prototype.hasOwnProperty.call(navigator, 'connection')) {\n\t\t\tconst connection = (navigator as any)['connection'] as EventTarget\n\t\t\tthis.disposables.push(\n\t\t\t\tlistenTo(connection, 'change', () => {\n\t\t\t\t\tdebug('navigator.connection change')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\n\t}\n\n\tprivate getMaxDelay() {\n\t\treturn document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY\n\t}\n\n\tprivate getMinDelay() {\n\t\treturn document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY\n\t}\n\n\tprivate clearReconnectTimeout() {\n\t\tif (this.reconnectTimeout) {\n\t\t\tclearTimeout(this.reconnectTimeout)\n\t\t\tthis.reconnectTimeout = null\n\t\t}\n\t}\n\n\tprivate clearRecheckConnectingTimeout() {\n\t\tif (this.recheckConnectingTimeout) {\n\t\t\tclearTimeout(this.recheckConnectingTimeout)\n\t\t\tthis.recheckConnectingTimeout = null\n\t\t}\n\t}\n\n\t/**\n\t * Checks if reconnection should be attempted and initiates it if appropriate.\n\t * This method is called in response to network events, tab visibility changes,\n\t * and other hints that connectivity may have been restored.\n\t *\n\t * The method intelligently handles various connection states:\n\t * - Already connected: no action needed\n\t * - Currently connecting: waits or retries based on attempt age\n\t * - Disconnected: initiates immediate reconnection attempt\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically on network/visibility events, but can be called manually\n\t * manager.maybeReconnected()\n\t * ```\n\t */\n\tmaybeReconnected() {\n\t\tdebug('ReconnectManager.maybeReconnected')\n\t\t// It doesn't make sense to have another check scheduled if we're already checking it now.\n\t\t// If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway\n\t\tthis.clearRecheckConnectingTimeout()\n\n\t\t// readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: already connected')\n\t\t\t// nothing to do, we're already OK\n\t\t\treturn\n\t\t}\n\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: connecting')\n\t\t\t// We might be waiting for a TCP connection that sent SYN out and will never get it back,\n\t\t\t// while a new connection appeared. On the other hand, we might have just started connecting\n\t\t\t// and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew\n\t\t\t// if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent\n\t\t\t// new connections as long as `maybeReconnected` is not looped itself\n\t\t\tassert(\n\t\t\t\tthis.lastAttemptStart,\n\t\t\t\t'ReadyState=CONNECTING without lastAttemptStart should be impossible'\n\t\t\t)\n\t\t\tconst sinceLastStart = Date.now() - this.lastAttemptStart\n\t\t\tif (sinceLastStart < ATTEMPT_TIMEOUT) {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, rechecking later')\n\t\t\t\tthis.recheckConnectingTimeout = setTimeout(\n\t\t\t\t\t() => this.maybeReconnected(),\n\t\t\t\t\tATTEMPT_TIMEOUT - sinceLastStart\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now')\n\t\t\t\t// Last connection attempt was started a while ago, it's possible that network conditions\n\t\t\t\t// changed, and it's worth retrying to connect. `disconnected` will handle reconnection\n\t\t\t\t//\n\t\t\t\t// NOTE: The danger here is looping in connection attemps if connections are slow.\n\t\t\t\t// Make sure that `maybeReconnected` is not called in the `disconnected` codepath!\n\t\t\t\tthis.clearRecheckConnectingTimeout()\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t}\n\n\t\t\treturn\n\t\t}\n\n\t\tdebug('ReconnectManager.maybeReconnected: closing/closed/null, retry now')\n\t\t// readyState is CLOSING or CLOSED, or the websocket is null\n\t\t// Restart the backoff and retry ASAP (honouring the min delay)\n\t\t// this.state doesn't really matter, because disconnected() will handle any state correctly\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.disconnected()\n\t}\n\n\t/**\n\t * Handles disconnection events and schedules reconnection attempts with exponential backoff.\n\t * This method is called when the WebSocket connection is lost or fails to establish.\n\t *\n\t * It implements intelligent delay calculation based on:\n\t * - Previous attempt timing\n\t * - Current tab visibility (active vs inactive delays)\n\t * - Exponential backoff for repeated failures\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when connection is lost\n\t * // Schedules reconnection with appropriate delay\n\t * manager.disconnected()\n\t * ```\n\t */\n\tdisconnected() {\n\t\tdebug('ReconnectManager.disconnected')\n\t\t// This either means we're freshly disconnected, or the last connection attempt failed;\n\t\t// either way, time to try again.\n\n\t\t// Guard against delayed notifications and recheck synchronously\n\t\tif (\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN &&\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.CONNECTING\n\t\t) {\n\t\t\tdebug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')\n\t\t\tthis.clearReconnectTimeout()\n\n\t\t\tlet delayLeft\n\t\t\tif (this.state === 'connected') {\n\t\t\t\t// it's the first sign that we got disconnected; the state will be updated below,\n\t\t\t\t// just set the appropriate delay for now\n\t\t\t\tthis.intendedDelay = this.getMinDelay()\n\t\t\t\tdelayLeft = this.intendedDelay\n\t\t\t} else {\n\t\t\t\tdelayLeft =\n\t\t\t\t\tthis.lastAttemptStart !== null\n\t\t\t\t\t\t? this.lastAttemptStart + this.intendedDelay - Date.now()\n\t\t\t\t\t\t: 0\n\t\t\t}\n\n\t\t\tif (delayLeft > 0) {\n\t\t\t\tdebug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft)\n\t\t\t\t// try again later\n\t\t\t\tthis.state = 'delay'\n\n\t\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft)\n\t\t\t} else {\n\t\t\t\t// not connected and not delayed, time to retry\n\t\t\t\tthis.state = 'pendingAttempt'\n\n\t\t\t\tthis.intendedDelay = Math.min(\n\t\t\t\t\tthis.getMaxDelay(),\n\t\t\t\t\tMath.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT\n\t\t\t\t)\n\t\t\t\tdebug(\n\t\t\t\t\t'ReconnectManager.disconnected: attempting a connection, next delay',\n\t\t\t\t\tthis.intendedDelay\n\t\t\t\t)\n\t\t\t\tthis.scheduleAttempt()\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handles successful connection events and resets reconnection state.\n\t * This method is called when the WebSocket successfully connects to the server.\n\t *\n\t * It clears any pending reconnection attempts and resets the delay back to minimum\n\t * for future connection attempts.\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when WebSocket opens successfully\n\t * manager.connected()\n\t * ```\n\t */\n\tconnected() {\n\t\tdebug('ReconnectManager.connected')\n\t\t// this notification could've been delayed, recheck synchronously\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.connected: websocket is OPEN')\n\t\t\tthis.state = 'connected'\n\t\t\tthis.clearReconnectTimeout()\n\t\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\t}\n\t}\n\n\t/**\n\t * Permanently closes the reconnection manager and cleans up all resources.\n\t * This stops all pending reconnection attempts and removes event listeners.\n\t * Once closed, the manager cannot be reused.\n\t */\n\tclose() {\n\t\tthis.disposables.forEach((d) => d())\n\t\tthis.isDisposed = true\n\t}\n}\n\nfunction httpToWs(url: string) {\n\treturn url.replace(/^http(s)?:/, 'ws$1:')\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -25,8 +25,11 @@ __export(RoomSession_exports, {
|
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(RoomSession_exports);
|
|
27
27
|
const RoomSessionState = {
|
|
28
|
+
/** Session is waiting for the initial connect message from the client */
|
|
28
29
|
AwaitingConnectMessage: "awaiting-connect-message",
|
|
30
|
+
/** Session is disconnected but waiting for final cleanup before removal */
|
|
29
31
|
AwaitingRemoval: "awaiting-removal",
|
|
32
|
+
/** Session is fully connected and actively synchronizing */
|
|
30
33
|
Connected: "connected"
|
|
31
34
|
};
|
|
32
35
|
const SESSION_START_WAIT_TIME = 1e4;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/RoomSession.ts"],
|
|
4
|
-
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentDataEvent } from './protocol'\n\n/**\n * Enumeration of possible states for a room session during its lifecycle.\n *\n * Room sessions progress through these states as clients connect, authenticate,\n * and disconnect from collaborative rooms.\n *\n * @internal\n */\nexport const RoomSessionState = {\n\t/** Session is waiting for the initial connect message from the client */\n\tAwaitingConnectMessage: 'awaiting-connect-message',\n\t/** Session is disconnected but waiting for final cleanup before removal */\n\tAwaitingRemoval: 'awaiting-removal',\n\t/** Session is fully connected and actively synchronizing */\n\tConnected: 'connected',\n} as const\n\n/**\n * Type representing the possible states a room session can be in.\n *\n * @example\n * ```ts\n * const sessionState: RoomSessionState = RoomSessionState.Connected\n * if (sessionState === RoomSessionState.AwaitingConnectMessage) {\n * console.log('Session waiting for connect message')\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]\n\n/**\n * Maximum time in milliseconds to wait for a connect message after socket connection.\n *\n * If a client connects but doesn't send a connect message within this time,\n * the session will be terminated.\n *\n * @public\n */\nexport const SESSION_START_WAIT_TIME = 10000\n\n/**\n * Time in milliseconds to wait before completely removing a disconnected session.\n *\n * This grace period allows for quick reconnections without losing session state,\n * which is especially helpful for brief network interruptions.\n *\n * @public\n */\nexport const SESSION_REMOVAL_WAIT_TIME = 5000\n\n/**\n * Maximum time in milliseconds a connected session can remain idle before cleanup.\n *\n * Sessions that don't receive any messages or interactions for this duration\n * may be considered for cleanup to free server resources.\n *\n * @public\n */\nexport const SESSION_IDLE_TIMEOUT = 20000\n\n/**\n * Represents a client session within a collaborative room, tracking the connection\n * state, permissions, and synchronization details for a single user.\n *\n * Each session corresponds to one WebSocket connection and progresses through\n * different states during its lifecycle. The session type is a discriminated union\n * based on the current state, ensuring type safety when accessing state-specific properties.\n *\n * @example\n * ```ts\n * // Check session state and access appropriate properties\n * function handleSession(session: RoomSession<MyRecord, UserMeta>) {\n * switch (session.state) {\n * case RoomSessionState.AwaitingConnectMessage:\n * console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)\n * break\n * case RoomSessionState.Connected:\n * console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)\n * break\n * case RoomSessionState.AwaitingRemoval:\n * console.log(`Session will be removed at ${session.cancellationTime}`)\n * break\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type RoomSession<R extends UnknownRecord, Meta> =\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingConnectMessage\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Timestamp when the session was created */\n\t\t\tsessionStartTime: number\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.AwaitingRemoval\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Timestamp when the session was marked for removal */\n\t\t\tcancellationTime: number\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n\t| {\n\t\t\t/** Current state of the session */\n\t\t\tstate: typeof RoomSessionState.Connected\n\t\t\t/** Unique identifier for this session */\n\t\t\tsessionId: string\n\t\t\t/** Presence identifier for live cursor/selection tracking, if available */\n\t\t\tpresenceId: string | null\n\t\t\t/** WebSocket connection wrapper for this session */\n\t\t\tsocket: TLRoomSocket<R>\n\t\t\t/** Serialized schema information for this connected session */\n\t\t\tserializedSchema: SerializedSchema\n\t\t\t/** Timestamp of the last interaction or message from this session */\n\t\t\tlastInteractionTime: number\n\t\t\t/** Timer for debouncing operations, if active */\n\t\t\tdebounceTimer: ReturnType<typeof setTimeout> | null\n\t\t\t/** Queue of data messages waiting to be sent to this session */\n\t\t\toutstandingDataMessages: TLSocketServerSentDataEvent<R>[]\n\t\t\t/** Custom metadata associated with this session */\n\t\t\tmeta: Meta\n\t\t\t/** Whether this session has read-only permissions */\n\t\t\tisReadonly: boolean\n\t\t\t/** Whether this session requires legacy protocol rejection handling */\n\t\t\trequiresLegacyRejection: boolean\n\t }\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYO,MAAM,mBAAmB;AAAA;AAAA,EAE/B,wBAAwB;AAAA;AAAA,EAExB,iBAAiB;AAAA;AAAA,EAEjB,WAAW;AACZ;AAyBO,MAAM,0BAA0B;AAUhC,MAAM,4BAA4B;AAUlC,MAAM,uBAAuB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -22,19 +22,42 @@ __export(ServerSocketAdapter_exports, {
|
|
|
22
22
|
});
|
|
23
23
|
module.exports = __toCommonJS(ServerSocketAdapter_exports);
|
|
24
24
|
class ServerSocketAdapter {
|
|
25
|
+
/**
|
|
26
|
+
* Creates a new ServerSocketAdapter instance.
|
|
27
|
+
*
|
|
28
|
+
* opts - Configuration options for the adapter
|
|
29
|
+
*/
|
|
25
30
|
constructor(opts) {
|
|
26
31
|
this.opts = opts;
|
|
27
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Checks if the underlying WebSocket connection is currently open and ready to send messages.
|
|
35
|
+
*
|
|
36
|
+
* @returns True if the connection is open (readyState === 1), false otherwise
|
|
37
|
+
*/
|
|
28
38
|
// eslint-disable-next-line no-restricted-syntax
|
|
29
39
|
get isOpen() {
|
|
30
40
|
return this.opts.ws.readyState === 1;
|
|
31
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Sends a sync protocol message to the connected client. The message is JSON stringified
|
|
44
|
+
* before being sent through the WebSocket. If configured, the onBeforeSendMessage callback
|
|
45
|
+
* is invoked before sending.
|
|
46
|
+
*
|
|
47
|
+
* @param msg - The sync protocol message to send
|
|
48
|
+
*/
|
|
32
49
|
// see TLRoomSocket for details on why this accepts a union and not just arrays
|
|
33
50
|
sendMessage(msg) {
|
|
34
51
|
const message = JSON.stringify(msg);
|
|
35
52
|
this.opts.onBeforeSendMessage?.(msg, message);
|
|
36
53
|
this.opts.ws.send(message);
|
|
37
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Closes the WebSocket connection with an optional close code and reason.
|
|
57
|
+
*
|
|
58
|
+
* @param code - Optional close code (default: 1000 for normal closure)
|
|
59
|
+
* @param reason - Optional human-readable reason for closing
|
|
60
|
+
*/
|
|
38
61
|
close(code, reason) {
|
|
39
62
|
this.opts.ws.close(code, reason);
|
|
40
63
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/ServerSocketAdapter.ts"],
|
|
4
|
-
"sourcesContent": ["import { UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentEvent } from './protocol'\n\n/**\n * Minimal server-side WebSocket interface that is compatible with\n *\n * - The standard WebSocket interface (
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["import { UnknownRecord } from '@tldraw/store'\nimport { TLRoomSocket } from './TLSyncRoom'\nimport { TLSocketServerSentEvent } from './protocol'\n\n/**\n * Minimal server-side WebSocket interface that is compatible with various WebSocket implementations.\n * This interface abstracts over different WebSocket libraries and platforms to provide a consistent\n * API for the ServerSocketAdapter.\n *\n * Supports:\n * - The standard WebSocket interface (Cloudflare, Deno, some Node.js setups)\n * - The 'ws' WebSocket interface (Node.js ws library)\n * - The Bun.serve socket implementation\n *\n * @public\n * @example\n * ```ts\n * // Standard WebSocket\n * const standardWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')\n *\n * // Node.js 'ws' library WebSocket\n * import WebSocket from 'ws'\n * const nodeWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')\n *\n * // Bun WebSocket (in server context)\n * // const bunWs: WebSocketMinimal = server.upgrade(request)\n * ```\n */\nexport interface WebSocketMinimal {\n\t/**\n\t * Optional method to add event listeners for WebSocket events.\n\t * Not all WebSocket implementations provide this method.\n\t *\n\t * @param type - The event type to listen for\n\t * @param listener - The event handler function\n\t */\n\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\taddEventListener?: (type: 'message' | 'close' | 'error', listener: (event: any) => void) => void\n\n\t/**\n\t * Optional method to remove event listeners for WebSocket events.\n\t * Not all WebSocket implementations provide this method.\n\t *\n\t * @param type - The event type to stop listening for\n\t * @param listener - The event handler function to remove\n\t */\n\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\tremoveEventListener?: (\n\t\ttype: 'message' | 'close' | 'error',\n\t\tlistener: (event: any) => void\n\t) => void\n\n\t/**\n\t * Sends a string message through the WebSocket connection.\n\t *\n\t * @param data - The string data to send\n\t */\n\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\tsend: (data: string) => void\n\n\t/**\n\t * Closes the WebSocket connection.\n\t *\n\t * @param code - Optional close code (default: 1000 for normal closure)\n\t * @param reason - Optional human-readable close reason\n\t */\n\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\tclose: (code?: number, reason?: string) => void\n\n\t/**\n\t * The current state of the WebSocket connection.\n\t * - 0: CONNECTING\n\t * - 1: OPEN\n\t * - 2: CLOSING\n\t * - 3: CLOSED\n\t */\n\treadyState: number\n}\n\n/**\n * Configuration options for creating a ServerSocketAdapter instance.\n *\n * @internal\n */\nexport interface ServerSocketAdapterOptions<R extends UnknownRecord> {\n\t/** The underlying WebSocket connection to wrap */\n\treadonly ws: WebSocketMinimal\n\n\t/**\n\t * Optional callback invoked before each message is sent to the client.\n\t * Useful for logging, metrics, or message transformation.\n\t *\n\t * @param msg - The message object being sent\n\t * @param stringified - The JSON stringified version of the message\n\t */\n\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\treadonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent<R>, stringified: string) => void\n}\n\n/**\n * Server-side adapter that wraps various WebSocket implementations to provide a consistent\n * TLRoomSocket interface for the TLSyncRoom. This adapter handles the differences between\n * WebSocket libraries and platforms, allowing sync-core to work across different server\n * environments.\n *\n * The adapter implements the TLRoomSocket interface, providing methods for sending messages,\n * checking connection status, and closing connections.\n *\n * @internal\n * @example\n * ```ts\n * import { ServerSocketAdapter } from '@tldraw/sync-core'\n *\n * // Wrap a standard WebSocket\n * const adapter = new ServerSocketAdapter({\n * ws: webSocketConnection,\n * onBeforeSendMessage: (msg, json) => {\n * console.log('Sending:', msg.type)\n * }\n * })\n *\n * // Use with TLSyncRoom\n * room.handleNewSession({\n * sessionId: 'session-123',\n * socket: adapter,\n * isReadonly: false\n * })\n * ```\n */\nexport class ServerSocketAdapter<R extends UnknownRecord> implements TLRoomSocket<R> {\n\t/**\n\t * Creates a new ServerSocketAdapter instance.\n\t *\n\t * opts - Configuration options for the adapter\n\t */\n\tconstructor(public readonly opts: ServerSocketAdapterOptions<R>) {}\n\n\t/**\n\t * Checks if the underlying WebSocket connection is currently open and ready to send messages.\n\t *\n\t * @returns True if the connection is open (readyState === 1), false otherwise\n\t */\n\t// eslint-disable-next-line no-restricted-syntax\n\tget isOpen(): boolean {\n\t\treturn this.opts.ws.readyState === 1 // ready state open\n\t}\n\n\t/**\n\t * Sends a sync protocol message to the connected client. The message is JSON stringified\n\t * before being sent through the WebSocket. If configured, the onBeforeSendMessage callback\n\t * is invoked before sending.\n\t *\n\t * @param msg - The sync protocol message to send\n\t */\n\t// see TLRoomSocket for details on why this accepts a union and not just arrays\n\tsendMessage(msg: TLSocketServerSentEvent<R>) {\n\t\tconst message = JSON.stringify(msg)\n\t\tthis.opts.onBeforeSendMessage?.(msg, message)\n\t\tthis.opts.ws.send(message)\n\t}\n\n\t/**\n\t * Closes the WebSocket connection with an optional close code and reason.\n\t *\n\t * @param code - Optional close code (default: 1000 for normal closure)\n\t * @param reason - Optional human-readable reason for closing\n\t */\n\tclose(code?: number, reason?: string) {\n\t\tthis.opts.ws.close(code, reason)\n\t}\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAiIO,MAAM,oBAAwE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMpF,YAA4B,MAAqC;AAArC;AAAA,EAAsC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQlE,IAAI,SAAkB;AACrB,WAAO,KAAK,KAAK,GAAG,eAAe;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,YAAY,KAAiC;AAC5C,UAAM,UAAU,KAAK,UAAU,GAAG;AAClC,SAAK,KAAK,sBAAsB,KAAK,OAAO;AAC5C,SAAK,KAAK,GAAG,KAAK,OAAO;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,MAAe,QAAiB;AACrC,SAAK,KAAK,GAAG,MAAM,MAAM,MAAM;AAAA,EAChC;AACD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -22,6 +22,14 @@ __export(TLRemoteSyncError_exports, {
|
|
|
22
22
|
});
|
|
23
23
|
module.exports = __toCommonJS(TLRemoteSyncError_exports);
|
|
24
24
|
class TLRemoteSyncError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* Creates a new TLRemoteSyncError with the specified reason.
|
|
27
|
+
*
|
|
28
|
+
* reason - The specific reason code or custom string describing why the sync failed.
|
|
29
|
+
* When using predefined reasons from TLSyncErrorCloseEventReason, the client
|
|
30
|
+
* can handle specific error types appropriately. Custom strings allow for
|
|
31
|
+
* application-specific error details.
|
|
32
|
+
*/
|
|
25
33
|
constructor(reason) {
|
|
26
34
|
super(`sync error: ${reason}`);
|
|
27
35
|
this.reason = reason;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/TLRemoteSyncError.ts"],
|
|
4
|
-
"sourcesContent": ["import { TLSyncErrorCloseEventReason } from './TLSyncClient'\n\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["import { TLSyncErrorCloseEventReason } from './TLSyncClient'\n\n/**\n * Specialized error class for synchronization-related failures in tldraw collaboration.\n *\n * This error is thrown when the sync client encounters fatal errors that prevent\n * successful synchronization with the server. It captures both the error message\n * and the specific reason code that triggered the failure.\n *\n * Common scenarios include schema version mismatches, authentication failures,\n * network connectivity issues, and server-side validation errors.\n *\n * @example\n * ```ts\n * import { TLRemoteSyncError, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'\n *\n * // Handle sync errors in your application\n * syncClient.onSyncError((error) => {\n * if (error instanceof TLRemoteSyncError) {\n * switch (error.reason) {\n * case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n * // Redirect user to login\n * break\n * case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:\n * // Show update required message\n * break\n * default:\n * console.error('Sync error:', error.message)\n * }\n * }\n * })\n * ```\n *\n * @example\n * ```ts\n * // Server-side: throwing a sync error\n * if (!hasPermission(userId, roomId)) {\n * throw new TLRemoteSyncError(TLSyncErrorCloseEventReason.FORBIDDEN)\n * }\n * ```\n *\n * @public\n */\nexport class TLRemoteSyncError extends Error {\n\toverride name = 'RemoteSyncError'\n\n\t/**\n\t * Creates a new TLRemoteSyncError with the specified reason.\n\t *\n\t * reason - The specific reason code or custom string describing why the sync failed.\n\t * When using predefined reasons from TLSyncErrorCloseEventReason, the client\n\t * can handle specific error types appropriately. Custom strings allow for\n\t * application-specific error details.\n\t */\n\tconstructor(public readonly reason: TLSyncErrorCloseEventReason | string) {\n\t\tsuper(`sync error: ${reason}`)\n\t}\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA2CO,MAAM,0BAA0B,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAW5C,YAA4B,QAA8C;AACzE,UAAM,eAAe,MAAM,EAAE;AADF;AAAA,EAE5B;AAAA,EAZS,OAAO;AAajB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|