@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
|
@@ -41,7 +41,37 @@ function debug(...args: any[]) {
|
|
|
41
41
|
// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,
|
|
42
42
|
// pings need to be implemented one level up, on the application API side, which for our
|
|
43
43
|
// codebase means whatever code that uses ClientWebSocketAdapter.
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* A WebSocket adapter that provides persistent connection management for tldraw synchronization.
|
|
46
|
+
* This adapter handles connection establishment, reconnection logic, and message routing between
|
|
47
|
+
* the sync client and server. It implements automatic reconnection with exponential backoff
|
|
48
|
+
* and supports connection loss detection.
|
|
49
|
+
*
|
|
50
|
+
* Note: This adapter requires users to implement their own connection loss detection (e.g., pings)
|
|
51
|
+
* as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* // Create a WebSocket adapter with connection URI
|
|
57
|
+
* const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync')
|
|
58
|
+
*
|
|
59
|
+
* // Listen for connection status changes
|
|
60
|
+
* adapter.onStatusChange((status) => {
|
|
61
|
+
* console.log('Connection status:', status)
|
|
62
|
+
* })
|
|
63
|
+
*
|
|
64
|
+
* // Listen for incoming messages
|
|
65
|
+
* adapter.onReceiveMessage((message) => {
|
|
66
|
+
* console.log('Received:', message)
|
|
67
|
+
* })
|
|
68
|
+
*
|
|
69
|
+
* // Send a message when connected
|
|
70
|
+
* if (adapter.connectionStatus === 'online') {
|
|
71
|
+
* adapter.sendMessage({ type: 'ping' })
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
45
75
|
export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord> {
|
|
46
76
|
_ws: WebSocket | null = null
|
|
47
77
|
|
|
@@ -50,6 +80,11 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
50
80
|
/** @internal */
|
|
51
81
|
readonly _reconnectManager: ReconnectManager
|
|
52
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Permanently closes the WebSocket adapter and disposes of all resources.
|
|
85
|
+
* Once closed, the adapter cannot be reused and should be discarded.
|
|
86
|
+
* This method is idempotent - calling it multiple times has no additional effect.
|
|
87
|
+
*/
|
|
53
88
|
// TODO: .close should be a project-wide interface with a common contract (.close()d thing
|
|
54
89
|
// can only be garbage collected, and can't be used anymore)
|
|
55
90
|
close() {
|
|
@@ -59,6 +94,14 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
59
94
|
this._ws?.close()
|
|
60
95
|
}
|
|
61
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new ClientWebSocketAdapter instance.
|
|
99
|
+
*
|
|
100
|
+
* @param getUri - Function that returns the WebSocket URI to connect to.
|
|
101
|
+
* Can return a string directly or a Promise that resolves to a string.
|
|
102
|
+
* This function is called each time a connection attempt is made,
|
|
103
|
+
* allowing for dynamic URI generation (e.g., for authentication tokens).
|
|
104
|
+
*/
|
|
62
105
|
constructor(getUri: () => Promise<string> | string) {
|
|
63
106
|
this._reconnectManager = new ReconnectManager(this, getUri)
|
|
64
107
|
}
|
|
@@ -189,12 +232,31 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
189
232
|
'initial'
|
|
190
233
|
)
|
|
191
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Gets the current connection status of the WebSocket.
|
|
237
|
+
*
|
|
238
|
+
* @returns The current connection status: 'online', 'offline', or 'error'
|
|
239
|
+
*/
|
|
192
240
|
// eslint-disable-next-line no-restricted-syntax
|
|
193
241
|
get connectionStatus(): TLPersistentClientSocketStatus {
|
|
194
242
|
const status = this._connectionStatus.get()
|
|
195
243
|
return status === 'initial' ? 'offline' : status
|
|
196
244
|
}
|
|
197
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Sends a message to the server through the WebSocket connection.
|
|
248
|
+
* Messages are automatically chunked if they exceed size limits.
|
|
249
|
+
*
|
|
250
|
+
* @param msg - The message to send to the server
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* adapter.sendMessage({
|
|
255
|
+
* type: 'push',
|
|
256
|
+
* diff: { 'shape:abc123': [2, { x: [1, 150] }] }
|
|
257
|
+
* })
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
198
260
|
sendMessage(msg: TLSocketClientSentEvent<TLRecord>) {
|
|
199
261
|
assert(!this.isDisposed, 'Tried to send message on a disposed socket')
|
|
200
262
|
|
|
@@ -210,6 +272,29 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
210
272
|
}
|
|
211
273
|
|
|
212
274
|
private messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()
|
|
275
|
+
/**
|
|
276
|
+
* Registers a callback to handle incoming messages from the server.
|
|
277
|
+
*
|
|
278
|
+
* @param cb - Callback function that will be called with each received message
|
|
279
|
+
* @returns A cleanup function to remove the message listener
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* const unsubscribe = adapter.onReceiveMessage((message) => {
|
|
284
|
+
* switch (message.type) {
|
|
285
|
+
* case 'connect':
|
|
286
|
+
* console.log('Connected to room')
|
|
287
|
+
* break
|
|
288
|
+
* case 'data':
|
|
289
|
+
* console.log('Received data:', message.diff)
|
|
290
|
+
* break
|
|
291
|
+
* }
|
|
292
|
+
* })
|
|
293
|
+
*
|
|
294
|
+
* // Later, remove the listener
|
|
295
|
+
* unsubscribe()
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
213
298
|
onReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {
|
|
214
299
|
assert(!this.isDisposed, 'Tried to add message listener on a disposed socket')
|
|
215
300
|
|
|
@@ -220,6 +305,26 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
220
305
|
}
|
|
221
306
|
|
|
222
307
|
private statusListeners = new Set<TLSocketStatusListener>()
|
|
308
|
+
/**
|
|
309
|
+
* Registers a callback to handle connection status changes.
|
|
310
|
+
*
|
|
311
|
+
* @param cb - Callback function that will be called when the connection status changes
|
|
312
|
+
* @returns A cleanup function to remove the status listener
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ```ts
|
|
316
|
+
* const unsubscribe = adapter.onStatusChange((status) => {
|
|
317
|
+
* if (status.status === 'error') {
|
|
318
|
+
* console.error('Connection error:', status.reason)
|
|
319
|
+
* } else {
|
|
320
|
+
* console.log('Status changed to:', status.status)
|
|
321
|
+
* }
|
|
322
|
+
* })
|
|
323
|
+
*
|
|
324
|
+
* // Later, remove the listener
|
|
325
|
+
* unsubscribe()
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
223
328
|
onStatusChange(cb: TLSocketStatusListener) {
|
|
224
329
|
assert(!this.isDisposed, 'Tried to add status listener on a disposed socket')
|
|
225
330
|
|
|
@@ -229,6 +334,19 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
229
334
|
}
|
|
230
335
|
}
|
|
231
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Manually restarts the WebSocket connection.
|
|
339
|
+
* This closes the current connection (if any) and attempts to establish a new one.
|
|
340
|
+
* Useful for implementing connection loss detection and recovery.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* // Restart connection after detecting it's stale
|
|
345
|
+
* if (lastPongTime < Date.now() - 30000) {
|
|
346
|
+
* adapter.restart()
|
|
347
|
+
* }
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
232
350
|
restart() {
|
|
233
351
|
assert(!this.isDisposed, 'Tried to restart a disposed socket')
|
|
234
352
|
debug('restarting')
|
|
@@ -238,21 +356,78 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|
|
238
356
|
}
|
|
239
357
|
}
|
|
240
358
|
|
|
241
|
-
|
|
242
|
-
|
|
359
|
+
/**
|
|
360
|
+
* Minimum reconnection delay in milliseconds when the browser tab is active and focused.
|
|
361
|
+
*
|
|
362
|
+
* @internal
|
|
363
|
+
*/
|
|
243
364
|
export const ACTIVE_MIN_DELAY = 500
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Maximum reconnection delay in milliseconds when the browser tab is active and focused.
|
|
368
|
+
*
|
|
369
|
+
* @internal
|
|
370
|
+
*/
|
|
244
371
|
export const ACTIVE_MAX_DELAY = 2000
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden.
|
|
375
|
+
* This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab.
|
|
376
|
+
*
|
|
377
|
+
* @internal
|
|
378
|
+
*/
|
|
248
379
|
export const INACTIVE_MIN_DELAY = 1000
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden.
|
|
383
|
+
* Set to 5 minutes to balance between maintaining sync and conserving resources.
|
|
384
|
+
*
|
|
385
|
+
* @internal
|
|
386
|
+
*/
|
|
249
387
|
export const INACTIVE_MAX_DELAY = 1000 * 60 * 5
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Exponential backoff multiplier for calculating reconnection delays.
|
|
391
|
+
* Each failed connection attempt increases the delay by this factor until max delay is reached.
|
|
392
|
+
*
|
|
393
|
+
* @internal
|
|
394
|
+
*/
|
|
250
395
|
export const DELAY_EXPONENT = 1.5
|
|
251
|
-
|
|
252
|
-
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Maximum time in milliseconds to wait for a connection attempt before considering it failed.
|
|
399
|
+
* This helps detect connections stuck in the CONNECTING state and retry with fresh attempts.
|
|
400
|
+
*
|
|
401
|
+
* @internal
|
|
402
|
+
*/
|
|
253
403
|
export const ATTEMPT_TIMEOUT = 1000
|
|
254
404
|
|
|
255
|
-
/**
|
|
405
|
+
/**
|
|
406
|
+
* Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies.
|
|
407
|
+
* This class handles connection attempts, tracks connection state, and implements exponential backoff
|
|
408
|
+
* with different delays based on whether the browser tab is active or inactive.
|
|
409
|
+
*
|
|
410
|
+
* The ReconnectManager responds to various browser events like network status changes,
|
|
411
|
+
* tab visibility changes, and connection events to optimize reconnection timing and
|
|
412
|
+
* minimize unnecessary connection attempts.
|
|
413
|
+
*
|
|
414
|
+
* @internal
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```ts
|
|
418
|
+
* const manager = new ReconnectManager(
|
|
419
|
+
* socketAdapter,
|
|
420
|
+
* () => 'ws://localhost:3000/sync'
|
|
421
|
+
* )
|
|
422
|
+
*
|
|
423
|
+
* // Manager automatically handles:
|
|
424
|
+
* // - Initial connection
|
|
425
|
+
* // - Reconnection on disconnect
|
|
426
|
+
* // - Exponential backoff on failures
|
|
427
|
+
* // - Tab visibility-aware delays
|
|
428
|
+
* // - Network status change responses
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
256
431
|
export class ReconnectManager {
|
|
257
432
|
private isDisposed = false
|
|
258
433
|
private disposables: (() => void)[] = [
|
|
@@ -268,6 +443,12 @@ export class ReconnectManager {
|
|
|
268
443
|
intendedDelay: number = ACTIVE_MIN_DELAY
|
|
269
444
|
private state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'
|
|
270
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Creates a new ReconnectManager instance.
|
|
448
|
+
*
|
|
449
|
+
* socketAdapter - The ClientWebSocketAdapter instance to manage
|
|
450
|
+
* getUri - Function that returns the WebSocket URI for connection attempts
|
|
451
|
+
*/
|
|
271
452
|
constructor(
|
|
272
453
|
private socketAdapter: ClientWebSocketAdapter,
|
|
273
454
|
private getUri: () => Promise<string> | string
|
|
@@ -356,6 +537,22 @@ export class ReconnectManager {
|
|
|
356
537
|
}
|
|
357
538
|
}
|
|
358
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Checks if reconnection should be attempted and initiates it if appropriate.
|
|
542
|
+
* This method is called in response to network events, tab visibility changes,
|
|
543
|
+
* and other hints that connectivity may have been restored.
|
|
544
|
+
*
|
|
545
|
+
* The method intelligently handles various connection states:
|
|
546
|
+
* - Already connected: no action needed
|
|
547
|
+
* - Currently connecting: waits or retries based on attempt age
|
|
548
|
+
* - Disconnected: initiates immediate reconnection attempt
|
|
549
|
+
*
|
|
550
|
+
* @example
|
|
551
|
+
* ```ts
|
|
552
|
+
* // Called automatically on network/visibility events, but can be called manually
|
|
553
|
+
* manager.maybeReconnected()
|
|
554
|
+
* ```
|
|
555
|
+
*/
|
|
359
556
|
maybeReconnected() {
|
|
360
557
|
debug('ReconnectManager.maybeReconnected')
|
|
361
558
|
// It doesn't make sense to have another check scheduled if we're already checking it now.
|
|
@@ -409,6 +606,22 @@ export class ReconnectManager {
|
|
|
409
606
|
this.disconnected()
|
|
410
607
|
}
|
|
411
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Handles disconnection events and schedules reconnection attempts with exponential backoff.
|
|
611
|
+
* This method is called when the WebSocket connection is lost or fails to establish.
|
|
612
|
+
*
|
|
613
|
+
* It implements intelligent delay calculation based on:
|
|
614
|
+
* - Previous attempt timing
|
|
615
|
+
* - Current tab visibility (active vs inactive delays)
|
|
616
|
+
* - Exponential backoff for repeated failures
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* ```ts
|
|
620
|
+
* // Called automatically when connection is lost
|
|
621
|
+
* // Schedules reconnection with appropriate delay
|
|
622
|
+
* manager.disconnected()
|
|
623
|
+
* ```
|
|
624
|
+
*/
|
|
412
625
|
disconnected() {
|
|
413
626
|
debug('ReconnectManager.disconnected')
|
|
414
627
|
// This either means we're freshly disconnected, or the last connection attempt failed;
|
|
@@ -458,6 +671,19 @@ export class ReconnectManager {
|
|
|
458
671
|
}
|
|
459
672
|
}
|
|
460
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Handles successful connection events and resets reconnection state.
|
|
676
|
+
* This method is called when the WebSocket successfully connects to the server.
|
|
677
|
+
*
|
|
678
|
+
* It clears any pending reconnection attempts and resets the delay back to minimum
|
|
679
|
+
* for future connection attempts.
|
|
680
|
+
*
|
|
681
|
+
* @example
|
|
682
|
+
* ```ts
|
|
683
|
+
* // Called automatically when WebSocket opens successfully
|
|
684
|
+
* manager.connected()
|
|
685
|
+
* ```
|
|
686
|
+
*/
|
|
461
687
|
connected() {
|
|
462
688
|
debug('ReconnectManager.connected')
|
|
463
689
|
// this notification could've been delayed, recheck synchronously
|
|
@@ -469,6 +695,11 @@ export class ReconnectManager {
|
|
|
469
695
|
}
|
|
470
696
|
}
|
|
471
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Permanently closes the reconnection manager and cleans up all resources.
|
|
700
|
+
* This stops all pending reconnection attempts and removes event listeners.
|
|
701
|
+
* Once closed, the manager cannot be reused.
|
|
702
|
+
*/
|
|
472
703
|
close() {
|
|
473
704
|
this.disposables.forEach((d) => d())
|
|
474
705
|
this.isDisposed = true
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { SerializedSchema } from '@tldraw/store'
|
|
2
|
+
import { TLRecord } from '@tldraw/tlschema'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
RoomSession,
|
|
6
|
+
RoomSessionState,
|
|
7
|
+
SESSION_IDLE_TIMEOUT,
|
|
8
|
+
SESSION_REMOVAL_WAIT_TIME,
|
|
9
|
+
SESSION_START_WAIT_TIME,
|
|
10
|
+
} from './RoomSession'
|
|
11
|
+
import { TLRoomSocket } from './TLSyncRoom'
|
|
12
|
+
|
|
13
|
+
// Mock socket implementation for testing
|
|
14
|
+
const createMockSocket = (): TLRoomSocket<TLRecord> => ({
|
|
15
|
+
isOpen: true,
|
|
16
|
+
sendMessage: vi.fn(),
|
|
17
|
+
close: vi.fn(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Mock serialized schema for testing
|
|
21
|
+
const mockSerializedSchema: SerializedSchema = {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
storeVersion: 1,
|
|
24
|
+
recordVersions: {},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('RoomSession timeout constants', () => {
|
|
28
|
+
it('should have logical timeout ordering for session management', () => {
|
|
29
|
+
// This test ensures the timeout constants have a logical relationship
|
|
30
|
+
// that supports proper session lifecycle management:
|
|
31
|
+
// - Quick cleanup for disconnected sessions
|
|
32
|
+
// - Reasonable wait time for initial connections
|
|
33
|
+
// - Patient timeout for active sessions
|
|
34
|
+
expect(SESSION_REMOVAL_WAIT_TIME).toBeLessThan(SESSION_START_WAIT_TIME)
|
|
35
|
+
expect(SESSION_START_WAIT_TIME).toBeLessThan(SESSION_IDLE_TIMEOUT)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('RoomSession state transitions', () => {
|
|
40
|
+
const baseSessionData = {
|
|
41
|
+
sessionId: 'test-session-id',
|
|
42
|
+
presenceId: 'test-presence-id',
|
|
43
|
+
socket: createMockSocket(),
|
|
44
|
+
meta: { userId: 'test-user' },
|
|
45
|
+
isReadonly: false,
|
|
46
|
+
requiresLegacyRejection: false,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('should support complete session lifecycle', () => {
|
|
50
|
+
// Test that sessions can progress through their full lifecycle
|
|
51
|
+
// This validates the discriminated union works correctly for state management
|
|
52
|
+
|
|
53
|
+
// Start in awaiting state
|
|
54
|
+
const initialSession: RoomSession<TLRecord, { userId: string }> = {
|
|
55
|
+
state: RoomSessionState.AwaitingConnectMessage,
|
|
56
|
+
sessionStartTime: Date.now(),
|
|
57
|
+
...baseSessionData,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Progress to connected state (simulates successful connection)
|
|
61
|
+
const connectedSession: RoomSession<TLRecord, { userId: string }> = {
|
|
62
|
+
state: RoomSessionState.Connected,
|
|
63
|
+
sessionId: initialSession.sessionId,
|
|
64
|
+
presenceId: initialSession.presenceId,
|
|
65
|
+
socket: initialSession.socket,
|
|
66
|
+
meta: initialSession.meta,
|
|
67
|
+
isReadonly: initialSession.isReadonly,
|
|
68
|
+
requiresLegacyRejection: initialSession.requiresLegacyRejection,
|
|
69
|
+
serializedSchema: mockSerializedSchema,
|
|
70
|
+
lastInteractionTime: Date.now(),
|
|
71
|
+
debounceTimer: null,
|
|
72
|
+
outstandingDataMessages: [],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// End in awaiting removal state (simulates disconnection)
|
|
76
|
+
const removalSession: RoomSession<TLRecord, { userId: string }> = {
|
|
77
|
+
state: RoomSessionState.AwaitingRemoval,
|
|
78
|
+
sessionId: connectedSession.sessionId,
|
|
79
|
+
presenceId: connectedSession.presenceId,
|
|
80
|
+
socket: connectedSession.socket,
|
|
81
|
+
meta: connectedSession.meta,
|
|
82
|
+
isReadonly: connectedSession.isReadonly,
|
|
83
|
+
requiresLegacyRejection: connectedSession.requiresLegacyRejection,
|
|
84
|
+
cancellationTime: Date.now(),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Verify session ID remains consistent across state changes
|
|
88
|
+
// This is critical for tracking sessions through their lifecycle
|
|
89
|
+
expect(initialSession.sessionId).toBe(connectedSession.sessionId)
|
|
90
|
+
expect(connectedSession.sessionId).toBe(removalSession.sessionId)
|
|
91
|
+
|
|
92
|
+
// Verify state-specific properties are present when expected
|
|
93
|
+
expect(initialSession.state).toBe(RoomSessionState.AwaitingConnectMessage)
|
|
94
|
+
expect(connectedSession.state).toBe(RoomSessionState.Connected)
|
|
95
|
+
expect(removalSession.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/lib/RoomSession.ts
CHANGED
|
@@ -2,52 +2,154 @@ import { SerializedSchema, UnknownRecord } from '@tldraw/store'
|
|
|
2
2
|
import { TLRoomSocket } from './TLSyncRoom'
|
|
3
3
|
import { TLSocketServerSentDataEvent } from './protocol'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Enumeration of possible states for a room session during its lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* Room sessions progress through these states as clients connect, authenticate,
|
|
9
|
+
* and disconnect from collaborative rooms.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
6
13
|
export const RoomSessionState = {
|
|
14
|
+
/** Session is waiting for the initial connect message from the client */
|
|
7
15
|
AwaitingConnectMessage: 'awaiting-connect-message',
|
|
16
|
+
/** Session is disconnected but waiting for final cleanup before removal */
|
|
8
17
|
AwaitingRemoval: 'awaiting-removal',
|
|
18
|
+
/** Session is fully connected and actively synchronizing */
|
|
9
19
|
Connected: 'connected',
|
|
10
20
|
} as const
|
|
11
21
|
|
|
12
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Type representing the possible states a room session can be in.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const sessionState: RoomSessionState = RoomSessionState.Connected
|
|
28
|
+
* if (sessionState === RoomSessionState.AwaitingConnectMessage) {
|
|
29
|
+
* console.log('Session waiting for connect message')
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
13
35
|
export type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]
|
|
14
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Maximum time in milliseconds to wait for a connect message after socket connection.
|
|
39
|
+
*
|
|
40
|
+
* If a client connects but doesn't send a connect message within this time,
|
|
41
|
+
* the session will be terminated.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
15
45
|
export const SESSION_START_WAIT_TIME = 10000
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Time in milliseconds to wait before completely removing a disconnected session.
|
|
49
|
+
*
|
|
50
|
+
* This grace period allows for quick reconnections without losing session state,
|
|
51
|
+
* which is especially helpful for brief network interruptions.
|
|
52
|
+
*
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
16
55
|
export const SESSION_REMOVAL_WAIT_TIME = 5000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maximum time in milliseconds a connected session can remain idle before cleanup.
|
|
59
|
+
*
|
|
60
|
+
* Sessions that don't receive any messages or interactions for this duration
|
|
61
|
+
* may be considered for cleanup to free server resources.
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
17
65
|
export const SESSION_IDLE_TIMEOUT = 20000
|
|
18
66
|
|
|
19
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Represents a client session within a collaborative room, tracking the connection
|
|
69
|
+
* state, permissions, and synchronization details for a single user.
|
|
70
|
+
*
|
|
71
|
+
* Each session corresponds to one WebSocket connection and progresses through
|
|
72
|
+
* different states during its lifecycle. The session type is a discriminated union
|
|
73
|
+
* based on the current state, ensuring type safety when accessing state-specific properties.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* // Check session state and access appropriate properties
|
|
78
|
+
* function handleSession(session: RoomSession<MyRecord, UserMeta>) {
|
|
79
|
+
* switch (session.state) {
|
|
80
|
+
* case RoomSessionState.AwaitingConnectMessage:
|
|
81
|
+
* console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)
|
|
82
|
+
* break
|
|
83
|
+
* case RoomSessionState.Connected:
|
|
84
|
+
* console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)
|
|
85
|
+
* break
|
|
86
|
+
* case RoomSessionState.AwaitingRemoval:
|
|
87
|
+
* console.log(`Session will be removed at ${session.cancellationTime}`)
|
|
88
|
+
* break
|
|
89
|
+
* }
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
20
95
|
export type RoomSession<R extends UnknownRecord, Meta> =
|
|
21
96
|
| {
|
|
97
|
+
/** Current state of the session */
|
|
22
98
|
state: typeof RoomSessionState.AwaitingConnectMessage
|
|
99
|
+
/** Unique identifier for this session */
|
|
23
100
|
sessionId: string
|
|
101
|
+
/** Presence identifier for live cursor/selection tracking, if available */
|
|
24
102
|
presenceId: string | null
|
|
103
|
+
/** WebSocket connection wrapper for this session */
|
|
25
104
|
socket: TLRoomSocket<R>
|
|
105
|
+
/** Timestamp when the session was created */
|
|
26
106
|
sessionStartTime: number
|
|
107
|
+
/** Custom metadata associated with this session */
|
|
27
108
|
meta: Meta
|
|
109
|
+
/** Whether this session has read-only permissions */
|
|
28
110
|
isReadonly: boolean
|
|
111
|
+
/** Whether this session requires legacy protocol rejection handling */
|
|
29
112
|
requiresLegacyRejection: boolean
|
|
30
113
|
}
|
|
31
114
|
| {
|
|
115
|
+
/** Current state of the session */
|
|
32
116
|
state: typeof RoomSessionState.AwaitingRemoval
|
|
117
|
+
/** Unique identifier for this session */
|
|
33
118
|
sessionId: string
|
|
119
|
+
/** Presence identifier for live cursor/selection tracking, if available */
|
|
34
120
|
presenceId: string | null
|
|
121
|
+
/** WebSocket connection wrapper for this session */
|
|
35
122
|
socket: TLRoomSocket<R>
|
|
123
|
+
/** Timestamp when the session was marked for removal */
|
|
36
124
|
cancellationTime: number
|
|
125
|
+
/** Custom metadata associated with this session */
|
|
37
126
|
meta: Meta
|
|
127
|
+
/** Whether this session has read-only permissions */
|
|
38
128
|
isReadonly: boolean
|
|
129
|
+
/** Whether this session requires legacy protocol rejection handling */
|
|
39
130
|
requiresLegacyRejection: boolean
|
|
40
131
|
}
|
|
41
132
|
| {
|
|
133
|
+
/** Current state of the session */
|
|
42
134
|
state: typeof RoomSessionState.Connected
|
|
135
|
+
/** Unique identifier for this session */
|
|
43
136
|
sessionId: string
|
|
137
|
+
/** Presence identifier for live cursor/selection tracking, if available */
|
|
44
138
|
presenceId: string | null
|
|
139
|
+
/** WebSocket connection wrapper for this session */
|
|
45
140
|
socket: TLRoomSocket<R>
|
|
141
|
+
/** Serialized schema information for this connected session */
|
|
46
142
|
serializedSchema: SerializedSchema
|
|
143
|
+
/** Timestamp of the last interaction or message from this session */
|
|
47
144
|
lastInteractionTime: number
|
|
145
|
+
/** Timer for debouncing operations, if active */
|
|
48
146
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
147
|
+
/** Queue of data messages waiting to be sent to this session */
|
|
49
148
|
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
|
|
149
|
+
/** Custom metadata associated with this session */
|
|
50
150
|
meta: Meta
|
|
151
|
+
/** Whether this session has read-only permissions */
|
|
51
152
|
isReadonly: boolean
|
|
153
|
+
/** Whether this session requires legacy protocol rejection handling */
|
|
52
154
|
requiresLegacyRejection: boolean
|
|
53
155
|
}
|