@tldraw/sync-core 4.5.2 → 4.6.0-canary.4ec045c286e1

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.
@@ -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
- // eslint-disable-next-line local/prefer-class-methods
154
- pruneSessions = () => {
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) > SESSION_IDLE_TIMEOUT
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> = [interval(this.pruneSessions, 2000)]
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.
@@ -21,7 +21,7 @@ import { getTlsyncProtocolVersion } from '../lib/protocol'
21
21
  import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
22
22
  import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom'
23
23
  import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
24
- import { RoomSnapshot } from '../lib/TLSyncRoom'
24
+ import { TLSyncRoom, type RoomSnapshot } from '../lib/TLSyncRoom'
25
25
 
26
26
  function getStore() {
27
27
  const schema = createTLSchema()
@@ -805,6 +805,524 @@ describe(TLSocketRoom, () => {
805
805
  })
806
806
  })
807
807
 
808
+ describe('Hibernation support', () => {
809
+ function connectSession(room: TLSocketRoom, sessionId: string, socket: WebSocketMinimal) {
810
+ room.handleSocketConnect({ sessionId, socket })
811
+ const connectRequest = {
812
+ type: 'connect' as const,
813
+ connectRequestId: `connect-${sessionId}`,
814
+ lastServerClock: 0,
815
+ protocolVersion: getTlsyncProtocolVersion(),
816
+ schema: createTLSchema().serialize(),
817
+ }
818
+ room.handleSocketMessage(sessionId, JSON.stringify(connectRequest))
819
+ }
820
+
821
+ describe('getSessionSnapshot', () => {
822
+ it('returns null for unknown session', () => {
823
+ const room = new TLSocketRoom({})
824
+ expect(room.getSessionSnapshot('nonexistent')).toBeNull()
825
+ })
826
+
827
+ it('returns null for session not yet connected', () => {
828
+ const room = new TLSocketRoom({})
829
+ const socket = createMockSocket()
830
+ room.handleSocketConnect({ sessionId: 'test', socket })
831
+ expect(room.getSessionSnapshot('test')).toBeNull()
832
+ })
833
+
834
+ it('returns snapshot for connected session', () => {
835
+ const room = new TLSocketRoom({})
836
+ const socket = createMockSocket()
837
+ connectSession(room, 'test', socket)
838
+
839
+ const snapshot = room.getSessionSnapshot('test')
840
+ expect(snapshot).not.toBeNull()
841
+ expect(snapshot!.serializedSchema).toBeDefined()
842
+ expect(snapshot!.isReadonly).toBe(false)
843
+ expect(snapshot!.presenceId).toBeDefined()
844
+ expect(snapshot!.requiresLegacyRejection).toBe(false)
845
+ expect(snapshot!.supportsStringAppend).toBe(true)
846
+ })
847
+
848
+ it('includes presence record when present', () => {
849
+ const store = getStore()
850
+ store.ensureStoreIsUsable()
851
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
852
+ const socket = createMockSocket()
853
+ connectSession(room, 'test', socket)
854
+
855
+ const presence = InstancePresenceRecordType.create({
856
+ id: InstancePresenceRecordType.createId('p1'),
857
+ userId: 'user1',
858
+ userName: 'User 1',
859
+ currentPageId: PageRecordType.createId('page'),
860
+ })
861
+ const pushRequest = {
862
+ type: 'push' as const,
863
+ clientClock: 1,
864
+ presence: [RecordOpType.Put, presence] as [typeof RecordOpType.Put, typeof presence],
865
+ }
866
+ room.handleSocketMessage('test', JSON.stringify(pushRequest))
867
+
868
+ const snapshot = room.getSessionSnapshot('test')
869
+ expect(snapshot).not.toBeNull()
870
+ expect(snapshot!.presenceRecord).not.toBeNull()
871
+ expect((snapshot!.presenceRecord as any).userId).toBe('user1')
872
+ })
873
+ })
874
+
875
+ describe('handleSocketResume', () => {
876
+ it('creates a connected session that handles pings', () => {
877
+ const room = new TLSocketRoom({})
878
+ const socket = createMockSocket()
879
+
880
+ connectSession(room, 'original', socket)
881
+ const snapshot = room.getSessionSnapshot('original')!
882
+
883
+ // Simulate hibernation: create a new room
884
+ const room2 = new TLSocketRoom({})
885
+ const socket2 = createMockSocket()
886
+ room2.handleSocketResume({
887
+ sessionId: 'original',
888
+ socket: socket2,
889
+ snapshot,
890
+ })
891
+
892
+ expect(room2.getNumActiveSessions()).toBe(1)
893
+ expect(room2.getSessions()[0].isConnected).toBe(true)
894
+
895
+ // Should handle pings (pong is sent)
896
+ room2.handleSocketMessage('original', JSON.stringify({ type: 'ping' }))
897
+ expect(socket2.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }))
898
+ })
899
+
900
+ it('creates a connected session that handles pushes', () => {
901
+ const store = getStore()
902
+ store.ensureStoreIsUsable()
903
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
904
+ const socket = createMockSocket()
905
+ connectSession(room, 'original', socket)
906
+ const snapshot = room.getSessionSnapshot('original')!
907
+
908
+ // Simulate hibernation: new room with same storage
909
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
910
+ const socket2 = createMockSocket()
911
+ room2.handleSocketResume({
912
+ sessionId: 'original',
913
+ socket: socket2,
914
+ snapshot,
915
+ })
916
+
917
+ // Send a push with a new page
918
+ const pageId = PageRecordType.createId('new-page')
919
+ const pushRequest = {
920
+ type: 'push' as const,
921
+ clientClock: 1,
922
+ diff: {
923
+ [pageId]: [
924
+ RecordOpType.Put,
925
+ PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
926
+ ],
927
+ },
928
+ }
929
+ room2.handleSocketMessage('original', JSON.stringify(pushRequest))
930
+
931
+ // Should have processed the push (record exists)
932
+ const record = room2.getRecord(pageId)
933
+ expect(record).toBeDefined()
934
+ expect((record as TLPage).name).toBe('New Page')
935
+ })
936
+
937
+ it('restores presence into the presence store', () => {
938
+ const store = getStore()
939
+ store.ensureStoreIsUsable()
940
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
941
+ const socket = createMockSocket()
942
+ connectSession(room, 'test', socket)
943
+
944
+ // Push presence
945
+ const presence = InstancePresenceRecordType.create({
946
+ id: InstancePresenceRecordType.createId('p1'),
947
+ userId: 'user1',
948
+ userName: 'User 1',
949
+ currentPageId: PageRecordType.createId('page'),
950
+ })
951
+ room.handleSocketMessage(
952
+ 'test',
953
+ JSON.stringify({
954
+ type: 'push',
955
+ clientClock: 1,
956
+ presence: [RecordOpType.Put, presence],
957
+ })
958
+ )
959
+
960
+ const snapshot = room.getSessionSnapshot('test')!
961
+ expect(snapshot.presenceRecord).not.toBeNull()
962
+
963
+ // Resume in a new room
964
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
965
+ const socket2 = createMockSocket()
966
+ room2.handleSocketResume({
967
+ sessionId: 'test',
968
+ socket: socket2,
969
+ snapshot,
970
+ })
971
+
972
+ // Presence should be restored
973
+ const presenceRecords = room2.getPresenceRecords()
974
+ expect(Object.keys(presenceRecords)).toHaveLength(1)
975
+ const restored = Object.values(presenceRecords)[0]
976
+ expect((restored as any).userId).toBe('user1')
977
+ })
978
+
979
+ it('does not attach event listeners', () => {
980
+ const room = new TLSocketRoom({})
981
+ const socket = createMockSocket()
982
+ connectSession(room, 'test', socket)
983
+ const snapshot = room.getSessionSnapshot('test')!
984
+
985
+ const room2 = new TLSocketRoom({})
986
+ const socket2 = createMockSocket()
987
+ room2.handleSocketResume({
988
+ sessionId: 'test',
989
+ socket: socket2,
990
+ snapshot,
991
+ })
992
+
993
+ // addEventListener should NOT have been called on the resumed socket
994
+ expect(socket2.addEventListener).not.toHaveBeenCalled()
995
+ })
996
+
997
+ it('supports session metadata', () => {
998
+ const room = new TLSocketRoom<TLRecord, TestSessionMeta>({})
999
+ const socket = createMockSocket()
1000
+ const snapshot = {
1001
+ serializedSchema: createTLSchema().serialize(),
1002
+ isReadonly: false,
1003
+ presenceId: null,
1004
+ presenceRecord: null,
1005
+ requiresLegacyRejection: false,
1006
+ supportsStringAppend: true,
1007
+ }
1008
+
1009
+ room.handleSocketResume({
1010
+ sessionId: 'test',
1011
+ socket,
1012
+ snapshot,
1013
+ meta: { userId: 'user1', userName: 'Alice' },
1014
+ })
1015
+
1016
+ const sessions = room.getSessions()
1017
+ expect(sessions[0].meta).toEqual({ userId: 'user1', userName: 'Alice' })
1018
+ })
1019
+
1020
+ it('handles readonly sessions', () => {
1021
+ const room = new TLSocketRoom({})
1022
+ const socket = createMockSocket()
1023
+ const snapshot = {
1024
+ serializedSchema: createTLSchema().serialize(),
1025
+ isReadonly: true,
1026
+ presenceId: null,
1027
+ presenceRecord: null,
1028
+ requiresLegacyRejection: false,
1029
+ supportsStringAppend: true,
1030
+ }
1031
+
1032
+ room.handleSocketResume({
1033
+ sessionId: 'test',
1034
+ socket,
1035
+ snapshot,
1036
+ })
1037
+
1038
+ expect(room.getSessions()[0].isReadonly).toBe(true)
1039
+ })
1040
+ })
1041
+
1042
+ describe('clientTimeout', () => {
1043
+ it('uses default idle timeout when not specified', () => {
1044
+ const room = new TLSocketRoom({})
1045
+ const socket = createMockSocket()
1046
+ connectSession(room, 'test', socket)
1047
+
1048
+ expect(room.getNumActiveSessions()).toBe(1)
1049
+ })
1050
+
1051
+ it('accepts Infinity as clientTimeout', () => {
1052
+ const room = new TLSocketRoom({ clientTimeout: Infinity })
1053
+ const socket = createMockSocket()
1054
+ connectSession(room, 'test', socket)
1055
+
1056
+ expect(room.getNumActiveSessions()).toBe(1)
1057
+ })
1058
+
1059
+ it('accepts custom clientTimeout value', () => {
1060
+ const room = new TLSocketRoom({ clientTimeout: 60000 })
1061
+ expect(room.opts.clientTimeout).toBe(60000)
1062
+ })
1063
+ })
1064
+
1065
+ describe('onSessionSnapshot', () => {
1066
+ it('calls onSessionSnapshot after debounce on message receipt', () => {
1067
+ vi.useFakeTimers()
1068
+ try {
1069
+ const onSessionSnapshot = vi.fn()
1070
+ const room = new TLSocketRoom({ onSessionSnapshot })
1071
+ const socket = createMockSocket()
1072
+ connectSession(room, 'test', socket)
1073
+
1074
+ // Send a ping to trigger the debounce
1075
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1076
+
1077
+ // Not called immediately
1078
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1079
+
1080
+ // Advance past the 5s debounce
1081
+ vi.advanceTimersByTime(5100)
1082
+
1083
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
1084
+ expect(onSessionSnapshot).toHaveBeenCalledWith(
1085
+ 'test',
1086
+ expect.objectContaining({
1087
+ serializedSchema: expect.anything(),
1088
+ isReadonly: false,
1089
+ })
1090
+ )
1091
+
1092
+ room.close()
1093
+ } finally {
1094
+ vi.useRealTimers()
1095
+ }
1096
+ })
1097
+
1098
+ it('does not call onSessionSnapshot after socket close (timer cleared)', () => {
1099
+ vi.useFakeTimers()
1100
+ try {
1101
+ const onSessionSnapshot = vi.fn()
1102
+ const room = new TLSocketRoom({ onSessionSnapshot })
1103
+ const socket = createMockSocket()
1104
+ connectSession(room, 'test', socket)
1105
+
1106
+ // Send a message to start the debounce
1107
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1108
+
1109
+ // Close the socket before the debounce fires
1110
+ room.handleSocketClose('test')
1111
+
1112
+ // Advance past the 5s debounce
1113
+ vi.advanceTimersByTime(5100)
1114
+
1115
+ // Should never have been called - timer was cleared on close
1116
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1117
+
1118
+ room.close()
1119
+ } finally {
1120
+ vi.useRealTimers()
1121
+ }
1122
+ })
1123
+
1124
+ it('resets the debounce on subsequent messages', () => {
1125
+ vi.useFakeTimers()
1126
+ try {
1127
+ const onSessionSnapshot = vi.fn()
1128
+ const room = new TLSocketRoom({ onSessionSnapshot })
1129
+ const socket = createMockSocket()
1130
+ connectSession(room, 'test', socket)
1131
+
1132
+ // Send first message
1133
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1134
+
1135
+ // Advance 3s (within the 5s window)
1136
+ vi.advanceTimersByTime(3000)
1137
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1138
+
1139
+ // Send another message, resetting the debounce
1140
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1141
+
1142
+ // Advance another 3s (6s total, but only 3s since last message)
1143
+ vi.advanceTimersByTime(3000)
1144
+ expect(onSessionSnapshot).not.toHaveBeenCalled()
1145
+
1146
+ // Advance past the new debounce window
1147
+ vi.advanceTimersByTime(2100)
1148
+ expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
1149
+
1150
+ room.close()
1151
+ } finally {
1152
+ vi.useRealTimers()
1153
+ }
1154
+ })
1155
+ })
1156
+
1157
+ describe('resume-then-close (presence cleanup)', () => {
1158
+ it('removes presence when a resumed session is immediately closed', () => {
1159
+ vi.useFakeTimers()
1160
+ try {
1161
+ const store = getStore()
1162
+ store.ensureStoreIsUsable()
1163
+ const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
1164
+ const socket = createMockSocket()
1165
+ connectSession(room, 'test', socket)
1166
+
1167
+ // Push presence
1168
+ const presence = InstancePresenceRecordType.create({
1169
+ id: InstancePresenceRecordType.createId('p1'),
1170
+ userId: 'user1',
1171
+ userName: 'User 1',
1172
+ currentPageId: PageRecordType.createId('page'),
1173
+ })
1174
+ room.handleSocketMessage(
1175
+ 'test',
1176
+ JSON.stringify({
1177
+ type: 'push',
1178
+ clientClock: 1,
1179
+ presence: [RecordOpType.Put, presence],
1180
+ })
1181
+ )
1182
+
1183
+ const snapshot = room.getSessionSnapshot('test')!
1184
+ expect(snapshot.presenceRecord).not.toBeNull()
1185
+
1186
+ // Simulate hibernation: new room with a second connected client
1187
+ const room2 = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() })
1188
+ const observerSocket = createMockSocket()
1189
+ connectSession(room2, 'observer', observerSocket)
1190
+ vi.mocked(observerSocket.send).mockClear()
1191
+
1192
+ // Resume the session that's about to disconnect
1193
+ const closingSocket = createMockSocket()
1194
+ room2.handleSocketResume({
1195
+ sessionId: 'test',
1196
+ socket: closingSocket,
1197
+ snapshot,
1198
+ })
1199
+
1200
+ // Presence should be restored (only the resumed session has presence)
1201
+ expect(Object.keys(room2.getPresenceRecords())).toHaveLength(1)
1202
+
1203
+ // Clear any messages from resume so we only see what happens after close
1204
+ vi.mocked(observerSocket.send).mockClear()
1205
+
1206
+ // Now immediately close it (simulating webSocketClose after hibernation)
1207
+ room2.handleSocketClose('test')
1208
+
1209
+ // Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
1210
+ vi.advanceTimersByTime(6200)
1211
+
1212
+ // Presence should be gone
1213
+ expect(Object.keys(room2.getPresenceRecords())).toHaveLength(0)
1214
+
1215
+ // The observer should have received a presence removal broadcast.
1216
+ // Messages are wrapped in a { type: 'data', data: [...] } envelope.
1217
+ const sentMessages = vi.mocked(observerSocket.send).mock.calls.map((c) => JSON.parse(c[0]))
1218
+ const hasPresenceRemoval = sentMessages.some(
1219
+ (msg: {
1220
+ type: string
1221
+ data?: Array<{ type: string; diff?: Record<string, [string]> }>
1222
+ }) =>
1223
+ msg.type === 'data' &&
1224
+ msg.data?.some(
1225
+ (inner) =>
1226
+ inner.type === 'patch' &&
1227
+ inner.diff &&
1228
+ Object.values(inner.diff).some((op) => op[0] === 'remove')
1229
+ )
1230
+ )
1231
+ expect(hasPresenceRemoval).toBe(true)
1232
+
1233
+ room2.close()
1234
+ } finally {
1235
+ vi.useRealTimers()
1236
+ }
1237
+ })
1238
+ })
1239
+
1240
+ describe('on-demand session pruning', () => {
1241
+ it('prunes timed-out sessions during handleSocketMessage', async () => {
1242
+ const room = new TLSocketRoom({
1243
+ clientTimeout: 1,
1244
+ })
1245
+ const socket = createMockSocket()
1246
+ connectSession(room, 'test', socket)
1247
+ expect(room.getNumActiveSessions()).toBe(1)
1248
+
1249
+ // Wait for both the client timeout and the prune throttle window (1s) to expire
1250
+ await new Promise((r) => setTimeout(r, 1100))
1251
+
1252
+ // Connect a second session and send a message to trigger pruning
1253
+ const socket2 = createMockSocket()
1254
+ connectSession(room, 'test2', socket2)
1255
+
1256
+ // The timed-out socket should have been closed
1257
+ expect(socket.close).toHaveBeenCalled()
1258
+
1259
+ room.close()
1260
+ })
1261
+
1262
+ it('runs prune after handleMessage so the sender is not evicted by their own message', () => {
1263
+ // If prune ran before handleMessage, an idle client's message would trigger
1264
+ // prune first and cancel them before lastInteractionTime is updated. Verify
1265
+ // order: handleMessage must run before pruneSessions.
1266
+ const room = new TLSocketRoom({})
1267
+ const syncRoom = (room as unknown as { room: TLSyncRoom<TLRecord, void> }).room
1268
+ const socket = createMockSocket()
1269
+ connectSession(room, 'test', socket)
1270
+
1271
+ const callOrder: string[] = []
1272
+ const realHandleMessage = syncRoom.handleMessage.bind(syncRoom)
1273
+ vi.spyOn(syncRoom, 'handleMessage').mockImplementation((sessionId, message) => {
1274
+ callOrder.push('handleMessage')
1275
+ return realHandleMessage(sessionId, message)
1276
+ })
1277
+ const originalPrune = syncRoom.pruneSessions
1278
+ const wrappedPrune = function (this: typeof syncRoom) {
1279
+ callOrder.push('prune')
1280
+ return originalPrune.call(this)
1281
+ }
1282
+ wrappedPrune.cancel = originalPrune.cancel?.bind(originalPrune)
1283
+ syncRoom.pruneSessions = wrappedPrune as typeof originalPrune
1284
+
1285
+ room.handleSocketMessage('test', JSON.stringify({ type: 'ping' }))
1286
+
1287
+ expect(callOrder).toEqual(['handleMessage', 'prune'])
1288
+ vi.restoreAllMocks()
1289
+ room.close()
1290
+ })
1291
+
1292
+ it('fully removes sessions after disconnect even with no further messages', () => {
1293
+ vi.useFakeTimers()
1294
+ try {
1295
+ const onSessionRemoved = vi.fn()
1296
+ const room = new TLSocketRoom({ onSessionRemoved })
1297
+ const socket = createMockSocket()
1298
+ connectSession(room, 'test', socket)
1299
+ expect(room.getNumActiveSessions()).toBe(1)
1300
+
1301
+ // Disconnect the only client
1302
+ room.handleSocketClose('test')
1303
+
1304
+ // Session should be in AwaitingRemoval, not yet fully removed
1305
+ expect(room.getNumActiveSessions()).toBe(1)
1306
+ expect(onSessionRemoved).not.toHaveBeenCalled()
1307
+
1308
+ // Advance past SESSION_REMOVAL_WAIT_TIME (5s) + buffer (100ms) + throttle (1s)
1309
+ vi.advanceTimersByTime(6200)
1310
+
1311
+ // Session should now be fully removed via the scheduled follow-up prune
1312
+ expect(room.getNumActiveSessions()).toBe(0)
1313
+ expect(onSessionRemoved).toHaveBeenCalledWith(
1314
+ room,
1315
+ expect.objectContaining({ sessionId: 'test', numSessionsRemaining: 0 })
1316
+ )
1317
+
1318
+ room.close()
1319
+ } finally {
1320
+ vi.useRealTimers()
1321
+ }
1322
+ })
1323
+ })
1324
+ })
1325
+
808
1326
  describe('TLSocketRoom.updateStore', () => {
809
1327
  let storage = new InMemorySyncStorage<TLRecord>({ snapshot: DEFAULT_INITIAL_SNAPSHOT })
810
1328