@tldraw/sync-core 5.5.0-next.a2698a88ecfb → 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 (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 +35 -13
  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 +50 -5
  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 +36 -14
  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 +50 -5
  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 +7 -7
  34. package/src/lib/ClientWebSocketAdapter.test.ts +190 -16
  35. package/src/lib/ClientWebSocketAdapter.ts +45 -16
  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 +66 -5
  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 +130 -0
  47. package/src/test/objectStore.test.ts +35 -0
@@ -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'
@@ -273,13 +273,16 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
273
273
  })
274
274
  )
275
275
  }
276
+ // The default logger belongs to both layers: TLSyncRoom is where authorizer throws are
277
+ // logged, and a host that passes no `log` would otherwise never see them.
278
+ this.log = 'log' in opts ? opts.log : { error: console.error }
276
279
  this.room = new TLSyncRoom<R, SessionMeta>({
277
280
  onPresenceChange: opts.onPresenceChange,
278
281
  onCommittedChanges: opts.onCommittedChanges,
279
282
  objectTypes: opts.objectTypes,
280
283
  authorizeRecord: opts.authorizeRecord,
281
284
  schema: opts.schema ?? (createTLSchema() as any),
282
- log: opts.log,
285
+ log: this.log,
283
286
  storage,
284
287
  clientTimeout: opts.clientTimeout,
285
288
  })
@@ -295,7 +298,6 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
295
298
  })
296
299
  }
297
300
  })
298
- this.log = 'log' in opts ? opts.log : { error: console.error }
299
301
  }
300
302
 
301
303
  /**
@@ -475,6 +477,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
475
477
  })
476
478
  this.room.pruneSessions()
477
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)
478
485
  } else {
479
486
  this.log?.error?.('Error assembling message', res.error)
480
487
  // close the socket to reset the connection
@@ -854,12 +861,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
854
861
  if (this.isClosed()) {
855
862
  throw new Error('Cannot update store on a closed room')
856
863
  }
864
+ // read through the transaction rather than getSnapshot(): the document snapshot excludes the
865
+ // object-store lane, which would make object-lane records invisible (and undeletable) here
866
+ const records = this.storage.transaction((txn) => Object.fromEntries(txn.entries())).result
857
867
  // eslint-disable-next-line @typescript-eslint/no-deprecated
858
- const ctx = new StoreUpdateContext<R>(
859
- // eslint-disable-next-line @typescript-eslint/no-deprecated
860
- Object.fromEntries(this.getCurrentSnapshot().documents.map((d) => [d.state.id, d.state])),
861
- this.room.schema
862
- )
868
+ const ctx = new StoreUpdateContext<R>(records, this.room.schema)
863
869
  try {
864
870
  await updater(ctx)
865
871
  } finally {
@@ -908,7 +914,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
908
914
  * The client will attempt to reconnect automatically unless a fatal reason is provided.
909
915
  *
910
916
  * @param sessionId - Session identifier to remove
911
- * @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)`.
912
919
  *
913
920
  * @example
914
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.
@@ -258,7 +281,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
258
281
  }, 1000)
259
282
 
260
283
  private scheduleFollowUpPrune() {
261
- if (this.pruneTimer) return
284
+ // don't leave a stray timer on a room the host has already torn down
285
+ if (this._isClosed || this.pruneTimer) return
262
286
  this.pruneTimer = setTimeout(this.pruneSessions, SESSION_REMOVAL_WAIT_TIME + 100)
263
287
  }
264
288
 
@@ -273,11 +297,27 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
273
297
  * and stops background processes.
274
298
  */
275
299
  close() {
300
+ this._isClosed = true
276
301
  this.disposables.forEach((d) => d())
277
302
  this.sessions.forEach((session) => {
278
- session.socket.close()
303
+ this.clearDebounceTimer(session)
304
+ try {
305
+ session.socket.close()
306
+ } catch {
307
+ // noop, one bad socket must not leave the rest open
308
+ }
279
309
  })
280
- this._isClosed = true
310
+ // forgetting the sessions is what makes late socket close/error events no-ops, so
311
+ // nothing can emit session_removed / room_became_empty on a closed room
312
+ this.sessions.clear()
313
+ }
314
+
315
+ private clearDebounceTimer(session: RoomSession<R, SessionMeta>) {
316
+ if (session.state === RoomSessionState.Connected && session.debounceTimer !== null) {
317
+ clearTimeout(session.debounceTimer)
318
+ session.debounceTimer = null
319
+ session.outstandingDataMessages = []
320
+ }
281
321
  }
282
322
 
