@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist-cjs/index.d.ts +605 -75
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/RoomSession.js +3 -0
  6. package/dist-cjs/lib/RoomSession.js.map +2 -2
  7. package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
  8. package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
  9. package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
  10. package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
  11. package/dist-cjs/lib/TLSocketRoom.js +280 -56
  12. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncClient.js +45 -2
  14. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncRoom.js +161 -16
  16. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +30 -0
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js.map +2 -2
  20. package/dist-cjs/lib/findMin.js.map +2 -2
  21. package/dist-cjs/lib/interval.js.map +2 -2
  22. package/dist-cjs/lib/protocol.js.map +2 -2
  23. package/dist-cjs/lib/server-types.js.map +1 -1
  24. package/dist-esm/index.d.mts +605 -75
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
  27. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  28. package/dist-esm/lib/RoomSession.mjs +3 -0
  29. package/dist-esm/lib/RoomSession.mjs.map +2 -2
  30. package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
  31. package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
  32. package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
  33. package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
  34. package/dist-esm/lib/TLSocketRoom.mjs +280 -56
  35. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  36. package/dist-esm/lib/TLSyncClient.mjs +45 -2
  37. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  38. package/dist-esm/lib/TLSyncRoom.mjs +161 -16
  39. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  40. package/dist-esm/lib/chunk.mjs +30 -0
  41. package/dist-esm/lib/chunk.mjs.map +2 -2
  42. package/dist-esm/lib/diff.mjs.map +2 -2
  43. package/dist-esm/lib/findMin.mjs.map +2 -2
  44. package/dist-esm/lib/interval.mjs.map +2 -2
  45. package/dist-esm/lib/protocol.mjs.map +2 -2
  46. package/package.json +6 -6
  47. package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
  48. package/src/lib/ClientWebSocketAdapter.ts +240 -9
  49. package/src/lib/RoomSession.test.ts +97 -0
  50. package/src/lib/RoomSession.ts +105 -3
  51. package/src/lib/ServerSocketAdapter.test.ts +228 -0
  52. package/src/lib/ServerSocketAdapter.ts +124 -5
  53. package/src/lib/TLRemoteSyncError.ts +50 -1
  54. package/src/lib/TLSocketRoom.ts +377 -60
  55. package/src/lib/TLSyncClient.test.ts +828 -0
  56. package/src/lib/TLSyncClient.ts +251 -26
  57. package/src/lib/TLSyncRoom.ts +284 -24
  58. package/src/lib/chunk.ts +72 -1
  59. package/src/lib/diff.ts +128 -14
  60. package/src/lib/findMin.ts +6 -0
  61. package/src/lib/interval.ts +40 -0
  62. package/src/lib/protocol.ts +185 -7
  63. package/src/lib/server-types.test.ts +44 -0
  64. package/src/lib/server-types.ts +45 -1
  65. package/src/test/TLSocketRoom.test.ts +438 -29
  66. package/src/test/chunk.test.ts +200 -3
  67. package/src/test/diff.test.ts +396 -1
@@ -1,10 +1,11 @@
1
1
  import { InstancePresenceRecordType, PageRecordType } from '@tldraw/tlschema'
2
2
  import { createTLSchema, createTLStore, ZERO_INDEX_KEY } from 'tldraw'
3
- import { vi } from 'vitest'
4
- import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
5
- import { TLSocketRoom } from '../lib/TLSocketRoom'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
6
4
  import { RecordOpType } from '../lib/diff'
7
5
  import { getTlsyncProtocolVersion } from '../lib/protocol'
6
+ import { WebSocketMinimal } from '../lib/ServerSocketAdapter'
7
+ import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom'
8
+ import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
8
9
 
