@tldraw/sync 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.cb6f90590225

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
@@ -39,33 +39,122 @@ const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
39
39
 
40
40
  const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
41
41
 
42
- /** @public */
42
+ /**
43
+ * A store wrapper specifically for remote collaboration that excludes local-only states.
44
+ * This type represents a tldraw store that is synchronized with a remote multiplayer server.
45
+ *
46
+ * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states
47
+ * since remote stores are always either loading, connected to a server, or in an error state.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * function MyCollaborativeApp() {
52
+ * const store: RemoteTLStoreWithStatus = useSync({
53
+ * uri: 'wss://myserver.com/sync/room-123',
54
+ * assets: myAssetStore
55
+ * })
56
+ *
57
+ * if (store.status === 'loading') {
58
+ * return <div>Connecting to multiplayer session...</div>
59
+ * }
60
+ *
61
+ * if (store.status === 'error') {
62
+ * return <div>Connection failed: {store.error.message}</div>
63
+ * }
64
+ *
65
+ * // store.status === 'synced-remote'
66
+ * return <Tldraw store={store.store} />
67
+ * }
68
+ * ```
69
+ *
70
+ * @public
71
+ */
43
72
  export type RemoteTLStoreWithStatus = Exclude<
44
73
  TLStoreWithStatus,
45
74
  { status: 'synced-local' } | { status: 'not-synced' }
46
75
  >
47
76
 
48
77
  /**
49
- * useSync creates a store that is synced with a multiplayer server.
78
+ * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.
50
79
  *
51
- * The store can be passed directly into the `<Tldraw />` component to enable multiplayer features.
52
- * It will handle loading states, and enable multiplayer UX like user cursors and following.
80
+ * This hook manages the complete lifecycle of a collaborative tldraw session, including
81
+ * WebSocket connection establishment, state synchronization, user presence, and error handling.
82
+ * The returned store can be passed directly to the Tldraw component to enable multiplayer features.
53
83
  *
54
- * To enable external blob storage, you should also pass in an `assets` object that implements the {@link tldraw#TLAssetStore} interface.
55
- * If you don't do this, adding large images and videos to rooms will cause performance issues at serialization boundaries.
84
+ * The store progresses through multiple states:
85
+ * - `loading`: Establishing connection and synchronizing initial state
86
+ * - `synced-remote`: Successfully connected and actively synchronizing changes
87
+ * - `error`: Connection failed or synchronization error occurred
88
+ *
89
+ * For optimal performance with media assets, provide an `assets` store that implements
90
+ * external blob storage. Without this, large images and videos will be stored inline
91
+ * as base64, causing performance issues during serialization.
92
+ *
93
+ * @param opts - Configuration options for multiplayer synchronization
94
+ * - `uri` - WebSocket server URI (string or async function returning URI)
95
+ * - `assets` - Asset store for blob storage (required for production use)
96
+ * - `userInfo` - User information for presence system (can be reactive signal)
97
+ * - `getUserPresence` - Optional function to customize presence data
98
+ * - `onCustomMessageReceived` - Handler for custom socket messages
99
+ * - `roomId` - Room identifier for analytics (internal use)
100
+ * - `onMount` - Callback when editor mounts (internal use)
101
+ * - `trackAnalyticsEvent` - Analytics event tracker (internal use)
102
+ *
103
+ * @returns A reactive store wrapper with connection status and synchronized store
56
104
  *
57
105
  * @example
58
106
  * ```tsx
59
- * function MyApp() {
60
- * const store = useSync({
61
- * uri: 'wss://myapp.com/sync/my-test-room',
62
- * assets: myAssetStore
63
- * })
64
- * return <Tldraw store={store} />
107
+ * // Basic multiplayer setup
108
+ * function CollaborativeApp() {
109
+ * const store = useSync({
110
+ * uri: 'wss://myserver.com/sync/room-123',
111
+ * assets: myAssetStore,
112
+ * userInfo: {
113
+ * id: 'user-1',
114
+ * name: 'Alice',
115
+ * color: '#ff0000'
116
+ * }
117
+ * })
118
+ *
119
+ * if (store.status === 'loading') {
120
+ * return <div>Connecting to collaboration session...</div>
121
+ * }
122
+ *
123
+ * if (store.status === 'error') {
124
+ * return <div>Failed to connect: {store.error.message}</div>
125
+ * }
126
+ *
127
+ * return <Tldraw store={store.store} />
65
128
  * }
129
+ * ```
66
130
  *
131
+ * @example
132
+ * ```tsx
133
+ * // Dynamic authentication with reactive user info
134
+ * import { atom } from '@tldraw/state'
135
+ *
136
+ * function AuthenticatedApp() {
137
+ * const currentUser = atom('user', { id: 'user-1', name: 'Alice', color: '#ff0000' })
138
+ *
139
+ * const store = useSync({
140
+ * uri: async () => {
141
+ * const token = await getAuthToken()
142
+ * return `wss://myserver.com/sync/room-123?token=${token}`
143
+ * },
144
+ * assets: authenticatedAssetStore,
145
+ * userInfo: currentUser, // Reactive signal
146
+ * getUserPresence: (store, user) => {
147
+ * return {
148
+ * userId: user.id,
149
+ * userName: user.name,
150
+ * cursor: getCurrentCursor(store)
151
+ * }
152
+ * }
153
+ * })
154
+ *
155
+ * return <Tldraw store={store.store} />
156
+ * }
67
157
  * ```
68
- * @param opts - Options for the multiplayer sync store. See {@link UseSyncOptions} and {@link tldraw#TLStoreSchemaOptions}.
69
158
  *
70
159
  * @public
71
160
  */
