@tldraw/sync-core 5.5.0-next.f9308d324e1c → 5.6.0-canary.5bfc37740468
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 +8 -4
- 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/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 +8 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +82 -50
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/chunk.js +35 -3
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js +15 -7
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-esm/index.d.mts +8 -4
- 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/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 +8 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +82 -50
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +35 -3
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs +16 -8
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/package.json +22 -18
- 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 +22 -24
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +24 -16
- package/src/lib/TLSocketRoom.ts +16 -9
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +11 -0
- package/src/lib/TLSyncRoom.ts +122 -54
- package/src/lib/TLSyncStorage.ts +7 -2
- package/src/lib/chunk.test.ts +88 -1
- package/src/lib/chunk.ts +66 -3
- package/src/lib/diff.test.ts +91 -1
- package/src/lib/diff.ts +30 -9
- package/src/test/TLSocketRoom.test.ts +63 -0
- package/src/test/TLSyncRoom.test.ts +300 -0
- package/src/test/objectStore.test.ts +35 -0
- package/src/test/upgradeDowngrade.test.ts +21 -0
package/src/lib/diff.test.ts
CHANGED
|
@@ -39,6 +39,52 @@ describe('computing diffs (D)', () => {
|
|
|
39
39
|
expect(patch).toEqual({ c: [ValueOpType.Put, 3] })
|
|
40
40
|
expect(applyObjectDiff(a, patch!)).toEqual(b)
|
|
41
41
|
})
|
|
42
|
+
|
|
43
|
+
it('[D2] treats a key whose value is undefined as absent', () => {
|
|
44
|
+
// undefined does not survive JSON, so `['put', undefined]` would arrive as
|
|
45
|
+
// `['put', null]` and fail validators that only accept the key being missing
|
|
46
|
+
const present = { a: 1, props: { foo: 'x' } }
|
|
47
|
+
const explicitlyUndefined = { a: 1, props: { foo: undefined } }
|
|
48
|
+
const absent = { a: 1, props: {} }
|
|
49
|
+
|
|
50
|
+
expect(diffRecord(present, explicitlyUndefined)).toEqual({
|
|
51
|
+
props: [ValueOpType.Patch, { foo: [ValueOpType.Delete] }],
|
|
52
|
+
})
|
|
53
|
+
expect(diffRecord(explicitlyUndefined, present)).toEqual({
|
|
54
|
+
props: [ValueOpType.Patch, { foo: [ValueOpType.Put, 'x'] }],
|
|
55
|
+
})
|
|
56
|
+
expect(diffRecord(absent, explicitlyUndefined)).toBeNull()
|
|
57
|
+
expect(diffRecord(explicitlyUndefined, absent)).toBeNull()
|
|
58
|
+
expect(diffRecord({ a: undefined }, { a: undefined })).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('[D2] only considers own keys, not Object.prototype members', () => {
|
|
62
|
+
const withCtor = { meta: { constructor: 1, toString: 2 } }
|
|
63
|
+
const without = { meta: {} }
|
|
64
|
+
|
|
65
|
+
expect(diffRecord(withCtor, without)).toEqual({
|
|
66
|
+
meta: [
|
|
67
|
+
ValueOpType.Patch,
|
|
68
|
+
{ constructor: [ValueOpType.Delete], toString: [ValueOpType.Delete] },
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
expect(diffRecord(without, withCtor)).toEqual({
|
|
72
|
+
meta: [
|
|
73
|
+
ValueOpType.Patch,
|
|
74
|
+
{ constructor: [ValueOpType.Put, 1], toString: [ValueOpType.Put, 2] },
|
|
75
|
+
],
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('[D2] ignores __proto__ keys, which applyObjectDiff would drop (AD8)', () => {
|
|
80
|
+
// JSON.parse creates '__proto__' as an own key; diffing it would store it on the server
|
|
81
|
+
// while peers receive an op they drop
|
|
82
|
+
const polluted = JSON.parse('{"__proto__":{"evil":1},"meta":{"__proto__":{"evil":1}}}')
|
|
83
|
+
const clean = { meta: {} }
|
|
84
|
+
|
|
85
|
+
expect(diffRecord(clean, polluted)).toBeNull()
|
|
86
|
+
expect(diffRecord(polluted, clean)).toBeNull()
|
|
87
|
+
})
|
|
42
88
|
})
|
|
43
89
|
|
|
44
90
|
describe('top-level keys vs props/meta (D3)', () => {
|
|
@@ -102,7 +148,8 @@ describe('computing diffs (D)', () => {
|
|
|
102
148
|
|
|
103
149
|
const diff = diffRecord(prev, next)
|
|
104
150
|
expect(diff).toBeTruthy()
|
|
105
|
-
|
|
151
|
+
// undefined is not representable on the wire, so it reads as the key being absent (D2)
|
|
152
|
+
expect(diff!.optional).toEqual([ValueOpType.Delete])
|
|
106
153
|
expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
|
|
107
154
|
})
|
|
108
155
|
})
|
|
@@ -378,6 +425,25 @@ describe('computing diffs (D)', () => {
|
|
|
378
425
|
arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, null] }],
|
|
379
426
|
})
|
|
380
427
|
})
|
|
428
|
+
|
|
429
|
+
it('[D6] puts a changed index when an item switches between array and object', () => {
|
|
430
|
+
// index deletes and named puts don't apply cleanly across shapes, so a patch would
|
|
431
|
+
// leave the receiver with a sparse array / an object where an array is expected
|
|
432
|
+
const arrayItem = { meta: { items: [[1, 2]] } }
|
|
433
|
+
const objectItem = { meta: { items: [{ x: 1 }] } }
|
|
434
|
+
|
|
435
|
+
const toObject = diffRecord(arrayItem, objectItem)!
|
|
436
|
+
expect(toObject).toEqual({
|
|
437
|
+
meta: [
|
|
438
|
+
ValueOpType.Patch,
|
|
439
|
+
{ items: [ValueOpType.Patch, { '0': [ValueOpType.Put, { x: 1 }] }] },
|
|
440
|
+
],
|
|
441
|
+
})
|
|
442
|
+
expect(applyObjectDiff(arrayItem, toObject)).toEqual(objectItem)
|
|
443
|
+
|
|
444
|
+
const toArray = diffRecord(objectItem, arrayItem)!
|
|
445
|
+
expect(applyObjectDiff(objectItem, toArray)).toEqual(arrayItem)
|
|
446
|
+
})
|
|
381
447
|
})
|
|
382
448
|
|
|
383
449
|
describe('different-length arrays (D7)', () => {
|
|
@@ -747,6 +813,30 @@ describe('applying diffs (AD)', () => {
|
|
|
747
813
|
|
|
748
814
|
expect(applyObjectDiff(obj, diff)).toBe(obj)
|
|
749
815
|
})
|
|
816
|
+
|
|
817
|
+
it('[AD5] deleting an inherited key has no effect', () => {
|
|
818
|
+
const obj = { a: 1 }
|
|
819
|
+
// `toString` collides with Object.prototype's own member in the literal's type, hence the cast
|
|
820
|
+
const diff = { toString: [ValueOpType.Delete] } as unknown as ObjectDiff
|
|
821
|
+
|
|
822
|
+
expect(applyObjectDiff(obj, diff)).toBe(obj)
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
describe('untrusted keys (AD8)', () => {
|
|
827
|
+
it('[AD8] ignores __proto__ keys instead of changing the prototype', () => {
|
|
828
|
+
// JSON.parse produces '__proto__' as an own key, so a peer can put it in a diff
|
|
829
|
+
const obj = { a: 1, props: { b: 2 } }
|
|
830
|
+
const diff: ObjectDiff = JSON.parse(
|
|
831
|
+
'{"__proto__":["put",{"isAdmin":true}],"props":["patch",{"__proto__":["put",{"polluted":1}]}]}'
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
const result: any = applyObjectDiff(obj, diff)
|
|
835
|
+
expect(result).toBe(obj)
|
|
836
|
+
expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
|
|
837
|
+
expect(result.isAdmin).toBeUndefined()
|
|
838
|
+
expect(result.props.polluted).toBeUndefined()
|
|
839
|
+
})
|
|
750
840
|
})
|
|
751
841
|
|
|
752
842
|
describe('patching non-objects (AD6)', () => {
|
package/src/lib/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
|
|
2
|
-
import { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
|
|
2
|
+
import { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Constants representing the types of operations that can be applied to records in network diffs.
|
|
@@ -200,15 +200,21 @@ function diffObject(
|
|
|
200
200
|
return null
|
|
201
201
|
}
|
|
202
202
|
let result: ObjectDiff | null = null
|
|
203
|
+
// Own-property checks throughout: `in` would see Object.prototype members, so a user key named
|
|
204
|
+
// e.g. 'constructor' would never diff as an add or delete. And a key whose value is `undefined`
|
|
205
|
+
// is treated as absent: `undefined` doesn't survive JSON, so `['put', undefined]` would arrive
|
|
206
|
+
// as `['put', null]` and fail validators that accept only the missing key.
|
|
203
207
|
for (const key of Object.keys(prev)) {
|
|
204
|
-
//
|
|
205
|
-
if (
|
|
208
|
+
// `result['__proto__'] = op` would set the diff's prototype, and applyObjectDiff drops the key (AD8)
|
|
209
|
+
if (key === '__proto__') continue
|
|
210
|
+
const prevValue = (prev as any)[key]
|
|
211
|
+
if (prevValue === undefined) continue
|
|
212
|
+
const nextValue = hasOwnProperty(next, key) ? (next as any)[key] : undefined
|
|
213
|
+
if (nextValue === undefined) {
|
|
206
214
|
if (!result) result = {}
|
|
207
215
|
result[key] = [ValueOpType.Delete]
|
|
208
216
|
continue
|
|
209
217
|
}
|
|
210
|
-
const prevValue = (prev as any)[key]
|
|
211
|
-
const nextValue = (next as any)[key]
|
|
212
218
|
if (
|
|
213
219
|
nestedKeys?.has(key) ||
|
|
214
220
|
(Array.isArray(prevValue) && Array.isArray(nextValue)) ||
|
|
@@ -226,10 +232,13 @@ function diffObject(
|
|
|
226
232
|
}
|
|
227
233
|
}
|
|
228
234
|
for (const key of Object.keys(next)) {
|
|
235
|
+
if (key === '__proto__') continue
|
|
236
|
+
const nextValue = (next as any)[key]
|
|
237
|
+
if (nextValue === undefined) continue
|
|
229
238
|
// if key is in next but not in prev then it was added
|
|
230
|
-
if (!(key
|
|
239
|
+
if (!hasOwnProperty(prev, key) || (prev as any)[key] === undefined) {
|
|
231
240
|
if (!result) result = {}
|
|
232
|
-
result[key] = [ValueOpType.Put,
|
|
241
|
+
result[key] = [ValueOpType.Put, nextValue]
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
return result
|
|
@@ -245,7 +254,15 @@ function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean):
|
|
|
245
254
|
return [ValueOpType.Append, appendedText, valueA.length]
|
|
246
255
|
}
|
|
247
256
|
return [ValueOpType.Put, valueB]
|
|
248
|
-
} else if (
|
|
257
|
+
} else if (
|
|
258
|
+
!valueA ||
|
|
259
|
+
!valueB ||
|
|
260
|
+
typeof valueA !== 'object' ||
|
|
261
|
+
typeof valueB !== 'object' ||
|
|
262
|
+
// an array on one side and a plain object on the other can't be expressed as a patch:
|
|
263
|
+
// index deletes and named puts don't apply cleanly to either shape
|
|
264
|
+
Array.isArray(valueA) !== Array.isArray(valueB)
|
|
265
|
+
) {
|
|
249
266
|
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
|
|
250
267
|
} else {
|
|
251
268
|
const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
|
|
@@ -348,6 +365,10 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
|
|
|
348
365
|
}
|
|
349
366
|
}
|
|
350
367
|
for (const [key, op] of Object.entries(objectDiff)) {
|
|
368
|
+
// Diffs come off the wire from untrusted peers. `newObject['__proto__'] = v` would set the
|
|
369
|
+
// record's prototype (JSON.parse creates '__proto__' as an own key), so a client could give
|
|
370
|
+
// server-held records attacker-controlled inherited fields; skip such keys outright.
|
|
371
|
+
if (key === '__proto__') continue
|
|
351
372
|
switch (op[0]) {
|
|
352
373
|
case ValueOpType.Put: {
|
|
353
374
|
const value = op[1]
|
|
@@ -383,7 +404,7 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
|
|
|
383
404
|
break
|
|
384
405
|
}
|
|
385
406
|
case ValueOpType.Delete: {
|
|
386
|
-
if (key
|
|
407
|
+
if (hasOwnProperty(object, key)) {
|
|
387
408
|
if (!newObject) {
|
|
388
409
|
if (isArray) {
|
|
389
410
|
console.error("Can't delete array item yet (this should never happen)")
|
|
@@ -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)', () => {
|
|
@@ -1949,6 +1995,111 @@ describe('25. Messaging and broadcast (RB)', () => {
|
|
|
1949
1995
|
expect(socketB.sendMessage).not.toHaveBeenCalled()
|
|
1950
1996
|
})
|
|
1951
1997
|
|
|
1998
|
+
it('[RB3] a debounced flush into a socket that closed meanwhile cancels the session instead of sending', () => {
|
|
1999
|
+
vi.useFakeTimers()
|
|
2000
|
+
const { room, socketB } = setupTwoSessions()
|
|
2001
|
+
const newPage = makePage('page_3', 'v1')
|
|
2002
|
+
|
|
2003
|
+
room.handleMessage('a', {
|
|
2004
|
+
type: 'push',
|
|
2005
|
+
clientClock: 1,
|
|
2006
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
2007
|
+
} as TLPushRequest<TLRecord>)
|
|
2008
|
+
room.handleMessage('a', {
|
|
2009
|
+
type: 'push',
|
|
2010
|
+
clientClock: 2,
|
|
2011
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
2012
|
+
} as TLPushRequest<TLRecord>)
|
|
2013
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2014
|
+
|
|
2015
|
+
// the socket closes while the second message sits in the debounce buffer
|
|
2016
|
+
socketB.isOpen = false
|
|
2017
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2018
|
+
|
|
2019
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2020
|
+
expect(room.sessions.get('b')?.state).toBe(RoomSessionState.AwaitingRemoval)
|
|
2021
|
+
})
|
|
2022
|
+
|
|
2023
|
+
it('[RC7] close() drops pending debounced flushes so nothing is sent into closed sockets afterwards', () => {
|
|
2024
|
+
vi.useFakeTimers()
|
|
2025
|
+
const { room, socketB } = setupTwoSessions()
|
|
2026
|
+
const newPage = makePage('page_3', 'v1')
|
|
2027
|
+
|
|
2028
|
+
room.handleMessage('a', {
|
|
2029
|
+
type: 'push',
|
|
2030
|
+
clientClock: 1,
|
|
2031
|
+
diff: { [newPage.id]: ['put', newPage] },
|
|
2032
|
+
} as TLPushRequest<TLRecord>)
|
|
2033
|
+
room.handleMessage('a', {
|
|
2034
|
+
type: 'push',
|
|
2035
|
+
clientClock: 2,
|
|
2036
|
+
diff: { [newPage.id]: ['patch', { name: ['put', 'v2'] }] },
|
|
2037
|
+
} as TLPushRequest<TLRecord>)
|
|
2038
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2039
|
+
|
|
2040
|
+
room.close()
|
|
2041
|
+
expect(room.isClosed()).toBe(true)
|
|
2042
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2043
|
+
|
|
2044
|
+
expect(socketB.sendMessage).toHaveBeenCalledTimes(1)
|
|
2045
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2046
|
+
})
|
|
2047
|
+
|
|
2048
|
+
it('[RC7] close() closes every socket even when one of them throws on close', () => {
|
|
2049
|
+
const { room, socketA, socketB } = setupTwoSessions()
|
|
2050
|
+
socketA.close.mockImplementationOnce(() => {
|
|
2051
|
+
throw new Error('already closing')
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
expect(() => room.close()).not.toThrow()
|
|
2055
|
+
expect(room.isClosed()).toBe(true)
|
|
2056
|
+
expect(socketB.close).toHaveBeenCalled()
|
|
2057
|
+
})
|
|
2058
|
+
|
|
2059
|
+
it('[RC7] close() forgets its sessions, and late socket close events emit no lifecycle events', () => {
|
|
2060
|
+
vi.useFakeTimers()
|
|
2061
|
+
const { room, socketA } = setupTwoSessions()
|
|
2062
|
+
const removed = vi.fn()
|
|
2063
|
+
const becameEmpty = vi.fn()
|
|
2064
|
+
room.events.on('session_removed', removed)
|
|
2065
|
+
room.events.on('room_became_empty', becameEmpty)
|
|
2066
|
+
|
|
2067
|
+
// b's socket closes before close(), a's closes after: both must stay silent
|
|
2068
|
+
room.handleClose('b')
|
|
2069
|
+
room.close()
|
|
2070
|
+
expect(room.sessions.size).toBe(0)
|
|
2071
|
+
room.handleClose('a')
|
|
2072
|
+
|
|
2073
|
+
vi.advanceTimersByTime(SESSION_REMOVAL_WAIT_TIME + 2001)
|
|
2074
|
+
expect(removed).not.toHaveBeenCalled()
|
|
2075
|
+
expect(becameEmpty).not.toHaveBeenCalled()
|
|
2076
|
+
expect(socketA.sendMessage).not.toHaveBeenCalled()
|
|
2077
|
+
})
|
|
2078
|
+
|
|
2079
|
+
it('[RC7] sessions added after close() are rejected and their sockets closed', () => {
|
|
2080
|
+
const { room } = makeRoom()
|
|
2081
|
+
room.close()
|
|
2082
|
+
|
|
2083
|
+
const late = makeSocket()
|
|
2084
|
+
room.handleNewSession({ sessionId: 'late', socket: late, meta: undefined, isReadonly: false })
|
|
2085
|
+
expect(late.close).toHaveBeenCalled()
|
|
2086
|
+
|
|
2087
|
+
const resumed = makeSocket()
|
|
2088
|
+
room.handleResumedSession({
|
|
2089
|
+
sessionId: 'resumed',
|
|
2090
|
+
socket: resumed,
|
|
2091
|
+
meta: undefined,
|
|
2092
|
+
isReadonly: false,
|
|
2093
|
+
serializedSchema: room.serializedSchema,
|
|
2094
|
+
presenceId: null,
|
|
2095
|
+
presenceRecord: null,
|
|
2096
|
+
requiresLegacyRejection: false,
|
|
2097
|
+
supportsStringAppend: true,
|
|
2098
|
+
})
|
|
2099
|
+
expect(resumed.close).toHaveBeenCalled()
|
|
2100
|
+
expect(room.sessions.size).toBe(0)
|
|
2101
|
+
})
|
|
2102
|
+
|
|
1952
2103
|
it('[RB4] a per-session migration failure during broadcast rejects only the affected session', () => {
|
|
1953
2104
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1954
2105
|
try {
|
|
@@ -2235,6 +2386,31 @@ describe('26. Session lifecycle (SES)', () => {
|
|
|
2235
2386
|
expect(room.sessions.size).toBe(0)
|
|
2236
2387
|
})
|
|
2237
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
|
+
|
|
2238
2414
|
it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
|
|
2239
2415
|
const { room } = makeRoom()
|
|
2240
2416
|
connectSession(room, 'v7-session', { protocolVersion: 7 })
|
|
@@ -2306,6 +2482,130 @@ describe('26. Session lifecycle (SES)', () => {
|
|
|
2306
2482
|
expect(socket.__lastMessage).toEqual({ type: 'pong' })
|
|
2307
2483
|
})
|
|
2308
2484
|
|
|
2485
|
+
it('[SES5] no session receives string-append ops while a client without append support is connected', () => {
|
|
2486
|
+
vi.useFakeTimers()
|
|
2487
|
+
const { room } = makeRoom()
|
|
2488
|
+
const v8 = connectSession(room, 'v8', { protocolVersion: getTlsyncProtocolVersion() })
|
|
2489
|
+
const v7 = connectSession(room, 'v7', { protocolVersion: 7 })
|
|
2490
|
+
expect(room.getCanEmitStringAppend()).toBe(false)
|
|
2491
|
+
|
|
2492
|
+
const hasAppend = (msg: any) => JSON.stringify(msg).includes('"append"')
|
|
2493
|
+
|
|
2494
|
+
// a full put over an existing record whose only change is a string append
|
|
2495
|
+
room.handleMessage('v8', {
|
|
2496
|
+
type: 'push',
|
|
2497
|
+
clientClock: 1,
|
|
2498
|
+
diff: { [pageRecord.id]: ['put', { ...pageRecord, name: pageRecord.name + ' more' }] },
|
|
2499
|
+
} as TLPushRequest<TLRecord>)
|
|
2500
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2501
|
+
expect(sentDataMessages(v7)).toEqual([
|
|
2502
|
+
{
|
|
2503
|
+
type: 'patch',
|
|
2504
|
+
diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more'] }] },
|
|
2505
|
+
serverClock: 1,
|
|
2506
|
+
},
|
|
2507
|
+
])
|
|
2508
|
+
expect(sentDataMessages(v8).some(hasAppend)).toBe(false)
|
|
2509
|
+
clearSocket(v7)
|
|
2510
|
+
clearSocket(v8)
|
|
2511
|
+
|
|
2512
|
+
// a patch whose recomputed broadcast would otherwise be an append
|
|
2513
|
+
room.handleMessage('v8', {
|
|
2514
|
+
type: 'push',
|
|
2515
|
+
clientClock: 2,
|
|
2516
|
+
diff: { [pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }] },
|
|
2517
|
+
} as TLPushRequest<TLRecord>)
|
|
2518
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL + 1)
|
|
2519
|
+
expect(sentDataMessages(v7)).toEqual([
|
|
2520
|
+
{
|
|
2521
|
+
type: 'patch',
|
|
2522
|
+
diff: {
|
|
2523
|
+
[pageRecord.id]: ['patch', { name: ['put', pageRecord.name + ' more and more'] }],
|
|
2524
|
+
},
|
|
2525
|
+
serverClock: 2,
|
|
2526
|
+
},
|
|
2527
|
+
])
|
|
2528
|
+
expect(sentDataMessages(v8)).toEqual([
|
|
2529
|
+
{ type: 'push_result', clientClock: 2, serverClock: 2, action: 'commit' },
|
|
2530
|
+
])
|
|
2531
|
+
})
|
|
2532
|
+
|
|
2533
|
+
it('[SES6] handleResumedSession rejects a session whose schema the server can no longer reconcile', () => {
|
|
2534
|
+
const { room } = makeRoom()
|
|
2535
|
+
const socket = makeSocket()
|
|
2536
|
+
const newerSchema: SerializedSchemaV2 = {
|
|
2537
|
+
schemaVersion: 2,
|
|
2538
|
+
sequences: {
|
|
2539
|
+
...(room.serializedSchema as SerializedSchemaV2).sequences,
|
|
2540
|
+
'com.tldraw.store': 999,
|
|
2541
|
+
},
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
room.handleResumedSession({
|
|
2545
|
+
sessionId: 'resumed',
|
|
2546
|
+
socket,
|
|
2547
|
+
meta: undefined,
|
|
2548
|
+
isReadonly: false,
|
|
2549
|
+
serializedSchema: newerSchema,
|
|
2550
|
+
presenceId: null,
|
|
2551
|
+
presenceRecord: null,
|
|
2552
|
+
requiresLegacyRejection: false,
|
|
2553
|
+
supportsStringAppend: true,
|
|
2554
|
+
})
|
|
2555
|
+
|
|
2556
|
+
expect(room.sessions.has('resumed')).toBe(false)
|
|
2557
|
+
expect(socket.close).toHaveBeenCalledWith(
|
|
2558
|
+
TLSyncErrorCloseEventCode,
|
|
2559
|
+
TLSyncErrorCloseEventReason.SERVER_TOO_OLD
|
|
2560
|
+
)
|
|
2561
|
+
})
|
|
2562
|
+
|
|
2563
|
+
it('[SES6] rejecting a resumed session removes its restored presence for other sessions', () => {
|
|
2564
|
+
vi.useFakeTimers()
|
|
2565
|
+
const { room } = makeRoom()
|
|
2566
|
+
const makePresence = (name: string) =>
|
|
2567
|
+
InstancePresenceRecordType.create({
|
|
2568
|
+
id: InstancePresenceRecordType.createId(name),
|
|
2569
|
+
currentPageId: pageRecord.id,
|
|
2570
|
+
userId: createUserId(name),
|
|
2571
|
+
userName: name,
|
|
2572
|
+
})
|
|
2573
|
+
const resume = (sessionId: string, serializedSchema: SerializedSchema) => {
|
|
2574
|
+
const socket = makeSocket()
|
|
2575
|
+
const presenceRecord = makePresence(sessionId)
|
|
2576
|
+
room.handleResumedSession({
|
|
2577
|
+
sessionId,
|
|
2578
|
+
socket,
|
|
2579
|
+
meta: undefined,
|
|
2580
|
+
isReadonly: false,
|
|
2581
|
+
serializedSchema,
|
|
2582
|
+
presenceId: presenceRecord.id,
|
|
2583
|
+
presenceRecord,
|
|
2584
|
+
requiresLegacyRejection: false,
|
|
2585
|
+
supportsStringAppend: true,
|
|
2586
|
+
})
|
|
2587
|
+
return { socket, presenceId: presenceRecord.id }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const other = resume('other', room.serializedSchema)
|
|
2591
|
+
const rejected = resume('rejected', {
|
|
2592
|
+
schemaVersion: 2,
|
|
2593
|
+
sequences: {
|
|
2594
|
+
...(room.serializedSchema as SerializedSchemaV2).sequences,
|
|
2595
|
+
'com.tldraw.store': 999,
|
|
2596
|
+
},
|
|
2597
|
+
})
|
|
2598
|
+
vi.advanceTimersByTime(DATA_MESSAGE_DEBOUNCE_INTERVAL * 2)
|
|
2599
|
+
|
|
2600
|
+
expect(room.sessions.has('rejected')).toBe(false)
|
|
2601
|
+
expect(room.presenceStore.get(rejected.presenceId)).toBeUndefined()
|
|
2602
|
+
// other clients still hold this presence from before the socket slept
|
|
2603
|
+
expect(sentDataMessages(other.socket).at(-1)).toMatchObject({
|
|
2604
|
+
type: 'patch',
|
|
2605
|
+
diff: { [rejected.presenceId]: [RecordOpType.Remove] },
|
|
2606
|
+
})
|
|
2607
|
+
})
|
|
2608
|
+
|
|
2309
2609
|
it('[SES7] a message from an unknown session id logs a warning and is ignored', () => {
|
|
2310
2610
|
const warn = vi.fn()
|
|
2311
2611
|
const { room } = makeRoom({ log: { warn } })
|
|
@@ -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', () => {
|
|
@@ -413,6 +413,27 @@ it('[HS3] out-of-date clients will receive incompatibility errors', () => {
|
|
|
413
413
|
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
+
it.each([
|
|
417
|
+
['a v2 schema without sequences', { schemaVersion: 2 }],
|
|
418
|
+
['a v2 schema with null sequences', { schemaVersion: 2, sequences: null }],
|
|
419
|
+
['a null schema', null],
|
|
420
|
+
])('[HS3] clients sending %s are rejected as too old', (_, schema) => {
|
|
421
|
+
const server = new TestServer(schemaV3)
|
|
422
|
+
const id = 'test_malformed_schema'
|
|
423
|
+
const socket = mockSocket()
|
|
424
|
+
|
|
425
|
+
server.room.handleNewSession({ sessionId: id, socket, meta: undefined, isReadonly: false })
|
|
426
|
+
server.room.handleMessage(id, {
|
|
427
|
+
type: 'connect',
|
|
428
|
+
connectRequestId: 'test',
|
|
429
|
+
lastServerClock: 0,
|
|
430
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
431
|
+
schema: schema as any,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
expect(socket.close).toHaveBeenCalledWith(4099, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
435
|
+
})
|
|
436
|
+
|
|
416
437
|
it('[HS2] clients using an out-of-date protocol will receive compatibility errors', () => {
|
|
417
438
|
const actualVersion = getTlsyncProtocolVersion()
|
|
418
439
|
mockGetTlsyncProtocolVersion.mockReturnValue(actualVersion + 1)
|