@tldraw/sync-core 5.5.0-canary.d0d1c4dfcd91 → 5.5.0-canary.d112bdaa69ee
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 +46 -18
- 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 +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 +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 +47 -19
- 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 +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 +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 +221 -16
- package/src/lib/ClientWebSocketAdapter.ts +62 -23
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +20 -22
- package/src/lib/TLSocketRoom.ts +16 -9
- 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 +63 -0
- package/src/test/TLSyncRoom.test.ts +71 -0
- package/src/test/objectStore.test.ts +35 -0
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.
|
|
@@ -559,7 +582,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
559
582
|
|
|
560
583
|
try {
|
|
561
584
|
if (fatalReason) {
|
|
562
|
-
session
|
|
585
|
+
// the session is already gone, so a throwing close() would leave its socket open
|
|
586
|
+
session.socket.close(TLSyncErrorCloseEventCode, truncateCloseReason(fatalReason))
|
|
563
587
|
} else {
|
|
564
588
|
session.socket.close()
|
|
565
589
|
}
|
|
@@ -941,7 +965,8 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
941
965
|
* Sends appropriate error messages before closing the connection.
|
|
942
966
|
*
|
|
943
967
|
* @param sessionId - The session to reject
|
|
944
|
-
* @param fatalReason - The reason for rejection (optional)
|
|
968
|
+
* @param fatalReason - The reason for rejection (optional). WebSocket close reasons are capped
|
|
969
|
+
* at 123 UTF-8 bytes, so a longer reason is truncated and ends with `... (+N bytes)`.
|
|
945
970
|
* @example
|
|
946
971
|
* ```ts
|
|
947
972
|
* // Reject due to version mismatch
|
|
@@ -998,6 +1023,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
998
1023
|
|
|
999
1024
|
private forceAllReconnect() {
|
|
1000
1025
|
for (const session of this.sessions.values()) {
|
|
1026
|
+
// Only connected clients hold state at a clock we can no longer diff from. A session
|
|
1027
|
+
// mid-handshake gets hydrated from its own lastServerClock in the same transaction, so
|
|
1028
|
+
// removing it would close its socket and then re-add it as Connected (resurrected).
|
|
1029
|
+
if (session.state !== RoomSessionState.Connected) continue
|
|
1001
1030
|
this.removeSession(session.sessionId)
|
|
1002
1031
|
}
|
|
1003
1032
|
}
|
|
@@ -1091,7 +1120,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1091
1120
|
|
|
1092
1121
|
const requiresDownMigrations = migrations.value.length > 0
|
|
1093
1122
|
|
|
1094
|
-
const connect =
|
|
1123
|
+
const connect = (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => {
|
|
1095
1124
|
this.sessions.set(session.sessionId, {
|
|
1096
1125
|
state: RoomSessionState.Connected,
|
|
1097
1126
|
sessionId: session.sessionId,
|
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)', () => {
|
|
@@ -403,4 +409,51 @@ describe('JsonChunkAssembler (CH4–CH8)', () => {
|
|
|
403
409
|
})
|
|
404
410
|
})
|
|
405
411
|
})
|
|
412
|
+
|
|
413
|
+
describe('assembly bounds (CH10)', () => {
|
|
414
|
+
it('[CH10] rejects a first chunk declaring more than MAX_CHUNK_COUNT chunks', () => {
|
|
415
|
+
const unchunker = new JsonChunkAssembler()
|
|
416
|
+
|
|
417
|
+
const result = unchunker.handleMessage(`${MAX_CHUNK_COUNT}_{"a":`)
|
|
418
|
+
expect(result).toMatchObject({
|
|
419
|
+
error: expect.objectContaining({ message: `Too many chunks: ${MAX_CHUNK_COUNT + 1}` }),
|
|
420
|
+
})
|
|
421
|
+
expect(unchunker.state).toBe('idle')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('[CH10] accepts a declared count at the limit', () => {
|
|
425
|
+
const unchunker = new JsonChunkAssembler()
|
|
426
|
+
|
|
427
|
+
expect(unchunker.handleMessage(`${MAX_CHUNK_COUNT - 1}_{"a":`)).toBeNull()
|
|
428
|
+
expect(unchunker.state).not.toBe('idle')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('[CH10] rejects an assembly once its accumulated bodies pass MAX_ASSEMBLED_MESSAGE_CHARS', () => {
|
|
432
|
+
const unchunker = new JsonChunkAssembler()
|
|
433
|
+
// A chunk stream that never sends its final chunk. Before CH10 this accumulated
|
|
434
|
+
// without limit for as long as the sender kept sending.
|
|
435
|
+
const body = 'x'.repeat(1024 * 1024)
|
|
436
|
+
const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
|
|
437
|
+
|
|
438
|
+
let result = null
|
|
439
|
+
for (let i = 0; i < chunksNeeded; i++) {
|
|
440
|
+
result = unchunker.handleMessage(`${chunksNeeded - i}_${body}`)
|
|
441
|
+
if (result) break
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
expect(result).toMatchObject({ error: expect.any(MessageTooLargeError) })
|
|
445
|
+
expect(unchunker.state).toBe('idle')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('[CH10] leaves the assembler usable after rejecting an oversized assembly', () => {
|
|
449
|
+
const unchunker = new JsonChunkAssembler()
|
|
450
|
+
const body = 'x'.repeat(1024 * 1024)
|
|
451
|
+
const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
|
|
452
|
+
for (let i = 0; i < chunksNeeded; i++) {
|
|
453
|
+
if (unchunker.handleMessage(`${chunksNeeded - i}_${body}`)) break
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
|
|
457
|
+
})
|
|
458
|
+
})
|
|
406
459
|
})
|
package/src/lib/chunk.ts
CHANGED
|
@@ -8,6 +8,35 @@ const MAX_BYTES_PER_CHAR = 4
|
|
|
8
8
|
// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
|
|
9
9
|
const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
|
|
10
10
|
|
|
11
|
+
// JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender
|
|
12
|
+
// decides how many that is — the count is just a prefix on the first chunk. Nothing in the
|
|
13
|
+
// protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection
|
|
14
|
+
// can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.
|
|
15
|
+
//
|
|
16
|
+
// The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the
|
|
17
|
+
// 32MB of retained string data a room can afford to hold per session and stay inside a Durable
|
|
18
|
+
// Object's 128MB instance limit with room for the document itself. It is far above anything
|
|
19
|
+
// chunk() produces for a real document push. The character cap is the real bound; the chunk-count
|
|
20
|
+
// cap only rejects an absurd declared count on arrival instead of waiting for the characters to
|
|
21
|
+
// accumulate, and is loose enough not to constrain a sender that chunks more finely than the
|
|
22
|
+
// default.
|
|
23
|
+
export const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024
|
|
24
|
+
export const MAX_CHUNK_COUNT = 100_000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.
|
|
28
|
+
* Distinct from the other assembly errors because it is fatal: retrying sends the same
|
|
29
|
+
* oversized message, so the session is rejected rather than reset.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export class MessageTooLargeError extends Error {
|
|
34
|
+
constructor(chars: number) {
|
|
35
|
+
super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)
|
|
36
|
+
this.name = 'MessageTooLargeError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
11
40
|
/**
|
|
12
41
|
* Splits a string into smaller chunks suitable for transmission over WebSockets.
|
|
13
42
|
* This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
|
|
@@ -100,6 +129,7 @@ export class JsonChunkAssembler {
|
|
|
100
129
|
| {
|
|
101
130
|
chunksReceived: string[]
|
|
102
131
|
totalChunks: number
|
|
132
|
+
charsReceived: number
|
|
103
133
|
} = 'idle'
|
|
104
134
|
|
|
105
135
|
/**
|
|
@@ -110,7 +140,8 @@ export class JsonChunkAssembler {
|
|
|
110
140
|
* @param msg - The message to process, either JSON or chunk format
|
|
111
141
|
* @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
|
|
112
142
|
* - `\{ data: object, stringified: string \}` - Successfully parsed complete message
|
|
113
|
-
* - `\{ error: Error \}` - Parse error
|
|
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'
|
|
@@ -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 })
|
|
@@ -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 })
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TLSocketServerSentEvent,
|
|
9
9
|
getTlsyncProtocolVersion,
|
|
10
10
|
} from '../lib/protocol'
|
|
11
|
+
import { TLSocketRoom } from '../lib/TLSocketRoom'
|
|
11
12
|
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
|
|
12
13
|
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
|
|
13
14
|
|
|
@@ -354,6 +355,40 @@ describe('object store lane', () => {
|
|
|
354
355
|
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
|
|
355
356
|
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
|
|
356
357
|
})
|
|
358
|
+
|
|
359
|
+
it('[US1][US3] updateStore sees and can delete object-lane records', async () => {
|
|
360
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
361
|
+
const socketRoom = new TLSocketRoom<R, void>({
|
|
362
|
+
schema,
|
|
363
|
+
storage: new InMemorySyncStorage<R>({
|
|
364
|
+
snapshot: { documents: [], clock: 0, documentClock: 0, schema: schema.serialize() },
|
|
365
|
+
objectTypes: ['note'],
|
|
366
|
+
}),
|
|
367
|
+
objectTypes: ['note'],
|
|
368
|
+
})
|
|
369
|
+
disposables.push(() => socketRoom.close())
|
|
370
|
+
const doc = Doc.create({ title: 'canvas' })
|
|
371
|
+
const note = Note.create({ text: 'annotation' })
|
|
372
|
+
socketRoom.storage.transaction((txn) => {
|
|
373
|
+
txn.set(doc.id, doc)
|
|
374
|
+
txn.set(note.id, note)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// the update context reads both lanes, not just the document snapshot
|
|
378
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
379
|
+
await socketRoom.updateStore((store) => {
|
|
380
|
+
expect(store.get(note.id)).toEqual(note)
|
|
381
|
+
expect(
|
|
382
|
+
store
|
|
383
|
+
.getAll()
|
|
384
|
+
.map((r) => r.id)
|
|
385
|
+
.sort()
|
|
386
|
+
).toEqual([doc.id, note.id].sort())
|
|
387
|
+
store.delete(note.id)
|
|
388
|
+
})
|
|
389
|
+
expect(socketRoom.getRecord(note.id)).toBeUndefined()
|
|
390
|
+
expect(socketRoom.getRecord(doc.id)).toEqual(doc)
|
|
391
|
+
})
|
|
357
392
|
})
|
|
358
393
|
|
|
359
394
|
describe('connect message', () => {
|