@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.
- package/dist-cjs/index.d.ts +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +23 -7
- 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/TLSocketRoom.js +5 -1
- 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 +22 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +25 -2
- 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 +24 -8
- 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/TLSocketRoom.mjs +6 -2
- 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 +22 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +25 -2
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/package.json +11 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +104 -19
- package/src/lib/ClientWebSocketAdapter.ts +33 -10
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +20 -22
- package/src/lib/TLSocketRoom.ts +8 -2
- package/src/lib/TLSyncClient.ts +2 -0
- package/src/lib/TLSyncRoom.ts +32 -3
- package/src/lib/chunk.test.ts +54 -1
- package/src/lib/chunk.ts +46 -2
- package/src/test/TLSocketRoom.test.ts +24 -0
- 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
|
|
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
|
|
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 })
|