@tldraw/sync-core 5.6.0-next.d6710de420bf → 5.6.0-next.f151f7f3ebd6
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 +22 -6
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +40 -11
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +5 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-esm/index.d.mts +22 -6
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +40 -11
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +5 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/package.json +7 -7
- package/src/lib/TLSocketRoom.ts +50 -11
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +9 -0
- package/src/test/TLSocketRoom.test.ts +132 -1
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -355,10 +355,12 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
355
355
|
) {
|
|
356
356
|
const { sessionId, socket, isReadonly = false, objectAccess = 'write' } = opts
|
|
357
357
|
const handleSocketMessage = (event: MessageEvent) =>
|
|
358
|
-
this.handleSocketMessage(sessionId, event.data)
|
|
359
|
-
const handleSocketError = this.handleSocketError
|
|
360
|
-
const handleSocketClose = this.handleSocketClose
|
|
358
|
+
this.handleSocketMessage(sessionId, event.data, socket)
|
|
359
|
+
const handleSocketError = () => this.handleSocketError(sessionId, socket)
|
|
360
|
+
const handleSocketClose = () => this.handleSocketClose(sessionId, socket)
|
|
361
361
|
|
|
362
|
+
// A reconnect under the same session id can leave the old socket open.
|
|
363
|
+
this.sessions.get(sessionId)?.unlisten()
|
|
362
364
|
this.sessions.set(sessionId, {
|
|
363
365
|
assembler: new JsonChunkAssembler(),
|
|
364
366
|
socket,
|
|
@@ -422,8 +424,14 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
422
424
|
* The method handles message chunking/reassembly and forwards complete messages
|
|
423
425
|
* to the underlying sync room for processing.
|
|
424
426
|
*
|
|
427
|
+
* Pass the socket the message came from whenever you have it. A client that reconnects under
|
|
428
|
+
* the same session id can leave its previous socket delivering messages for a while; with the
|
|
429
|
+
* socket given, those are dropped instead of being spliced into the new socket's chunked
|
|
430
|
+
* messages.
|
|
431
|
+
*
|
|
425
432
|
* @param sessionId - Session identifier matching the one used in handleSocketConnect
|
|
426
433
|
* @param message - Raw message data from the client (string or binary)
|
|
434
|
+
* @param socket - The socket the message arrived on, when known
|
|
427
435
|
*
|
|
428
436
|
* @example
|
|
429
437
|
* ```ts
|
|
@@ -435,17 +443,30 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
435
443
|
* },
|
|
436
444
|
* message(ws, message) {
|
|
437
445
|
* const { sessionId, room } = ws.data
|
|
438
|
-
* room.handleSocketMessage(sessionId, message)
|
|
446
|
+
* room.handleSocketMessage(sessionId, message, ws)
|
|
439
447
|
* }
|
|
440
448
|
* })
|
|
441
449
|
* ```
|
|
442
450
|
*/
|
|
443
|
-
handleSocketMessage(
|
|
444
|
-
|
|
445
|
-
|
|
451
|
+
handleSocketMessage(
|
|
452
|
+
sessionId: string,
|
|
453
|
+
message: string | AllowSharedBufferSource,
|
|
454
|
+
socket?: WebSocketMinimal
|
|
455
|
+
) {
|
|
456
|
+
const entry = this.sessions.get(sessionId)
|
|
457
|
+
if (!entry) {
|
|
446
458
|
this.log?.warn?.('Received message from unknown session', sessionId)
|
|
447
459
|
return
|
|
448
460
|
}
|
|
461
|
+
// Each socket carries its own chunk sequence, but the assembler belongs to the session id.
|
|
462
|
+
// Feeding a superseded socket's in-flight chunks into the replacement's assembler breaks
|
|
463
|
+
// both messages ("Unexpected non-chunk message", "Chunks received in wrong order", or a
|
|
464
|
+
// lone tail chunk parsed as JSON) — see #10132.
|
|
465
|
+
if (socket && entry.socket !== socket) {
|
|
466
|
+
this.log?.warn?.('Ignoring message from superseded socket', sessionId)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
const assembler = entry.assembler
|
|
449
470
|
|
|
450
471
|
try {
|
|
451
472
|
const messageString =
|
|
@@ -500,17 +521,22 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
500
521
|
* where socket event listeners cannot be attached directly. This will initiate cleanup
|
|
501
522
|
* and session removal for the affected client.
|
|
502
523
|
*
|
|
524
|
+
* Pass the socket the error came from whenever you have it, so a superseded socket's error
|
|
525
|
+
* after a same-id reconnect doesn't cancel the replacement session.
|
|
526
|
+
*
|
|
503
527
|
* @param sessionId - Session identifier matching the one used in handleSocketConnect
|
|
528
|
+
* @param socket - The socket the error came from, when known
|
|
504
529
|
*
|
|
505
530
|
* @example
|
|
506
531
|
* ```ts
|
|
507
532
|
* // In a custom WebSocket handler
|
|
508
533
|
* socket.addEventListener('error', () => {
|
|
509
|
-
* room.handleSocketError(sessionId)
|
|
534
|
+
* room.handleSocketError(sessionId, socket)
|
|
510
535
|
* })
|
|
511
536
|
* ```
|
|
512
537
|
*/
|
|
513
|
-
handleSocketError(sessionId: string) {
|
|
538
|
+
handleSocketError(sessionId: string, socket?: WebSocketMinimal) {
|
|
539
|
+
if (this.isSupersededSocket(sessionId, socket)) return
|
|
514
540
|
this.clearSnapshotTimer(sessionId)
|
|
515
541
|
this.room.handleClose(sessionId)
|
|
516
542
|
}
|
|
@@ -520,21 +546,33 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
520
546
|
* environments where socket event listeners cannot be attached directly. This will
|
|
521
547
|
* initiate cleanup and session removal for the disconnected client.
|
|
522
548
|
*
|
|
549
|
+
* Pass the socket the close came from whenever you have it. A client that reconnects under the
|
|
550
|
+
* same session id can leave its previous socket half-open, and that socket's late close would
|
|
551
|
+
* otherwise cancel the new, live session.
|
|
552
|
+
*
|
|
523
553
|
* @param sessionId - Session identifier matching the one used in handleSocketConnect
|
|
554
|
+
* @param socket - The socket that closed, when known
|
|
524
555
|
*
|
|
525
556
|
* @example
|
|
526
557
|
* ```ts
|
|
527
558
|
* // In a custom WebSocket handler
|
|
528
559
|
* socket.addEventListener('close', () => {
|
|
529
|
-
* room.handleSocketClose(sessionId)
|
|
560
|
+
* room.handleSocketClose(sessionId, socket)
|
|
530
561
|
* })
|
|
531
562
|
* ```
|
|
532
563
|
*/
|
|
533
|
-
handleSocketClose(sessionId: string) {
|
|
564
|
+
handleSocketClose(sessionId: string, socket?: WebSocketMinimal) {
|
|
565
|
+
if (this.isSupersededSocket(sessionId, socket)) return
|
|
534
566
|
this.clearSnapshotTimer(sessionId)
|
|
535
567
|
this.room.handleClose(sessionId)
|
|
536
568
|
}
|
|
537
569
|
|
|
570
|
+
private isSupersededSocket(sessionId: string, socket: WebSocketMinimal | undefined) {
|
|
571
|
+
if (!socket) return false
|
|
572
|
+
const entry = this.sessions.get(sessionId)
|
|
573
|
+
return !!entry && entry.socket !== socket
|
|
574
|
+
}
|
|
575
|
+
|
|
538
576
|
/**
|
|
539
577
|
* Resumes a previously-connected session directly into `Connected` state, bypassing
|
|
540
578
|
* the connect handshake. Use this after server hibernation (e.g., Cloudflare Durable
|
|
@@ -576,6 +614,7 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
576
614
|
) {
|
|
577
615
|
const { sessionId, socket, snapshot } = opts
|
|
578
616
|
|
|
617
|
+
this.sessions.get(sessionId)?.unlisten()
|
|
579
618
|
this.sessions.set(sessionId, {
|
|
580
619
|
assembler: new JsonChunkAssembler(),
|
|
581
620
|
socket,
|
|
@@ -375,6 +375,67 @@ describe('TLSyncClient', () => {
|
|
|
375
375
|
expect(pushWithPage!.diff![localPage.id][0]).toBe(RecordOpType.Put)
|
|
376
376
|
})
|
|
377
377
|
|
|
378
|
+
it('[CL5] a reconnect flushes pending store history first, so a change queued behind the frame throttle is neither lost nor resurrected', () => {
|
|
379
|
+
connectClient()
|
|
380
|
+
|
|
381
|
+
socket.mockConnectionStatus('offline')
|
|
382
|
+
const localPage = makePage('Offline Page')
|
|
383
|
+
store.put([localPage]) // reaches speculativeChanges
|
|
384
|
+
|
|
385
|
+
// In the browser the store delivers history on the next animation frame, so the client's
|
|
386
|
+
// view of speculative changes can lag the store. Hold the next flush back the same way.
|
|
387
|
+
const flushHistory = store._flushHistory
|
|
388
|
+
store._flushHistory = () => {}
|
|
389
|
+
store.remove([localPage.id]) // ...so this delete is still queued when we reconnect
|
|
390
|
+
store._flushHistory = flushHistory
|
|
391
|
+
|
|
392
|
+
socket.mockConnectionStatus('online')
|
|
393
|
+
socket.mockServerMessage(
|
|
394
|
+
createConnectMessage({ hydrationType: 'wipe_presence', serverClock: 2, diff: {} })
|
|
395
|
+
)
|
|
396
|
+
vi.advanceTimersByTime(100)
|
|
397
|
+
|
|
398
|
+
// the user deleted the page: it must not come back, and must not be pushed as a put
|
|
399
|
+
expect(store.get(localPage.id)).toBeUndefined()
|
|
400
|
+
expect(
|
|
401
|
+
getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
|
|
402
|
+
).toBe(false)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('[CL5] a store listener that throws during the reconnect flush does not stall the connect', () => {
|
|
406
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
407
|
+
try {
|
|
408
|
+
client = createClient()
|
|
409
|
+
let listenerThrown = false
|
|
410
|
+
store.listen(() => {
|
|
411
|
+
if (listenerThrown) return
|
|
412
|
+
listenerThrown = true
|
|
413
|
+
throw new Error('listener error')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// queue a local change behind the frame throttle so the connect flush delivers it
|
|
417
|
+
const flushHistory = store._flushHistory
|
|
418
|
+
store._flushHistory = () => {}
|
|
419
|
+
const localPage = makePage('Local Page', 'a5')
|
|
420
|
+
store.put([localPage])
|
|
421
|
+
store._flushHistory = flushHistory
|
|
422
|
+
|
|
423
|
+
const serverDiff = documentScopeDiff()
|
|
424
|
+
delete serverDiff[localPage.id] // the server never saw it
|
|
425
|
+
socket.mockServerMessage(createConnectMessage({ diff: serverDiff }))
|
|
426
|
+
vi.advanceTimersByTime(100)
|
|
427
|
+
|
|
428
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
429
|
+
expect(onLoad).toHaveBeenCalled()
|
|
430
|
+
expect(store.get(localPage.id)).toEqual(localPage)
|
|
431
|
+
expect(
|
|
432
|
+
getSentPushes().some((msg) => msg.diff?.[localPage.id]?.[0] === RecordOpType.Put)
|
|
433
|
+
).toBe(true)
|
|
434
|
+
} finally {
|
|
435
|
+
consoleSpy.mockRestore()
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
|
|
378
439
|
it('[CL6] wipe_all reconnect wipes document records before applying the server diff', () => {
|
|
379
440
|
client = createClient()
|
|
380
441
|
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -754,6 +754,15 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|
|
754
754
|
this.resetConnection(true)
|
|
755
755
|
return
|
|
756
756
|
}
|
|
757
|
+
// Local changes reach `push` (and so `speculativeChanges`) on the store's frame-throttled
|
|
758
|
+
// history flush. Anything still queued there would be neither reverted below nor
|
|
759
|
+
// re-applied on top of the server state, so bring speculativeChanges up to date first.
|
|
760
|
+
try {
|
|
761
|
+
this.store._flushHistory()
|
|
762
|
+
} catch (e) {
|
|
763
|
+
console.error(e)
|
|
764
|
+
}
|
|
765
|
+
|
|
757
766
|
// at the end of this process we want to have at most one pending push request
|
|
758
767
|
// based on anything inside this.speculativeChanges
|
|
759
768
|
transact(() => {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
ZERO_INDEX_KEY,
|
|
22
22
|
} from 'tldraw'
|
|
23
23
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
24
|
-
import { MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
|
|
24
|
+
import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from '../lib/chunk'
|
|
25
25
|
import { RecordOpType } from '../lib/diff'
|
|
26
26
|
import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
|
|
27
27
|
import { getTlsyncProtocolVersion } from '../lib/protocol'
|
|
@@ -454,6 +454,75 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
454
454
|
expect(room.getSessions()).toHaveLength(0)
|
|
455
455
|
})
|
|
456
456
|
|
|
457
|
+
it('[SR5] ignores chunks from a superseded socket so they do not corrupt the replacement socket', () => {
|
|
458
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
459
|
+
const room = new TLSocketRoom({ log })
|
|
460
|
+
const oldSocket = createMockSocket()
|
|
461
|
+
connectSession(room, 'S', oldSocket)
|
|
462
|
+
// the client reconnects under the same session id while the old socket is still draining
|
|
463
|
+
const newSocket = createMockSocket()
|
|
464
|
+
connectSession(room, 'S', newSocket)
|
|
465
|
+
vi.mocked(newSocket.send).mockClear()
|
|
466
|
+
|
|
467
|
+
const oldChunks = chunk(JSON.stringify({ type: 'ping' }), 4)
|
|
468
|
+
const newChunks = chunk(JSON.stringify({ type: 'ping' }), 5)
|
|
469
|
+
expect(oldChunks.length).toBeGreaterThan(1)
|
|
470
|
+
expect(newChunks.length).toBeGreaterThan(1)
|
|
471
|
+
// interleave the two sockets' chunk sequences, as a hibernation host delivers them
|
|
472
|
+
for (let i = 0; i < Math.max(oldChunks.length, newChunks.length); i++) {
|
|
473
|
+
if (oldChunks[i]) room.handleSocketMessage('S', oldChunks[i], oldSocket)
|
|
474
|
+
if (newChunks[i]) room.handleSocketMessage('S', newChunks[i], newSocket)
|
|
475
|
+
}
|
|
476
|
+
// a lone complete message from the old socket mid-sequence is ignored too
|
|
477
|
+
room.handleSocketMessage('S', JSON.stringify({ type: 'ping' }), oldSocket)
|
|
478
|
+
|
|
479
|
+
expect(log.error).not.toHaveBeenCalled()
|
|
480
|
+
expect(newSocket.close).not.toHaveBeenCalled()
|
|
481
|
+
expect(room.getSessions()).toEqual([
|
|
482
|
+
expect.objectContaining({ sessionId: 'S', isConnected: true }),
|
|
483
|
+
])
|
|
484
|
+
expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toEqual([
|
|
485
|
+
'pong',
|
|
486
|
+
])
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it("[SR4][SR5] the listeners attached by handleSocketConnect drop a superseded socket's messages", () => {
|
|
490
|
+
function createListeningSocket() {
|
|
491
|
+
const listeners: Record<string, ((e: any) => void)[]> = {}
|
|
492
|
+
const socket: WebSocketMinimal & { fire(type: string, data: string): void } = {
|
|
493
|
+
send: vi.fn(),
|
|
494
|
+
close: vi.fn(),
|
|
495
|
+
readyState: WebSocket.OPEN,
|
|
496
|
+
addEventListener: vi.fn((type: string, fn: (e: any) => void) => {
|
|
497
|
+
;(listeners[type] ??= []).push(fn)
|
|
498
|
+
}),
|
|
499
|
+
removeEventListener: vi.fn(),
|
|
500
|
+
fire(type, data) {
|
|
501
|
+
for (const fn of listeners[type] ?? []) fn({ data })
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
return socket
|
|
505
|
+
}
|
|
506
|
+
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
507
|
+
const room = new TLSocketRoom({ log })
|
|
508
|
+
const oldSocket = createListeningSocket()
|
|
509
|
+
connectSession(room, 'S', oldSocket)
|
|
510
|
+
const newSocket = createListeningSocket()
|
|
511
|
+
connectSession(room, 'S', newSocket)
|
|
512
|
+
|
|
513
|
+
const [first, ...rest] = chunk(JSON.stringify({ type: 'ping' }), 5)
|
|
514
|
+
newSocket.fire('message', first)
|
|
515
|
+
// the old socket's tail chunk would otherwise be assembled onto the new socket's sequence
|
|
516
|
+
oldSocket.fire('message', '0_kPAAAjzgAA')
|
|
517
|
+
for (const c of rest) newSocket.fire('message', c)
|
|
518
|
+
|
|
519
|
+
expect(log.error).not.toHaveBeenCalled()
|
|
520
|
+
expect(newSocket.close).not.toHaveBeenCalled()
|
|
521
|
+
expect(vi.mocked(newSocket.send).mock.calls.map((c) => JSON.parse(c[0]).type)).toContain(
|
|
522
|
+
'pong'
|
|
523
|
+
)
|
|
524
|
+
})
|
|
525
|
+
|
|
457
526
|
it('[SR5] warns when receiving a message from an unknown session', () => {
|
|
458
527
|
const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
|
|
459
528
|
const room = new TLSocketRoom({ log })
|
|
@@ -585,6 +654,68 @@ describe('28. TLSocketRoom (SR)', () => {
|
|
|
585
654
|
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
586
655
|
expect(socket.close).toHaveBeenCalled()
|
|
587
656
|
})
|
|
657
|
+
|
|
658
|
+
it.each(['handleSocketClose', 'handleSocketError'] as const)(
|
|
659
|
+
"[SR6][SR12] %s ignores a superseded socket's late event after a same-id reconnect",
|
|
660
|
+
(method) => {
|
|
661
|
+
vi.useFakeTimers()
|
|
662
|
+
try {
|
|
663
|
+
const onSessionSnapshot = vi.fn()
|
|
664
|
+
const room = new TLSocketRoom({ onSessionSnapshot })
|
|
665
|
+
const oldSocket = createMockSocket()
|
|
666
|
+
connectSession(room, 'S', oldSocket)
|
|
667
|
+
const newSocket = createMockSocket()
|
|
668
|
+
connectSession(room, 'S', newSocket)
|
|
669
|
+
|
|
670
|
+
room[method]('S', oldSocket)
|
|
671
|
+
|
|
672
|
+
expect(room.getSessions()).toEqual([
|
|
673
|
+
expect.objectContaining({ sessionId: 'S', isConnected: true }),
|
|
674
|
+
])
|
|
675
|
+
expect(newSocket.close).not.toHaveBeenCalled()
|
|
676
|
+
// the live session's pending snapshot survives the superseded event
|
|
677
|
+
vi.advanceTimersByTime(5100)
|
|
678
|
+
expect(onSessionSnapshot).toHaveBeenCalledTimes(1)
|
|
679
|
+
|
|
680
|
+
room[method]('S', newSocket)
|
|
681
|
+
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
682
|
+
} finally {
|
|
683
|
+
vi.useRealTimers()
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
it("[SR4][SR6] a reconnect detaches the previous socket's listeners", () => {
|
|
689
|
+
const listeners = new Map<string, (e: any) => void>()
|
|
690
|
+
const oldSocket = createMockSocket({
|
|
691
|
+
addEventListener: vi.fn((type: string, fn: (e: any) => void) => listeners.set(type, fn)),
|
|
692
|
+
removeEventListener: vi.fn((type: string, fn: (e: any) => void) => {
|
|
693
|
+
if (listeners.get(type) === fn) listeners.delete(type)
|
|
694
|
+
}),
|
|
695
|
+
})
|
|
696
|
+
connectSession(room, 'S', oldSocket)
|
|
697
|
+
expect([...listeners.keys()].sort()).toEqual(['close', 'error', 'message'])
|
|
698
|
+
|
|
699
|
+
connectSession(room, 'S', createMockSocket())
|
|
700
|
+
|
|
701
|
+
expect(listeners.size).toBe(0)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it.each(['close', 'error'] as const)(
|
|
705
|
+
'[SR4][SR6] firing the attached %s listener cancels the session',
|
|
706
|
+
(type) => {
|
|
707
|
+
const listeners = new Map<string, (e: any) => void>()
|
|
708
|
+
const socket = createMockSocket({
|
|
709
|
+
addEventListener: vi.fn((t: string, fn: (e: any) => void) => listeners.set(t, fn)),
|
|
710
|
+
})
|
|
711
|
+
connectSession(room, 'S', socket)
|
|
712
|
+
|
|
713
|
+
listeners.get(type)!(new Event(type))
|
|
714
|
+
|
|
715
|
+
expect(room.getSessions()[0].isConnected).toBe(false)
|
|
716
|
+
expect(socket.close).toHaveBeenCalled()
|
|
717
|
+
}
|
|
718
|
+
)
|
|
588
719
|
})
|
|
589
720
|
|
|
590
721
|
describe('session reporting', () => {
|