@tldraw/sync-core 5.5.0-next.9665a55b2322 → 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 +11 -1
- 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 +20 -2
- 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 +12 -2
- 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 +20 -2
- 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 +7 -7
- package/src/lib/ClientWebSocketAdapter.test.ts +73 -19
- package/src/lib/ClientWebSocketAdapter.ts +15 -2
- 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 +27 -2
- 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 +25 -0
|
@@ -12,6 +12,7 @@ vi.mock('@tldraw/utils', async (importOriginal) => {
|
|
|
12
12
|
// NOTE: setupVitest.js replaces the global WebSocket with the 'ws' package's WebSocket,
|
|
13
13
|
// matching the WebSocketServer the tests connect to.
|
|
14
14
|
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
|
|
15
|
+
import { MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
|
|
15
16
|
import {
|
|
16
17
|
ACTIVE_MAX_DELAY,
|
|
17
18
|
ACTIVE_MIN_DELAY,
|
|
@@ -73,24 +74,38 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
73
74
|
connectedServerSocket = socket
|
|
74
75
|
})
|
|
75
76
|
|
|
77
|
+
// Bound per test on an OS-assigned port. A fixed port raced against the previous test's
|
|
78
|
+
// server, which closes asynchronously, and intermittently failed with EADDRINUSE.
|
|
79
|
+
let port: number
|
|
80
|
+
|
|
76
81
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
77
|
-
beforeEach(() => {
|
|
82
|
+
beforeEach(async () => {
|
|
78
83
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
85
|
+
// Attach before the adapter exists: the server is already listening by the time the
|
|
86
|
+
// adapter is constructed, so a handler added afterwards can miss the first connection.
|
|
81
87
|
wsServer.on('connection', connectMock as any)
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
wsServer.once('listening', resolve)
|
|
90
|
+
wsServer.once('error', reject)
|
|
91
|
+
})
|
|
92
|
+
port = (wsServer.address() as { port: number }).port
|
|
93
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
82
94
|
})
|
|
83
95
|
|
|
84
96
|
afterEach(() => {
|
|
85
97
|
consoleWarnSpy.mockRestore()
|
|
86
98
|
adapter.close()
|
|
99
|
+
// Not awaited: the next test binds its own OS-assigned port, so a server still closing
|
|
100
|
+
// here can't collide with it. Awaiting would deliver in-flight messages to the socket
|
|
101
|
+
// adapter.close() just orphaned.
|
|
87
102
|
wsServer.close()
|
|
88
103
|
connectMock.mockClear()
|
|
89
104
|
})
|
|
90
105
|
|
|
91
106
|
describe('connection and initial state (CW1, CW2)', () => {
|
|
92
107
|
it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
|
|
93
|
-
const newAdapter = new ClientWebSocketAdapter(() =>
|
|
108
|
+
const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
94
109
|
try {
|
|
95
110
|
expect(newAdapter._connectionStatus.get()).toBe('initial')
|
|
96
111
|
expect(newAdapter.connectionStatus).toBe('offline')
|
|
@@ -113,7 +128,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
113
128
|
let uriCallCount = 0
|
|
114
129
|
const dynamicAdapter = new ClientWebSocketAdapter(() => {
|
|
115
130
|
uriCallCount++
|
|
116
|
-
return `ws://localhost
|
|
131
|
+
return `ws://localhost:${port}?attempt=${uriCallCount}`
|
|
117
132
|
})
|
|
118
133
|
try {
|
|
119
134
|
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -143,7 +158,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
143
158
|
// no socket can be created until the promise resolves
|
|
144
159
|
expect(asyncAdapter._ws).toBeNull()
|
|
145
160
|
|
|
146
|
-
resolveUri!(
|
|
161
|
+
resolveUri!(`ws://localhost:${port}`)
|
|
147
162
|
|
|
148
163
|
await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
|
|
149
164
|
expect(asyncAdapter.connectionStatus).toBe('online')
|
|
@@ -301,7 +316,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
301
316
|
|
|
302
317
|
describe('URI conversion (CW1)', () => {
|
|
303
318
|
it('[CW1] converts http URIs to ws and connects', async () => {
|
|
304
|
-
const httpAdapter = new ClientWebSocketAdapter(() =>
|
|
319
|
+
const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
|
|
305
320
|
try {
|
|
306
321
|
await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
|
|
307
322
|
expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
|
|
@@ -312,7 +327,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
312
327
|
})
|
|
313
328
|
|
|
314
329
|
it('[CW1] converts https URIs to wss', async () => {
|
|
315
|
-
const httpsAdapter = new ClientWebSocketAdapter(() =>
|
|
330
|
+
const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
|
|
316
331
|
try {
|
|
317
332
|
await waitFor(() => httpsAdapter._ws !== null)
|
|
318
333
|
expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
|
|
@@ -386,6 +401,32 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
386
401
|
)
|
|
387
402
|
})
|
|
388
403
|
|
|
404
|
+
it('[CW6] refuses to send a message the server would reject as too large', async () => {
|
|
405
|
+
const onMessage = vi.fn()
|
|
406
|
+
connectMock.mockImplementationOnce((ws: any) => {
|
|
407
|
+
ws.on('message', onMessage)
|
|
408
|
+
})
|
|
409
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
410
|
+
|
|
411
|
+
const onStatusChange = vi.fn()
|
|
412
|
+
adapter.onStatusChange(onStatusChange)
|
|
413
|
+
|
|
414
|
+
const message = {
|
|
415
|
+
...connectMessage(),
|
|
416
|
+
largeData: 'x'.repeat(MAX_ASSEMBLED_MESSAGE_CHARS),
|
|
417
|
+
} as any
|
|
418
|
+
adapter.sendMessage(message)
|
|
419
|
+
|
|
420
|
+
// nothing goes on the wire: sending would only earn a close and a reconnect that
|
|
421
|
+
// re-sends the same message
|
|
422
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
423
|
+
expect(adapter.connectionStatus).toBe('error')
|
|
424
|
+
expect(onStatusChange).toHaveBeenCalledWith({
|
|
425
|
+
status: 'error',
|
|
426
|
+
reason: TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE,
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
389
430
|
it('[CW6] silently drops the message when there is no socket', async () => {
|
|
390
431
|
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
391
432
|
|
|
@@ -529,7 +570,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
529
570
|
})
|
|
530
571
|
|
|
531
572
|
it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
|
|
532
|
-
const getUri = vi.fn(() =>
|
|
573
|
+
const getUri = vi.fn(() => `ws://localhost:${port}`)
|
|
533
574
|
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
534
575
|
testAdapter.close()
|
|
535
576
|
const callsAtClose = getUri.mock.calls.length
|
|
@@ -544,7 +585,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
544
585
|
let uriCallCount = 0
|
|
545
586
|
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
546
587
|
uriCallCount++
|
|
547
|
-
return
|
|
588
|
+
return `ws://localhost:${port}`
|
|
548
589
|
})
|
|
549
590
|
const onStatusChange = vi.fn()
|
|
550
591
|
testAdapter.onStatusChange(onStatusChange)
|
|
@@ -606,17 +647,26 @@ describe('ReconnectManager', () => {
|
|
|
606
647
|
connectedServerSocket = socket
|
|
607
648
|
})
|
|
608
649
|
|
|
650
|
+
// See the note on the other suite's port: bound per test on an OS-assigned port.
|
|
651
|
+
let port: number
|
|
652
|
+
|
|
609
653
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
610
|
-
beforeEach(() => {
|
|
654
|
+
beforeEach(async () => {
|
|
611
655
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
612
|
-
|
|
613
|
-
wsServer = new WebSocketServer({ port: 2234 })
|
|
656
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
614
657
|
wsServer.on('connection', connectMock as any)
|
|
658
|
+
await new Promise<void>((resolve, reject) => {
|
|
659
|
+
wsServer.once('listening', resolve)
|
|
660
|
+
wsServer.once('error', reject)
|
|
661
|
+
})
|
|
662
|
+
port = (wsServer.address() as { port: number }).port
|
|
663
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
615
664
|
})
|
|
616
665
|
|
|
617
666
|
afterEach(() => {
|
|
618
667
|
consoleWarnSpy.mockRestore()
|
|
619
668
|
adapter.close()
|
|
669
|
+
// Not awaited - see the note on the other suite's teardown.
|
|
620
670
|
wsServer.close()
|
|
621
671
|
connectMock.mockClear()
|
|
622
672
|
})
|
|
@@ -631,7 +681,7 @@ describe('ReconnectManager', () => {
|
|
|
631
681
|
})
|
|
632
682
|
|
|
633
683
|
it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
|
|
634
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
684
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
635
685
|
try {
|
|
636
686
|
const manager = testAdapter._reconnectManager
|
|
637
687
|
// make every disconnected() call take the "attempt now" branch
|
|
@@ -656,7 +706,7 @@ describe('ReconnectManager', () => {
|
|
|
656
706
|
|
|
657
707
|
it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
|
|
658
708
|
const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
|
659
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
709
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
660
710
|
try {
|
|
661
711
|
const manager = testAdapter._reconnectManager
|
|
662
712
|
;(manager as any).lastAttemptStart = Date.now() - 1_000_000
|
|
@@ -828,7 +878,7 @@ describe('ReconnectManager', () => {
|
|
|
828
878
|
if (failure === 'throws') throw error
|
|
829
879
|
return Promise.reject(error)
|
|
830
880
|
}
|
|
831
|
-
return
|
|
881
|
+
return `ws://localhost:${port}`
|
|
832
882
|
})
|
|
833
883
|
try {
|
|
834
884
|
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -843,7 +893,7 @@ describe('ReconnectManager', () => {
|
|
|
843
893
|
)
|
|
844
894
|
|
|
845
895
|
it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
|
|
846
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
896
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
847
897
|
const manager = testAdapter._reconnectManager
|
|
848
898
|
|
|
849
899
|
testAdapter.close()
|
|
@@ -862,6 +912,10 @@ describe('ReconnectManager', () => {
|
|
|
862
912
|
})
|
|
863
913
|
|
|
864
914
|
describe('URI failure boundaries', () => {
|
|
915
|
+
// These tests never open a real socket - the adapter is closed first, or the socket is a
|
|
916
|
+
// mock - so the URI only has to parse. No server, hence no port to bind.
|
|
917
|
+
const UNUSED_URI = 'ws://localhost:1'
|
|
918
|
+
|
|
865
919
|
it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
|
|
866
920
|
const uri = promiseWithResolve<string>()
|
|
867
921
|
const getUri = vi.fn(() => uri)
|
|
@@ -872,7 +926,7 @@ describe('URI failure boundaries', () => {
|
|
|
872
926
|
try {
|
|
873
927
|
await Promise.resolve()
|
|
874
928
|
testAdapter.close()
|
|
875
|
-
if (outcome === 'resolves') uri.resolve(
|
|
929
|
+
if (outcome === 'resolves') uri.resolve(UNUSED_URI)
|
|
876
930
|
else uri.reject(new Error('token endpoint unavailable'))
|
|
877
931
|
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
878
932
|
|
|
@@ -890,7 +944,7 @@ describe('URI failure boundaries', () => {
|
|
|
890
944
|
it.each([
|
|
891
945
|
{ uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
|
|
892
946
|
{
|
|
893
|
-
uri:
|
|
947
|
+
uri: UNUSED_URI,
|
|
894
948
|
openSocket: true,
|
|
895
949
|
error: 'There should be no connection attempts while already connected',
|
|
896
950
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { atom, Atom } from '@tldraw/state'
|
|
2
2
|
import { TLRecord } from '@tldraw/tlschema'
|
|
3
3
|
import { assert, warnOnce } from '@tldraw/utils'
|
|
4
|
-
import { chunk } from './chunk'
|
|
4
|
+
import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
|
|
5
5
|
import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'
|
|
6
6
|
import {
|
|
7
7
|
TLPersistentClientSocket,
|
|
@@ -280,7 +280,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
|
|
|
280
280
|
|
|
281
281
|
if (!this._ws) return
|
|
282
282
|
if (this.connectionStatus === 'online') {
|
|
283
|
-
const
|
|
283
|
+
const stringified = JSON.stringify(msg)
|
|
284
|
+
// The server rejects a session whose assembled message exceeds this, so sending would
|
|
285
|
+
// only earn a close and a reconnect that re-sends the same message. Fail here the way
|
|
286
|
+
// the server would, so onSyncError runs once instead of the client looping forever.
|
|
287
|
+
if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
|
|
288
|
+
this._handleDisconnect(
|
|
289
|
+
'closed',
|
|
290
|
+
TLSyncErrorCloseEventCode,
|
|
291
|
+
true,
|
|
292
|
+
TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
|
|
293
|
+
)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const chunks = chunk(stringified)
|
|
284
297
|
for (const part of chunks) {
|
|
285
298
|
this._ws.send(part)
|
|
286
299
|
}
|
|
@@ -51,6 +51,27 @@ describe('InMemorySyncStorage', () => {
|
|
|
51
51
|
expect(storage.getClock()).toBe(25)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
it('[IM1] handles snapshots with far more records and tombstones than fit in a spread call', () => {
|
|
55
|
+
const documents = []
|
|
56
|
+
for (let i = 0; i < 150_000; i++) {
|
|
57
|
+
documents.push({
|
|
58
|
+
state: { ...contractRecords[0], id: `shape:${i}` } as TLRecord,
|
|
59
|
+
lastChangedClock: i,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const tombstones: Record<string, number> = {}
|
|
63
|
+
for (let i = 0; i < 150_000; i++) tombstones[`shape:gone-${i}`] = 150_000 + i
|
|
64
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
65
|
+
snapshot: makeContractSnapshot(contractRecords, {
|
|
66
|
+
documents,
|
|
67
|
+
tombstones,
|
|
68
|
+
documentClock: 0,
|
|
69
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
expect(storage.getClock()).toBe(299_999)
|
|
73
|
+
})
|
|
74
|
+
|
|
54
75
|
it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
|
|
55
76
|
const storage = new InMemorySyncStorage<TLRecord>({
|
|
56
77
|
snapshot: makeContractSnapshot(contractRecords, {
|
|
@@ -153,27 +153,27 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
|
|
|
153
153
|
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
|
|
154
154
|
} = {}) {
|
|
155
155
|
this.objectTypes = new Set(objectTypes ?? [])
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
|
|
160
|
-
)
|
|
156
|
+
// a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call
|
|
157
|
+
// arguments overflows the stack at roughly 100k+ records/tombstones
|
|
158
|
+
let maxClockValue = 0
|
|
161
159
|
// route snapshot entries into their partitions (a seed snapshot may carry object-lane
|
|
162
160
|
// records merged in, e.g. loaded from a separate persistence lane)
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
d.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
161
|
+
const documentEntries: [string, { state: R; lastChangedClock: number }][] = []
|
|
162
|
+
const objectEntries: [string, { state: R; lastChangedClock: number }][] = []
|
|
163
|
+
for (const d of snapshot.documents) {
|
|
164
|
+
if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock
|
|
165
|
+
const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries
|
|
166
|
+
entries.push([
|
|
167
|
+
d.state.id,
|
|
168
|
+
{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
|
|
169
|
+
])
|
|
170
|
+
}
|
|
171
|
+
const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})
|
|
172
|
+
for (const [, clock] of tombstoneEntries) {
|
|
173
|
+
if (clock > maxClockValue) maxClockValue = clock
|
|
174
|
+
}
|
|
175
|
+
this.documents = new AtomMap('room documents', documentEntries)
|
|
176
|
+
this.objects = new AtomMap('room objects', objectEntries)
|
|
177
177
|
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
|
|
178
178
|
|
|
179
179
|
this.documentClock = atom('document clock', documentClock)
|
|
@@ -192,9 +192,7 @@ export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStora
|
|
|
192
192
|
'room tombstones',
|
|
193
193
|
// If the tombstone history starts now (or we didn't have the
|
|
194
194
|
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
195
|
-
tombstoneHistoryStartsAtClock === documentClock
|
|
196
|
-
? []
|
|
197
|
-
: objectMapEntries(snapshot.tombstones ?? {})
|
|
195
|
+
tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
|
|
198
196
|
)
|
|
199
197
|
if (onChange) {
|
|
200
198
|
this.onChange(onChange)
|
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'
|
|
@@ -477,6 +477,11 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
477
477
|
})
|
|
478
478
|
this.room.pruneSessions()
|
|
479
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)
|
|
480
485
|
} else {
|
|
481
486
|
this.log?.error?.('Error assembling message', res.error)
|
|
482
487
|
// close the socket to reset the connection
|
|
@@ -909,7 +914,8 @@ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta =
|
|
|
909
914
|
* The client will attempt to reconnect automatically unless a fatal reason is provided.
|
|
910
915
|
*
|
|
911
916
|
* @param sessionId - Session identifier to remove
|
|
912
|
-
* @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)`.
|
|
913
919
|
*
|
|
914
920
|
* @example
|
|
915
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.
|
|
@@ -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
|
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'
|
|
@@ -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 })
|
|
@@ -2340,6 +2340,31 @@ describe('26. Session lifecycle (SES)', () => {
|
|
|
2340
2340
|
expect(room.sessions.size).toBe(0)
|
|
2341
2341
|
})
|
|
2342
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
|
+
|
|
2343
2368
|
it('[HS2][SES5] sets supportsStringAppend to false for protocol version 7', () => {
|
|
2344
2369
|
const { room } = makeRoom()
|
|
2345
2370
|
connectSession(room, 'v7-session', { protocolVersion: 7 })
|