@tldraw/sync-core 4.5.2 → 4.6.0-canary.00a8c03b5687
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 +86 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/ClientWebSocketAdapter.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +1 -1
- package/dist-cjs/lib/ServerSocketAdapter.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +137 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +1 -1
- package/dist-cjs/lib/TLSyncClient.js.map +1 -1
- package/dist-cjs/lib/TLSyncRoom.js +71 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +1 -1
- package/dist-esm/index.d.mts +86 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +1 -1
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +137 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +1 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +1 -1
- package/dist-esm/lib/TLSyncRoom.mjs +73 -6
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +3 -0
- package/src/lib/ClientWebSocketAdapter.ts +5 -5
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +1 -2
- package/src/lib/InMemorySyncStorage.ts +3 -3
- package/src/lib/ServerSocketAdapter.ts +6 -6
- package/src/lib/TLSocketRoom.ts +193 -6
- package/src/lib/TLSyncClient.test.ts +4 -7
- package/src/lib/TLSyncClient.ts +1 -1
- package/src/lib/TLSyncRoom.ts +96 -4
- package/src/lib/protocol.ts +2 -0
- package/src/test/RandomSource.ts +1 -1
- package/src/test/TLSocketRoom.test.ts +527 -13
- package/src/test/TestSocketPair.ts +1 -1
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { StoreSchema, UnknownRecord } from '@tldraw/store'
|
|
2
|
-
import { createTLSchema, TLStoreSnapshot } from '@tldraw/tlschema'
|
|
1
|
+
import type { SerializedSchema, StoreSchema, UnknownRecord } from '@tldraw/store'
|
|
2
|
+
import { createTLSchema, TLInstancePresence, TLStoreSnapshot } from '@tldraw/tlschema'
|
|
3
3
|
import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from '@tldraw/utils'
|
|
4
4
|
import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './InMemorySyncStorage'
|
|
5
5
|
import { RoomSessionState } from './RoomSession'
|
|
@@ -14,6 +14,22 @@ import {
|
|
|
14
14
|
import { JsonChunkAssembler } from './chunk'
|
|
15
15
|
import { TLSocketServerSentEvent } from './protocol'
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Strip potentially large fields from a tldraw instance_presence record so the
|
|
19
|
+
* snapshot stays small when stored in WebSocket attachments (e.g. for hibernation).
|
|
20
|
+
* Keeps cursor, selection, page, and user identity; clears scribbles, chatMessage, brush.
|
|
21
|
+
*/
|
|
22
|
+
function stripPresenceForSnapshot(record: UnknownRecord): UnknownRecord {
|
|
23
|
+
if (record.typeName !== 'instance_presence') return record
|
|
24
|
+
const stripped = { ...record } as TLInstancePresence
|
|
25
|
+
stripped.scribbles = []
|
|
26
|
+
stripped.chatMessage = ''
|
|
27
|
+
stripped.selectedShapeIds = []
|
|
28
|
+
stripped.brush = null
|
|
29
|
+
|
|
30
|
+
return stripped as unknown as UnknownRecord
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* Logging interface for TLSocketRoom operations. Provides optional methods
|
|
19
35
|
* for warning and error logging during synchronization operations.
|
|
@@ -43,6 +59,24 @@ export interface TLSyncLog {
|
|
|
43
59
|
error?(...args: any[]): void
|
|
44
60
|
}
|
|
45
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A snapshot of per-session state that can be persisted and used to resume a session
|
|
64
|
+
* after the server restarts (e.g., after Cloudflare Durable Object hibernation).
|
|
65
|
+
*
|
|
66
|
+
* Obtain via {@link TLSocketRoom.getSessionSnapshot} and restore via
|
|
67
|
+
* {@link TLSocketRoom.handleSocketResume}.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
export interface SessionStateSnapshot {
|
|
72
|
+
serializedSchema: SerializedSchema
|
|
73
|
+
isReadonly: boolean
|
|
74
|
+
presenceId: string | null
|
|
75
|
+
presenceRecord: UnknownRecord | null
|
|
76
|
+
requiresLegacyRejection: boolean
|
|
77
|
+
supportsStringAppend: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
46
80
|
/**
|
|
47
81
|
* Base options for TLSocketRoom.
|
|
48
82
|
* @public
|
|
@@ -62,13 +96,13 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
62
96
|
clientTimeout?: number
|
|
63
97
|
log?: TLSyncLog
|
|
64
98
|
// a callback that is called when a client is disconnected
|
|
65
|
-
// eslint-disable-next-line
|
|
99
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
66
100
|
onSessionRemoved?: (
|
|
67
101
|
room: TLSocketRoom<R, SessionMeta>,
|
|
68
102
|
args: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta }
|
|
69
103
|
) => void
|
|
70
104
|
// a callback that is called whenever a message is sent
|
|
71
|
-
// eslint-disable-next-line
|
|
105
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
72
106
|
onBeforeSendMessage?: (args: {
|
|
73
107
|
sessionId: string
|
|
74
108
|
/** @internal keep the protocol private for now */
|
|
@@ -76,7 +110,7 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
76
110
|
stringified: string
|
|
77
111
|
meta: SessionMeta
|
|
78
112
|
}) => void
|
|
79
|
-
// eslint-disable-next-line
|
|
113
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
80
114
|
onAfterReceiveMessage?: (args: {
|
|
81
115
|
sessionId: string
|
|
82
116
|
/** @internal keep the protocol private for now */
|
|
@@ -86,6 +120,14 @@ export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
|
|
|
86
120
|
}) => void
|
|
87
121
|
/** @internal */
|
|
88
122
|
onPresenceChange?(): void
|
|
123
|
+
/**
|
|
124
|
+
* When set, the room will call {@link TLSocketRoom.getSessionSnapshot} after
|
|
125
|
+
* no message activity for a session for 5s and pass the result to this callback.
|
|
126
|
+
* Use for persisting snapshots to WebSocket attachments (e.g. Cloudflare hibernation).
|
|
127
|
+
* The room clears any pending snapshot when the session closes.
|
|
128
|
+
*/
|
|
129
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
130
|
+
onSessionSnapshot?: (sessionId: string, snapshot: SessionStateSnapshot) => void
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
/**
|
|
@@ -152,7 +194,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
152
194
|
private room: TLSyncRoom<R, SessionMeta>
|
|
153
195
|
private readonly sessions = new Map<
|
|
154
196
|
string,
|
|
155
|
-
// eslint-disable-next-line
|
|
197
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
156
198
|
{ assembler: JsonChunkAssembler; socket: WebSocketMinimal; unlisten: () => void }
|
|
157
199
|
>()
|
|
158
200
|
readonly log?: TLSyncLog
|
|
@@ -160,6 +202,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
160
202
|
public storage: TLSyncStorage<R>
|
|
161
203
|
|
|
162
204
|
private disposables = new Set<() => void>()
|
|
205
|
+
private readonly snapshotTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
163
206
|
|
|
164
207
|
/**
|
|
165
208
|
* Creates a new TLSocketRoom instance for managing collaborative document synchronization.
|
|
@@ -203,9 +246,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
203
246
|
schema: opts.schema ?? (createTLSchema() as any),
|
|
204
247
|
log: opts.log,
|
|
205
248
|
storage,
|
|
249
|
+
clientTimeout: opts.clientTimeout,
|
|
206
250
|
})
|
|
207
251
|
this.storage = storage
|
|
208
252
|
this.room.events.on('session_removed', (args) => {
|
|
253
|
+
this.clearSnapshotTimer(args.sessionId)
|
|
209
254
|
this.sessions.delete(args.sessionId)
|
|
210
255
|
if (this.opts.onSessionRemoved) {
|
|
211
256
|
this.opts.onSessionRemoved(this, {
|
|
@@ -306,6 +351,27 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
306
351
|
socket.addEventListener?.('error', handleSocketError)
|
|
307
352
|
}
|
|
308
353
|
|
|
354
|
+
private clearSnapshotTimer(sessionId: string) {
|
|
355
|
+
const t = this.snapshotTimers.get(sessionId)
|
|
356
|
+
if (t) {
|
|
357
|
+
clearTimeout(t)
|
|
358
|
+
this.snapshotTimers.delete(sessionId)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private scheduleDebouncedSnapshot(sessionId: string) {
|
|
363
|
+
if (!this.opts.onSessionSnapshot) return
|
|
364
|
+
this.clearSnapshotTimer(sessionId)
|
|
365
|
+
this.snapshotTimers.set(
|
|
366
|
+
sessionId,
|
|
367
|
+
setTimeout(() => {
|
|
368
|
+
this.snapshotTimers.delete(sessionId)
|
|
369
|
+
const snapshot = this.getSessionSnapshot(sessionId)
|
|
370
|
+
if (snapshot) this.opts.onSessionSnapshot!(sessionId, snapshot)
|
|
371
|
+
}, 5000)
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
309
375
|
/**
|
|
310
376
|
* Processes a message received from a client WebSocket. Use this method in server
|
|
311
377
|
* environments where WebSocket event listeners cannot be attached directly to socket
|
|
@@ -362,6 +428,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
362
428
|
}
|
|
363
429
|
|
|
364
430
|
this.room.handleMessage(sessionId, res.data as any)
|
|
431
|
+
this.room.pruneSessions()
|
|
432
|
+
this.scheduleDebouncedSnapshot(sessionId)
|
|
365
433
|
} else {
|
|
366
434
|
this.log?.error?.('Error assembling message', res.error)
|
|
367
435
|
// close the socket to reset the connection
|
|
@@ -391,6 +459,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
391
459
|
* ```
|
|
392
460
|
*/
|
|
393
461
|
handleSocketError(sessionId: string) {
|
|
462
|
+
this.clearSnapshotTimer(sessionId)
|
|
394
463
|
this.room.handleClose(sessionId)
|
|
395
464
|
}
|
|
396
465
|
|
|
@@ -410,9 +479,124 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
410
479
|
* ```
|
|
411
480
|
*/
|
|
412
481
|
handleSocketClose(sessionId: string) {
|
|
482
|
+
this.clearSnapshotTimer(sessionId)
|
|
413
483
|
this.room.handleClose(sessionId)
|
|
414
484
|
}
|
|
415
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Resumes a previously-connected session directly into `Connected` state, bypassing
|
|
488
|
+
* the connect handshake. Use this after server hibernation (e.g., Cloudflare Durable
|
|
489
|
+
* Object hibernation) when WebSocket connections survived but all in-memory state was lost.
|
|
490
|
+
*
|
|
491
|
+
* The session is restored using a {@link SessionStateSnapshot} previously obtained
|
|
492
|
+
* via {@link TLSocketRoom.getSessionSnapshot}. The client is unaware the server restarted and
|
|
493
|
+
* continues sending messages normally.
|
|
494
|
+
*
|
|
495
|
+
* Unlike {@link TLSocketRoom.handleSocketConnect}, this method does NOT attach WebSocket event
|
|
496
|
+
* listeners. In hibernation environments, events are delivered via class methods
|
|
497
|
+
* (e.g., `webSocketMessage`) rather than `addEventListener`.
|
|
498
|
+
*
|
|
499
|
+
* @param opts - Resume options
|
|
500
|
+
* - sessionId - Unique identifier for the client session
|
|
501
|
+
* - socket - WebSocket-like object for client communication
|
|
502
|
+
* - snapshot - Session state snapshot from {@link TLSocketRoom.getSessionSnapshot}
|
|
503
|
+
* - meta - Additional session metadata (required if SessionMeta is not void)
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```ts
|
|
507
|
+
* // After Cloudflare DO hibernation wake
|
|
508
|
+
* for (const ws of ctx.getWebSockets()) {
|
|
509
|
+
* const data = ws.deserializeAttachment()
|
|
510
|
+
* room.handleSocketResume({
|
|
511
|
+
* sessionId: data.sessionId,
|
|
512
|
+
* socket: ws,
|
|
513
|
+
* snapshot: data.snapshot,
|
|
514
|
+
* })
|
|
515
|
+
* }
|
|
516
|
+
* ```
|
|
517
|
+
*/
|
|
518
|
+
handleSocketResume(
|
|
519
|
+
opts: {
|
|
520
|
+
sessionId: string
|
|
521
|
+
socket: WebSocketMinimal
|
|
522
|
+
snapshot: SessionStateSnapshot
|
|
523
|
+
} & (SessionMeta extends void ? object : { meta: SessionMeta })
|
|
524
|
+
) {
|
|
525
|
+
const { sessionId, socket, snapshot } = opts
|
|
526
|
+
|
|
527
|
+
this.sessions.set(sessionId, {
|
|
528
|
+
assembler: new JsonChunkAssembler(),
|
|
529
|
+
socket,
|
|
530
|
+
unlisten: () => {
|
|
531
|
+
// no-op: hibernation environments use class methods, not addEventListener
|
|
532
|
+
},
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
this.room.handleResumedSession({
|
|
536
|
+
sessionId,
|
|
537
|
+
isReadonly: snapshot.isReadonly,
|
|
538
|
+
serializedSchema: snapshot.serializedSchema,
|
|
539
|
+
presenceId: snapshot.presenceId,
|
|
540
|
+
presenceRecord: snapshot.presenceRecord,
|
|
541
|
+
requiresLegacyRejection: snapshot.requiresLegacyRejection,
|
|
542
|
+
supportsStringAppend: snapshot.supportsStringAppend,
|
|
543
|
+
socket: new ServerSocketAdapter({
|
|
544
|
+
ws: socket,
|
|
545
|
+
onBeforeSendMessage: this.opts.onBeforeSendMessage
|
|
546
|
+
? (message, stringified) =>
|
|
547
|
+
this.opts.onBeforeSendMessage!({
|
|
548
|
+
sessionId,
|
|
549
|
+
message,
|
|
550
|
+
stringified,
|
|
551
|
+
meta: this.room.sessions.get(sessionId)?.meta as SessionMeta,
|
|
552
|
+
})
|
|
553
|
+
: undefined,
|
|
554
|
+
}),
|
|
555
|
+
meta: 'meta' in opts ? (opts.meta as any) : undefined,
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Returns a snapshot of a connected session's state that can be persisted and later
|
|
561
|
+
* used with {@link TLSocketRoom.handleSocketResume} to restore the session after hibernation.
|
|
562
|
+
*
|
|
563
|
+
* Returns `null` if the session doesn't exist or isn't in the `Connected` state.
|
|
564
|
+
*
|
|
565
|
+
* @param sessionId - The session to snapshot
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```ts
|
|
569
|
+
* // Store snapshot in a Cloudflare WebSocket attachment
|
|
570
|
+
* const snapshot = room.getSessionSnapshot(sessionId)
|
|
571
|
+
* if (snapshot) {
|
|
572
|
+
* ws.serializeAttachment({ sessionId, snapshot })
|
|
573
|
+
* }
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
getSessionSnapshot(sessionId: string): SessionStateSnapshot | null {
|
|
577
|
+
const session = this.room.sessions.get(sessionId)
|
|
578
|
+
if (!session || session.state !== RoomSessionState.Connected) {
|
|
579
|
+
return null
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let presenceRecord: UnknownRecord | null = null
|
|
583
|
+
if (session.presenceId) {
|
|
584
|
+
const record = this.room.presenceStore.get(session.presenceId)
|
|
585
|
+
if (record) {
|
|
586
|
+
presenceRecord = stripPresenceForSnapshot(record as UnknownRecord)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
serializedSchema: session.serializedSchema,
|
|
592
|
+
isReadonly: session.isReadonly,
|
|
593
|
+
presenceId: session.presenceId,
|
|
594
|
+
presenceRecord,
|
|
595
|
+
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
596
|
+
supportsStringAppend: session.supportsStringAppend,
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
416
600
|
/**
|
|
417
601
|
* Returns the current document clock value. The clock is a monotonically increasing
|
|
418
602
|
* integer that increments with each document change, providing a consistent ordering
|
|
@@ -702,6 +886,9 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
702
886
|
*/
|
|
703
887
|
close() {
|
|
704
888
|
this.room.close()
|
|
889
|
+
for (const sessionId of this.snapshotTimers.keys()) {
|
|
890
|
+
this.clearSnapshotTimer(sessionId)
|
|
891
|
+
}
|
|
705
892
|
this.disposables.forEach((d) => d())
|
|
706
893
|
this.disposables.clear()
|
|
707
894
|
}
|
|
@@ -30,13 +30,10 @@ const protocolVersion = getTlsyncProtocolVersion()
|
|
|
30
30
|
type TestRecord = TLRecord
|
|
31
31
|
|
|
32
32
|
// Mock socket implementation for testing
|
|
33
|
-
class MockSocket
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
TLSocketServerSentEvent<TestRecord>
|
|
38
|
-
>
|
|
39
|
-
{
|
|
33
|
+
class MockSocket implements TLPersistentClientSocket<
|
|
34
|
+
TLSocketClientSentEvent<TestRecord>,
|
|
35
|
+
TLSocketServerSentEvent<TestRecord>
|
|
36
|
+
> {
|
|
40
37
|
connectionStatus: 'online' | 'offline' | 'error' = 'offline'
|
|
41
38
|
private messageListeners: Array<(msg: TLSocketServerSentEvent<TestRecord>) => void> = []
|
|
42
39
|
private statusListeners: Array<(event: TLSocketStatusChangeEvent) => void> = []
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -903,7 +903,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
903
903
|
}
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
-
// eslint-disable-next-line
|
|
906
|
+
// eslint-disable-next-line tldraw/prefer-class-methods
|
|
907
907
|
private rebase = () => {
|
|
908
908
|
// need to make sure that our speculative changes are in sync with the actual store instance before
|
|
909
909
|
// proceeding, to avoid inconsistency bugs.
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
isNativeStructuredClone,
|
|
16
16
|
objectMapEntriesIterable,
|
|
17
17
|
Result,
|
|
18
|
+
throttle,
|
|
18
19
|
} from '@tldraw/utils'
|
|
19
20
|
import { createNanoEvents } from 'nanoevents'
|
|
20
21
|
import {
|
|
@@ -150,12 +151,17 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
150
151
|
|
|
151
152
|
private lastDocumentClock = 0
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
private pruneTimer: ReturnType<typeof setTimeout> | null = null
|
|
155
|
+
|
|
156
|
+
pruneSessions = throttle(() => {
|
|
157
|
+
if (this.pruneTimer) {
|
|
158
|
+
clearTimeout(this.pruneTimer)
|
|
159
|
+
this.pruneTimer = null
|
|
160
|
+
}
|
|
155
161
|
for (const client of this.sessions.values()) {
|
|
156
162
|
switch (client.state) {
|
|
157
163
|
case RoomSessionState.Connected: {
|
|
158
|
-
const hasTimedOut = timeSince(client.lastInteractionTime) >
|
|
164
|
+
const hasTimedOut = timeSince(client.lastInteractionTime) > this.sessionIdleTimeout
|
|
159
165
|
if (hasTimedOut || !client.socket.isOpen) {
|
|
160
166
|
this.cancelSession(client.sessionId)
|
|
161
167
|
}
|
|
@@ -166,6 +172,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
166
172
|
if (hasTimedOut || !client.socket.isOpen) {
|
|
167
173
|
// remove immediately
|
|
168
174
|
this.removeSession(client.sessionId)
|
|
175
|
+
} else {
|
|
176
|
+
this.scheduleFollowUpPrune()
|
|
169
177
|
}
|
|
170
178
|
break
|
|
171
179
|
}
|
|
@@ -173,6 +181,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
173
181
|
const hasTimedOut = timeSince(client.cancellationTime) > SESSION_REMOVAL_WAIT_TIME
|
|
174
182
|
if (hasTimedOut) {
|
|
175
183
|
this.removeSession(client.sessionId)
|
|
184
|
+
} else {
|
|
185
|
+
this.scheduleFollowUpPrune()
|
|
176
186
|
}
|
|
177
187
|
break
|
|
178
188
|
}
|
|
@@ -181,11 +191,16 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
181
191
|
}
|
|
182
192
|
}
|
|
183
193
|
}
|
|
194
|
+
}, 1000)
|
|
195
|
+
|
|
196
|
+
private scheduleFollowUpPrune() {
|
|
197
|
+
if (this.pruneTimer) return
|
|
198
|
+
this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100)
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
readonly presenceStore = new PresenceStore<R>()
|
|
187
202
|
|
|
188
|
-
private disposables: Array<() => void> = [
|
|
203
|
+
private disposables: Array<() => void> = []
|
|
189
204
|
|
|
190
205
|
private _isClosed = false
|
|
191
206
|
|
|
@@ -225,17 +240,20 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
225
240
|
private log?: TLSyncLog
|
|
226
241
|
public readonly schema: StoreSchema<R, any>
|
|
227
242
|
private onPresenceChange?(): void
|
|
243
|
+
private readonly sessionIdleTimeout: number
|
|
228
244
|
|
|
229
245
|
constructor(opts: {
|
|
230
246
|
log?: TLSyncLog
|
|
231
247
|
schema: StoreSchema<R, any>
|
|
232
248
|
onPresenceChange?(): void
|
|
233
249
|
storage: TLSyncStorage<R>
|
|
250
|
+
clientTimeout?: number
|
|
234
251
|
}) {
|
|
235
252
|
this.schema = opts.schema
|
|
236
253
|
this.log = opts.log
|
|
237
254
|
this.onPresenceChange = opts.onPresenceChange
|
|
238
255
|
this.storage = opts.storage
|
|
256
|
+
this.sessionIdleTimeout = opts.clientTimeout ?? SESSION_IDLE_TIMEOUT
|
|
239
257
|
|
|
240
258
|
assert(
|
|
241
259
|
isNativeStructuredClone,
|
|
@@ -277,6 +295,24 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
277
295
|
}
|
|
278
296
|
})
|
|
279
297
|
)
|
|
298
|
+
|
|
299
|
+
this.disposables.push(() => {
|
|
300
|
+
this.pruneSessions.cancel()
|
|
301
|
+
if (this.pruneTimer) {
|
|
302
|
+
clearTimeout(this.pruneTimer)
|
|
303
|
+
this.pruneTimer = null
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// When clientTimeout is finite, run periodic pruning so idle sessions are
|
|
308
|
+
// cleaned up even with no traffic. When Infinity or 0 we skip the interval
|
|
309
|
+
// (e.g. for hibernation); without it, pruning only runs on message or when
|
|
310
|
+
// socket close/error triggers cancelSession, so pruning idle sessions
|
|
311
|
+
// reliably depends on the runtime delivering those events.
|
|
312
|
+
if (Number.isFinite(this.sessionIdleTimeout) && this.sessionIdleTimeout > 0) {
|
|
313
|
+
const pruneIntervalMs = Math.min(2000, Math.floor(this.sessionIdleTimeout / 4))
|
|
314
|
+
this.disposables.push(interval(() => this.pruneSessions(), pruneIntervalMs))
|
|
315
|
+
}
|
|
280
316
|
}
|
|
281
317
|
private broadcastExternalStorageChanges() {
|
|
282
318
|
this.storage.transaction((txn) => {
|
|
@@ -412,6 +448,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
412
448
|
} catch {
|
|
413
449
|
// noop, calling .close() multiple times is fine
|
|
414
450
|
}
|
|
451
|
+
|
|
452
|
+
this.scheduleFollowUpPrune()
|
|
415
453
|
}
|
|
416
454
|
|
|
417
455
|
readonly internalTxnId = 'TLSyncRoom.txn'
|
|
@@ -529,6 +567,60 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
529
567
|
return this
|
|
530
568
|
}
|
|
531
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Resume a previously-connected session directly into `Connected` state, bypassing the
|
|
572
|
+
* connect handshake. Used after server hibernation when the WebSocket is still alive but
|
|
573
|
+
* all in-memory state has been lost.
|
|
574
|
+
*
|
|
575
|
+
* @internal
|
|
576
|
+
*/
|
|
577
|
+
handleResumedSession(opts: {
|
|
578
|
+
sessionId: string
|
|
579
|
+
socket: TLRoomSocket<R>
|
|
580
|
+
meta: SessionMeta
|
|
581
|
+
isReadonly: boolean
|
|
582
|
+
serializedSchema: SerializedSchema
|
|
583
|
+
presenceId: string | null
|
|
584
|
+
presenceRecord: UnknownRecord | null
|
|
585
|
+
requiresLegacyRejection: boolean
|
|
586
|
+
supportsStringAppend: boolean
|
|
587
|
+
}) {
|
|
588
|
+
const {
|
|
589
|
+
sessionId,
|
|
590
|
+
socket,
|
|
591
|
+
meta,
|
|
592
|
+
isReadonly,
|
|
593
|
+
serializedSchema,
|
|
594
|
+
presenceId,
|
|
595
|
+
presenceRecord,
|
|
596
|
+
requiresLegacyRejection,
|
|
597
|
+
supportsStringAppend,
|
|
598
|
+
} = opts
|
|
599
|
+
|
|
600
|
+
const migrations = this.schema.getMigrationsSince(serializedSchema)
|
|
601
|
+
const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false
|
|
602
|
+
|
|
603
|
+
this.sessions.set(sessionId, {
|
|
604
|
+
state: RoomSessionState.Connected,
|
|
605
|
+
sessionId,
|
|
606
|
+
socket,
|
|
607
|
+
presenceId: presenceId ?? this.presenceType?.createId() ?? null,
|
|
608
|
+
serializedSchema,
|
|
609
|
+
requiresDownMigrations,
|
|
610
|
+
lastInteractionTime: Date.now(),
|
|
611
|
+
debounceTimer: null,
|
|
612
|
+
outstandingDataMessages: [],
|
|
613
|
+
meta,
|
|
614
|
+
isReadonly,
|
|
615
|
+
requiresLegacyRejection,
|
|
616
|
+
supportsStringAppend,
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
if (presenceRecord && presenceId) {
|
|
620
|
+
this.presenceStore.set(presenceId, presenceRecord as R)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
532
624
|
/**
|
|
533
625
|
* Checks if all connected sessions support string append operations (protocol version 8+).
|
|
534
626
|
* If any client is on an older version, returns false to enable legacy append mode.
|
package/src/lib/protocol.ts
CHANGED
|
@@ -69,8 +69,10 @@ export const TLIncompatibilityReason = {
|
|
|
69
69
|
* @internal
|
|
70
70
|
* @deprecated replaced by websocket .close status/reason
|
|
71
71
|
*/
|
|
72
|
+
/* eslint-disable @typescript-eslint/no-deprecated */
|
|
72
73
|
export type TLIncompatibilityReason =
|
|
73
74
|
(typeof TLIncompatibilityReason)[keyof typeof TLIncompatibilityReason]
|
|
75
|
+
/* eslint-enable @typescript-eslint/no-deprecated */
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
78
|
* Union type representing all possible message types that can be sent from server to client.
|
package/src/test/RandomSource.ts
CHANGED
|
@@ -19,7 +19,7 @@ export class RandomSource {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
randomAction<Result>(
|
|
22
|
-
// eslint-disable-next-line
|
|
22
|
+
// eslint-disable-next-line tldraw/method-signature-style
|
|
23
23
|
choices: Array<(() => Result) | { weight: number; do: () => any }>,
|
|
24
24
|
randomWeights?: boolean
|
|
25
25
|
): Result {
|