@@ -267,39 +356,125 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
267
356
  }
268
357
 
269
358
  /**
270
- * Options for the {@link useSync} hook.
359
+ * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.
360
+ *
361
+ * This interface defines the required and optional settings for connecting to a multiplayer
362
+ * server, managing user presence, handling assets, and customizing the collaboration experience.
363
+ *
364
+ * @example
365
+ * ```tsx
366
+ * const syncOptions: UseSyncOptions = {
367
+ * uri: 'wss://myserver.com/sync/room-123',
368
+ * assets: myAssetStore,
369
+ * userInfo: {
370
+ * id: 'user-1',
371
+ * name: 'Alice',
372
+ * color: '#ff0000'
373
+ * },
374
+ * getUserPresence: (store, user) => ({
375
+ * userId: user.id,
376
+ * userName: user.name,
377
+ * cursor: getCursorPosition()
378
+ * })
379
+ * }
380
+ * ```
381
+ *
271
382
  * @public
272
383
  */
273
384
  export interface UseSyncOptions {
274
385
  /**
275
- * The URI of the multiplayer server. This must include the protocol,
386
+ * The WebSocket URI of the multiplayer server for real-time synchronization.
276
387
  *
277
- * e.g. `wss://server.example.com/my-room` or `ws://localhost:5858/my-room`.
388
+ * Must include the protocol (wss:// for secure, ws:// for local development).
389
+ * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.
278
390
  *
279
- * Note that the protocol can also be `https` or `http` and it will upgrade to a websocket
280
- * connection.
391
+ * Can be a static string or a function that returns a URI (useful for dynamic
392
+ * authentication tokens or room routing). The function is called on each
393
+ * connection attempt, allowing for token refresh and dynamic routing.
281
394
  *
282
- * Optionally, you can pass a function which will be called each time a connection is
283
- * established to get the URI. This is useful if you need to include e.g. a short-lived session
284
- * token for authentication.
395
+ * Reserved query parameters `sessionId` and `storeId` are automatically added
396
+ * by the sync system and should not be included in your URI.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * // Static URI
401
+ * uri: 'wss://myserver.com/sync/room-123'
402
+ *
403
+ * // Dynamic URI with authentication
404
+ * uri: async () => {
405
+ * const token = await getAuthToken()
406
+ * return `wss://myserver.com/sync/room-123?token=${token}`
407
+ * }
408
+ * ```
285
409
  */
286
410
  uri: string | (() => string | Promise<string>)
411
+
287
412
  /**
288
- * A signal that contains the user information needed for multiplayer features.
289
- * This should be synchronized with the `userPreferences` configuration for the main `<Tldraw />` component.
290
- * If not provided, a default implementation based on localStorage will be used.
413
+ * User information for multiplayer presence and identification.
414
+ *
415
+ * Can be a static object or a reactive signal that updates when user
416
+ * information changes. The presence system automatically updates when
417
+ * reactive signals change, allowing real-time user profile updates.
418
+ *
419
+ * Should be synchronized with the `userPreferences` prop of the main
420
+ * Tldraw component for consistent user experience. If not provided,
421
+ * defaults to localStorage-based user preferences.
422
+ *
423
+ * @example
424
+ * ```ts
425
+ * // Static user info
426
+ * userInfo: { id: 'user-123', name: 'Alice', color: '#ff0000' }
427
+ *
428
+ * // Reactive user info
429
+ * const userSignal = atom('user', { id: 'user-123', name: 'Alice', color: '#ff0000' })
430
+ * userInfo: userSignal
431
+ * ```
291
432
  */
292
433
  userInfo?: TLPresenceUserInfo | Signal<TLPresenceUserInfo>
434
+
293
435
  /**
294
- * The asset store for blob storage. See {@link tldraw#TLAssetStore}.
436
+ * Asset store implementation for handling file uploads and storage.
437
+ *
438
+ * Required for production applications to handle images, videos, and other
439
+ * media efficiently. Without an asset store, files are stored inline as
440
+ * base64, which causes performance issues with large files.
295
441
  *
296
- * If you don't have time to implement blob storage and just want to get started, you can use the inline base64 asset store. {@link tldraw#inlineBase64AssetStore}
297
- * Note that storing base64 blobs inline in JSON is very inefficient and will cause performance issues quickly with large images and videos.
442
+ * The asset store must implement upload (for new files) and resolve
443
+ * (for displaying existing files) methods. For prototyping, you can use
444
+ * {@link tldraw#inlineBase64AssetStore} but this is not recommended for production.
445
+ *
446
+ * @example
447
+ * ```ts
448
+ * const myAssetStore: TLAssetStore = {
449
+ * upload: async (asset, file) => {
450
+ * const url = await uploadToCloudStorage(file)
451
+ * return { src: url }
452
+ * },
453
+ * resolve: (asset, context) => {
454
+ * return getOptimizedUrl(asset.src, context)
455
+ * }
456
+ * }
457
+ * ```
298
458
  */
299
459
  assets: TLAssetStore
300
460
 
301
461
  /**
302
- * A handler for custom socket messages.
462
+ * Handler for receiving custom messages sent through the multiplayer connection.
463
+ *
464
+ * Use this to implement custom communication channels between clients beyond
465
+ * the standard shape and presence synchronization. Messages are sent using
466
+ * the TLSyncClient's sendMessage method.
467
+ *
468
+ * @param data - The custom message data received from another client
469
+ *
470
+ * @example
471
+ * ```ts
472
+ * onCustomMessageReceived: (data) => {
473
+ * if (data.type === 'chat') {
474
+ * displayChatMessage(data.message, data.userId)
475
+ * }
476
+ * }
477
+ * ```
303
478
  */
304
479
  onCustomMessageReceived?(data: any): void
305
480
 
@@ -311,10 +486,36 @@ export interface UseSyncOptions {
311
486
  trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
312
487
 
313
488
  /**
314
- * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object. The
315
- * result of this function will be synchronized across all clients to display presence
316
- * indicators such as cursors. See {@link @tldraw/tlschema#getDefaultUserPresence} for
489
+ * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.
490
+ *
491
+ * This function is called reactively whenever the store state changes and
492
+ * determines what presence information to broadcast to other clients. The
493
+ * result is synchronized across all connected clients for displaying cursors,
494
+ * selections, and other collaborative indicators.
495
+ *
496
+ * If not provided, uses the default implementation which includes standard
497
+ * cursor position and selection state. Custom implementations allow you to
498
+ * add additional presence data like current tool, view state, or custom status.
499
+ *
500
+ * See {@link @tldraw/tlschema#getDefaultUserPresence} for
317
501
  * the default implementation of this function.
502
+ *
503
+ * @param store - The current TLStore
504
+ * @param user - The current user information
505
+ * @returns Presence state to broadcast to other clients, or null to hide presence
506
+ *
507
+ * @example
508
+ * ```ts
509
+ * getUserPresence: (store, user) => {
510
+ * return {
511
+ * userId: user.id,
512
+ * userName: user.name,
513
+ * cursor: { x: 100, y: 200 },
514
+ * currentTool: 'select',
515
+ * isActive: true
516
+ * }
517
+ * }
518
+ * ```
318
519
  */
319
520
  getUserPresence?(store: TLStore, user: TLPresenceUserInfo): TLPresenceStateInfo | null
320
521
  }
@@ -0,0 +1,189 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // Mock modules before imports
4
+ vi.mock('./useSync')
5
+ vi.mock('react', async () => {
6
+ const actual = await vi.importActual('react')
7
+ return {
8
+ ...actual,
9
+ useMemo: vi.fn((fn, _deps) => fn()),
10
+ useCallback: vi.fn((fn, _deps) => fn),
11
+ }
12
+ })
13
+ vi.mock('tldraw', async () => {
14
+ const actual = await vi.importActual('tldraw')
15
+ return {
16
+ ...actual,
17
+ useShallowObjectIdentity: vi.fn((obj) => obj),
18
+ uniqueId: vi.fn(() => 'mock-unique-id'),
19
+ getHashForString: vi.fn((str) => `hash-${str}`),
20
+ }
21
+ })
22
+
23
+ import {
24
+ TLAsset,
25
+ defaultBindingUtils,
26
+ defaultShapeUtils,
27
+ getHashForString,
28
+ uniqueId,
29
+ useShallowObjectIdentity,
30
+ } from 'tldraw'
31
+ import { useSync } from './useSync'
32
+ import { useSyncDemo } from './useSyncDemo'
33
+
34
+ // Mock fetch globally
35
+ const mockFetch = vi.fn() as any
36
+ global.fetch = mockFetch
37
+
38
+ // Mock console.error and alert to avoid noise in tests
39
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
40
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
41
+
42
+ describe('useSyncDemo', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks()
45
+ consoleSpy.mockClear()
46
+ alertSpy.mockClear()
47
+ vi.mocked(useSync).mockReturnValue({ status: 'loading' } as any)
48
+ vi.mocked(useShallowObjectIdentity).mockImplementation((obj) => obj)
49
+ vi.mocked(uniqueId).mockReturnValue('mock-unique-id')
50
+ vi.mocked(getHashForString).mockImplementation((str) => `hash-${str}`)
51
+ })
52
+
53
+ describe('core functionality', () => {
54
+ it('should construct correct URI with room ID encoding', () => {
55
+ const roomIdWithSpecialChars = 'room with spaces & symbols!'
56
+ useSyncDemo({ roomId: roomIdWithSpecialChars })
57
+
58
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
59
+ expect(useSyncCall.uri).toContain(encodeURIComponent(roomIdWithSpecialChars))
60
+ expect(useSyncCall.roomId).toBe(roomIdWithSpecialChars)
61
+ })
62
+
63
+ it('should use custom host when provided', () => {
64
+ const customHost = 'https://custom.server.com'
65
+ useSyncDemo({ roomId: 'test-room', host: customHost })
66
+
67
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
68
+ expect(useSyncCall.uri).toBe(`${customHost}/connect/test-room`)
69
+ })
70
+
71
+ it('should merge custom shape utils with defaults', () => {
72
+ const customShapeUtils = [{ type: 'custom-shape' } as any]
73
+ useSyncDemo({ roomId: 'test-room', shapeUtils: customShapeUtils } as any)
74
+
75
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
76
+ expect((useSyncCall as any).shapeUtils).toEqual([...defaultShapeUtils, ...customShapeUtils])
77
+ })
78
+
79
+ it('should merge custom binding utils with defaults', () => {
80
+ const customBindingUtils = [{ type: 'custom-binding' } as any]
81
+ useSyncDemo({ roomId: 'test-room', bindingUtils: customBindingUtils } as any)
82
+
83
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
84
+ expect((useSyncCall as any).bindingUtils).toEqual([
85
+ ...defaultBindingUtils,
86
+ ...customBindingUtils,
87
+ ])
88
+ })
89
+ })
90
+
91
+ describe('asset upload restrictions', () => {
92
+ it('should block uploads for tldraw domains', async () => {
93
+ useSyncDemo({ roomId: 'test-room', host: 'https://demo.tldraw.xyz' })
94
+
95
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
96
+ const assetStore = useSyncCall.assets
97
+ const file = new File(['test content'], 'test-file.jpg')
98
+
99
+ await expect(assetStore.upload({} as TLAsset, file)).rejects.toThrow(
100
+ 'Uploading images is disabled in this demo.'
101
+ )
102
+ expect(alertSpy).toHaveBeenCalledWith('Uploading images is disabled in this demo.')
103
+ })
104
+
105
+ it('should allow uploads for non-tldraw domains', async () => {
106
+ useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
107
+
108
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
109
+ const assetStore = useSyncCall.assets
110
+ const file = new File(['test content'], 'test-file.jpg')
111
+
112
+ vi.mocked(mockFetch).mockResolvedValueOnce(new Response())
113
+ vi.mocked(uniqueId).mockReturnValueOnce('unique-123')
114
+
115
+ const result = await assetStore.upload({} as TLAsset, file)
116
+
117
+ expect(mockFetch).toHaveBeenCalledWith(
118
+ 'https://demo.server.com/uploads/unique-123-test-file-jpg',
119
+ expect.objectContaining({ method: 'POST', body: file })
120
+ )
121
+ expect(result).toEqual({ src: 'https://demo.server.com/uploads/unique-123-test-file-jpg' })
122
+ })
123
+ })
124
+
125
+ describe('bookmark asset creation', () => {
126
+ it('should create bookmark assets with metadata when successful', async () => {
127
+ const mockEditor = { registerExternalAssetHandler: vi.fn() } as any
128
+ useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
129
+
130
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
131
+ useSyncCall.onMount!(mockEditor)
132
+
133
+ const handlerFunction = mockEditor.registerExternalAssetHandler.mock.calls[0][1]
134
+ const testUrl = 'https://example.com'
135
+ const mockMeta = { title: 'Example', description: 'Test' }
136
+
137
+ vi.mocked(mockFetch).mockResolvedValueOnce({
138
+ json: () => Promise.resolve(mockMeta),
139
+ } as Response)
140
+ vi.mocked(getHashForString).mockReturnValueOnce('url-hash-123')
141
+
142
+ const result = await handlerFunction({ url: testUrl })
143
+
144
+ expect(result.type).toBe('bookmark')
145
+ expect(result.props.src).toBe(testUrl)
146
+ expect(result.props.title).toBe('Example')
147
+ })
148
+
149
+ it('should handle bookmark creation errors gracefully', async () => {
150
+ const mockEditor = { registerExternalAssetHandler: vi.fn() } as any
151
+ useSyncDemo({ roomId: 'test-room' })
152
+
153
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
154
+ useSyncCall.onMount!(mockEditor)
155
+
156
+ const handlerFunction = mockEditor.registerExternalAssetHandler.mock.calls[0][1]
157
+ const testUrl = 'https://example.com'
158
+
159
+ vi.mocked(mockFetch).mockRejectedValueOnce(new Error('Network error'))
160
+ vi.mocked(getHashForString).mockReturnValueOnce('url-hash-123')
161
+
162
+ const result = await handlerFunction({ url: testUrl })
163
+
164
+ expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error))
165
+ expect(result.props.title).toBe('')
166
+ expect(result.props.description).toBe('')
167
+ })
168
+ })
169
+
170
+ describe('file name sanitization', () => {
171
+ it('should sanitize file names in upload URLs', async () => {
172
+ useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
173
+
174
+ const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
175
+ const assetStore = useSyncCall.assets
176
+ const file = new File(['test'], 'file with spaces & symbols!.jpg')
177
+
178
+ vi.mocked(mockFetch).mockResolvedValueOnce(new Response())
179
+ vi.mocked(uniqueId).mockReturnValueOnce('unique-123')
180
+
181
+ await assetStore.upload({} as TLAsset, file)
182
+
183
+ expect(mockFetch).toHaveBeenCalledWith(
184
+ 'https://demo.server.com/uploads/unique-123-file-with-spaces---symbols--jpg',
185
+ expect.any(Object)
186
+ )
187
+ })
188
+ })
189
+ })
@@ -44,12 +44,19 @@ export interface UseSyncDemoOptions {
44
44
  }
