@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.
- package/dist-cjs/index.d.ts +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +35 -13
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +9 -8
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +3 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +50 -5
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +35 -3
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-esm/index.d.mts +7 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +36 -14
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +10 -9
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +3 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +50 -5
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +35 -3
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/package.json +7 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +190 -16
- package/src/lib/ClientWebSocketAdapter.ts +45 -16
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +20 -22
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +22 -14
- package/src/lib/TLSocketRoom.ts +16 -9
- package/src/lib/TLSyncClient.ts +2 -0
- package/src/lib/TLSyncRoom.ts +66 -5
- package/src/lib/chunk.test.ts +88 -1
- package/src/lib/chunk.ts +66 -3
- package/src/test/TLSocketRoom.test.ts +63 -0
- package/src/test/TLSyncRoom.test.ts +130 -0
- package/src/test/objectStore.test.ts +35 -0
package/src/lib/TLSocketRoom.ts
CHANGED
|
@@ -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:
|
|
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
|
package/src/lib/TLSyncClient.ts
CHANGED
|
@@ -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
|
/**
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/lib/chunk.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { assert } from 'tldraw'
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 })
|