@tldraw/sync-core 5.2.0-next.5d769d321393 → 5.2.0-next.79b13319d317
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/DOCS.md +662 -0
- package/README.md +5 -1
- package/dist-cjs/index.js +1 -1
- package/dist-esm/index.mjs +1 -1
- package/package.json +9 -8
- package/src/test/TLSocketRoom.test.ts +3 -3
package/DOCS.md
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# @tldraw/sync-core
|
|
2
|
+
|
|
3
|
+
The `@tldraw/sync-core` package provides the foundational infrastructure for real-time collaboration and synchronization in tldraw applications. It implements a robust client-server protocol for sharing drawing state across multiple users, handling network reliability, conflict resolution, and maintaining data consistency in distributed environments.
|
|
4
|
+
|
|
5
|
+
## 1. Introduction
|
|
6
|
+
|
|
7
|
+
**Sync-core** is the engine that powers real-time collaboration in tldraw. It enables multiple users to work on the same drawing simultaneously, automatically synchronizing changes while gracefully handling network issues and conflicts.
|
|
8
|
+
|
|
9
|
+
You create collaborative tldraw applications by connecting clients to sync rooms, where each room manages the shared state for a document. Changes made by any user are automatically distributed to all other connected users in near real-time.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { TLSyncClient } from '@tldraw/sync-core'
|
|
13
|
+
|
|
14
|
+
// Connect to a collaborative room
|
|
15
|
+
const syncClient = new TLSyncClient({
|
|
16
|
+
store: myTldrawStore,
|
|
17
|
+
socket: myWebSocketAdapter,
|
|
18
|
+
roomId: 'drawing-room-123',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
syncClient.connect()
|
|
22
|
+
// Now all changes to myTldrawStore are synchronized with other users
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
When you update the store locally, the changes are immediately visible in your UI (optimistic updates), then sent to the server for validation and distribution to other clients.
|
|
26
|
+
|
|
27
|
+
> Tip: Sync-core is designed to work with any WebSocket implementation, making it suitable for various deployment scenarios from simple Node.js servers to edge computing platforms.
|
|
28
|
+
|
|
29
|
+
## 2. Core Concepts
|
|
30
|
+
|
|
31
|
+
### Client-Server Architecture
|
|
32
|
+
|
|
33
|
+
Sync-core uses a **server-authoritative** model where the server is the single source of truth for all changes. This ensures data consistency while still providing responsive local interactions:
|
|
34
|
+
|
|
35
|
+
- **Optimistic Updates**: Your local changes apply immediately for responsive UI
|
|
36
|
+
- **Server Validation**: The server validates and potentially modifies your changes
|
|
37
|
+
- **Conflict Resolution**: If conflicts occur, the server's version takes precedence
|
|
38
|
+
|
|
39
|
+
### Rooms and Sessions
|
|
40
|
+
|
|
41
|
+
A **room** represents a collaborative document space where multiple users can work together:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// Server-side room management
|
|
45
|
+
const room = new TLSyncRoom({
|
|
46
|
+
store: serverStore,
|
|
47
|
+
roomId: 'drawing-room-123',
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Each connected client creates a session
|
|
51
|
+
room.handleSocketConnect(clientSocket, sessionMeta)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Each client connection creates a **session** within the room, tracking that user's connection state, permissions, and presence information.
|
|
55
|
+
|
|
56
|
+
### Network Diffs and Synchronization
|
|
57
|
+
|
|
58
|
+
Instead of sending entire document states, sync-core uses **network diffs** - compact representations of what actually changed:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Example network diff for updating a shape's position
|
|
62
|
+
const diff = {
|
|
63
|
+
'shape:abc123': [
|
|
64
|
+
RecordOpType.Patch,
|
|
65
|
+
{
|
|
66
|
+
x: [ValueOpType.Put, 150],
|
|
67
|
+
y: [ValueOpType.Put, 200],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This approach minimizes bandwidth usage and enables efficient synchronization even with large documents.
|
|
74
|
+
|
|
75
|
+
## 3. Basic Usage
|
|
76
|
+
|
|
77
|
+
### Setting Up a Sync Client
|
|
78
|
+
|
|
79
|
+
To enable synchronization in your tldraw application, you need three components: a store, a WebSocket adapter, and a sync client:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { createTLStore } from '@tldraw/store'
|
|
83
|
+
import { createTLSchema } from '@tldraw/tlschema'
|
|
84
|
+
import { TLSyncClient, ClientWebSocketAdapter } from '@tldraw/sync-core'
|
|
85
|
+
|
|
86
|
+
// Create your tldraw store
|
|
87
|
+
const store = createTLStore({
|
|
88
|
+
schema: createTLSchema(),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Create a WebSocket connection
|
|
92
|
+
const socket = new ClientWebSocketAdapter('ws://localhost:3000/sync')
|
|
93
|
+
|
|
94
|
+
// Create the sync client
|
|
95
|
+
const syncClient = new TLSyncClient({
|
|
96
|
+
store,
|
|
97
|
+
socket,
|
|
98
|
+
roomId: 'my-drawing-room',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Start synchronization
|
|
102
|
+
syncClient.connect()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Once connected, any changes to your store will automatically sync with other clients in the same room.
|
|
106
|
+
|
|
107
|
+
### Monitoring Connection Status
|
|
108
|
+
|
|
109
|
+
The sync client provides reactive status information:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { react } from '@tldraw/state'
|
|
113
|
+
|
|
114
|
+
// React to connection status changes
|
|
115
|
+
react('connection status', () => {
|
|
116
|
+
const status = syncClient.status.get()
|
|
117
|
+
|
|
118
|
+
switch (status) {
|
|
119
|
+
case 'offline':
|
|
120
|
+
console.log('No network connection')
|
|
121
|
+
break
|
|
122
|
+
case 'connecting':
|
|
123
|
+
console.log('Connecting to server...')
|
|
124
|
+
break
|
|
125
|
+
case 'online':
|
|
126
|
+
console.log('Connected and synchronized')
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The status signal automatically updates as network conditions change, allowing your UI to reflect the current connection state.
|
|
133
|
+
|
|
134
|
+
### Handling Connection Events
|
|
135
|
+
|
|
136
|
+
You can listen to specific sync events for custom behavior:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
syncClient.onReceiveMessage((message) => {
|
|
140
|
+
switch (message.type) {
|
|
141
|
+
case 'connect':
|
|
142
|
+
console.log('Successfully connected to room')
|
|
143
|
+
break
|
|
144
|
+
case 'incompatibility-error':
|
|
145
|
+
console.log('Client version incompatible with server')
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
> Tip: Always handle incompatibility errors gracefully - they indicate version mismatches between your client and server.
|
|
152
|
+
|
|
153
|
+
## 4. Advanced Topics
|
|
154
|
+
|
|
155
|
+
### Server-Side Room Management
|
|
156
|
+
|
|
157
|
+
On the server side, you manage rooms that coordinate multiple client sessions:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { TLSyncRoom } from '@tldraw/sync-core'
|
|
161
|
+
|
|
162
|
+
class CollaborationServer {
|
|
163
|
+
private rooms = new Map<string, TLSyncRoom>()
|
|
164
|
+
|
|
165
|
+
getOrCreateRoom(roomId: string) {
|
|
166
|
+
if (!this.rooms.has(roomId)) {
|
|
167
|
+
const room = new TLSyncRoom({
|
|
168
|
+
store: this.createRoomStore(),
|
|
169
|
+
roomId,
|
|
170
|
+
// Optional persistence adapter
|
|
171
|
+
persistenceAdapter: this.createPersistenceAdapter(roomId),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
this.rooms.set(roomId, room)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return this.rooms.get(roomId)!
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
handleClientConnection(socket: WebSocket, roomId: string) {
|
|
181
|
+
const room = this.getOrCreateRoom(roomId)
|
|
182
|
+
room.handleSocketConnect(socket, {
|
|
183
|
+
sessionId: generateSessionId(),
|
|
184
|
+
userId: extractUserId(socket),
|
|
185
|
+
isReadonly: checkPermissions(socket),
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Rooms automatically handle session lifecycle, broadcasting changes, and cleaning up disconnected clients.
|
|
192
|
+
|
|
193
|
+
### Custom WebSocket Adapters
|
|
194
|
+
|
|
195
|
+
While sync-core provides a `ClientWebSocketAdapter`, you can implement custom adapters for specific requirements:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { TLPersistentClientSocket } from '@tldraw/sync-core'
|
|
199
|
+
|
|
200
|
+
class CustomSocketAdapter implements TLPersistentClientSocket {
|
|
201
|
+
status = atom<TLPersistentClientSocketStatus>('offline')
|
|
202
|
+
|
|
203
|
+
sendMessage(message: any): void {
|
|
204
|
+
// Your custom sending logic
|
|
205
|
+
this.customWebSocket.send(JSON.stringify(message))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
onReceiveMessage = createNanoEvents<any>()
|
|
209
|
+
onStatusChange = createNanoEvents<TLPersistentClientSocketStatus>()
|
|
210
|
+
|
|
211
|
+
restart(): void {
|
|
212
|
+
// Your reconnection logic
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Custom adapters let you integrate with existing WebSocket libraries or add custom authentication and error handling.
|
|
218
|
+
|
|
219
|
+
### Conflict Resolution Strategies
|
|
220
|
+
|
|
221
|
+
When multiple users edit simultaneously, conflicts can occur. Sync-core's server-authoritative model resolves these automatically:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// Client A moves shape to x: 100
|
|
225
|
+
store.update('shape:abc', (shape) => ({ ...shape, x: 100 }))
|
|
226
|
+
|
|
227
|
+
// Simultaneously, Client B moves same shape to x: 200
|
|
228
|
+
// Server receives both changes and determines the final state
|
|
229
|
+
// All clients receive the server's authoritative version
|
|
230
|
+
|
|
231
|
+
react('shape changes', () => {
|
|
232
|
+
const shape = store.get('shape:abc')
|
|
233
|
+
// Final position will be whatever the server decided
|
|
234
|
+
console.log('Final position:', shape?.x)
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The server applies changes in the order it receives them, with later changes taking precedence for conflicting properties.
|
|
239
|
+
|
|
240
|
+
### Presence and Live Cursors
|
|
241
|
+
|
|
242
|
+
Sync-core supports real-time presence information like cursor positions:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// Client sends presence updates
|
|
246
|
+
syncClient.updatePresence({
|
|
247
|
+
cursor: { x: 150, y: 200 },
|
|
248
|
+
selection: ['shape:abc123'],
|
|
249
|
+
userName: 'Alice',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Other clients receive presence updates
|
|
253
|
+
syncClient.onPresenceUpdate((presenceUpdates) => {
|
|
254
|
+
for (const [sessionId, presence] of presenceUpdates) {
|
|
255
|
+
updateLiveCursor(sessionId, presence.cursor)
|
|
256
|
+
updateUserSelection(sessionId, presence.selection)
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Presence updates are ephemeral - they don't persist to storage and are only visible to currently connected users.
|
|
262
|
+
|
|
263
|
+
### Schema Evolution and Migrations
|
|
264
|
+
|
|
265
|
+
When your application's data schema changes, sync-core coordinates migrations across clients:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const schema = createTLSchema({
|
|
269
|
+
// Your shape definitions
|
|
270
|
+
shapes: {
|
|
271
|
+
myShape: MyShapeUtil,
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// The client sends its schema version during connection
|
|
276
|
+
const syncClient = new TLSyncClient({
|
|
277
|
+
store: createTLStore({ schema }),
|
|
278
|
+
socket,
|
|
279
|
+
roomId: 'room-123',
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
If schema versions don't match between client and server, sync-core will:
|
|
284
|
+
|
|
285
|
+
1. Attempt automatic migration if possible
|
|
286
|
+
2. Send an incompatibility error if migration fails
|
|
287
|
+
3. Allow graceful degradation for unknown record types
|
|
288
|
+
|
|
289
|
+
> Tip: Design your schema changes to be backward-compatible when possible to avoid forcing all users to upgrade simultaneously.
|
|
290
|
+
|
|
291
|
+
## 5. Debugging
|
|
292
|
+
|
|
293
|
+
Sync-core provides several tools for understanding and debugging synchronization behavior in your collaborative applications.
|
|
294
|
+
|
|
295
|
+
### Connection Diagnostics
|
|
296
|
+
|
|
297
|
+
Monitor the detailed connection lifecycle:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { TLSyncClient } from '@tldraw/sync-core'
|
|
301
|
+
|
|
302
|
+
const syncClient = new TLSyncClient({
|
|
303
|
+
/* ... */
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Enable detailed logging
|
|
307
|
+
syncClient.onReceiveMessage((message) => {
|
|
308
|
+
console.log('Received:', message.type, message)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
syncClient.onStatusChange((status, previous) => {
|
|
312
|
+
console.log(`Status: ${previous} → ${status}`)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Connection attempt
|
|
316
|
+
syncClient.connect()
|
|
317
|
+
|
|
318
|
+
// Output shows the complete handshake:
|
|
319
|
+
// Status: offline → connecting
|
|
320
|
+
// Received: connect { hydrationType: 'wipe_all', ... }
|
|
321
|
+
// Status: connecting → online
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
This reveals the exact sequence of messages during connection establishment and any errors that occur.
|
|
325
|
+
|
|
326
|
+
### Message Flow Analysis
|
|
327
|
+
|
|
328
|
+
Track all synchronization messages to understand data flow:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
// Log outgoing messages
|
|
332
|
+
const originalSend = syncClient.socket.sendMessage
|
|
333
|
+
syncClient.socket.sendMessage = (message) => {
|
|
334
|
+
console.log('Sending:', message.type, message)
|
|
335
|
+
originalSend.call(syncClient.socket, message)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Example output when making a change:
|
|
339
|
+
// Sending: push { diff: { "shape:abc123": [2, { x: [1, 150] }] } }
|
|
340
|
+
// Received: data { diff: { "shape:abc123": [2, { x: [1, 150] }] } }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
This shows how local changes become push messages and return as data messages from the server.
|
|
344
|
+
|
|
345
|
+
### Network Diff Inspection
|
|
346
|
+
|
|
347
|
+
Understand what changes are being synchronized:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { diffRecord } from '@tldraw/sync-core'
|
|
351
|
+
|
|
352
|
+
// Monitor store changes and see their diff representation
|
|
353
|
+
const unsubscribe = store.listen(
|
|
354
|
+
(entry) => {
|
|
355
|
+
if (entry.changes.length > 0) {
|
|
356
|
+
for (const change of entry.changes) {
|
|
357
|
+
console.log('Change type:', change.source)
|
|
358
|
+
console.log('Record diff:', change)
|
|
359
|
+
|
|
360
|
+
// For detailed diff analysis
|
|
361
|
+
if (change.type === 'update') {
|
|
362
|
+
const diff = diffRecord(change.prev, change.record)
|
|
363
|
+
console.log('Network diff would be:', diff)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
{ source: 'user' }
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
// Example output:
|
|
372
|
+
// Change type: user
|
|
373
|
+
// Record diff: { type: 'update', id: 'shape:abc123', ... }
|
|
374
|
+
// Network diff would be: { x: [1, 150], y: [1, 200] }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Session and Room Debugging
|
|
378
|
+
|
|
379
|
+
On the server side, inspect room and session states:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
class DebuggableRoom extends TLSyncRoom {
|
|
383
|
+
debugSessions() {
|
|
384
|
+
console.log(`Room ${this.roomId} has ${this.getNumActiveConnections()} connections:`)
|
|
385
|
+
|
|
386
|
+
for (const [sessionId, session] of this.sessions) {
|
|
387
|
+
console.log(
|
|
388
|
+
` ${sessionId}: ${session.state} (${session.isReadonly ? 'readonly' : 'read-write'})`
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
debugLastChange() {
|
|
394
|
+
console.log('Last document change:', this.documentState.clock)
|
|
395
|
+
console.log('Store has', Object.keys(this.store.serialize()).length, 'records')
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Use during development
|
|
400
|
+
const room = new DebuggableRoom({
|
|
401
|
+
/* ... */
|
|
402
|
+
})
|
|
403
|
+
setInterval(() => room.debugSessions(), 5000)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Error Diagnosis
|
|
407
|
+
|
|
408
|
+
Handle and debug common synchronization errors:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
syncClient.onReceiveMessage((message) => {
|
|
412
|
+
switch (message.type) {
|
|
413
|
+
case 'incompatibility-error':
|
|
414
|
+
console.error('Schema mismatch:', {
|
|
415
|
+
clientSchema: message.clientSchema,
|
|
416
|
+
serverSchema: message.serverSchema,
|
|
417
|
+
reason: message.reason,
|
|
418
|
+
})
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
case 'error':
|
|
422
|
+
console.error('Sync error:', message.error)
|
|
423
|
+
// Common causes:
|
|
424
|
+
// - Room not found (check roomId)
|
|
425
|
+
// - Permission denied (check authentication)
|
|
426
|
+
// - Invalid record data (check schema validation)
|
|
427
|
+
break
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// Network-level debugging
|
|
432
|
+
syncClient.socket.onStatusChange((status) => {
|
|
433
|
+
if (status === 'offline') {
|
|
434
|
+
console.log('Connection lost - check network and server health')
|
|
435
|
+
|
|
436
|
+
// Attempt manual reconnection
|
|
437
|
+
setTimeout(() => {
|
|
438
|
+
syncClient.socket.restart()
|
|
439
|
+
}, 1000)
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Performance Monitoring
|
|
445
|
+
|
|
446
|
+
Track synchronization performance metrics:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
class SyncProfiler {
|
|
450
|
+
private messageCount = 0
|
|
451
|
+
private bytesTransferred = 0
|
|
452
|
+
private roundTripTimes: number[] = []
|
|
453
|
+
|
|
454
|
+
profile(syncClient: TLSyncClient) {
|
|
455
|
+
const startTime = Date.now()
|
|
456
|
+
|
|
457
|
+
syncClient.onReceiveMessage((message) => {
|
|
458
|
+
this.messageCount++
|
|
459
|
+
this.bytesTransferred += JSON.stringify(message).length
|
|
460
|
+
|
|
461
|
+
// Track ping/pong for latency
|
|
462
|
+
if (message.type === 'pong') {
|
|
463
|
+
const roundTrip = Date.now() - message.sentAt
|
|
464
|
+
this.roundTripTimes.push(roundTrip)
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// Periodic reporting
|
|
469
|
+
setInterval(() => {
|
|
470
|
+
const avgLatency =
|
|
471
|
+
this.roundTripTimes.length > 0
|
|
472
|
+
? this.roundTripTimes.reduce((a, b) => a + b, 0) / this.roundTripTimes.length
|
|
473
|
+
: 0
|
|
474
|
+
|
|
475
|
+
console.log('Sync Performance:', {
|
|
476
|
+
uptime: Date.now() - startTime,
|
|
477
|
+
messages: this.messageCount,
|
|
478
|
+
bytesTransferred: this.bytesTransferred,
|
|
479
|
+
avgLatencyMs: avgLatency,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
this.roundTripTimes = [] // Reset for next period
|
|
483
|
+
}, 30000)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
new SyncProfiler().profile(syncClient)
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
> Tip: High message counts or latency often indicate network issues or inefficient change patterns. Consider batching rapid changes or optimizing your shape update logic.
|
|
491
|
+
|
|
492
|
+
## 6. Integration
|
|
493
|
+
|
|
494
|
+
### React Integration
|
|
495
|
+
|
|
496
|
+
Sync-core integrates seamlessly with React applications through the store's reactive signals:
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
import { useEditor } from '@tldraw/editor'
|
|
500
|
+
import { react } from '@tldraw/state'
|
|
501
|
+
import { useEffect, useState } from 'react'
|
|
502
|
+
|
|
503
|
+
function CollaborationStatusBadge() {
|
|
504
|
+
const editor = useEditor()
|
|
505
|
+
const [status, setStatus] = useState<string>('offline')
|
|
506
|
+
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
if (!editor.store.syncClient) return
|
|
509
|
+
|
|
510
|
+
return react('sync status', () => {
|
|
511
|
+
setStatus(editor.store.syncClient.status.get())
|
|
512
|
+
})
|
|
513
|
+
}, [editor])
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className={`status-badge ${status}`}>
|
|
517
|
+
{status === 'online' ? '🟢 Connected' : '🔴 Offline'}
|
|
518
|
+
</div>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
The reactive nature of sync-core means your React components automatically update when connection status or synchronized data changes.
|
|
524
|
+
|
|
525
|
+
### Custom Persistence
|
|
526
|
+
|
|
527
|
+
Integrate with your existing database or storage systems:
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { TLSyncRoom } from '@tldraw/sync-core'
|
|
531
|
+
|
|
532
|
+
class DatabasePersistenceAdapter {
|
|
533
|
+
constructor(
|
|
534
|
+
private db: Database,
|
|
535
|
+
private roomId: string
|
|
536
|
+
) {}
|
|
537
|
+
|
|
538
|
+
async loadRoom(): Promise<SerializedStore> {
|
|
539
|
+
const roomData = await this.db.query('SELECT document_state FROM rooms WHERE id = ?', [
|
|
540
|
+
this.roomId,
|
|
541
|
+
])
|
|
542
|
+
return JSON.parse(roomData.document_state)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async saveRoom(serializedStore: SerializedStore): Promise<void> {
|
|
546
|
+
await this.db.query('UPDATE rooms SET document_state = ?, updated_at = NOW() WHERE id = ?', [
|
|
547
|
+
JSON.stringify(serializedStore),
|
|
548
|
+
this.roomId,
|
|
549
|
+
])
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const room = new TLSyncRoom({
|
|
554
|
+
store: createTLStore({ schema }),
|
|
555
|
+
roomId: 'room-123',
|
|
556
|
+
persistenceAdapter: new DatabasePersistenceAdapter(myDatabase, 'room-123'),
|
|
557
|
+
})
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
This allows rooms to persist their state to your preferred storage backend while maintaining real-time synchronization.
|
|
561
|
+
|
|
562
|
+
### Authentication and Authorization
|
|
563
|
+
|
|
564
|
+
Implement custom authentication by extending the WebSocket adapter:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
class AuthenticatedSocketAdapter extends ClientWebSocketAdapter {
|
|
568
|
+
constructor(
|
|
569
|
+
url: string,
|
|
570
|
+
private authToken: string
|
|
571
|
+
) {
|
|
572
|
+
super(url)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
protected connect(): void {
|
|
576
|
+
this.ws = new WebSocket(this.url, [], {
|
|
577
|
+
headers: {
|
|
578
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
579
|
+
},
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
this.setupEventHandlers()
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Server-side authentication
|
|
587
|
+
room.handleSocketConnect(socket, {
|
|
588
|
+
sessionId: generateSessionId(),
|
|
589
|
+
userId: extractUserFromToken(authToken),
|
|
590
|
+
isReadonly: !hasEditPermission(authToken, roomId),
|
|
591
|
+
})
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Multi-Room Applications
|
|
595
|
+
|
|
596
|
+
Manage multiple collaborative documents in a single application:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
class RoomManager {
|
|
600
|
+
private rooms = new Map<string, TLSyncClient>()
|
|
601
|
+
|
|
602
|
+
joinRoom(roomId: string): TLSyncClient {
|
|
603
|
+
if (this.rooms.has(roomId)) {
|
|
604
|
+
return this.rooms.get(roomId)!
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const store = createTLStore({ schema: mySchema })
|
|
608
|
+
const socket = new ClientWebSocketAdapter(`ws://localhost:3000/rooms/${roomId}`)
|
|
609
|
+
const syncClient = new TLSyncClient({ store, socket, roomId })
|
|
610
|
+
|
|
611
|
+
this.rooms.set(roomId, syncClient)
|
|
612
|
+
syncClient.connect()
|
|
613
|
+
|
|
614
|
+
return syncClient
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
leaveRoom(roomId: string): void {
|
|
618
|
+
const client = this.rooms.get(roomId)
|
|
619
|
+
if (client) {
|
|
620
|
+
client.disconnect()
|
|
621
|
+
this.rooms.delete(roomId)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const roomManager = new RoomManager()
|
|
627
|
+
const drawingRoom = roomManager.joinRoom('drawing-123')
|
|
628
|
+
const presentationRoom = roomManager.joinRoom('slides-456')
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
### Edge Computing and Cloudflare Workers
|
|
632
|
+
|
|
633
|
+
Sync-core works well with edge computing platforms:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
// Cloudflare Worker example
|
|
637
|
+
export default {
|
|
638
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
639
|
+
if (request.headers.get('Upgrade') !== 'websocket') {
|
|
640
|
+
return new Response('Expected websocket', { status: 426 })
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
644
|
+
const roomId = new URL(request.url).pathname.split('/').pop()
|
|
645
|
+
|
|
646
|
+
const room = this.getOrCreateRoom(roomId, env)
|
|
647
|
+
room.handleSocketConnect(server, {
|
|
648
|
+
sessionId: crypto.randomUUID(),
|
|
649
|
+
// Extract user info from request headers or auth
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
return new Response(null, {
|
|
653
|
+
status: 101,
|
|
654
|
+
webSocket: client,
|
|
655
|
+
})
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The lightweight nature of sync-core makes it suitable for serverless and edge environments where traditional long-running connections might be challenging.
|
|
661
|
+
|
|
662
|
+
> Tip: When deploying to edge environments, consider the trade-offs between geographical distribution (lower latency) and consistency (potential for split-brain scenarios).
|
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
This project contains core functionality for [tldraw sync](https://tldraw.dev/docs/sync). [Click here](https://tldraw.dev/blog/product/announcing-tldraw-sync) to learn more.
|
|
4
4
|
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
|
8
|
+
|
|
5
9
|
## License
|
|
6
10
|
|
|
7
11
|
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
|
@@ -18,7 +22,7 @@ You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?a
|
|
|
18
22
|
|
|
19
23
|
## Contribution
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
|
22
26
|
|
|
23
27
|
## Community
|
|
24
28
|
|
package/dist-cjs/index.js
CHANGED
|
@@ -61,7 +61,7 @@ var import_TLSyncRoom = require("./lib/TLSyncRoom");
|
|
|
61
61
|
var import_TLSyncStorage = require("./lib/TLSyncStorage");
|
|
62
62
|
(0, import_utils.registerTldrawLibraryVersion)(
|
|
63
63
|
"@tldraw/sync-core",
|
|
64
|
-
"5.2.0-next.
|
|
64
|
+
"5.2.0-next.79b13319d317",
|
|
65
65
|
"cjs"
|
|
66
66
|
);
|
|
67
67
|
//# sourceMappingURL=index.js.map
|
package/dist-esm/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync-core",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (multiplayer sync).",
|
|
4
|
-
"version": "5.2.0-next.
|
|
4
|
+
"version": "5.2.0-next.79b13319d317",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw GB Ltd.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"dist-esm",
|
|
31
31
|
"dist-cjs",
|
|
32
|
-
"src"
|
|
32
|
+
"src",
|
|
33
|
+
"DOCS.md"
|
|
33
34
|
],
|
|
34
35
|
"scripts": {
|
|
35
36
|
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
|
@@ -48,17 +49,17 @@
|
|
|
48
49
|
"@types/uuid-readable": "^0.0.3",
|
|
49
50
|
"react": "^19.2.1",
|
|
50
51
|
"react-dom": "^19.2.1",
|
|
51
|
-
"tldraw": "5.2.0-next.
|
|
52
|
+
"tldraw": "5.2.0-next.79b13319d317",
|
|
52
53
|
"typescript": "^5.8.3",
|
|
53
54
|
"uuid-by-string": "^4.0.0",
|
|
54
55
|
"uuid-readable": "^0.0.2",
|
|
55
|
-
"vitest": "^
|
|
56
|
+
"vitest": "^4.1.7"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
|
-
"@tldraw/state": "5.2.0-next.
|
|
59
|
-
"@tldraw/store": "5.2.0-next.
|
|
60
|
-
"@tldraw/tlschema": "5.2.0-next.
|
|
61
|
-
"@tldraw/utils": "5.2.0-next.
|
|
59
|
+
"@tldraw/state": "5.2.0-next.79b13319d317",
|
|
60
|
+
"@tldraw/store": "5.2.0-next.79b13319d317",
|
|
61
|
+
"@tldraw/tlschema": "5.2.0-next.79b13319d317",
|
|
62
|
+
"@tldraw/utils": "5.2.0-next.79b13319d317",
|
|
62
63
|
"nanoevents": "^7.0.1",
|
|
63
64
|
"ws": "^8.18.0"
|
|
64
65
|
},
|
|
@@ -429,7 +429,7 @@ describe(TLSocketRoom, () => {
|
|
|
429
429
|
|
|
430
430
|
describe('Session management', () => {
|
|
431
431
|
let room: TLSocketRoom
|
|
432
|
-
let onSessionRemoved: ReturnType<typeof vi.fn
|
|
432
|
+
let onSessionRemoved: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
433
433
|
|
|
434
434
|
beforeEach(() => {
|
|
435
435
|
onSessionRemoved = vi.fn()
|
|
@@ -488,8 +488,8 @@ describe(TLSocketRoom, () => {
|
|
|
488
488
|
describe('Message handling', () => {
|
|
489
489
|
let room: TLSocketRoom
|
|
490
490
|
let socket: WebSocketMinimal
|
|
491
|
-
let onBeforeSendMessage: ReturnType<typeof vi.fn
|
|
492
|
-
let onAfterReceiveMessage: ReturnType<typeof vi.fn
|
|
491
|
+
let onBeforeSendMessage: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
492
|
+
let onAfterReceiveMessage: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
|
493
493
|
|
|
494
494
|
beforeEach(() => {
|
|
495
495
|
onBeforeSendMessage = vi.fn()
|