45
45
 
46
46
  /**
47
- * Depending on the environment this package is used in, process.env may not be available. Wrap
48
- * `process.env` accesses in this to make sure they don't fail.
47
+ * Safely accesses environment variables across different bundling environments.
48
+ *
49
+ * Depending on the environment this package is used in, process.env may not be available. This function
50
+ * wraps `process.env` accesses in a try/catch to prevent runtime errors in environments where process
51
+ * is not defined.
49
52
  *
50
53
  * The reason that this is just a try/catch and not a dynamic check e.g. `process &&
51
54
  * process.env[key]` is that many bundlers implement `process.env.WHATEVER` using compile-time
52
55
  * string replacement, rather than actually creating a runtime implementation of a `process` object.
56
+ *
57
+ * @param cb - Callback function that accesses an environment variable
58
+ * @returns The environment variable value if available, otherwise undefined
59
+ * @internal
53
60
  */
54
61
  function getEnv(cb: () => string | undefined): string | undefined {
55
62
  try {
@@ -124,6 +131,16 @@ export function useSyncDemo(
124
131
  })
125
132
  }
126
133
 
134
+ /**
135
+ * Determines whether file uploads should be disabled for a given host.
136
+ *
137
+ * Uploads are disabled for production tldraw domains to prevent abuse of the demo server
138
+ * infrastructure. This includes tldraw.com and tldraw.xyz domains and their subdomains.
139
+ *
140
+ * @param host - The host URL to check for upload restrictions
141
+ * @returns True if uploads should be disabled, false otherwise
142
+ * @internal
143
+ */
127
144
  function shouldDisallowUploads(host: string) {
128
145
  const disallowedHosts = ['tldraw.com', 'tldraw.xyz']
129
146
  return disallowedHosts.some(
@@ -131,6 +148,33 @@ function shouldDisallowUploads(host: string) {
131
148
  )
132
149
  }
133
150
 
151
+ /**
152
+ * Creates an asset store implementation optimized for the tldraw demo server.
153
+ *
154
+ * This asset store handles file uploads to the demo server and provides intelligent
155
+ * asset resolution with automatic image optimization based on network conditions,
156
+ * screen density, and display size. It includes safeguards to prevent uploads to
157
+ * production domains and optimizes images through the tldraw image processing service.
158
+ *
159
+ * @param host - The demo server host URL for file uploads and asset resolution
160
+ * @returns A TLAssetStore implementation with upload and resolve capabilities
161
+ * @example
162
+ * ```ts
163
+ * const assetStore = createDemoAssetStore('https://demo.tldraw.xyz')
164
+ *
165
+ * // Upload a file
166
+ * const result = await assetStore.upload(asset, file)
167
+ * console.log('Uploaded to:', result.src)
168
+ *
169
+ * // Resolve optimized asset URL
170
+ * const optimizedUrl = assetStore.resolve(imageAsset, {
171
+ * steppedScreenScale: 1.5,
172
+ * dpr: 2,
173
+ * networkEffectiveType: '4g'
174
+ * })
175
+ * ```
176
+ * @internal
177
+ */
134
178
  function createDemoAssetStore(host: string): TLAssetStore {
135
179
  return {
136
180
  upload: async (_asset, file) => {
@@ -211,6 +255,28 @@ function createDemoAssetStore(host: string): TLAssetStore {
211
255
  }
212
256
  }
213
257
 
258
+ /**
259
+ * Creates a bookmark asset by fetching metadata from a URL using the demo server.
260
+ *
261
+ * This function uses the demo server's bookmark unfurling service to extract metadata
262
+ * like title, description, favicon, and preview image from a given URL. If the metadata
263
+ * fetch fails, it returns a blank bookmark asset with just the URL.
264
+ *
265
+ * @param host - The demo server host URL to use for bookmark unfurling
266
+ * @param url - The URL to create a bookmark asset from
267
+ * @returns A promise that resolves to a TLAsset of type 'bookmark' with extracted metadata
268
+ * @example
269
+ * ```ts
270
+ * const asset = await createAssetFromUrlUsingDemoServer(
271
+ * 'https://demo.tldraw.xyz',
272
+ * 'https://example.com'
273
+ * )
274
+ *
275
+ * console.log(asset.props.title) // "Example Domain"
276
+ * console.log(asset.props.description) // "This domain is for use in illustrative examples..."
277
+ * ```
278
+ * @internal
279
+ */
214
280
  async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
215
281
  const urlHash = getHashForString(url)
216
282
  try {