283
323
  /**
@@ -519,6 +559,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
519
559
  // place, so sockets that defer serialization don't see an emptied array
520
560
  const data = session.outstandingDataMessages
521
561
  session.outstandingDataMessages = []
562
+ if (!session.socket.isOpen) {
563
+ // same as the immediate-send path: send() into a closed socket throws on
564
+ // some runtimes (Cloudflare), and here that would be from inside a timer
565
+ this.cancelSession(sessionId)
566
+ return
567
+ }
522
568
  session.socket.sendMessage({ type: 'data', data })
523
569
  }
524
570
  }
@@ -532,10 +578,12 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
532
578
  }
533
579
 
534
580
  this.sessions.delete(sessionId)
581
+ this.clearDebounceTimer(session)
535
582
 
536
583
  try {
537
584
  if (fatalReason) {
538
- 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))
539
587
  } else {
540
588
  session.socket.close()
541
589
  }
@@ -570,6 +618,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
570
618
  return
571
619
  }
572
620
 
621
+ this.clearDebounceTimer(session)
573
622
  this.sessions.set(sessionId, {
574
623
  state: RoomSessionState.AwaitingRemoval,
575
624
  sessionId,
@@ -692,7 +741,13 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
692
741
  objectAccess?: TLObjectStoreAccess
693
742
  }) {
694
743
  const { sessionId, socket, meta, isReadonly, objectAccess } = opts
744
+ // a connect racing close() would otherwise create a session nothing ever prunes
745
+ if (this._isClosed) {
746
+ socket.close()
747
+ return this
748
+ }
695
749
  const existing = this.sessions.get(sessionId)
750
+ if (existing) this.clearDebounceTimer(existing)
696
751
  this.sessions.set(sessionId, {
697
752
  state: RoomSessionState.AwaitingConnectMessage,
698
753
  sessionId,
@@ -741,6 +796,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
741
796
  supportsStringAppend,
742
797
  } = opts
743
798
 
799
+ if (this._isClosed) {
800
+ socket.close()
801
+ return
802
+ }
803
+
744
804
  const migrations = this.schema.getMigrationsSince(serializedSchema)
745
805
  const requiresDownMigrations = migrations.ok ? migrations.value.length > 0 : false
746
806
 
@@ -905,7 +965,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
905
965
  * Sends appropriate error messages before closing the connection.
906
966
  *
907
967
  * @param sessionId - The session to reject
908
- * @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)`.
909
970
  * @example
910
971
  * ```ts
911
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)', () => {
@@ -134,6 +140,40 @@ describe('chunk (CH1–CH3)', () => {
134
140
  `)
135
141
  })
136
142
  })
143
+
144
+ describe('surrogate pairs (CH9)', () => {
145
+ it('[CH9] never splits a surrogate pair across two chunks', () => {
146
+ // WebSocket.send converts each chunk to a USVString, so a lone surrogate would arrive
147
+ // as U+FFFD and the reassembled message would carry corrupted text
148
+ const emoji = '\u{1F600}'
149
+ const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff
150
+ const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff
151
+ const msgs = [
152
+ ...Array.from(
153
+ { length: 8 },
154
+ (_, padding) => 'x'.repeat(padding) + emoji + 'y'.repeat(6) + emoji
155
+ ),
156
+ // adjacent pairs: after a nudge the next boundary lands on another pair
157
+ 'x' + emoji.repeat(3),
158
+ ]
159
+ for (const msg of msgs) {
160
+ for (const maxSize of [3, 4, 5, 6, 7, 8]) {
161
+ const chunks = chunk(msg, maxSize)
162
+ const bodies = chunks.map((c) => c.slice(c.indexOf('_') + 1))
163
+ expect(bodies.join('')).toBe(msg)
164
+ for (const [i, body] of bodies.entries()) {
165
+ expect(body.length).toBeGreaterThan(0)
166
+ expect(isHigh(body.charCodeAt(body.length - 1))).toBe(false)
167
+ expect(isLow(body.charCodeAt(0))).toBe(false)
168
+ // keeping a pair whole may cost one character over the CH3 bound, never more
169
+ expect(chunks[i].length).toBeLessThanOrEqual(
170
+ Math.max(maxSize, chunks[i].indexOf('_') + 2) + 1
171
+ )
172
+ }
173
+ }
174
+ }
175
+ })
176
+ })
137
177
  })
138
178
 
139
179
  const testObject = {} as any
@@ -369,4 +409,51 @@ describe('JsonChunkAssembler (CH4–CH8)', () => {
369
409
  })
370
410
  })
371
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
+ })
372
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).
@@ -38,7 +67,18 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
38
67
  let offset = msg.length
39
68
  while (offset > 0) {
40
69
  const prefix = `${chunkNumber}_`
41
- const chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
70
+ let chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
71
+ // Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a
72
+ // USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently
73
+ // carries corrupted text. Shrink by one so the pair moves whole into the preceding
74
+ // chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).
75
+ if (
76
+ chunkSize < offset &&
77
+ isLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&
78
+ isHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))
79
+ ) {
80
+ chunkSize += chunkSize > 1 ? -1 : 1
81
+ }
42
82
  chunks.unshift(prefix + msg.slice(offset - chunkSize, offset))
43
83
  offset -= chunkSize
44
84
  chunkNumber++
@@ -47,6 +87,14 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
47
87
  }
48
88
  }
49
89
 
90
+ function isHighSurrogate(code: number) {
91
+ return code >= 0xd800 && code <= 0xdbff
92
+ }
93
+
94
+ function isLowSurrogate(code: number) {
95
+ return code >= 0xdc00 && code <= 0xdfff
96
+ }
97
+
50
98
  // The 's' flag (dotAll) makes '.' match any character including line terminators
51
99
  // like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
52
100
  const chunkRe = /^(\d+)_(.*)$/s
@@ -81,6 +129,7 @@ export class JsonChunkAssembler {
81
129
  | {
82
130
  chunksReceived: string[]
83
131
  totalChunks: number
132
+ charsReceived: number
84
133
  } = 'idle'
85
134
 
86
135
  /**
@@ -91,7 +140,8 @@ export class JsonChunkAssembler {
91
140
  * @param msg - The message to process, either JSON or chunk format
92
141
  * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
93
142
  * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
94
- * - `\{ 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
95
145
  * - `null` - Chunk received but more chunks expected
96
146
  *
97
147
  * @example
@@ -125,17 +175,30 @@ export class JsonChunkAssembler {
125
175
  const data = match[2]
126
176
 
127
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
+ }
128
183
  this.state = {
129
184
  chunksReceived: [data],
130
- totalChunks: numChunksRemaining + 1,
185
+ totalChunks,
186
+ charsReceived: data.length,
131
187
  }
132
188
  } else {
133
189
  this.state.chunksReceived.push(data)
190
+ this.state.charsReceived += data.length
134
191
  if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
135
192
  this.state = 'idle'
136
193
  return { error: new Error(`Chunks received in wrong order`) }
137
194
  }
138
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
+ }
139
202
  if (this.state.chunksReceived.length === this.state.totalChunks) {
140
203
  try {
141
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'
@@ -210,6 +211,45 @@ describe('28. TLSocketRoom (SR)', () => {
210
211
  consoleSpy.mockRestore()
211
212
  })
212
213
 
214
+ it('[SR3] the default logger also reaches TLSyncRoom, so authorizer failures are reported', () => {
215
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
216
+ try {
217
+ const room = new TLSocketRoom({
218
+ authorizeRecord: {
219
+ page: () => {
220
+ throw new Error('authorizer exploded')
221
+ },
222
+ },
223
+ })
224
+ const socket = createMockSocket()
225
+ connectSession(room, 'test-session', socket)
226
+
227
+ const pageId = PageRecordType.createId('new-page')
228
+ room.handleSocketMessage(
229
+ 'test-session',
230
+ JSON.stringify({
231
+ type: 'push',
232
+ clientClock: 1,
233
+ diff: {
234
+ [pageId]: [
235
+ RecordOpType.Put,
236
+ PageRecordType.create({ id: pageId, name: 'New Page', index: 'a2' as any }),
237
+ ],
238
+ },
239
+ })
240
+ )
241
+
242
+ // the authorizer threw, and the host heard about it without passing a logger
243
+ expect(room.getRecord(pageId)).toBeUndefined()
244
+ expect(consoleSpy).toHaveBeenCalledWith(
245
+ 'record authorizer threw; rejecting the write',
246
+ expect.objectContaining({ message: 'authorizer exploded' })
247
+ )
248
+ } finally {
249
+ consoleSpy.mockRestore()
250
+ }
251
+ })
252
+
213
253
  it('[SR3] uses custom logger when provided', () => {
214
254
  const mockLog: TLSyncLog = {
215
255
  warn: vi.fn(),
@@ -353,6 +393,29 @@ describe('28. TLSocketRoom (SR)', () => {
353
393
  expect(room.getSessions()[0].isConnected).toBe(false)
354
394
  })
355
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
+
356
419
  it('[SR5] rejects the session with UNKNOWN_ERROR when message handling throws', () => {
357
420
  const log: TLSyncLog = { warn: vi.fn(), error: vi.fn() }
358
421
  const room = new TLSocketRoom({ log })
@@ -1949,6 +1949,111 @@ describe('25. Messaging and broadcast (RB)', () => {
1949
1949
  expect(socketB.sendMessage).not.toHaveBeenCalled()
1950
1950
  })
1951
1951
 
1952
+ it('[RB3] a debounced flush into a socket that closed meanwhile cancels the session instead of sending', () => {
1953
+ vi.useFakeTimers()
1954
+ const { room, socketB } = setupTwoSessions()
1955
+ const newPage = makePage('page_3', 'v1')
1956
+
1957
+ room.handleMessage('a', {
1958
+ type: 'push',
1959
+ clientClock: 1,
1960
+ diff: { [newPage.id]: ['put', newPage] },
1961
+ } as TLPushRequest<TLRecord>)
1962
+ room.handleMessage('a', {
1963
+ type: 'push',
1964
+ clientClock: 2,
1965
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1966
+ } as TLPushRequest<TLRecord>)
1967
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1968
+
1969
+ // the socket closes while the second message sits in the debounce buffer
1970
+ socketB.isOpen = false
1971
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1972
+
1973
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1974
+ expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
1975
+ })
1976
+
1977
+ it('[RC7] close() drops pending debounced flushes so nothing is sent into closed sockets afterwards', () => {
1978
+ vi.useFakeTimers()
1979
+ const { room, socketB } = setupTwoSessions()
1980
+ const newPage = makePage('page_3', 'v1')
1981
+
1982
+ room.handleMessage('a', {
1983
+ type: 'push',
1984
+ clientClock: 1,
1985
+ diff: { [newPage.id]: ['put', newPage] },
1986
+ } as TLPushRequest<TLRecord>)
1987
+ room.handleMessage('a', {
1988
+ type: 'push',
1989
+ clientClock: 2,
1990
+ diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
1991
+ } as TLPushRequest<TLRecord>)
1992
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1993
+
1994
+ room.close()
1995
+ expect(room.isClosed()).toBe(true)
1996
+ vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
1997
+
1998
+ expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
1999
+ expect(socketB.close).toHaveBeenCalled()
2000
+ })
2001
+
2002
+ it('[RC7] close() closes every socket even when one of them throws on close', () => {
2003
+ const { room, socketA, socketB } = setupTwoSessions()
2004
+ socketA.close.mockImplementationOnce(() => {
2005
+ throw new Error('already closing')
2006
+ })
2007
+
2008
+ expect(() => room.close()).not.toThrow()
2009
+ expect(room.isClosed()).toBe(true)
2010
+ expect(socketB.close).toHaveBeenCalled()
2011
+ })
2012
+
2013
+ it('[RC7] close() forgets its sessions, and late socket close events emit no lifecycle events', () => {
2014
+ vi.useFakeTimers()
2015
+ const { room, socketA } = setupTwoSessions()
2016
+ const removed = vi.fn()
2017
+ const becameEmpty = vi.fn()
2018
+ room.events.on('session_removed', removed)
2019
+ room.events.on('room_became_empty', becameEmpty)
2020
+
2021
+ // b's socket closes before close(), a's closes after: both must stay silent
2022
+ room.handleClose('b')
2023
+ room.close()
2024
+ expect(room.sessions.size).toBe(0)
2025
+ room.handleClose('a')
2026
+
2027
+ vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
2028
+ expect(removed).not.toHaveBeenCalled()
2029
+ expect(becameEmpty).not.toHaveBeenCalled()
2030
+ expect(socketA.sendMessage).not.toHaveBeenCalled()
2031
+ })
2032
+
2033
+ it('[RC7] sessions added after close() are rejected and their sockets closed', () => {
2034
+ const { room } = makeRoom()
2035
+ room.close()
2036
+
2037
+ const late = makeSocket()
2038
+ room.handleNewSession({ sessionId: 'late', socket: late, meta: undefined, isReadonly: false })
2039
+ expect(late.close).toHaveBeenCalled()
2040
+
2041
+ const resumed = makeSocket()
2042
+ room.handleResumedSession({
2043
+ sessionId: 'resumed',
2044
+ socket: resumed,
2045
+ meta: undefined,
2046
+ isReadonly: false,
2047
+ serializedSchema: room.serializedSchema,
2048
+ presenceId: null,
2049
+ presenceRecord: null,
2050
+ requiresLegacyRejection: false,
2051
+ supportsStringAppend: true,
2052
+ })
2053
+ expect(resumed.close).toHaveBeenCalled()
2054
+ expect(room.sessions.size).toBe(0)
2055
+ })
2056
+
1952
2057
  it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
1953
2058
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1954
2059
  try {
@@ -2235,6 +2340,31 @@ describe('26. Session lifecycle (SES)', () => {
2235
2340
  expect(room.sessions.size).toBe(0)
2236
2341
  })
2237
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
+
2238
2368
  it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
2239
2369
  const { room } = makeRoom()
2240
2370
  connectSession(room, 'v7-session', { protocolVersion: 7 })