@tldraw/sync-core 5.5.0-next.9665a55b2322 → 5.5.0-next.b6a9f7cd7be2

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 (40) 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 +11 -1
  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/TLSocketRoom.js +5 -1
  8. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncClient.js +3 -1
  10. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncRoom.js +20 -2
  12. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  13. package/dist-cjs/lib/chunk.js +25 -2
  14. package/dist-cjs/lib/chunk.js.map +2 -2
  15. package/dist-esm/index.d.mts +7 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ClientWebSocketAdapter.mjs +12 -2
  18. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  19. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  20. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  21. package/dist-esm/lib/TLSocketRoom.mjs +6 -2
  22. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  23. package/dist-esm/lib/TLSyncClient.mjs +3 -1
  24. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  25. package/dist-esm/lib/TLSyncRoom.mjs +20 -2
  26. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  27. package/dist-esm/lib/chunk.mjs +25 -2
  28. package/dist-esm/lib/chunk.mjs.map +2 -2
  29. package/package.json +7 -7
  30. package/src/lib/ClientWebSocketAdapter.test.ts +73 -19
  31. package/src/lib/ClientWebSocketAdapter.ts +15 -2
  32. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  33. package/src/lib/InMemorySyncStorage.ts +20 -22
  34. package/src/lib/TLSocketRoom.ts +8 -2
  35. package/src/lib/TLSyncClient.ts +2 -0
  36. package/src/lib/TLSyncRoom.ts +27 -2
  37. package/src/lib/chunk.test.ts +54 -1
  38. package/src/lib/chunk.ts +46 -2
  39. package/src/test/TLSocketRoom.test.ts +24 -0
  40. package/src/test/TLSyncRoom.test.ts +25 -0
@@ -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
 
@@ -529,7 +570,7 @@ describe('ClientWebSocketAdapter', () => {
529
570
  })
530
571
 
531
572
  it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
532
- const getUri = vi.fn(() => 'ws://localhost:2233')
573
+ const getUri = vi.fn(() => `ws://localhost:${port}`)
533
574
  const testAdapter = new ClientWebSocketAdapter(getUri)
534
575
  testAdapter.close()
535
576
  const callsAtClose = getUri.mock.calls.length
