@tldraw/sync 5.5.0-next.f9308d324e1c → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/useSync.js +38 -64
- package/dist-cjs/useSync.js.map +2 -2
- package/dist-cjs/useSyncDemo.js +32 -46
- package/dist-cjs/useSyncDemo.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/useSync.mjs +38 -64
- package/dist-esm/useSync.mjs.map +2 -2
- package/dist-esm/useSyncDemo.mjs +32 -46
- package/dist-esm/useSyncDemo.mjs.map +2 -2
- package/package.json +7 -7
- package/src/useSync.test.tsx +95 -0
- package/src/useSync.ts +62 -81
- package/src/useSyncDemo.ts +32 -54
package/src/useSync.ts
CHANGED
|
@@ -49,6 +49,25 @@ const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
|
|
|
49
49
|
|
|
50
50
|
const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
|
|
51
51
|
|
|
52
|
+
// Keyed by `reason`, which is an arbitrary string off the server's close event, so a Map rather
|
|
53
|
+
// than an object literal: a reason of 'constructor' or '__proto__' would find a prototype member.
|
|
54
|
+
const SYNC_ERROR_EVENT_NAMES = new Map<string, string>([
|
|
55
|
+
[TLSyncErrorCloseEventReason.NOT_FOUND, 'room-not-found'],
|
|
56
|
+
[TLSyncErrorCloseEventReason.FORBIDDEN, 'forbidden'],
|
|
57
|
+
[TLSyncErrorCloseEventReason.NOT_AUTHENTICATED, 'not-authenticated'],
|
|
58
|
+
[TLSyncErrorCloseEventReason.RATE_LIMITED, 'rate-limited'],
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
type SyncSocket = TLPersistentClientSocket<
|
|
62
|
+
TLSocketClientSentEvent<TLRecord>,
|
|
63
|
+
TLSocketServerSentEvent<TLRecord>
|
|
64
|
+
>
|
|
65
|
+
|
|
66
|
+
// The socket reports 'error' but the collaboration status only knows 'offline'.
|
|
67
|
+
function toCollaborationStatus(status: SyncSocket['connectionStatus']) {
|
|
68
|
+
return status === 'error' ? 'offline' : status
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
/**
|
|
53
72
|
* A store wrapper specifically for remote collaboration that excludes local-only states.
|
|
54
73
|
* This type represents a tldraw store that is synchronized with a remote multiplayer server.
|
|
@@ -214,33 +233,26 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
214
233
|
useEffect(() => {
|
|
215
234
|
const storeId = uniqueId()
|
|
216
235
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
name: match.userName,
|
|
238
|
-
color: match.color,
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
return null
|
|
242
|
-
}),
|
|
243
|
-
}
|
|
236
|
+
const currentUserSignal = _users?.currentUser ?? defaultUserStore.currentUser
|
|
237
|
+
const users: Required<TLUserStore> = {
|
|
238
|
+
currentUser: currentUserSignal,
|
|
239
|
+
resolve:
|
|
240
|
+
_users?.resolve ??
|
|
241
|
+
createCachedUserResolve((userId) => {
|
|
242
|
+
const id = createUserId(userId)
|
|
243
|
+
const current = currentUserSignal.get()
|
|
244
|
+
if (current && current.id === id) return current
|
|
245
|
+
// Only the default user store falls back to presence records for other users.
|
|
246
|
+
if (_users) return null
|
|
247
|
+
const match = store.query
|
|
248
|
+
.records('instance_presence')
|
|
249
|
+
.get()
|
|
250
|
+
.find((p) => p.userId === id)
|
|
251
|
+
return match
|
|
252
|
+
? UserRecordType.create({ id, name: match.userName, color: match.color })
|
|
253
|
+
: null
|
|
254
|
+
}),
|
|
255
|
+
}
|
|
244
256
|
|
|
245
257
|
// This always returns a non-null user for presence display, falling back
|
|
246
258
|
// to anonymous user preferences. The store receives the raw `users` object
|
|
@@ -257,41 +269,23 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
257
269
|
})
|
|
258
270
|
})
|
|
259
271
|
|
|
260
|
-
let socket:
|
|
261
|
-
TLSocketClientSentEvent<TLRecord>,
|
|
262
|
-
TLSocketServerSentEvent<TLRecord>
|
|
263
|
-
>
|
|
272
|
+
let socket: SyncSocket
|
|
264
273
|
if (connect) {
|
|
265
274
|
if (uri) {
|
|
266
275
|
throw new Error('uri and connect cannot be used together')
|
|
267
276
|
}
|
|
268
277
|
|
|
269
|
-
socket = connect({
|
|
270
|
-
sessionId: TAB_ID,
|
|
271
|
-
storeId,
|
|
272
|
-
}) as TLPersistentClientSocket<
|
|
273
|
-
TLSocketClientSentEvent<TLRecord>,
|
|
274
|
-
TLSocketServerSentEvent<TLRecord>
|
|
275
|
-
>
|
|
278
|
+
socket = connect({ sessionId: TAB_ID, storeId }) as SyncSocket
|
|
276
279
|
} else if (uri) {
|
|
277
|
-
if (connect) {
|
|
278
|
-
throw new Error('uri and connect cannot be used together')
|
|
279
|
-
}
|
|
280
|
-
|
|
281
280
|
socket = new ClientWebSocketAdapter(async () => {
|
|
282
|
-
const uriString = typeof uri === 'string' ? uri : await uri()
|
|
283
|
-
|
|
284
281
|
// set sessionId as a query param on the uri
|
|
285
|
-
const withParams = new URL(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
throw new Error(
|
|
293
|
-
'useSync. "storeId" is a reserved query param name. Please use a different name'
|
|
294
|
-
)
|
|
282
|
+
const withParams = new URL(typeof uri === 'string' ? uri : await uri())
|
|
283
|
+
for (const param of ['sessionId', 'storeId']) {
|
|
284
|
+
if (withParams.searchParams.has(param)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`useSync. "${param}" is a reserved query param name. Please use a different name`
|
|
287
|
+
)
|
|
288
|
+
}
|
|
295
289
|
}
|
|
296
290
|
|
|
297
291
|
withParams.searchParams.set('sessionId', TAB_ID)
|
|
@@ -304,12 +298,12 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
304
298
|
|
|
305
299
|
let didCancel = false
|
|
306
300
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
301
|
+
const collaborationStatusSignal = atom(
|
|
302
|
+
'collaboration status',
|
|
303
|
+
toCollaborationStatus(socket.connectionStatus)
|
|
304
|
+
)
|
|
311
305
|
const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
|
|
312
|
-
collaborationStatusSignal.set(
|
|
306
|
+
collaborationStatusSignal.set(toCollaborationStatus(socket.connectionStatus))
|
|
313
307
|
})
|
|
314
308
|
|
|
315
309
|
const syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')
|
|
@@ -349,30 +343,18 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
349
343
|
didCancel: () => didCancel,
|
|
350
344
|
onLoad(client) {
|
|
351
345
|
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
|
|
352
|
-
//
|
|
353
|
-
|
|
346
|
+
// Keep objectAccess (onAfterConnect always runs first for this client) but drop the
|
|
347
|
+
// rest: an `error` left behind by a previous client (e.g. NOT_FOUND on the old uri)
|
|
348
|
+
// would otherwise keep the hook reporting status 'error' for the new, healthy room.
|
|
349
|
+
setState((prev) => ({ readyClient: client, objectAccess: prev?.objectAccess }))
|
|
354
350
|
},
|
|
355
351
|
onSyncError(reason) {
|
|
356
352
|
console.error('sync error', reason)
|
|
357
353
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
case TLSyncErrorCloseEventReason.FORBIDDEN:
|
|
363
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })
|
|
364
|
-
break
|
|
365
|
-
case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:
|
|
366
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })
|
|
367
|
-
break
|
|
368
|
-
case TLSyncErrorCloseEventReason.RATE_LIMITED:
|
|
369
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })
|
|
370
|
-
break
|
|
371
|
-
default:
|
|
372
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })
|
|
373
|
-
break
|
|
374
|
-
}
|
|
375
|
-
|
|
354
|
+
track?.(MULTIPLAYER_EVENT_NAME, {
|
|
355
|
+
name: SYNC_ERROR_EVENT_NAMES.get(reason) ?? 'sync-error:' + reason,
|
|
356
|
+
roomId,
|
|
357
|
+
})
|
|
376
358
|
setState({ error: new TLRemoteSyncError(reason) })
|
|
377
359
|
socket.close()
|
|
378
360
|
},
|
|
@@ -424,10 +406,9 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
424
406
|
if (!state) return { status: 'loading' }
|
|
425
407
|
if (state.error) return { status: 'error', error: state.error }
|
|
426
408
|
if (!state.readyClient) return { status: 'loading' }
|
|
427
|
-
const connectionStatus = state.readyClient.socket.connectionStatus
|
|
428
409
|
return {
|
|
429
410
|
status: 'synced-remote',
|
|
430
|
-
connectionStatus: connectionStatus
|
|
411
|
+
connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
|
|
431
412
|
store: state.readyClient.store,
|
|
432
413
|
objectAccess: state.objectAccess ?? 'write',
|
|
433
414
|
}
|
package/src/useSyncDemo.ts
CHANGED
|
@@ -121,9 +121,9 @@ export function useSyncDemo(
|
|
|
121
121
|
assets,
|
|
122
122
|
onMount: useCallback(
|
|
123
123
|
(editor: Editor) => {
|
|
124
|
-
editor.registerExternalAssetHandler('url',
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
editor.registerExternalAssetHandler('url', ({ url }) =>
|
|
125
|
+
createAssetFromUrlUsingDemoServer(host, url)
|
|
126
|
+
)
|
|
127
127
|
},
|
|
128
128
|
[host]
|
|
129
129
|
),
|
|
@@ -182,9 +182,7 @@ function createDemoAssetStore(host: string): TLAssetStore {
|
|
|
182
182
|
alert('Uploading images is disabled in this demo.')
|
|
183
183
|
throw new Error('Uploading images is disabled in this demo.')
|
|
184
184
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
const objectName = `${id}-${file.name}`.replace(/\W/g, '-')
|
|
185
|
+
const objectName = `${uniqueId()}-${file.name}`.replace(/\W/g, '-')
|
|
188
186
|
const url = `${host}/uploads/${objectName}`
|
|
189
187
|
|
|
190
188
|
const response = await fetch(url, {
|
|
@@ -201,34 +199,34 @@ function createDemoAssetStore(host: string): TLAssetStore {
|
|
|
201
199
|
},
|
|
202
200
|
|
|
203
201
|
resolve(asset, context) {
|
|
204
|
-
|
|
202
|
+
const { src } = asset.props
|
|
203
|
+
if (!src) return null
|
|
205
204
|
|
|
206
205
|
// We don't deal with videos at the moment.
|
|
207
|
-
if (asset.type === 'video') return
|
|
206
|
+
if (asset.type === 'video') return src
|
|
208
207
|
|
|
209
208
|
// Assert it's an image to make TS happy.
|
|
210
209
|
if (asset.type !== 'image') return null
|
|
211
210
|
|
|
212
211
|
// Don't try to transform data: URLs, yikes.
|
|
213
|
-
if (!
|
|
214
|
-
return asset.props.src
|
|
212
|
+
if (!src.startsWith('http:') && !src.startsWith('https:')) return src
|
|
215
213
|
|
|
216
|
-
if (context.shouldResolveToOriginal) return
|
|
214
|
+
if (context.shouldResolveToOriginal) return src
|
|
217
215
|
|
|
218
216
|
// Don't try to transform animated images.
|
|
219
|
-
if (MediaHelpers.isAnimatedImageType(asset
|
|
220
|
-
return
|
|
217
|
+
if (MediaHelpers.isAnimatedImageType(asset.props.mimeType) || asset.props.isAnimated)
|
|
218
|
+
return src
|
|
221
219
|
|
|
222
220
|
// Don't try to transform vector images.
|
|
223
|
-
if (MediaHelpers.isVectorImageType(asset
|
|
221
|
+
if (MediaHelpers.isVectorImageType(asset.props.mimeType)) return src
|
|
224
222
|
|
|
225
|
-
const url = new URL(
|
|
223
|
+
const url = new URL(src)
|
|
226
224
|
|
|
227
225
|
// we only transform images that are hosted on domains we control
|
|
228
226
|
const isTldrawImage =
|
|
229
227
|
url.origin === host || /\.tldraw\.(?:com|xyz|dev|workers\.dev)$/.test(url.host)
|
|
230
228
|
|
|
231
|
-
if (!isTldrawImage) return
|
|
229
|
+
if (!isTldrawImage) return src
|
|
232
230
|
|
|
233
231
|
// Assets that are under a certain file size aren't worth transforming (and incurring cost).
|
|
234
232
|
// We still send them through the image worker to get them optimized though.
|
|
@@ -256,8 +254,7 @@ function createDemoAssetStore(host: string): TLAssetStore {
|
|
|
256
254
|
url.searchParams.set('w', width.toString())
|
|
257
255
|
}
|
|
258
256
|
|
|
259
|
-
|
|
260
|
-
return newUrl
|
|
257
|
+
return `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
|
|
261
258
|
},
|
|
262
259
|
}
|
|
263
260
|
}
|
|
@@ -285,47 +282,28 @@ function createDemoAssetStore(host: string): TLAssetStore {
|
|
|
285
282
|
* @internal
|
|
286
283
|
*/
|
|
287
284
|
async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
|
|
288
|
-
|
|
285
|
+
let meta: { description?: string; image?: string; favicon?: string; title?: string } | null = null
|
|
289
286
|
try {
|
|
290
287
|
// First, try to get the meta data from our endpoint
|
|
291
288
|
const fetchUrl = new URL(`${host}/bookmarks/unfurl`)
|
|
292
289
|
fetchUrl.searchParams.set('url', url)
|
|
293
|
-
|
|
294
|
-
const meta = (await (await fetch(fetchUrl, { method: 'POST' })).json()) as {
|
|
295
|
-
description?: string
|
|
296
|
-
image?: string
|
|
297
|
-
favicon?: string
|
|
298
|
-
title?: string
|
|
299
|
-
} | null
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
id: AssetRecordType.createId(urlHash),
|
|
303
|
-
typeName: 'asset',
|
|
304
|
-
type: 'bookmark',
|
|
305
|
-
props: {
|
|
306
|
-
src: url,
|
|
307
|
-
description: meta?.description ?? '',
|
|
308
|
-
image: meta?.image ?? '',
|
|
309
|
-
favicon: meta?.favicon ?? '',
|
|
310
|
-
title: meta?.title ?? '',
|
|
311
|
-
},
|
|
312
|
-
meta: {},
|
|
313
|
-
}
|
|
290
|
+
meta = await (await fetch(fetchUrl, { method: 'POST' })).json()
|
|
314
291
|
} catch (error) {
|
|
315
|
-
// Otherwise,
|
|
292
|
+
// Otherwise, fall back to a blank bookmark
|
|
316
293
|
console.error(error)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
id: AssetRecordType.createId(getHashForString(url)),
|
|
298
|
+
typeName: 'asset',
|
|
299
|
+
type: 'bookmark',
|
|
300
|
+
props: {
|
|
301
|
+
src: url,
|
|
302
|
+
description: meta?.description ?? '',
|
|
303
|
+
image: meta?.image ?? '',
|
|
304
|
+
favicon: meta?.favicon ?? '',
|
|
305
|
+
title: meta?.title ?? '',
|
|
306
|
+
},
|
|
307
|
+
meta: {},
|
|
330
308
|
}
|
|
331
309
|
}
|