@tldraw/sync 5.5.0-next.9352f6ad229c → 5.5.0-next.9ac8a22a5891
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 +37 -60
- package/dist-cjs/useSync.js.map +2 -2
- package/dist-cjs/useSyncDemo.js +31 -45
- package/dist-cjs/useSyncDemo.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/useSync.mjs +37 -60
- package/dist-esm/useSync.mjs.map +2 -2
- package/dist-esm/useSyncDemo.mjs +31 -45
- package/dist-esm/useSyncDemo.mjs.map +2 -2
- package/package.json +7 -7
- package/src/useSync.ts +58 -75
- 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,37 +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
280
|
socket = new ClientWebSocketAdapter(async () => {
|
|
278
|
-
const uriString = typeof uri === 'string' ? uri : await uri()
|
|
279
|
-
|
|
280
281
|
// set sessionId as a query param on the uri
|
|
281
|
-
const withParams = new URL(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
throw new Error(
|
|
289
|
-
'useSync. "storeId" is a reserved query param name. Please use a different name'
|
|
290
|
-
)
|
|
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
|
+
}
|
|
291
289
|
}
|
|
292
290
|
|
|
293
291
|
withParams.searchParams.set('sessionId', TAB_ID)
|
|
@@ -300,12 +298,12 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
300
298
|
|
|
301
299
|
let didCancel = false
|
|
302
300
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
301
|
+
const collaborationStatusSignal = atom(
|
|
302
|
+
'collaboration status',
|
|
303
|
+
toCollaborationStatus(socket.connectionStatus)
|
|
304
|
+
)
|
|
307
305
|
const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
|
|
308
|
-
collaborationStatusSignal.set(
|
|
306
|
+
collaborationStatusSignal.set(toCollaborationStatus(socket.connectionStatus))
|
|
309
307
|
})
|
|
310
308
|
|
|
311
309
|
const syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')
|
|
@@ -353,24 +351,10 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
353
351
|
onSyncError(reason) {
|
|
354
352
|
console.error('sync error', reason)
|
|
355
353
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
case TLSyncErrorCloseEventReason.FORBIDDEN:
|
|
361
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })
|
|
362
|
-
break
|
|
363
|
-
case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:
|
|
364
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })
|
|
365
|
-
break
|
|
366
|
-
case TLSyncErrorCloseEventReason.RATE_LIMITED:
|
|
367
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })
|
|
368
|
-
break
|
|
369
|
-
default:
|
|
370
|
-
track?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })
|
|
371
|
-
break
|
|
372
|
-
}
|
|
373
|
-
|
|
354
|
+
track?.(MULTIPLAYER_EVENT_NAME, {
|
|
355
|
+
name: SYNC_ERROR_EVENT_NAMES.get(reason) ?? 'sync-error:' + reason,
|
|
356
|
+
roomId,
|
|
357
|
+
})
|
|
374
358
|
setState({ error: new TLRemoteSyncError(reason) })
|
|
375
359
|
socket.close()
|
|
376
360
|
},
|
|
@@ -422,10 +406,9 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
|
|
|
422
406
|
if (!state) return { status: 'loading' }
|
|
423
407
|
if (state.error) return { status: 'error', error: state.error }
|
|
424
408
|
if (!state.readyClient) return { status: 'loading' }
|
|
425
|
-
const connectionStatus = state.readyClient.socket.connectionStatus
|
|
426
409
|
return {
|
|
427
410
|
status: 'synced-remote',
|
|
428
|
-
connectionStatus: connectionStatus
|
|
411
|
+
connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
|
|
429
412
|
store: state.readyClient.store,
|
|
430
413
|
objectAccess: state.objectAccess ?? 'write',
|
|
431
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
|
}
|