@tldraw/sync 5.5.0-next.9665a55b2322 → 5.5.0-next.9ceb52b8a830

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/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 users: Required<TLUserStore> = _users
218
- ? {
219
- currentUser: _users.currentUser,
220
- resolve:
221
- _users.resolve ??
222
- createCachedUserResolve((userId) => {
223
- const current = _users.currentUser.get()
224
- return current && current.id === createUserId(userId) ? current : null
225
- }),
226
- }
227
- : {
228
- currentUser: defaultUserStore.currentUser,
229
- resolve: createCachedUserResolve((userId) => {
230
- const current = defaultUserStore.currentUser.get()
231
- if (current && current.id === createUserId(userId)) return current
232
- const presences = store.query.records('instance_presence').get()
233
- const match = presences.find((p) => p.userId === createUserId(userId))
234
- if (match) {
235
- return UserRecordType.create({
236
- id: createUserId(userId),
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: TLPersistentClientSocket<
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(uriString)
282
- if (withParams.searchParams.has('sessionId')) {
283
- throw new Error(
284
- 'useSync. "sessionId" is a reserved query param name. Please use a different name'
285
- )
286
- }
287
- if (withParams.searchParams.has('storeId')) {
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
- function getConnectionStatus() {
304
- return socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus
305
- }
306
- const collaborationStatusSignal = atom('collaboration status', getConnectionStatus())
301
+ const collaborationStatusSignal = atom(
302
+ 'collaboration status',
303
+ toCollaborationStatus(socket.connectionStatus)
304
+ )
307
305
  const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
308
- collaborationStatusSignal.set(getConnectionStatus())
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
- switch (reason) {
357
- case TLSyncErrorCloseEventReason.NOT_FOUND:
358
- track?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
359
- break
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 === 'error' ? 'offline' : connectionStatus,
411
+ connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
429
412
  store: state.readyClient.store,
430
413
  objectAccess: state.objectAccess ?? 'write',
431
414
  }
@@ -121,9 +121,9 @@ export function useSyncDemo(
121
121
  assets,
122
122
  onMount: useCallback(
123
123
  (editor: Editor) => {
124
- editor.registerExternalAssetHandler('url', async ({ url }) => {
125
- return await createAssetFromUrlUsingDemoServer(host, url)
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 id = uniqueId()
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
- if (!asset.props.src) return null
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 asset.props.src
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 (!asset.props.src.startsWith('http:') && !asset.props.src.startsWith('https:'))
214
- return asset.props.src
212
+ if (!src.startsWith('http:') && !src.startsWith('https:')) return src
215
213
 
216
- if (context.shouldResolveToOriginal) return asset.props.src
214
+ if (context.shouldResolveToOriginal) return src
217
215
 
218
216
  // Don't try to transform animated images.
219
- if (MediaHelpers.isAnimatedImageType(asset?.props.mimeType) || asset.props.isAnimated)
220
- return asset.props.src
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?.props.mimeType)) return asset.props.src
221
+ if (MediaHelpers.isVectorImageType(asset.props.mimeType)) return src
224
222
 
225
- const url = new URL(asset.props.src)
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 asset.props.src
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
- const newUrl = `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
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
- const urlHash = getHashForString(url)
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, fallback to a blank bookmark
292
+ // Otherwise, fall back to a blank bookmark
316
293
  console.error(error)
317
- return {
318
- id: AssetRecordType.createId(urlHash),
319
- typeName: 'asset',
320
- type: 'bookmark',
321
- props: {
322
- src: url,
323
- description: '',
324
- image: '',
325
- favicon: '',
326
- title: '',
327
- },
328
- meta: {},
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
  }