9
10
  function getStore() {
10
11
  const schema = createTLSchema()
@@ -12,18 +13,25 @@ function getStore() {
12
13
  return store
13
14
  }
14
15
 
15
- describe(TLSocketRoom, () => {
16
- it('allows being initialized with an empty TLStoreSnapshot', () => {
17
- const store = getStore()
18
- const snapshot = store.getStoreSnapshot()
19
- const room = new TLSocketRoom({
20
- initialSnapshot: snapshot,
21
- })
16
+ // Mock WebSocket implementation for testing
17
+ function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketMinimal {
18
+ return {
19
+ send: vi.fn(),
20
+ close: vi.fn(),
21
+ readyState: WebSocket.OPEN,
22
+ addEventListener: vi.fn(),
23
+ removeEventListener: vi.fn(),
24
+ ...overrides,
25
+ }
26
+ }
22
27
 
23
- expect(room.getCurrentSnapshot()).toMatchObject({ clock: 0, documents: [] })
24
- expect(room.getCurrentSnapshot().documents.length).toBe(0)
25
- })
28
+ // Helper to create test session metadata
29
+ interface TestSessionMeta {
30
+ userId: string
31
+ userName: string
32
+ }
26
33
 
34
+ describe(TLSocketRoom, () => {
27
35
  it('allows being initialized with a non-empty TLStoreSnapshot', () => {
28
36
  const store = getStore()
29
37
  // populate with an empty document (document:document and page:page records)
@@ -103,22 +111,6 @@ describe(TLSocketRoom, () => {
103
111
  `)
104
112
  })
105
113
 
106
- it('getPresenceRecords returns empty object when no presence records exist', () => {
107
- const store = getStore()
108
- // Don't add any presence records, just the default document
109
- store.ensureStoreIsUsable()
110
-
111
- const snapshot = store.getStoreSnapshot()
112
- const room = new TLSocketRoom({
113
- initialSnapshot: snapshot,
114
- })
115
-
116
- const presenceRecords = room.getPresenceRecords()
117
-
118
- expect(presenceRecords).toEqual({})
119
- expect(Object.keys(presenceRecords)).toHaveLength(0)
120
- })
121
-
122
114
  it('getPresenceRecords correctly handles presence records', () => {
123
115
  const store = getStore()
124
116
  store.ensureStoreIsUsable()
@@ -407,4 +399,421 @@ describe(TLSocketRoom, () => {
407
399
  expect(result.schema).toEqual(originalSchema)
408
400
  })
409
401
  })
402
+
403
+ describe('Constructor options', () => {
404
+ it('sets up logging with default console.error when log option missing', () => {
405
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
406
+ const room = new TLSocketRoom({})
407
+ // Create a session first, then send invalid message to trigger JSON parse error
408
+ const socket = createMockSocket()
409
+ room.handleSocketConnect({
410
+ sessionId: 'test-session',
411
+ socket,
412
+ })
413
+ // Send invalid JSON to trigger JSON parse error which should call console.error
414
+ room.handleSocketMessage('test-session', '{invalid json')
415
+ expect(consoleSpy).toHaveBeenCalled()
416
+ consoleSpy.mockRestore()
417
+ })
418
+
419
+ it('uses custom logger when provided', () => {
420
+ const mockLog: TLSyncLog = {
421
+ warn: vi.fn(),
422
+ error: vi.fn(),
423
+ }
424
+ const room = new TLSocketRoom({ log: mockLog })
425
+ // Create a session first, then send invalid message
426
+ const socket = createMockSocket()
427
+ room.handleSocketConnect({
428
+ sessionId: 'test-session',
429
+ socket,
430
+ })
431
+ // Send invalid JSON to trigger JSON parse error which should call log.error
432
+ room.handleSocketMessage('test-session', '{invalid json')
433
+ expect(mockLog.error).toHaveBeenCalled()
434
+ })
435
+
436
+ it('initializes with custom client timeout', () => {
437
+ const customTimeout = 15000
438
+ const room = new TLSocketRoom({ clientTimeout: customTimeout })
439
+ expect(room.opts.clientTimeout).toBe(customTimeout)
440
+ })
441
+ })
442
+
443
+ describe('Session management', () => {
444
+ let room: TLSocketRoom
445
+ let onSessionRemoved: ReturnType<typeof vi.fn>
446
+
447
+ beforeEach(() => {
448
+ onSessionRemoved = vi.fn()
449
+ room = new TLSocketRoom({ onSessionRemoved })
450
+ })
451
+
452
+ it('handles multiple concurrent sessions', () => {
453
+ const sessions = ['session1', 'session2', 'session3']
454
+ const sockets = sessions.map(() => createMockSocket())
455
+
456
+ sessions.forEach((sessionId, index) => {
457
+ room.handleSocketConnect({
458
+ sessionId,
459
+ socket: sockets[index],
460
+ })
461
+
462
+ const connectRequest = {
463
+ type: 'connect' as const,
464
+ connectRequestId: `connect-${index}`,
465
+ lastServerClock: 0,
466
+ protocolVersion: getTlsyncProtocolVersion(),
467
+ schema: createTLSchema().serialize(),
468
+ }
469
+ room.handleSocketMessage(sessionId, JSON.stringify(connectRequest))
470
+ })
471
+
472
+ expect(room.getNumActiveSessions()).toBe(3)
473
+
474
+ const sessionInfo = room.getSessions()
475
+ expect(sessionInfo).toHaveLength(3)
476
+ expect(sessionInfo.every((s) => s.isConnected)).toBe(true)
477
+ })
478
+
479
+ it('handles readonly sessions correctly', () => {
480
+ const socket = createMockSocket()
481
+ room.handleSocketConnect({
482
+ sessionId: 'readonly-session',
483
+ socket,
484
+ isReadonly: true,
485
+ })
486
+
487
+ const connectRequest = {
488
+ type: 'connect' as const,
489
+ connectRequestId: 'connect-1',
490
+ lastServerClock: 0,
491
+ protocolVersion: getTlsyncProtocolVersion(),
492
+ schema: createTLSchema().serialize(),
493
+ }
494
+ room.handleSocketMessage('readonly-session', JSON.stringify(connectRequest))
495
+
496
+ const sessions = room.getSessions()
497
+ expect(sessions[0].isReadonly).toBe(true)
498
+ })
499
+ })
500
+
501
+ describe('Message handling', () => {
502
+ let room: TLSocketRoom
503
+ let socket: WebSocketMinimal
504
+ let onBeforeSendMessage: ReturnType<typeof vi.fn>
505
+ let onAfterReceiveMessage: ReturnType<typeof vi.fn>
506
+
507
+ beforeEach(() => {
508
+ onBeforeSendMessage = vi.fn()
509
+ onAfterReceiveMessage = vi.fn()
510
+ room = new TLSocketRoom({
511
+ onBeforeSendMessage,
512
+ onAfterReceiveMessage,
513
+ })
514
+ socket = createMockSocket()
515
+ })
516
+
517
+ it('calls onBeforeSendMessage for outgoing messages', () => {
518
+ room.handleSocketConnect({
519
+ sessionId: 'test-session',
520
+ socket,
521
+ })
522
+
523
+ const connectRequest = {
524
+ type: 'connect' as const,
525
+ connectRequestId: 'connect-1',
526
+ lastServerClock: 0,
527
+ protocolVersion: getTlsyncProtocolVersion(),
528
+ schema: createTLSchema().serialize(),
529
+ }
530
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
531
+
532
+ expect(onBeforeSendMessage).toHaveBeenCalled()
533
+ const call = onBeforeSendMessage.mock.calls[0][0]
534
+ expect(call.sessionId).toBe('test-session')
535
+ expect(call.message).toBeDefined()
536
+ expect(call.stringified).toBeDefined()
537
+ })
538
+
539
+ it('calls onAfterReceiveMessage for valid incoming messages', () => {
540
+ room.handleSocketConnect({
541
+ sessionId: 'test-session',
542
+ socket,
543
+ })
544
+
545
+ const connectRequest = {
546
+ type: 'connect' as const,
547
+ connectRequestId: 'connect-1',
548
+ lastServerClock: 0,
549
+ protocolVersion: getTlsyncProtocolVersion(),
550
+ schema: createTLSchema().serialize(),
551
+ }
552
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
553
+
554
+ expect(onAfterReceiveMessage).toHaveBeenCalled()
555
+ const call = onAfterReceiveMessage.mock.calls[0][0]
556
+ expect(call.sessionId).toBe('test-session')
557
+ expect(call.message).toBeDefined()
558
+ expect(call.stringified).toBeDefined()
559
+ })
560
+ })
561
+
562
+ describe('WebSocket error handling', () => {
563
+ let room: TLSocketRoom
564
+ let socket: WebSocketMinimal
565
+
566
+ beforeEach(() => {
567
+ room = new TLSocketRoom({})
568
+ socket = createMockSocket()
569
+ })
570
+
571
+ it('handles socket errors correctly', () => {
572
+ room.handleSocketConnect({
573
+ sessionId: 'test-session',
574
+ socket,
575
+ })
576
+
577
+ const connectRequest = {
578
+ type: 'connect' as const,
579
+ connectRequestId: 'connect-1',
580
+ lastServerClock: 0,
581
+ protocolVersion: getTlsyncProtocolVersion(),
582
+ schema: createTLSchema().serialize(),
583
+ }
584
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
585
+
586
+ expect(room.getSessions()).toHaveLength(1)
587
+
588
+ // Trigger socket error - should not throw
589
+ expect(() => room.handleSocketError('test-session')).not.toThrow()
590
+ })
591
+
592
+ it('handles socket close correctly', () => {
593
+ room.handleSocketConnect({
594
+ sessionId: 'test-session',
595
+ socket,
596
+ })
597
+
598
+ const connectRequest = {
599
+ type: 'connect' as const,
600
+ connectRequestId: 'connect-1',
601
+ lastServerClock: 0,
602
+ protocolVersion: getTlsyncProtocolVersion(),
603
+ schema: createTLSchema().serialize(),
604
+ }
605
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
606
+
607
+ expect(room.getSessions()).toHaveLength(1)
608
+
609
+ // Trigger socket close - should not throw
610
+ expect(() => room.handleSocketClose('test-session')).not.toThrow()
611
+ })
612
+ })
613
+
614
+ describe('Custom messages', () => {
615
+ let room: TLSocketRoom
616
+ let socket: WebSocketMinimal
617
+
618
+ beforeEach(() => {
619
+ room = new TLSocketRoom({})
620
+ socket = createMockSocket()
621
+ })
622
+
623
+ it('sends custom messages to connected sessions', () => {
624
+ room.handleSocketConnect({
625
+ sessionId: 'test-session',
626
+ socket,
627
+ })
628
+
629
+ const connectRequest = {
630
+ type: 'connect' as const,
631
+ connectRequestId: 'connect-1',
632
+ lastServerClock: 0,
633
+ protocolVersion: getTlsyncProtocolVersion(),
634
+ schema: createTLSchema().serialize(),
635
+ }
636
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
637
+
638
+ const customData = { type: 'notification', message: 'Hello World' }
639
+ room.sendCustomMessage('test-session', customData)
640
+
641
+ expect(socket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'custom', data: customData }))
642
+ })
643
+
644
+ it('handles custom message to non-existent session gracefully', () => {
645
+ // Should not throw an error
646
+ expect(() => {
647
+ room.sendCustomMessage('nonexistent-session', { test: 'data' })
648
+ }).not.toThrow()
649
+ })
650
+ })
651
+
652
+ describe('Session closing', () => {
653
+ let room: TLSocketRoom
654
+ let socket: WebSocketMinimal
655
+
656
+ beforeEach(() => {
657
+ room = new TLSocketRoom({})
658
+ socket = createMockSocket()
659
+ })
660
+
661
+ it('closes session without fatal reason', () => {
662
+ room.handleSocketConnect({
663
+ sessionId: 'test-session',
664
+ socket,
665
+ })
666
+
667
+ const connectRequest = {
668
+ type: 'connect' as const,
669
+ connectRequestId: 'connect-1',
670
+ lastServerClock: 0,
671
+ protocolVersion: getTlsyncProtocolVersion(),
672
+ schema: createTLSchema().serialize(),
673
+ }
674
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
675
+
676
+ room.closeSession('test-session')
677
+
678
+ // Session should be removed
679
+ expect(room.getSessions()).toHaveLength(0)
680
+ })
681
+
682
+ it('closes session with fatal reason', () => {
683
+ room.handleSocketConnect({
684
+ sessionId: 'test-session',
685
+ socket,
686
+ })
687
+
688
+ const connectRequest = {
689
+ type: 'connect' as const,
690
+ connectRequestId: 'connect-1',
691
+ lastServerClock: 0,
692
+ protocolVersion: getTlsyncProtocolVersion(),
693
+ schema: createTLSchema().serialize(),
694
+ }
695
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
696
+
697
+ room.closeSession('test-session', TLSyncErrorCloseEventReason.FORBIDDEN)
698
+
699
+ // Session should be removed
700
+ expect(room.getSessions()).toHaveLength(0)
701
+ })
702
+ })
703
+
704
+ describe('Room lifecycle', () => {
705
+ it('closes room correctly', () => {
706
+ const room = new TLSocketRoom({})
707
+ const socket = createMockSocket()
708
+
709
+ room.handleSocketConnect({
710
+ sessionId: 'test-session',
711
+ socket,
712
+ })
713
+
714
+ const connectRequest = {
715
+ type: 'connect' as const,
716
+ connectRequestId: 'connect-1',
717
+ lastServerClock: 0,
718
+ protocolVersion: getTlsyncProtocolVersion(),
719
+ schema: createTLSchema().serialize(),
720
+ }
721
+ room.handleSocketMessage('test-session', JSON.stringify(connectRequest))
722
+
723
+ expect(room.getSessions()).toHaveLength(1)
724
+
725
+ // Close the room
726
+ room.close()
727
+
728
+ // Room should be marked as closed
729
+ expect(room.isClosed()).toBe(true)
730
+ })
731
+ })
732
+
733
+ describe('Store updates', () => {
734
+ it('executes async store updates', async () => {
735
+ const store = getStore()
736
+ store.ensureStoreIsUsable()
737
+ const room = new TLSocketRoom({
738
+ initialSnapshot: store.getStoreSnapshot(),
739
+ })
740
+
741
+ const initialClock = room.getCurrentDocumentClock()
742
+
743
+ await room.updateStore(async (store) => {
744
+ const page = PageRecordType.create({
745
+ id: PageRecordType.createId('new-page'),
746
+ name: 'New Page',
747
+ index: ZERO_INDEX_KEY,
748
+ })
749
+ store.put(page)
750
+ })
751
+
752
+ expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock)
753
+ })
754
+
755
+ it('handles errors in store updates', async () => {
756
+ const store = getStore()
757
+ store.ensureStoreIsUsable()
758
+ const room = new TLSocketRoom({
759
+ initialSnapshot: store.getStoreSnapshot(),
760
+ })
761
+
762
+ await expect(async () => {
763
+ await room.updateStore(() => {
764
+ throw new Error('Test error')
765
+ })
766
+ }).rejects.toThrow('Test error')
767
+ })
768
+ })
769
+
770
+ describe('Session metadata handling', () => {
771
+ it('handles sessions with metadata correctly', () => {
772
+ const roomWithMeta = new TLSocketRoom<any, TestSessionMeta>({})
773
+ const socket = createMockSocket()
774
+ const meta: TestSessionMeta = { userId: 'user123', userName: 'Alice' }
775
+
776
+ roomWithMeta.handleSocketConnect({
777
+ sessionId: 'meta-session',
778
+ socket,
779
+ meta,
780
+ })
781
+
782
+ const connectRequest = {
783
+ type: 'connect' as const,
784
+ connectRequestId: 'connect-1',
785
+ lastServerClock: 0,
786
+ protocolVersion: getTlsyncProtocolVersion(),
787
+ schema: createTLSchema().serialize(),
788
+ }
789
+ roomWithMeta.handleSocketMessage('meta-session', JSON.stringify(connectRequest))
790
+
791
+ const sessions = roomWithMeta.getSessions()
792
+ expect(sessions[0].meta).toEqual(meta)
793
+ })
794
+ })
795
+
796
+ describe('Clock operations', () => {
797
+ it('increments clock after store updates', async () => {
798
+ const store = getStore()
799
+ store.ensureStoreIsUsable()
800
+ const room = new TLSocketRoom({
801
+ initialSnapshot: store.getStoreSnapshot(),
802
+ })
803
+
804
+ const initialClock = room.getCurrentDocumentClock()
805
+
806
+ await room.updateStore((store) => {
807
+ store.put(
808
+ PageRecordType.create({
809
+ id: PageRecordType.createId('test-page'),
810
+ name: 'Test',
811
+ index: ZERO_INDEX_KEY,
812
+ })
813
+ )
814
+ })
815
+
816
+ expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock)
817
+ })
818
+ })
410
819
  })