@tldraw/sync-core 5.5.0-next.130e77150557 → 5.5.0-next.14c42da77533

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 +23 -7
  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 +22 -3
  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 +24 -8
  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 +22 -3
  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 +11 -7
  30. package/src/lib/ClientWebSocketAdapter.test.ts +104 -19
  31. package/src/lib/ClientWebSocketAdapter.ts +33 -10
  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 +32 -3
  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 +71 -0
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 })
@@ -1019,6 +1019,52 @@ describe('23. Connect handshake (HS)', () => {
1019
1019
  expect(room.sessions.get('current-client-session')?.state).toBe(RoomSessionState.Connected)
1020
1020
  expect(socket.__lastMessage?.type).toBe('connect')
1021
1021
  })
1022
+
1023
+ it('[RC5][HS6] a wipeAll during a handshake closes connected sessions but hydrates the connecting one', async () => {
1024
+ const { room, storage } = makeRoom()
1025
+ const socketA = connectSession(room, 'a')
1026
+ const removed = vi.fn()
1027
+ room.events.on('session_removed', removed)
1028
+
1029
+ // 'b' has opened its socket but not yet sent its connect message
1030
+ const socketB = makeSocket()
1031
+ room.handleNewSession({ sessionId: 'b', socket: socketB, meta: undefined, isReadonly: false })
1032
+
1033
+ // the room hasn't seen this change yet because the storage notifies on a microtask
1034
+ const newPage = makePage('wipe_page', 'Wipe Page')
1035
+ storage.transaction((txn) => {
1036
+ txn.set(newPage.id, newPage)
1037
+ })
1038
+ storage.tombstoneHistoryStartsAtClock.set(storage.getClock())
1039
+
1040
+ // the handshake's own transaction runs broadcastChanges first and hits the wipeAll
1041
+ room.handleMessage('b', {
1042
+ type: 'connect',
1043
+ connectRequestId: 'connect-b',
1044
+ lastServerClock: 0,
1045
+ protocolVersion: getTlsyncProtocolVersion(),
1046
+ schema: room.serializedSchema,
1047
+ } satisfies TLConnectRequest)
1048
+
1049
+ expect(socketA.close).toHaveBeenCalled()
1050
+ expect(room.sessions.has('a')).toBe(false)
1051
+
1052
+ expect(socketB.close).not.toHaveBeenCalled()
1053
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.Connected)
1054
+ expect(socketB.__messages).toHaveLength(1)
1055
+ const connectMessage = socketB.__lastMessage as Extract<
1056
+ TLSocketServerSentEvent<any>,
1057
+ { type: 'connect' }
1058
+ >
1059
+ expect(connectMessage.type).toBe('connect')
1060
+ expect(connectMessage.hydrationType).toBe('wipe_all')
1061
+ expect(connectMessage.diff[newPage.id]).toEqual(['put', newPage])
1062
+
1063
+ await Promise.resolve()
1064
+ await Promise.resolve()
1065
+ expect(removed.mock.calls.map(([e]) => e.sessionId)).toEqual(['a'])
1066
+ expect(room.sessions.has('b')).toBe(true)
1067
+ })
1022
1068
  })
1023
1069
 
1024
1070
  describe('24. Push handling (RP)', () => {
@@ -2340,6 +2386,31 @@ describe('26. Session lifecycle (SES)', () => {
2340
2386
  expect(room.sessions.size).toBe(0)
2341
2387
  })
2342
2388
 
2389
+ it('[SES4] a reason longer than 123 UTF-8 bytes is truncated on a code-point boundary with a dropped-bytes suffix', () => {
2390
+ const { room } = makeRoom()
2391
+
2392
+ const exact = connectSession(room, 'exact')
2393
+ room.rejectSession('exact', 'x'.repeat(123))
2394
+ expect(exact.close).toHaveBeenCalledWith(TLSyncErrorCloseEventCode, 'x'.repeat(123))
2395
+
2396
+ // '... (+200 bytes)' reserves 16 bytes, leaving 107 for the reason
2397
+ const ascii = connectSession(room, 'ascii')
2398
+ room.rejectSession('ascii', 'x'.repeat(200))
2399
+ expect(ascii.close).toHaveBeenCalledWith(
2400
+ TLSyncErrorCloseEventCode,
2401
+ 'x'.repeat(107) + '... (+93 bytes)'
2402
+ )
2403
+
2404
+ // 161 bytes in, 107 to fill: 1 + 26 × 4 = 105, and a 27th 4-byte character is dropped whole
2405
+ const emoji = connectSession(room, 'emoji')
2406
+ room.rejectSession('emoji', 'x' + '\u{1F600}'.repeat(40))
2407
+ expect(emoji.close).toHaveBeenCalledWith(
2408
+ TLSyncErrorCloseEventCode,
2409
+ 'x' + '\u{1F600}'.repeat(26) + '... (+56 bytes)'
2410
+ )
2411
+ expect(room.sessions.size).toBe(0)
2412
+ })
2413
+
2343
2414
  it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2344
2415
  const { room } = makeRoom()
2345
2416
  connectSession(room, 'v7-session', { protocolVersion: 7 })