@tldraw/sync-core 5.5.0-next.f9308d324e1c → 5.5.0

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 (47) hide show
  1. package/dist-cjs/index.d.ts +7 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
  6. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +9 -8
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +3 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +52 -6
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/chunk.js +35 -3
  16. package/dist-cjs/lib/chunk.js.map +2 -2
  17. package/dist-esm/index.d.mts +7 -2
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
  20. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  21. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  22. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  23. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  24. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  25. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  26. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  27. package/dist-esm/lib/TLSyncClient.mjs +3 -1
  28. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  29. package/dist-esm/lib/TLSyncRoom.mjs +52 -6
  30. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  31. package/dist-esm/lib/chunk.mjs +35 -3
  32. package/dist-esm/lib/chunk.mjs.map +2 -2
  33. package/package.json +11 -7
  34. package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
  35. package/src/lib/ClientWebSocketAdapter.ts +62 -23
  36. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  37. package/src/lib/InMemorySyncStorage.ts +20 -22
  38. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  39. package/src/lib/SQLiteSyncStorage.ts +22 -14
  40. package/src/lib/TLSocketRoom.ts +16 -9
  41. package/src/lib/TLSyncClient.ts +2 -0
  42. package/src/lib/TLSyncRoom.ts +71 -6
  43. package/src/lib/chunk.test.ts +88 -1
  44. package/src/lib/chunk.ts +66 -3
  45. package/src/test/TLSocketRoom.test.ts +63 -0
  46. package/src/test/TLSyncRoom.test.ts +176 -0
  47. package/src/test/objectStore.test.ts +35 -0
