@tldraw/sync 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248
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 +637 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/useSync.js +2 -5
- package/dist-cjs/useSync.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/useSync.mjs +2 -5
- package/dist-esm/useSync.mjs.map +2 -2
- package/package.json +9 -8
- package/src/useSync.ts +9 -5
package/DOCS.md
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# @tldraw/sync
|
|
2
|
+
|
|
3
|
+
The `@tldraw/sync` package provides React hooks and utilities for integrating real-time collaboration into your tldraw applications. Built on top of `@tldraw/sync-core`, it offers a developer-friendly API that enables multiplayer functionality with minimal configuration.
|
|
4
|
+
|
|
5
|
+
## 1. Introduction
|
|
6
|
+
|
|
7
|
+
Real-time collaboration transforms single-user drawing applications into shared creative spaces where multiple users can work together simultaneously. The sync package handles the complex coordination required for multiplayer experiences: synchronizing changes, managing user presence, handling network interruptions, and resolving conflicts.
|
|
8
|
+
|
|
9
|
+
You create collaborative tldraw applications using two main hooks:
|
|
10
|
+
|
|
11
|
+
- `useSync` - For production applications with custom servers
|
|
12
|
+
- `useSyncDemo` - For prototypes and demos using tldraw's hosted demo server
|
|
13
|
+
|
|
14
|
+
Both hooks return a store wrapped with connection status, allowing you to build responsive UIs that gracefully handle loading states, connection issues, and real-time updates.
|
|
15
|
+
|
|
16
|
+
## 2. Core Concepts
|
|
17
|
+
|
|
18
|
+
### Multiplayer Store State
|
|
19
|
+
|
|
20
|
+
The foundation of sync integration is the **RemoteTLStoreWithStatus**, an enhanced store wrapper that tracks both your drawing data and connection state:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { useSync } from '@tldraw/sync'
|
|
24
|
+
|
|
25
|
+
function MyApp() {
|
|
26
|
+
const store = useSync({
|
|
27
|
+
uri: 'wss://myserver.com/sync/room-123',
|
|
28
|
+
assets: myAssetStore,
|
|
29
|
+
userInfo: { id: 'user-1', name: 'Alice', color: '#ff0000' }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Store progresses through these states:
|
|
33
|
+
if (store.status === 'loading') return <div>Connecting...</div>
|
|
34
|
+
if (store.status === 'error') return <div>Connection failed: {store.error.message}</div>
|
|
35
|
+
|
|
36
|
+
// store.status === 'synced-remote'
|
|
37
|
+
return <Tldraw store={store.store} />
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The store moves through three distinct states as it establishes and maintains connection:
|
|
42
|
+
|
|
43
|
+
1. **loading** - Initial connection and synchronization with the server
|
|
44
|
+
2. **synced-remote** - Successfully connected and actively synchronizing changes
|
|
45
|
+
3. **error** - Connection failed or a synchronization error occurred
|
|
46
|
+
|
|
47
|
+
### User Presence
|
|
48
|
+
|
|
49
|
+
**/User presence** encompasses the real-time information about other users in your collaborative session. This includes cursor positions, current selections, and any custom presence data you want to share. The `useSync` hook handles this automatically, but you can provide a custom `getUserPresence` function to send additional data.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const store = useSync({
|
|
53
|
+
uri: wsUri,
|
|
54
|
+
assets: myAssets,
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The presence system automatically optimizes itself based on room occupancy, switching between 'solo' mode when you're alone and 'full' mode when collaborating with others.
|
|
59
|
+
|
|
60
|
+
### Asset Management
|
|
61
|
+
|
|
62
|
+
**Assets** are files like images, videos, and other media that users embed in their drawings. The sync package requires an asset store implementation that handles uploading files and resolving them for display:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const myAssetStore = {
|
|
66
|
+
upload: async (asset, file) => {
|
|
67
|
+
// Upload file to your storage service
|
|
68
|
+
const url = await uploadToStorage(file)
|
|
69
|
+
return { src: url }
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
resolve: (asset, context) => {
|
|
73
|
+
// Return optimized URLs based on context
|
|
74
|
+
// (screen DPI, network quality, display size)
|
|
75
|
+
return getOptimizedUrl(asset.src, context)
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 3. Basic Usage
|
|
81
|
+
|
|
82
|
+
### Quick Start with Demo Server
|
|
83
|
+
|
|
84
|
+
The fastest way to add collaboration to your application is using the demo server. This requires no backend setup and works immediately:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { useSyncDemo } from '@tldraw/sync'
|
|
88
|
+
import { Tldraw } from 'tldraw'
|
|
89
|
+
|
|
90
|
+
function CollaborativeApp() {
|
|
91
|
+
const store = useSyncDemo({
|
|
92
|
+
roomId: 'my-prototype-room-123',
|
|
93
|
+
userInfo: {
|
|
94
|
+
id: 'user-' + Math.random(),
|
|
95
|
+
name: 'Anonymous User',
|
|
96
|
+
color: '#' + Math.floor(Math.random()*16777215).toString(16)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (store.status === 'loading') {
|
|
101
|
+
return <div>Connecting to room...</div>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (store.status === 'error') {
|
|
105
|
+
return <div>Failed to connect: {store.error.message}</div>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return <Tldraw store={store.store} />
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> Tip: The demo server is perfect for prototyping and testing, but data is automatically deleted after ~24 hours and rooms are publicly accessible to anyone with the room ID.
|
|
113
|
+
|
|
114
|
+
### Production Integration
|
|
115
|
+
|
|
116
|
+
For production applications, use `useSync` with your own WebSocket server:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { useSync } from '@tldraw/sync'
|
|
120
|
+
import { Tldraw } from 'tldraw'
|
|
121
|
+
|
|
122
|
+
function ProductionApp() {
|
|
123
|
+
const store = useSync({
|
|
124
|
+
uri: 'wss://myserver.com/sync/project-collaboration-session',
|
|
125
|
+
userInfo: getCurrentUser(), // Your user system integration
|
|
126
|
+
assets: productionAssetStore, // Your asset storage integration
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div>
|
|
131
|
+
{store.status === 'loading' && <LoadingSpinner />}
|
|
132
|
+
{store.status === 'error' && <ErrorMessage error={store.error} />}
|
|
133
|
+
{store.status === 'synced-remote' && (
|
|
134
|
+
<>
|
|
135
|
+
<ConnectionIndicator status={store.connectionStatus} />
|
|
136
|
+
<Tldraw store={store.store} />
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 4. Advanced Configuration
|
|
145
|
+
|
|
146
|
+
### Dynamic Connection URIs
|
|
147
|
+
|
|
148
|
+
You can provide connection URIs dynamically, which is essential for authentication and room-specific routing:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const store = useSync({
|
|
152
|
+
uri: async () => {
|
|
153
|
+
const token = await getAuthToken()
|
|
154
|
+
const roomId = getCurrentRoomId()
|
|
155
|
+
return `wss://myserver.com/sync/${roomId}?token=${token}`
|
|
156
|
+
},
|
|
157
|
+
assets: authenticatedAssetStore,
|
|
158
|
+
userInfo: userSignal, // Can be a reactive signal that updates
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
When the URI function is async, the sync system waits for it to resolve before attempting connection. This ensures your authentication flow completes before establishing the WebSocket connection.
|
|
163
|
+
|
|
164
|
+
### Reactive User Information
|
|
165
|
+
|
|
166
|
+
User information can be static or reactive. Using reactive signals allows the presence system to automatically update when user details change:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { atom } from '@tldraw/state'
|
|
170
|
+
|
|
171
|
+
const currentUser = atom('currentUser', {
|
|
172
|
+
id: 'user-123',
|
|
173
|
+
name: 'Alice',
|
|
174
|
+
color: '#ff0000',
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const store = useSync({
|
|
178
|
+
uri: wsUri,
|
|
179
|
+
assets: myAssets,
|
|
180
|
+
userInfo: currentUser, // Reactive signal
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Later, when user updates their profile:
|
|
184
|
+
currentUser.set({
|
|
185
|
+
id: 'user-123',
|
|
186
|
+
name: 'Alice Cooper', // Updated name
|
|
187
|
+
color: '#00ff00', // New color
|
|
188
|
+
})
|
|
189
|
+
// Presence automatically updates for all connected users
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Custom Presence Data
|
|
193
|
+
|
|
194
|
+
The `getUserPresence` function allows you to include custom presence information beyond the standard cursor and selection data. The function receives the `store` and the current `user` info. It should return an object that conforms to the `TLPresenceStateInfo` type.
|
|
195
|
+
|
|
196
|
+
Note that the `store` object passed to this function is a `TLStore` instance, and does not have an `editor` property. To access editor-specific state like the current tool or cursor position, you will need to find a way to access the `Editor` instance from your component.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const store = useSync({
|
|
200
|
+
uri: wsUri,
|
|
201
|
+
assets: myAssets,
|
|
202
|
+
getUserPresence: (store, user) => {
|
|
203
|
+
// This function is called whenever the store changes.
|
|
204
|
+
// You can use it to derive presence information from the store.
|
|
205
|
+
// To get information like cursor position, you may need to
|
|
206
|
+
// find a way to access your <Tldraw /> component's editor instance.
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
userId: user.id,
|
|
210
|
+
userName: user.name,
|
|
211
|
+
// ... and other properties from TLPresenceStateInfo
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Asset Store Implementation
|
|
218
|
+
|
|
219
|
+
A complete asset store handles both uploading new files and resolving existing assets for optimal display:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const productionAssetStore = {
|
|
223
|
+
upload: async (asset, file) => {
|
|
224
|
+
// Generate unique filename
|
|
225
|
+
const filename = `${Date.now()}-${file.name}`
|
|
226
|
+
|
|
227
|
+
// Upload to your storage service
|
|
228
|
+
const formData = new FormData()
|
|
229
|
+
formData.append('file', file)
|
|
230
|
+
formData.append('filename', filename)
|
|
231
|
+
|
|
232
|
+
const response = await fetch('/api/upload', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
body: formData,
|
|
235
|
+
headers: {
|
|
236
|
+
Authorization: `Bearer ${await getAuthToken()}`,
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const { url } = await response.json()
|
|
241
|
+
return { src: url }
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
resolve: (asset, context) => {
|
|
245
|
+
const baseUrl = asset.src
|
|
246
|
+
|
|
247
|
+
// Return different resolutions based on context
|
|
248
|
+
if (context.shouldResolveToOriginal) {
|
|
249
|
+
return baseUrl // Full quality for printing/export
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Optimize based on display size and screen density
|
|
253
|
+
const targetWidth = Math.ceil(context.screenScale * context.imageSize.w)
|
|
254
|
+
const targetHeight = Math.ceil(context.screenScale * context.imageSize.h)
|
|
255
|
+
|
|
256
|
+
return `${baseUrl}?w=${targetWidth}&h=${targetHeight}&q=${context.networkQuality}`
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## 5. Connection Management
|
|
262
|
+
|
|
263
|
+
### Understanding Connection States
|
|
264
|
+
|
|
265
|
+
The sync system provides granular connection status information to help you build responsive UIs:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
function ConnectionAwareApp() {
|
|
269
|
+
const store = useSync({ /* ... */ })
|
|
270
|
+
|
|
271
|
+
if (store.status === 'loading') {
|
|
272
|
+
return <div>Establishing connection...</div>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (store.status === 'error') {
|
|
276
|
+
return (
|
|
277
|
+
<div>
|
|
278
|
+
<h3>Connection Error</h3>
|
|
279
|
+
<p>{store.error.message}</p>
|
|
280
|
+
<button onClick={() => window.location.reload()}>
|
|
281
|
+
Retry Connection
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// store.status === 'synced-remote'
|
|
288
|
+
return (
|
|
289
|
+
<div>
|
|
290
|
+
<NetworkIndicator status={store.connectionStatus} />
|
|
291
|
+
<Tldraw store={store.store} />
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function NetworkIndicator({ status }) {
|
|
297
|
+
if (status === 'offline') {
|
|
298
|
+
return <div className="warning">Working offline - changes will sync when reconnected</div>
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (status === 'online') {
|
|
302
|
+
return <div className="success">Connected and syncing</div>
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The connection status operates independently of the sync status. Even when `store.status` is 'synced-remote', the `connectionStatus` can be 'offline' if network connectivity is lost, allowing the application to continue working locally.
|
|
310
|
+
|
|
311
|
+
### Automatic Reconnection
|
|
312
|
+
|
|
313
|
+
The sync system handles network interruptions gracefully with automatic reconnection and state recovery:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// No additional code needed - reconnection is automatic
|
|
317
|
+
const store = useSync({
|
|
318
|
+
uri: 'wss://myserver.com/sync/room-123',
|
|
319
|
+
assets: myAssets,
|
|
320
|
+
userInfo: currentUser,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// The system automatically:
|
|
324
|
+
// 1. Detects network disconnection
|
|
325
|
+
// 2. Queues local changes while offline
|
|
326
|
+
// 3. Attempts reconnection with exponential backoff
|
|
327
|
+
// 4. Reconciles state when connection is restored
|
|
328
|
+
// 5. Handles conflicts between local and server changes
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Error Handling and Recovery
|
|
332
|
+
|
|
333
|
+
Different types of connection errors require different handling strategies:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
function ErrorHandlingApp() {
|
|
337
|
+
const store = useSync({ /* ... */ })
|
|
338
|
+
|
|
339
|
+
if (store.status === 'error') {
|
|
340
|
+
const error = store.error
|
|
341
|
+
|
|
342
|
+
// Check specific error types for appropriate responses
|
|
343
|
+
if (error.reason === 'NOT_FOUND') {
|
|
344
|
+
return <div>Room not found. Please check the room ID.</div>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (error.reason === 'FORBIDDEN') {
|
|
348
|
+
return <div>Access denied. Please check your permissions.</div>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (error.reason === 'NOT_AUTHENTICATED') {
|
|
352
|
+
return <div>Authentication required. <button onClick={login}>Login</button></div>
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (error.reason === 'RATE_LIMITED') {
|
|
356
|
+
return <div>Too many requests. Please wait before retrying.</div>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Generic network or server error
|
|
360
|
+
return (
|
|
361
|
+
<div>
|
|
362
|
+
<h3>Connection Error</h3>
|
|
363
|
+
<p>Unable to connect to collaboration server.</p>
|
|
364
|
+
<button onClick={() => window.location.reload()}>Retry</button>
|
|
365
|
+
</div>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return <Tldraw store={store.store} />
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## 6. Debugging
|
|
374
|
+
|
|
375
|
+
### Understanding Connection Behavior
|
|
376
|
+
|
|
377
|
+
The sync package provides several tools for understanding and debugging connection behavior in your application.
|
|
378
|
+
|
|
379
|
+
#### Connection Status Monitoring
|
|
380
|
+
|
|
381
|
+
You can monitor connection events by logging the status changes:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import { useEffect } from 'react'
|
|
385
|
+
|
|
386
|
+
function DebuggableApp() {
|
|
387
|
+
const store = useSync({
|
|
388
|
+
uri: 'wss://myserver.com/sync/room-123',
|
|
389
|
+
assets: myAssets,
|
|
390
|
+
userInfo: currentUser,
|
|
391
|
+
trackAnalyticsEvent: (name, data) => {
|
|
392
|
+
console.log('Sync Event:', name, data)
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
console.log('Store status changed:', store.status)
|
|
398
|
+
|
|
399
|
+
if (store.status === 'synced-remote') {
|
|
400
|
+
console.log('Connection status:', store.connectionStatus)
|
|
401
|
+
}
|
|
402
|
+
}, [store.status, store.status === 'synced-remote' ? store.connectionStatus : null])
|
|
403
|
+
|
|
404
|
+
return <Tldraw store={store.store} />
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
This will log events like:
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
Sync Event: room-not-found { roomId: "room-123" }
|
|
412
|
+
Sync Event: connected { isReadonly: false }
|
|
413
|
+
Store status changed: synced-remote
|
|
414
|
+
Connection status: online
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
#### Network Quality Detection
|
|
418
|
+
|
|
419
|
+
The demo asset store includes network quality detection that affects image resolution:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
// In the demo environment, you can observe network adaptation:
|
|
423
|
+
const store = useSyncDemo({
|
|
424
|
+
roomId: 'debug-room',
|
|
425
|
+
userInfo: currentUser,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// Images automatically adjust quality based on:
|
|
429
|
+
// - Connection speed (detected from WebSocket latency)
|
|
430
|
+
// - Screen pixel density
|
|
431
|
+
// - Actual display size
|
|
432
|
+
// - File size thresholds
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
#### Presence System Debugging
|
|
436
|
+
|
|
437
|
+
You can debug presence updates by monitoring presence mode changes:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { getDefaultUserPresence } from 'tldraw'
|
|
441
|
+
|
|
442
|
+
function PresenceDebuggingApp() {
|
|
443
|
+
const store = useSync({
|
|
444
|
+
uri: wsUri,
|
|
445
|
+
assets: myAssets,
|
|
446
|
+
getUserPresence: (store, user) => {
|
|
447
|
+
// See the "Custom Presence Data" section for details
|
|
448
|
+
// on how to implement this function.
|
|
449
|
+
const presence = getDefaultUserPresence(store, user)
|
|
450
|
+
console.log('Updating presence:', presence)
|
|
451
|
+
return presence
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (store.status === 'synced-remote') {
|
|
457
|
+
// Monitor presence mode changes
|
|
458
|
+
const unsubscribe = store.store.listen(() => {
|
|
459
|
+
const presences = store.store.allRecords().filter(r => r.typeName === 'instance_presence')
|
|
460
|
+
console.log('Active users:', presences.length)
|
|
461
|
+
console.log('Presence mode:', presences.length > 1 ? 'collaborative' : 'solo')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
return unsubscribe
|
|
465
|
+
}
|
|
466
|
+
}, [store])
|
|
467
|
+
|
|
468
|
+
return <Tldraw store={store.store} />
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### Common Connection Issues
|
|
473
|
+
|
|
474
|
+
**WebSocket Connection Failures**
|
|
475
|
+
|
|
476
|
+
If connections consistently fail, verify the WebSocket URL format:
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
// ✅ Correct formats:
|
|
480
|
+
'wss://myserver.com/sync'
|
|
481
|
+
'wss://myserver.com/sync/room-123'
|
|
482
|
+
'ws://localhost:3001/sync' // Development only
|
|
483
|
+
|
|
484
|
+
// ❌ Common mistakes:
|
|
485
|
+
'https://myserver.com/sync' // Wrong protocol
|
|
486
|
+
'wss://myserver.com/sync/' // Trailing slash may cause issues
|
|
487
|
+
'myserver.com/sync' // Missing protocol
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**Authentication Token Issues**
|
|
491
|
+
|
|
492
|
+
When using token authentication, ensure tokens remain valid throughout the session:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
const store = useSync({
|
|
496
|
+
uri: async () => {
|
|
497
|
+
const token = await refreshTokenIfNeeded() // Ensure token is fresh
|
|
498
|
+
return `wss://myserver.com/sync?token=${token}`
|
|
499
|
+
},
|
|
500
|
+
assets: myAssets,
|
|
501
|
+
userInfo: currentUser,
|
|
502
|
+
})
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Asset Upload Problems**
|
|
506
|
+
|
|
507
|
+
Asset upload failures often relate to CORS configuration or authentication:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
const debugAssetStore = {
|
|
511
|
+
upload: async (asset, file) => {
|
|
512
|
+
console.log('Uploading asset:', {
|
|
513
|
+
name: file.name,
|
|
514
|
+
size: file.size,
|
|
515
|
+
type: file.type,
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
const result = await uploadToServer(file)
|
|
520
|
+
console.log('Upload successful:', result)
|
|
521
|
+
return result
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.error('Upload failed:', error)
|
|
524
|
+
throw error
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
resolve: (asset, context) => {
|
|
529
|
+
console.log('Resolving asset:', asset.src, 'with context:', context)
|
|
530
|
+
return asset.src
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## 7. Integration with Authentication
|
|
536
|
+
|
|
537
|
+
### Token-Based Authentication
|
|
538
|
+
|
|
539
|
+
Most production applications require authentication. The sync package supports token-based auth through dynamic URI generation:
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
import { useAuth } from './auth-system'
|
|
543
|
+
|
|
544
|
+
function AuthenticatedApp() {
|
|
545
|
+
const { user, getToken } = useAuth()
|
|
546
|
+
|
|
547
|
+
const store = useSync({
|
|
548
|
+
uri: async () => {
|
|
549
|
+
if (!user) throw new Error('Not authenticated')
|
|
550
|
+
|
|
551
|
+
const token = await getToken()
|
|
552
|
+
return `wss://myserver.com/sync/room-123?token=${token}&userId=${user.id}`
|
|
553
|
+
},
|
|
554
|
+
userInfo: {
|
|
555
|
+
id: user.id,
|
|
556
|
+
name: user.displayName,
|
|
557
|
+
color: user.preferredColor
|
|
558
|
+
},
|
|
559
|
+
assets: createAuthenticatedAssetStore(getToken),
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
if (!user) {
|
|
563
|
+
return <LoginPrompt />
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return <Tldraw store={store.store} />
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Session Management
|
|
571
|
+
|
|
572
|
+
Handle authentication state changes gracefully by recreating the sync connection:
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
function SessionManagedApp() {
|
|
576
|
+
const { user, sessionId } = useAuth()
|
|
577
|
+
|
|
578
|
+
// Recreate sync connection when session changes
|
|
579
|
+
const store = useSync({
|
|
580
|
+
uri: `wss://myserver.com/sync?session=${sessionId}`,
|
|
581
|
+
userInfo: user ? {
|
|
582
|
+
id: user.id,
|
|
583
|
+
name: user.name,
|
|
584
|
+
color: user.color
|
|
585
|
+
} : null,
|
|
586
|
+
assets: myAssetStore,
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
// Handle logout
|
|
590
|
+
const handleLogout = () => {
|
|
591
|
+
// Sync connection will automatically clean up
|
|
592
|
+
// when component unmounts or deps change
|
|
593
|
+
logout()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<div>
|
|
598
|
+
{user && <button onClick={handleLogout}>Logout</button>}
|
|
599
|
+
<Tldraw store={store.store} />
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Role-Based Permissions
|
|
606
|
+
|
|
607
|
+
Integrate with permission systems by handling readonly mode:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
function PermissionAwareApp() {
|
|
611
|
+
const store = useSync({
|
|
612
|
+
uri: wsUri,
|
|
613
|
+
assets: myAssets,
|
|
614
|
+
userInfo: currentUser,
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
if (store.status === 'synced-remote') {
|
|
618
|
+
// Server can set readonly mode based on user permissions
|
|
619
|
+
const isReadonly = store.store.collaboration?.mode === 'readonly'
|
|
620
|
+
|
|
621
|
+
if (isReadonly) {
|
|
622
|
+
return (
|
|
623
|
+
<div>
|
|
624
|
+
<div className="notice">You have view-only access to this room</div>
|
|
625
|
+
<Tldraw store={store.store} />
|
|
626
|
+
</div>
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return <Tldraw store={store.store} />
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
The sync system automatically handles permission enforcement when the server sets readonly mode, preventing local changes from being applied or synchronized.
|
|
636
|
+
|
|
637
|
+
This comprehensive guide covers the essential concepts, practical implementation patterns, and advanced features of the `@tldraw/sync` package. The reactive nature of the sync system, combined with robust error handling and flexible configuration options, enables you to build reliable collaborative experiences that gracefully handle the complexities of real-time multiplayer interaction.
|
package/README.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
This project contains source code 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
|
+
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
|
8
|
+
|
|
9
|
+
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
|
10
|
+
|
|
11
|
+
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
|
12
|
+
|
|
5
13
|
## License
|
|
6
14
|
|
|
7
15
|
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 +26,7 @@ You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?a
|
|
|
18
26
|
|
|
19
27
|
## Contribution
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
|
22
30
|
|
|
23
31
|
## Community
|
|
24
32
|
|
package/dist-cjs/index.js
CHANGED
|
@@ -29,7 +29,7 @@ var import_useSync = require("./useSync");
|
|
|
29
29
|
var import_useSyncDemo = require("./useSyncDemo");
|
|
30
30
|
(0, import_utils.registerTldrawLibraryVersion)(
|
|
31
31
|
"@tldraw/sync",
|
|
32
|
-
"5.2.0-canary.
|
|
32
|
+
"5.2.0-canary.fff413eea248",
|
|
33
33
|
"cjs"
|
|
34
34
|
);
|
|
35
35
|
//# sourceMappingURL=index.js.map
|
package/dist-cjs/useSync.js
CHANGED
|
@@ -143,12 +143,9 @@ function useSync(opts) {
|
|
|
143
143
|
const presence = (0, import_tldraw.createPresenceStateDerivation)(currentUser, {
|
|
144
144
|
getUserPresence
|
|
145
145
|
})(store);
|
|
146
|
-
const
|
|
147
|
-
userId: { neq: currentUser.get().id }
|
|
148
|
-
}));
|
|
146
|
+
const otherSessions = store.query.ids("instance_presence");
|
|
149
147
|
const presenceMode = (0, import_tldraw.computed)("presenceMode", () => {
|
|
150
|
-
|
|
151
|
-
return "full";
|
|
148
|
+
return otherSessions.get().size === 0 ? "solo" : "full";
|
|
152
149
|
});
|
|
153
150
|
const client = new import_sync_core.TLSyncClient({
|
|
154
151
|
store,
|
package/dist-cjs/useSync.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/useSync.ts"],
|
|
4
|
-
"sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\tconst otherUserPresences = store.query.ids('instance_presence', () => ({\n\t\t\tuserId: { neq: currentUser.get().id },\n\t\t}))\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\tif (otherUserPresences.get().size === 0) return 'solo'\n\t\t\treturn 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["client"]
|
|
7
7
|
}
|
package/dist-esm/index.mjs
CHANGED
package/dist-esm/useSync.mjs
CHANGED
|
@@ -146,12 +146,9 @@ function useSync(opts) {
|
|
|
146
146
|
const presence = createPresenceStateDerivation(currentUser, {
|
|
147
147
|
getUserPresence
|
|
148
148
|
})(store);
|
|
149
|
-
const
|
|
150
|
-
userId: { neq: currentUser.get().id }
|
|
151
|
-
}));
|
|
149
|
+
const otherSessions = store.query.ids("instance_presence");
|
|
152
150
|
const presenceMode = computed("presenceMode", () => {
|
|
153
|
-
|
|
154
|
-
return "full";
|
|
151
|
+
return otherSessions.get().size === 0 ? "solo" : "full";
|
|
155
152
|
});
|
|
156
153
|
const client = new TLSyncClient({
|
|
157
154
|
store,
|
package/dist-esm/useSync.mjs.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/useSync.ts"],
|
|
4
|
-
"sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\tconst otherUserPresences = store.query.ids('instance_presence', () => ({\n\t\t\tuserId: { neq: currentUser.get().id },\n\t\t}))\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\tif (otherUserPresences.get().size === 0) return 'solo'\n\t\t\treturn 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,MAAM,gBAAgB;AAC/B;AAAA,EACC;AAAA,EAIA;AAAA,EAGA;AAAA,EACA;AAAA,OACM;AACP,SAAS,iBAAiB;AAC1B;AAAA,EAEC;AAAA,EAUA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AAEP,MAAM,yBAAyB;AAE/B,MAAM,8BAAsD,MAAM;AAAC;AAwH5D,SAAS,QAAQ,MAAsE;AAC7F,QAAM,CAAC,OAAO,QAAQ,IAAI,YAGhB,IAAI;AACd,QAAM;AAAA,IACL;AAAA,IACA,SAAS;AAAA,IACT;AAAA,IACA,OAAO;AAAA,IACP;AAAA,IACA;AAAA,IACA,qBAAqB;AAAA,IACrB,iBAAiB;AAAA,IACjB,yBAAyB;AAAA,IACzB;AAAA,IACA,GAAG;AAAA,EACJ,IAAI;AAKJ,QAAM,YAAmB;AAEzB,QAAM,iBAAiB,cAAc,MAAM;AAC3C,2BAAyB,cAAc;AACvC,0BAAwB,cAAc;AACtC,QAAM,SAAS,qBAAqB,UAAU;AAE9C,QAAM,kBAAkB;AAAA,IACtB,oBAAoB;AAAA,EACtB;AACA,QAAM,0BAA0B,SAAS,4BAA4B,2BAA2B;AAEhG,YAAU,MAAM;AACf,UAAM,UAAU,SAAS;AAEzB,UAAM,QAA+B,SAClC;AAAA,MACA,aAAa,OAAO;AAAA,MACpB,SACC,OAAO,WACP,wBAAwB,CAAC,WAAW;AACnC,cAAM,UAAU,OAAO,YAAY,IAAI;AACvC,eAAO,WAAW,QAAQ,OAAO,aAAa,MAAM,IAAI,UAAU;AAAA,MACnE,CAAC;AAAA,IACH,IACC;AAAA,MACA,aAAa,iBAAiB;AAAA,MAC9B,SAAS,wBAAwB,CAAC,WAAW;AAC5C,cAAM,UAAU,iBAAiB,YAAY,IAAI;AACjD,YAAI,WAAW,QAAQ,OAAO,aAAa,MAAM,EAAG,QAAO;AAC3D,cAAM,YAAY,MAAM,MAAM,QAAQ,mBAAmB,EAAE,IAAI;AAC/D,cAAM,QAAQ,UAAU,KAAK,CAAC,MAAM,EAAE,WAAW,aAAa,MAAM,CAAC;AACrE,YAAI,OAAO;AACV,iBAAO,eAAe,OAAO;AAAA,YAC5B,IAAI,aAAa,MAAM;AAAA,YACvB,MAAM,MAAM;AAAA,YACZ,OAAO,MAAM;AAAA,UACd,CAAC;AAAA,QACF;AACA,eAAO;AAAA,MACR,CAAC;AAAA,IACF;AAMF,UAAM,cAAc,SAAiB,eAAe,MAAM;AACzD,YAAM,OAAO,MAAM,YAAY,IAAI;AACnC,UAAI,KAAM,QAAO;AACjB,YAAM,QAAQ,mBAAmB;AACjC,aAAO,eAAe,OAAO;AAAA,QAC5B,IAAI,aAAa,MAAM,EAAE;AAAA,QACzB,MAAM,MAAM,QAAQ;AAAA,QACpB,OAAO,MAAM,SAAS,uBAAuB;AAAA,MAC9C,CAAC;AAAA,IACF,CAAC;AAED,QAAI;AAIJ,QAAI,SAAS;AACZ,UAAI,KAAK;AACR,cAAM,IAAI,MAAM,yCAAyC;AAAA,MAC1D;AAEA,eAAS,QAAQ;AAAA,QAChB,WAAW;AAAA,QACX;AAAA,MACD,CAAC;AAAA,IAIF,WAAW,KAAK;AACf,UAAI,SAAS;AACZ,cAAM,IAAI,MAAM,yCAAyC;AAAA,MAC1D;AAEA,eAAS,IAAI,uBAAuB,YAAY;AAC/C,cAAM,YAAY,OAAO,QAAQ,WAAW,MAAM,MAAM,IAAI;AAG5D,cAAM,aAAa,IAAI,IAAI,SAAS;AACpC,YAAI,WAAW,aAAa,IAAI,WAAW,GAAG;AAC7C,gBAAM,IAAI;AAAA,YACT;AAAA,UACD;AAAA,QACD;AACA,YAAI,WAAW,aAAa,IAAI,SAAS,GAAG;AAC3C,gBAAM,IAAI;AAAA,YACT;AAAA,UACD;AAAA,QACD;AAEA,mBAAW,aAAa,IAAI,aAAa,MAAM;AAC/C,mBAAW,aAAa,IAAI,WAAW,OAAO;AAC9C,eAAO,WAAW,SAAS;AAAA,MAC5B,CAAC;AAAA,IACF,OAAO;AACN,YAAM,IAAI,MAAM,iCAAiC;AAAA,IAClD;AAEA,QAAI,YAAY;AAEhB,aAAS,sBAAsB;AAC9B,aAAO,OAAO,qBAAqB,UAAU,YAAY,OAAO;AAAA,IACjE;AACA,UAAM,4BAA4B,KAAK,wBAAwB,oBAAoB,CAAC;AACpF,UAAM,kCAAkC,OAAO,eAAe,MAAM;AACnE,gCAA0B,IAAI,oBAAoB,CAAC;AAAA,IACpD,CAAC;AAED,UAAM,WAAW,KAAK,aAAa,WAAuC;AAE1E,UAAM,QAAQ,cAAc;AAAA,MAC3B,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,eAAe;AAAA,QACd,QAAQ;AAAA,QACR,MAAM;AAAA,MACP;AAAA,IACD,CAAC;AAED,UAAM,WAAW,8BAA8B,aAAa;AAAA,MAC3D;AAAA,IACD,CAAC,EAAE,KAAK;
|
|
4
|
+
"sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["client"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (multiplayer sync react bindings).",
|
|
4
|
-
"version": "5.2.0-canary.
|
|
4
|
+
"version": "5.2.0-canary.fff413eea248",
|
|
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",
|
|
@@ -50,15 +51,15 @@
|
|
|
50
51
|
"typescript": "^5.8.3",
|
|
51
52
|
"uuid-by-string": "^4.0.0",
|
|
52
53
|
"uuid-readable": "^0.0.2",
|
|
53
|
-
"vitest": "^
|
|
54
|
+
"vitest": "^4.1.7"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"@tldraw/state": "5.2.0-canary.
|
|
57
|
-
"@tldraw/state-react": "5.2.0-canary.
|
|
58
|
-
"@tldraw/sync-core": "5.2.0-canary.
|
|
59
|
-
"@tldraw/utils": "5.2.0-canary.
|
|
57
|
+
"@tldraw/state": "5.2.0-canary.fff413eea248",
|
|
58
|
+
"@tldraw/state-react": "5.2.0-canary.fff413eea248",
|
|
59
|
+
"@tldraw/sync-core": "5.2.0-canary.fff413eea248",
|
|
60
|
+
"@tldraw/utils": "5.2.0-canary.fff413eea248",
|
|
60
61
|
"nanoevents": "^7.0.1",
|
|
61
|
-
"tldraw": "5.2.0-canary.
|
|
62
|
+
"tldraw": "5.2.0-canary.fff413eea248",
|
|
62
63
|
"ws": "^8.18.0"
|
|
63
64
|
},
|
|
64
65
|
"peerDependencies": {
|
package/src/useSync.ts
CHANGED
|
@@ -319,13 +319,17 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
319
319
|
getUserPresence,
|
|
320
320
|
})(store)
|
|
321
321
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
// Every connected session — each tab, window, or device — pushes its
|
|
323
|
+
// presence on connect, so the store holds one instance_presence record per
|
|
324
|
+
// *other* session in the room, including the user's own other tabs. (The
|
|
325
|
+
// server never echoes a session its own record.) So an empty set means
|
|
326
|
+
// we're genuinely the only session and can throttle to solo; any other
|
|
327
|
+
// session — another user, or just another tab of our own — keeps us at the
|
|
328
|
+
// full sync rate so edits propagate without the solo-mode lag.
|
|
329
|
+
const otherSessions = store.query.ids('instance_presence')
|
|
325
330
|
|
|
326
331
|
const presenceMode = computed<TLPresenceMode>('presenceMode', () => {
|
|
327
|
-
|
|
328
|
-
return 'full'
|
|
332
|
+
return otherSessions.get().size === 0 ? 'solo' : 'full'
|
|
329
333
|
})
|
|
330
334
|
|
|
331
335
|
const client = new TLSyncClient<TLRecord, TLStore>({
|