@@ -544,7 +585,7 @@ describe('ClientWebSocketAdapter', () => {
544
585
  let uriCallCount = 0
545
586
  const testAdapter = new ClientWebSocketAdapter(() => {
546
587
  uriCallCount++
547
- return 'ws://localhost:2233'
588
+ return `ws://localhost:${port}`
548
589
  })
549
590
  const onStatusChange = vi.fn()
550
591
  testAdapter.onStatusChange(onStatusChange)
@@ -606,17 +647,26 @@ describe('ReconnectManager', () => {
606
647
  connectedServerSocket = socket
607
648
  })
608
649
 
650
+ // See the note on the other suite's port: bound per test on an OS-assigned port.
651
+ let port: number
652
+
609
653
  let consoleWarnSpy: ReturnType<typeof vi.spyOn>
610
- beforeEach(() => {
654
+ beforeEach(async () => {
611
655
  consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
612
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
613
- wsServer = new WebSocketServer({ port: 2234 })
656
+ wsServer = new WebSocketServer({ port: 0 })
614
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}`)
615
664
  })
616
665
 
617
666
  afterEach(() => {
618
667
  consoleWarnSpy.mockRestore()
619
668
  adapter.close()
669
+ // Not awaited - see the note on the other suite's teardown.
620
670
  wsServer.close()
621
671
  connectMock.mockClear()
622
672
  })
@@ -631,7 +681,7 @@ describe('ReconnectManager', () => {
631
681
  })
632
682
 
633
683
  it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
634
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
684
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
635
685
  try {
636
686
  const manager = testAdapter._reconnectManager
637
687
  // make every disconnected() call take the "attempt now" branch
@@ -656,7 +706,7 @@ describe('ReconnectManager', () => {
656
706
 
657
707
  it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
658
708
  const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
659
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
709
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
660
710
  try {
661
711
  const manager = testAdapter._reconnectManager
662
712
  ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
@@ -828,7 +878,7 @@ describe('ReconnectManager', () => {
828
878
  if (failure === 'throws') throw error
829
879
  return Promise.reject(error)
830
880
  }
831
- return 'ws://localhost:2234'
881
+ return `ws://localhost:${port}`
832
882
  })
833
883
  try {
834
884
  await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
@@ -843,7 +893,7 @@ describe('ReconnectManager', () => {
843
893
  )
844
894
 
845
895
  it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
846
- const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
896
+ const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
847
897
  const manager = testAdapter._reconnectManager
848
898
 
849
899
  testAdapter.close()
@@ -862,6 +912,10 @@ describe('ReconnectManager', () => {
862
912
  })
863
913
 
864
914
  describe('URI failure boundaries', () => {
915
+ // These tests never open a real socket - the adapter is closed first, or the socket is a
916
+ // mock - so the URI only has to parse. No server, hence no port to bind.
917
+ const UNUSED_URI = 'ws://localhost:1'
918
+
865
919
  it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
866
920
  const uri = promiseWithResolve<string>()
867
921
  const getUri = vi.fn(() => uri)
@@ -872,7 +926,7 @@ describe('URI failure boundaries', () => {
872
926
  try {
873
927
  await Promise.resolve()
874
928
  testAdapter.close()
875
- if (outcome === 'resolves') uri.resolve('ws://localhost:2234')
929
+ if (outcome === 'resolves') uri.resolve(UNUSED_URI)
876
930
  else uri.reject(new Error('token endpoint unavailable'))
877
931
  await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
878
932
 
@@ -890,7 +944,7 @@ describe('URI failure boundaries', () => {
890
944
  it.each([
891
945
  { uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
892
946
  {
893
- uri: 'ws://localhost:2234',
947
+ uri: UNUSED_URI,
894
948
  openSocket: true,
895
949
  error: 'There should be no connection attempts while already connected',
896
950
  },
@@ -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,
@@ -280,7 +280,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
280
280
 
281
281
  if (!this._ws) return
282
282
  if (this.connectionStatus === 'online') {
283
- const chunks = chunk(JSON.stringify(msg))
283
+ const stringified = JSON.stringify(msg)
284
+ // The server rejects a session whose assembled message exceeds this, so sending would
285
+ // only earn a close and a reconnect that re-sends the same message. Fail here the way
286
+ // the server would, so onSyncError runs once instead of the client looping forever.
287
+ if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
288
+ this._handleDisconnect(
289
+ 'closed',
290
+ TLSyncErrorCloseEventCode,
291
+ true,
292
+ TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
293
+ )
294
+ return
295
+ }
296
+ const chunks = chunk(stringified)
284
297
  for (const part of chunks) {
285
298
  this._ws.send(part)
286
299
  }
@@ -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)
@@ -1,7 +1,7 @@
1
1
  import type { SerializedSchema, StoreSchema, UnknownRecord } from '@tldraw/store'
2
2
  import { createTLSchema, TLInstancePresence, TLStoreSnapshot } from '@tldraw/tlschema'
3
3
  import { getOwnProperty, hasOwnProperty, isEqual, structuredClone } from '@tldraw/utils'
4
- import { JsonChunkAssembler } from './chunk'
4
+ import { JsonChunkAssembler, MessageTooLargeError } from './chunk'
5
5
  import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './InMemorySyncStorage'
6
6
  import { TLObjectStoreAccess, TLSocketServerSentEvent } from './protocol'
7
7
  import { RoomSessionState } from './RoomSession'
@@ -477,6 +477,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
477
477
  })
478
478
  this.room.pruneSessions()
479
479
  this.scheduleDebouncedSnapshot(sessionId)
480
+ } else if (res.error instanceof MessageTooLargeError) {
481
+ this.log?.error?.('Error assembling message', res.error)
482
+ // Resetting the connection would have the client reconnect and re-send the same
483
+ // oversized message, so reject the session instead and let the client surface it.
484
+ this.room.rejectSession(sessionId, TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE)
480
485
  } else {
481
486
  this.log?.error?.('Error assembling message', res.error)
482
487
  // close the socket to reset the connection
@@ -909,7 +914,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
909
914
  * The client will attempt to reconnect automatically unless a fatal reason is provided.
910
915
  *
911
916
  * @param sessionId - Session identifier to remove
912
- * @param fatalReason - Optional fatal error reason that prevents reconnection
917
+ * @param fatalReason - Optional fatal error reason that prevents reconnection. WebSocket close
918
+ * reasons are capped at 123 UTF-8 bytes, so a longer reason is truncated and ends with `... (+N bytes)`.
913
919
  *
914
920
  * @example
915
921
  * ```ts
@@ -122,6 +122,8 @@ export const TLSyncErrorCloseEventReason = {
122
122
  RATE_LIMITED: 'RATE_LIMITED',
123
123
  /** Room has reached maximum capacity */
124
124
  ROOM_FULL: 'ROOM_FULL',
125
+ /** A single message exceeded the size the server will assemble */
126
+ MESSAGE_TOO_LARGE: 'MESSAGE_TOO_LARGE',
125
127
  } as const
126
128
 
127
129
  /**
@@ -88,6 +88,29 @@ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
88
88
 
89
89
  const timeSince = (time: number) => Date.now() - time
90
90
 
91
+ // WebSocket close frames cap the reason at 123 UTF-8 bytes; a longer one makes `close()` throw
92
+ const MAX_CLOSE_REASON_BYTES = 123
93
+ const closeReasonEncoder = new TextEncoder()
94
+
95
+ const truncatedSuffix = (droppedBytes: number) => `... (+${droppedBytes} bytes)`
96
+
97
+ function truncateCloseReason(reason: string) {
98
+ const totalBytes = closeReasonEncoder.encode(reason).length
99
+ if (totalBytes <= MAX_CLOSE_REASON_BYTES) return reason
100
+ // the dropped count can't have more digits than the total, so reserving room for it always fits
101
+ const budget = MAX_CLOSE_REASON_BYTES - truncatedSuffix(totalBytes).length
102
+ let out = ''
103
+ let outBytes = 0
104
+ // step by code point so a multi-byte character is never cut in half
105
+ for (const char of reason) {
106
+ const charBytes = closeReasonEncoder.encode(char).length
107
+ if (outBytes + charBytes > budget) break
108
+ out += char
109
+ outBytes += charBytes
110
+ }
111
+ return out + truncatedSuffix(totalBytes - outBytes)
112
+ }
113
+
91
114
  /**
92
115
  * Snapshot of a room's complete state that can be persisted and restored.
93
116
  * Contains all documents, tombstones, and metadata needed to reconstruct the room.
@@ -559,7 +582,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
559
582
 
560
583
  try {
561
584
  if (fatalReason) {
562
- session.socket.close(TLSyncErrorCloseEventCode, fatalReason)
585
+ // the session is already gone, so a throwing close() would leave its socket open
586
+ session.socket.close(TLSyncErrorCloseEventCode, truncateCloseReason(fatalReason))
563
587
  } else {
564
588
  session.socket.close()
565
589
  }
@@ -941,7 +965,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
941
965
  * Sends appropriate error messages before closing the connection.
942
966
  *
943
967
  * @param sessionId - The session to reject
944
- * @param fatalReason - The reason for rejection (optional)
968
+ * @param fatalReason - The reason for rejection (optional). WebSocket close reasons are capped
969
+ * at 123 UTF-8 bytes, so a longer reason is truncated and ends with `... (+N bytes)`.
945
970
  * @example
946
971
  * ```ts
947
972
  * // Reject due to version mismatch
@@ -1,5 +1,11 @@
1
1
  import { assert } from 'tldraw'
2
- import { JsonChunkAssembler, chunk } from './chunk'
2
+ import {
3
+ JsonChunkAssembler,
4
+ MAX_ASSEMBLED_MESSAGE_CHARS,
5
+ MAX_CHUNK_COUNT,
6
+ MessageTooLargeError,
7
+ chunk,
8
+ } from './chunk'
3
9
 
4
10
  describe('chunk (CH1–CH3)', () => {
5
11
  describe('size boundary (CH1)', () => {
@@ -403,4 +409,51 @@ describe('JsonChunkAssembler (CH4–CH8)', () => {
403
409
  })
404
410
  })
405
411
  })
412
+
413
+ describe('assembly bounds (CH10)', () => {
414
+ it('[CH10] rejects a first chunk declaring more than MAX_CHUNK_COUNT chunks', () => {
415
+ const unchunker = new JsonChunkAssembler()
416
+
417
+ const result = unchunker.handleMessage(`${MAX_CHUNK_COUNT}_{"a":`)
418
+ expect(result).toMatchObject({
419
+ error: expect.objectContaining({ message: `Too many chunks: ${MAX_CHUNK_COUNT + 1}` }),
420
+ })
421
+ expect(unchunker.state).toBe('idle')
422
+ })
423
+
424
+ it('[CH10] accepts a declared count at the limit', () => {
425
+ const unchunker = new JsonChunkAssembler()
426
+
427
+ expect(unchunker.handleMessage(`${MAX_CHUNK_COUNT - 1}_{"a":`)).toBeNull()
428
+ expect(unchunker.state).not.toBe('idle')
429
+ })
430
+
431
+ it('[CH10] rejects an assembly once its accumulated bodies pass MAX_ASSEMBLED_MESSAGE_CHARS', () => {
432
+ const unchunker = new JsonChunkAssembler()
433
+ // A chunk stream that never sends its final chunk. Before CH10 this accumulated
434
+ // without limit for as long as the sender kept sending.
435
+ const body = 'x'.repeat(1024 * 1024)
436
+ const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
437
+
438
+ let result = null
439
+ for (let i = 0; i < chunksNeeded; i++) {
440
+ result = unchunker.handleMessage(`${chunksNeeded - i}_${body}`)
441
+ if (result) break
442
+ }
443
+
444
+ expect(result).toMatchObject({ error: expect.any(MessageTooLargeError) })
445
+ expect(unchunker.state).toBe('idle')
446
+ })
447
+
448
+ it('[CH10] leaves the assembler usable after rejecting an oversized assembly', () => {
449
+ const unchunker = new JsonChunkAssembler()
450
+ const body = 'x'.repeat(1024 * 1024)
451
+ const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
452
+ for (let i = 0; i < chunksNeeded; i++) {
453
+ if (unchunker.handleMessage(`${chunksNeeded - i}_${body}`)) break
454
+ }
455
+
456
+ expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
457
+ })
458
+ })
406
459
  })
package/src/lib/chunk.ts CHANGED
@@ -8,6 +8,35 @@ const MAX_BYTES_PER_CHAR = 4
8
8
  // in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
9
9
  const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
10
10
 
11
+ // JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender
12
+ // decides how many that is — the count is just a prefix on the first chunk. Nothing in the
13
+ // protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection
14
+ // can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.
15
+ //
16
+ // The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the
17
+ // 32MB of retained string data a room can afford to hold per session and stay inside a Durable
18
+ // Object's 128MB instance limit with room for the document itself. It is far above anything
19
+ // chunk() produces for a real document push. The character cap is the real bound; the chunk-count
20
+ // cap only rejects an absurd declared count on arrival instead of waiting for the characters to
21
+ // accumulate, and is loose enough not to constrain a sender that chunks more finely than the
22
+ // default.
23
+ export const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024
24
+ export const MAX_CHUNK_COUNT = 100_000
25
+
26
+ /**
27
+ * Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.
28
+ * Distinct from the other assembly errors because it is fatal: retrying sends the same
29
+ * oversized message, so the session is rejected rather than reset.
30
+ *
31
+ * @internal
32
+ */
33
+ export class MessageTooLargeError extends Error {
34
+ constructor(chars: number) {
35
+ super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)
36
+ this.name = 'MessageTooLargeError'
37
+ }
38
+ }
39
+
11
40
  /**
12
41
  * Splits a string into smaller chunks suitable for transmission over WebSockets.
13
42
  * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
@@ -100,6 +129,7 @@ export class JsonChunkAssembler {
100
129
  | {
101
130
  chunksReceived: string[]
102
131
  totalChunks: number
132
+ charsReceived: number
103
133
  } = 'idle'
104
134
 
105
135
  /**
@@ -110,7 +140,8 @@ export class JsonChunkAssembler {
110
140
  * @param msg - The message to process, either JSON or chunk format
111
141
  * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
112
142
  * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
113
- * - `\{ error: Error \}` - Parse error or invalid chunk sequence
143
+ * - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
144
+ * MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
114
145
  * - `null` - Chunk received but more chunks expected
115
146
  *
116
147
  * @example
@@ -144,17 +175,30 @@ export class JsonChunkAssembler {
144
175
  const data = match[2]
145
176
 
146
177
  if (this.state === 'idle') {
178
+ const totalChunks = numChunksRemaining + 1
179
+ if (totalChunks > MAX_CHUNK_COUNT) {
180
+ // Nothing has been buffered yet, so the state is already idle.
181
+ return { error: new Error(`Too many chunks: ${totalChunks}`) }
182
+ }
147
183
  this.state = {
148
184
  chunksReceived: [data],
149
- totalChunks: numChunksRemaining + 1,
185
+ totalChunks,
186
+ charsReceived: data.length,
150
187
  }
151
188
  } else {
152
189
  this.state.chunksReceived.push(data)
190
+ this.state.charsReceived += data.length
153
191
  if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
154
192
  this.state = 'idle'
155
193
  return { error: new Error(`Chunks received in wrong order`) }
156
194
  }
157
195
  }
196
+ if (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {
197
+ // Drop what was buffered rather than keeping it until the socket closes.
198
+ const charsReceived = this.state.charsReceived
199
+ this.state = 'idle'
200
+ return { error: new MessageTooLargeError(charsReceived) }
201
+ }
158
202
  if (this.state.chunksReceived.length === this.state.totalChunks) {
159
203
  try {
160
204
  const stringified = this.state.chunksReceived.join('')
@@ -21,6 +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
25
  import { RecordOpType } from '../lib/diff'
25
26
  import { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from '../lib/InMemorySyncStorage'
26
27
  import { getTlsyncProtocolVersion } from '../lib/protocol'
@@ -392,6 +393,29 @@ describe('28. TLSocketRoom (SR)', () => {
392
393
  expect(room.getSessions()[0].isConnected).toBe(false)
393
394
  })
394
395
 
396
+ it('[SR5] rejects the session when an assembly exceeds the size cap', () => {
397
+ const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
398
+ const room = new TLSocketRoom({ log })
399
+ const socket = createMockSocket()
400
+ connectSession(room, 'test-session', socket)
401
+
402
+ // A chunk stream that never sends its last chunk. Unlike the other assembly errors
403
+ // this one is fatal: a reconnecting client would re-send the same oversized message.
404
+ const body = 'x'.repeat(1024 * 1024)
405
+ const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
406
+ for (let i = 0; i < chunksNeeded; i++) {
407
+ room.handleSocketMessage('test-session', `${chunksNeeded - i}_${body}`)
408
+ if ((log.error as any).mock.calls.length) break
409
+ }
410
+
411
+ expect(log.error).toHaveBeenCalledWith('Error assembling message', expect.anything())
412
+ expect(socket.close).toHaveBeenCalledWith(
413
+ TLSyncErrorCloseEventCode,
414
+ TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
415
+ )
416
+ expect(room.getSessions()).toHaveLength(0)
417
+ })
418
+
395
419
  it('[SR5] rejects the session with UNKNOWN_ERROR when message handling throws', () => {
396
420
  const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
397
421
  const room = new TLSocketRoom({ log })
@@ -2340,6 +2340,31 @@ describe('26. Session lifecycle (SES)', () => {
2340
2340
  expect(room.sessions.size).toBe(0)
2341
2341
  })
2342
2342
 
2343
+ it('[SES4] a reason longer than 123 UTF-8 bytes is truncated on a code-point boundary with a dropped-bytes suffix', () => {
2344
+ const { room } = makeRoom()
2345
+
2346
+ const exact = connectSession(room, 'exact')
2347
+ room.rejectSession('exact', 'x'.repeat(123))
2348
+ expect(exact.close).toHaveBeenCalledWith(TLSyncErrorCloseEventCode, 'x'.repeat(123))
2349
+
2350
+ // '... (+200 bytes)' reserves 16 bytes, leaving 107 for the reason
2351
+ const ascii = connectSession(room, 'ascii')
2352
+ room.rejectSession('ascii', 'x'.repeat(200))
2353
+ expect(ascii.close).toHaveBeenCalledWith(
2354
+ TLSyncErrorCloseEventCode,
2355
+ 'x'.repeat(107) + '... (+93 bytes)'
2356
+ )
2357
+
2358
+ // 161 bytes in, 107 to fill: 1 + 26 × 4 = 105, and a 27th 4-byte character is dropped whole
2359
+ const emoji = connectSession(room, 'emoji')
2360
+ room.rejectSession('emoji', 'x' + '\u{1F600}'.repeat(40))
2361
+ expect(emoji.close).toHaveBeenCalledWith(
2362
+ TLSyncErrorCloseEventCode,
2363
+ 'x' + '\u{1F600}'.repeat(26) + '... (+56 bytes)'
2364
+ )
2365
+ expect(room.sessions.size).toBe(0)
2366
+ })
2367
+
2343
2368
  it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2344
2369
  const { room } = makeRoom()
2345
2370
  connectSession(room, 'v7-session', { protocolVersion: 7 })