@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.
- 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/TLSocketRoom.js +137 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +71 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- 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/TLSocketRoom.mjs +137 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +73 -6
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/lib/TLSocketRoom.ts +189 -2
- package/src/lib/TLSyncRoom.ts +96 -4
- package/src/test/TLSocketRoom.test.ts +519 -1
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.
|
|
@@ -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
|
|