@@ -1,4 +1,4 @@
1
- import { warnOnce } from '@tldraw/utils'
1
+ import { promiseWithResolve, warnOnce } from '@tldraw/utils'
2
2
  import { TLRecord, sleep } from 'tldraw'
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
@@ -12,6 +12,7 @@ vi.mock('@tldraw/utils', async (importOriginal) => {
12
12
  // NOTE: setupVitest.js replaces the global WebSocket with the 'ws' package's WebSocket,
13
13
  // matching the WebSocketServer the tests connect to.
14
14
  import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
15
+ import { MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
15
16
  import {
16
17
  ACTIVE_MAX_DELAY,
17
18
  ACTIVE_MIN_DELAY,
@@ -73,24 +74,38 @@ describe('ClientWebSocketAdapter', () => {
73
74
  connectedServerSocket = socket
74
75
  })
75
76
 
77
+ // Bound per test on an OS-assigned port. A fixed port raced against the previous test's
78
+ // server, which closes asynchronously, and intermittently failed with EADDRINUSE.
79
+ let port: number
80
+
76
81
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
77
- beforeEach(() => {
82
+ beforeEach(async () => {
78
83
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
79
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
80
- wsServer = new WebSocketServer({ port: 2233 })
84
+ wsServer = new WebSocketServer({ port: 0 })
85
+ // Attach before the adapter exists: the server is already listening by the time the
86
+ // adapter is constructed, so a handler added afterwards can miss the first connection.
81
87
  wsServer.on('connection', connectMock as any)
88
+ await new Promise<void>((resolve, reject) => {
89
+ wsServer.once('listening', resolve)
90
+ wsServer.once('error', reject)
91
+ })
92
+ port = (wsServer.address() as { port: number }).port
93
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
82
94
  })
83
95
 
84
96
  afterEach(() => {
85
97
  consoleWarnSpy.mockRestore()
86
98
  adapter.close()
99
+ // Not awaited: the next test binds its own OS-assigned port, so a server still closing
100
+ // here can't collide with it. Awaiting would deliver in-flight messages to the socket
101
+ // adapter.close() just orphaned.
87
102
  wsServer.close()
88
103
  connectMock.mockClear()
89
104
  })
90
105
 
91
106
  describe('connection and initial state (CW1, CW2)', () => {
92
107
  it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
93
- const newAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
108
+ const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
94
109
  try {
95
110
  expect(newAdapter._connectionStatus.get()).toBe('initial')
96
111
  expect(newAdapter.connectionStatus).toBe('offline')
@@ -113,7 +128,7 @@ describe('ClientWebSocketAdapter', () => {
113
128
  let uriCallCount = 0
114
129
  const dynamicAdapter = new ClientWebSocketAdapter(() => {
115
130
  uriCallCount++
116
- return `ws://localhost:2233?attempt=${uriCallCount}`
131
+ return `ws://localhost:${port}?attempt=${uriCallCount}`
117
132
  })
118
133
  try {
119
134
  await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
@@ -143,7 +158,7 @@ describe('ClientWebSocketAdapter', () => {
143
158
  // no socket can be created until the promise resolves
144
159
  expect(asyncAdapter._ws).toBeNull()
145
160
 
146
- resolveUri!('ws://localhost:2233')
161
+ resolveUri!(`ws://localhost:${port}`)
147
162
 
148
163
  await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
149
164
  expect(asyncAdapter.connectionStatus).toBe('online')
@@ -301,7 +316,7 @@ describe('ClientWebSocketAdapter', () => {
301
316
 
302
317
  describe('URI conversion (CW1)', () => {
303
318
  it('[CW1] converts http URIs to ws and connects', async () => {
304
- const httpAdapter = new ClientWebSocketAdapter(() => 'http://localhost:2233')
319
+ const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
305
320
  try {
306
321
  await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
307
322
  expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
@@ -312,7 +327,7 @@ describe('ClientWebSocketAdapter', () => {
312
327
  })
313
328
 
314
329
  it('[CW1] converts https URIs to wss', async () => {
315
- const httpsAdapter = new ClientWebSocketAdapter(() => 'https://localhost:2233')
330
+ const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
316
331
  try {
317
332
  await waitFor(() => httpsAdapter._ws !== null)
318
333
  expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
@@ -386,6 +401,32 @@ describe('ClientWebSocketAdapter', () => {
386
401
  )
387
402
  })
388
403
 
404
+ it('[CW6] refuses to send a message the server would reject as too large', async () => {
405
+ const onMessage = vi.fn()
406
+ connectMock.mockImplementationOnce((ws: any) => {
407
+ ws.on('message', onMessage)
408
+ })
409
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
410
+
411
+ const onStatusChange = vi.fn()
412
+ adapter.onStatusChange(onStatusChange)
413
+
414
+ const message = {
415
+ ...connectMessage(),
416
+ largeData: 'x'.repeat(MAX_ASSEMBLED_MESSAGE_CHARS),
417
+ } as any
418
+ adapter.sendMessage(message)
419
+
420
+ // nothing goes on the wire: sending would only earn a close and a reconnect that
421
+ // re-sends the same message
422
+ expect(onMessage).not.toHaveBeenCalled()
423
+ expect(adapter.connectionStatus).toBe('error')
424
+ expect(onStatusChange).toHaveBeenCalledWith({
425
+ status: 'error',
426
+ reason: TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE,
427
+ })
428
+ })
429
+
389
430
  it('[CW6] silently drops the message when there is no socket', async () => {
390
431
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
391
432
 
@@ -527,6 +568,46 @@ describe('ClientWebSocketAdapter', () => {
527
568
  adapter.close()
528
569
  expect(() => adapter.close()).not.toThrow()
529
570
  })
571
+
572
+ it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
573
+ const getUri = vi.fn(() => `ws://localhost:${port}`)
574
+ const testAdapter = new ClientWebSocketAdapter(getUri)
575
+ testAdapter.close()
576
+ const callsAtClose = getUri.mock.calls.length
577
+
578
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
579
+
580
+ expect(getUri).toHaveBeenCalledTimes(callsAtClose)
581
+ expect(testAdapter._ws).toBeNull()
582
+ })
583
+
584
+ it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
585
+ let uriCallCount = 0
586
+ const testAdapter = new ClientWebSocketAdapter(() => {
587
+ uriCallCount++
588
+ return `ws://localhost:${port}`
589
+ })
590
+ const onStatusChange = vi.fn()
591
+ testAdapter.onStatusChange(onStatusChange)
592
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
593
+ const callsBeforeClose = uriCallCount
594
+ onStatusChange.mockClear()
595
+
596
+ testAdapter.close()
597
+ expect(testAdapter.connectionStatus).toBe('offline')
598
+ // let the socket's own close event land, then run out any (leaked) reconnect timer
599
+ vi.useRealTimers()
600
+ await sleep(50)
601
+ vi.useFakeTimers()
602
+ vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
603
+ vi.useRealTimers()
604
+ await sleep(20)
605
+ vi.useFakeTimers()
606
+
607
+ expect(onStatusChange).not.toHaveBeenCalled()
608
+ expect(uriCallCount).toBe(callsBeforeClose)
609
+ expect(testAdapter._ws).toBeNull()
610
+ })
530
611
  })
531
612
 
532
613
  describe('orphaned sockets (CW10)', () => {
@@ -566,17 +647,26 @@ describe('ReconnectManager', () => {
566
647
  connectedServerSocket = socket
567
648
  })
568
649
 
650
+ // See the note on the other suite's port: bound per test on an OS-assigned port.
651
+ let port: number
652
+
569
653
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
570
- beforeEach(() => {
654
+ beforeEach(async () => {
571
655
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
572
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
573
- wsServer = new WebSocketServer({ port: 2234 })
656
+ wsServer = new WebSocketServer({ port: 0 })
574
657
  wsServer.on('connection', connectMock as any)
658
+ await new Promise<void>((resolve, reject) => {
659
+ wsServer.once('listening', resolve)
660
+ wsServer.once('error', reject)
661
+ })
662
+ port = (wsServer.address() as { port: number }).port
663
+ adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
575
664
  })
576
665
 
577
666
  afterEach(() => {
578
667
  consoleWarnSpy.mockRestore()
579
668
  adapter.close()
669
+ // Not awaited - see the note on the other suite's teardown.
580
670
  wsServer.close()
581
671
  connectMock.mockClear()
582
672
  })
@@ -591,7 +681,7 @@ describe('ReconnectManager', () => {
591
681
  })
592
682
 
593
683
  it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
594
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
684
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
595
685
  try {
596
686
  const manager = testAdapter._reconnectManager
597
687
  // make every disconnected() call take the "attempt now" branch
@@ -616,7 +706,7 @@ describe('ReconnectManager', () => {
616
706
 
617
707
  it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
618
708
  const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
619
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
709
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
620
710
  try {
621
711
  const manager = testAdapter._reconnectManager
622
712
  ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
@@ -710,7 +800,7 @@ describe('ReconnectManager', () => {
710
800
  // it's necessary to close the socket, as otherwise the websocket might stay half-open
711
801
  connectedServerSocket.close()
712
802
  wsServer.close()
713
- await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
803
+ await waitFor(() => adapter.connectionStatus === 'offline')
714
804
  expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
715
805
 
716
806
  hiddenMock.mockReturnValue(false)
@@ -776,8 +866,34 @@ describe('ReconnectManager', () => {
776
866
  expect(adapter._ws).toBeNull()
777
867
  })
778
868
 
869
+ it.each(['throws', 'rejects'])(
870
+ '[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
871
+ async (failure) => {
872
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
873
+ let attempts = 0
874
+ const testAdapter = new ClientWebSocketAdapter(() => {
875
+ attempts++
876
+ if (attempts < 3) {
877
+ const error = new Error('token endpoint unavailable')
878
+ if (failure === 'throws') throw error
879
+ return Promise.reject(error)
880
+ }
881
+ return `ws://localhost:${port}`
882
+ })
883
+ try {
884
+ await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
885
+ expect(attempts).toBe(3)
886
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
887
+ expect(testAdapter.connectionStatus).toBe('online')
888
+ } finally {
889
+ testAdapter.close()
890
+ consoleErrorSpy.mockRestore()
891
+ }
892
+ }
893
+ )
894
+
779
895
  it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
780
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
896
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
781
897
  const manager = testAdapter._reconnectManager
782
898
 
783
899
  testAdapter.close()
@@ -793,4 +909,93 @@ describe('ReconnectManager', () => {
793
909
  // closing again is safe
794
910
  expect(() => manager.close()).not.toThrow()
795
911
  })
912
+
913
+ it('[RM4] reconnect hints and the disconnect handler survive an undefined WebSocket global', async () => {
914
+ // Regression test for the undefined-WebSocket crash guarded in ClientWebSocketAdapter.ts (#10106).
915
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
916
+ const manager = adapter._reconnectManager
917
+ // jsdom reports a throwing event listener on window instead of from dispatchEvent
918
+ const listenerErrors: unknown[] = []
919
+ const onError = (event: ErrorEvent) => {
920
+ event.preventDefault()
921
+ listenerErrors.push(event.error)
922
+ }
923
+ window.addEventListener('error', onError)
924
+ const originalWebSocket = globalThis.WebSocket
925
+ try {
926
+ ;(globalThis as any).WebSocket = undefined
927
+
928
+ window.dispatchEvent(new Event('online'))
929
+ expect(() => manager.maybeReconnected()).not.toThrow()
930
+ expect(() => manager.connected()).not.toThrow()
931
+ expect(adapter.connectionStatus).toBe('online')
932
+
933
+ window.dispatchEvent(new Event('offline'))
934
+ expect(() => manager.disconnected()).not.toThrow()
935
+ expect(listenerErrors).toEqual([])
936
+ expect(adapter.connectionStatus).toBe('offline')
937
+ expect(adapter._ws).toBeNull()
938
+ } finally {
939
+ globalThis.WebSocket = originalWebSocket
940
+ window.removeEventListener('error', onError)
941
+ }
942
+ })
943
+ })
944
+
945
+ describe('URI failure boundaries', () => {
946
+ // These tests never open a real socket - the adapter is closed first, or the socket is a
947
+ // mock - so the URI only has to parse. No server, hence no port to bind.
948
+ const UNUSED_URI = 'ws://localhost:1'
949
+
950
+ it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
951
+ const uri = promiseWithResolve<string>()
952
+ const getUri = vi.fn(() => uri)
953
+ const testAdapter = new ClientWebSocketAdapter(getUri)
954
+ const onStatusChange = vi.fn()
955
+ testAdapter.onStatusChange(onStatusChange)
956
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
957
+ try {
958
+ await Promise.resolve()
959
+ testAdapter.close()
960
+ if (outcome === 'resolves') uri.resolve(UNUSED_URI)
961
+ else uri.reject(new Error('token endpoint unavailable'))
962
+ await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
963
+
964
+ expect(getUri).toHaveBeenCalledTimes(1)
965
+ expect(testAdapter._ws).toBeNull()
966
+ expect(testAdapter.connectionStatus).toBe('offline')
967
+ expect(onStatusChange).not.toHaveBeenCalled()
968
+ expect(consoleError).not.toHaveBeenCalled()
969
+ } finally {
970
+ testAdapter.close()
971
+ consoleError.mockRestore()
972
+ }
973
+ })
974
+
975
+ it.each([
976
+ { uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
977
+ {
978
+ uri: UNUSED_URI,
979
+ openSocket: true,
980
+ error: 'There should be no connection attempts while already connected',
981
+ },
982
+ ])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
983
+ let nextUri: string | Promise<string> = new Promise(() => {})
984
+ const testAdapter = new ClientWebSocketAdapter(() => nextUri)
985
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
986
+ try {
987
+ await Promise.resolve()
988
+ nextUri = uri
989
+ if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
990
+
991
+ const manager = testAdapter._reconnectManager as unknown as {
992
+ scheduleAttempt(): Promise<void>
993
+ }
994
+ await expect(manager.scheduleAttempt()).rejects.toThrow(error)
995
+ expect(consoleError).not.toHaveBeenCalled()
996
+ } finally {
997
+ testAdapter.close()
998
+ consoleError.mockRestore()
999
+ }
1000
+ })
796
1001
  })
@@ -1,7 +1,7 @@
1
1
  import { atom, Atom } from '@tldraw/state'
2
2
  import { TLRecord } from '@tldraw/tlschema'
3
3
  import { assert, warnOnce } from '@tldraw/utils'
4
- import { chunk } from './chunk'
4
+ import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
5
5
  import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'
6
6
  import {
7
7
  TLPersistentClientSocket,
@@ -11,6 +11,16 @@ import {
11
11
  TLSyncErrorCloseEventReason,
12
12
  } from './TLSyncClient'
13
13
 
14
+ // The ready states from the WebSocket spec, kept local instead of read off the global
15
+ // constructor: an extension or page teardown can leave `WebSocket` undefined by the time a
16
+ // window `online` or close handler fires, and `WebSocket.OPEN` then throws (#10106).
17
+ const ReadyState = {
18
+ CONNECTING: 0,
19
+ OPEN: 1,
20
+ CLOSING: 2,
21
+ CLOSED: 3,
22
+ } as const
23
+
14
24
  function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {
15
25
  target.addEventListener(event, handler)
16
26
  return () => {
@@ -93,8 +103,13 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
93
103
  close() {
94
104
  this.isDisposed = true
95
105
  this._reconnectManager.close()
106
+ // orphan the socket before closing it (as _closeSocket does) so its onclose can't run
107
+ // _handleDisconnect after disposal — that would notify listeners and schedule a reconnect
108
+ const ws = this._ws
109
+ this._ws = null
96
110
  // WebSocket.close() is idempotent
97
- this._ws?.close()
111
+ ws?.close()
112
+ this._connectionStatus.set('offline')
98
113
  }
99
114
 
100
115
  /**
@@ -171,8 +186,8 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
171
186
  assert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')
172
187
  assert(
173
188
  this._ws === null ||
174
- this._ws.readyState === WebSocket.CLOSED ||
175
- this._ws.readyState === WebSocket.CLOSING,
189
+ this._ws.readyState === ReadyState.CLOSED ||
190
+ this._ws.readyState === ReadyState.CLOSING,
176
191
  `Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`
177
192
  )
178
193
 
@@ -240,8 +255,6 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
240
255
  this._handleDisconnect('manual')
241
256
  }
242
257
 
243
- // TLPersistentClientSocket stuff
244
-
245
258
  _connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(
246
259
  'websocket connection status',
247
260
  'initial'
@@ -277,7 +290,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
277
290
 
278
291
  if (!this._ws) return
279
292
  if (this.connectionStatus === 'online') {
280
- const chunks = chunk(JSON.stringify(msg))
293
+ const stringified = JSON.stringify(msg)
294
+ // The server rejects a session whose assembled message exceeds this, so sending would
295
+ // only earn a close and a reconnect that re-sends the same message. Fail here the way
296
+ // the server would, so onSyncError runs once instead of the client looping forever.
297
+ if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
298
+ this._handleDisconnect(
299
+ 'closed',
300
+ TLSyncErrorCloseEventCode,
301
+ true,
302
+ TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
303
+ )
304
+ return
305
+ }
306
+ const chunks = chunk(stringified)
281
307
  for (const part of chunks) {
282
308
  this._ws.send(part)
283
309
  }
@@ -513,21 +539,33 @@ export class ReconnectManager {
513
539
  }
514
540
  }
515
541
 
516
- private scheduleAttempt() {
542
+ private async scheduleAttempt() {
517
543
  assert(this.state === 'pendingAttempt')
544
+ if (this.isDisposed) return
518
545
  debug('scheduling a connection attempt')
519
- Promise.resolve(this.getUri()).then((uri) => {
520
- // this can happen if the promise gets resolved too late
546
+
547
+ let uri: string
548
+ try {
549
+ uri = await this.getUri()
550
+ } catch (error) {
551
+ // A failed auth-token fetch must retry instead of leaving us with no socket or timer.
521
552
  if (this.state !== 'pendingAttempt' || this.isDisposed) return
522
- assert(
523
- this.socketAdapter._ws?.readyState !== WebSocket.OPEN,
524
- 'There should be no connection attempts while already connected'
525
- )
553
+ console.error('Failed to get the websocket URI, retrying', error)
554
+ this.state = 'delay'
555
+ this.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)
556
+ return
557
+ }
526
558
 
527
- this.lastAttemptStart = Date.now()
528
- this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
529
- this.state = 'pendingAttemptResult'
530
- })
559
+ // this can happen if the promise gets resolved too late
560
+ if (this.state !== 'pendingAttempt' || this.isDisposed) return
561
+ assert(
562
+ this.socketAdapter._ws?.readyState !== ReadyState.OPEN,
563
+ 'There should be no connection attempts while already connected'
564
+ )
565
+
566
+ this.lastAttemptStart = Date.now()
567
+ this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
568
+ this.state = 'pendingAttemptResult'
531
569
  }
532
570
 
533
571
  private getMaxDelay() {
@@ -575,13 +613,13 @@ export class ReconnectManager {
575
613
  this.clearRecheckConnectingTimeout()
576
614
 
577
615
  // readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)
578
- if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {
616
+ if (this.socketAdapter._ws?.readyState === ReadyState.OPEN) {
579
617
  debug('ReconnectManager.maybeReconnected: already connected')
580
618
  // nothing to do, we're already OK
581
619
  return
582
620
  }
583
621
 
584
- if (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) {
622
+ if (this.socketAdapter._ws?.readyState === ReadyState.CONNECTING) {
585
623
  debug('ReconnectManager.maybeReconnected: connecting')
586
624
  // We might be waiting for a TCP connection that sent SYN out and will never get it back,
587
625
  // while a new connection appeared. On the other hand, we might have just started connecting
@@ -641,11 +679,12 @@ export class ReconnectManager {
641
679
  debug('ReconnectManager.disconnected')
642
680
  // This either means we're freshly disconnected, or the last connection attempt failed;
643
681
  // either way, time to try again.
682
+ if (this.isDisposed) return
644
683
 
645
684
  // Guard against delayed notifications and recheck synchronously
646
685
  if (
647
- this.socketAdapter._ws?.readyState !== WebSocket.OPEN &&
648
- this.socketAdapter._ws?.readyState !== WebSocket.CONNECTING
686
+ this.socketAdapter._ws?.readyState !== ReadyState.OPEN &&
687
+ this.socketAdapter._ws?.readyState !== ReadyState.CONNECTING
649
688
  ) {
650
689
  debug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')
651
690
  this.clearReconnectTimeout()
@@ -702,7 +741,7 @@ export class ReconnectManager {
702
741
  connected() {
703
742
  debug('ReconnectManager.connected')
704
743
  // this notification could've been delayed, recheck synchronously
705
- if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {
744
+ if (this.socketAdapter._ws?.readyState === ReadyState.OPEN) {
706
745
  debug('ReconnectManager.connected: websocket is OPEN')
707
746
  this.state = 'connected'
708
747
  this.clearReconnectTimeout()
@@ -51,6 +51,27 @@ describe('InMemorySyncStorage', () => {
51
51
  expect(storage.getClock()).toBe(25)
52
52
  })
53
53
 
54
+ it('[IM1] handles snapshots with far more records and tombstones than fit in a spread call', () => {
55
+ const documents = []
56
+ for (let i = 0; i < 150_000; i++) {
57
+ documents.push({
58
+ state: { ...contractRecords[0], id: `shape:${i}` } as TLRecord,
59
+ lastChangedClock: i,
60
+ })
61
+ }
62
+ const tombstones: Record<string, number> = {}
63
+ for (let i = 0; i < 150_000; i++) tombstones[`shape:gone-${i}`] = 150_000 + i
64
+ const storage = new InMemorySyncStorage<TLRecord>({
65
+ snapshot: makeContractSnapshot(contractRecords, {
66
+ documents,
67
+ tombstones,
68
+ documentClock: 0,
69
+ tombstoneHistoryStartsAtClock: 0,
70
+ }),
71
+ })
72
+ expect(storage.getClock()).toBe(299_999)
73
+ })
74
+
54
75
  it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
55
76
  const storage = new InMemorySyncStorage<TLRecord>({
56
77
  snapshot: makeContractSnapshot(contractRecords, {
@@ -153,27 +153,27 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
153
153
  onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
154
154
  } = {}) {
155
155
  this.objectTypes = new Set(objectTypes ?? [])
156
- const maxClockValue = Math.max(
157
- 0,
158
- ...Object.values(snapshot.tombstones ?? {}),
159
- ...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
160
- )
156
+ // a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call
157
+ // arguments overflows the stack at roughly 100k+ records/tombstones
158
+ let maxClockValue = 0
161
159
  // route snapshot entries into their partitions (a seed snapshot may carry object-lane
162
160
  // records merged in, e.g. loaded from a separate persistence lane)
163
- const toEntry = (
164
- d: RoomSnapshot['documents'][number]
165
- ): [string, { state: R; lastChangedClock: number }] => [
166
- d.state.id,
167
- { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
168
- ]
169
- this.documents = new AtomMap(
170
- 'room documents',
171
- snapshot.documents.filter((d) => !this.objectTypes.has(d.state.typeName)).map(toEntry)
172
- )
173
- this.objects = new AtomMap(
174
- 'room objects',
175
- snapshot.documents.filter((d) => this.objectTypes.has(d.state.typeName)).map(toEntry)
176
- )
161
+ const documentEntries: [string, { state: R; lastChangedClock: number }][] = []
162
+ const objectEntries: [string, { state: R; lastChangedClock: number }][] = []
163
+ for (const d of snapshot.documents) {
164
+ if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock
165
+ const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries
166
+ entries.push([
167
+ d.state.id,
168
+ { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
169
+ ])
170
+ }
171
+ const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})
172
+ for (const [, clock] of tombstoneEntries) {
173
+ if (clock > maxClockValue) maxClockValue = clock
174
+ }
175
+ this.documents = new AtomMap('room documents', documentEntries)
176
+ this.objects = new AtomMap('room objects', objectEntries)
177
177
  const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
178
178
 
179
179
  this.documentClock = atom('document clock', documentClock)
@@ -192,9 +192,7 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
192
192
  'room tombstones',
193
193
  // If the tombstone history starts now (or we didn't have the
194
194
  // tombstoneHistoryStartsAtClock) then there are no tombstones
195
- tombstoneHistoryStartsAtClock === documentClock
196
- ? []
197
- : objectMapEntries(snapshot.tombstones ?? {})
195
+ tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
198
196
  )
199
197
  if (onChange) {
200
198
  this.onChange(onChange)