@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/dist-cjs/index.d.ts +230 -31
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/useSync.js.map +2 -2
- package/dist-cjs/useSyncDemo.js.map +2 -2
- package/dist-esm/index.d.mts +230 -31
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/useSync.mjs.map +2 -2
- package/dist-esm/useSyncDemo.mjs.map +2 -2
- package/package.json +8 -7
- package/src/useSync.ts +232 -31
- package/src/useSyncDemo.test.ts +189 -0
- package/src/useSyncDemo.ts +68 -2
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
|
-
/**
|
|
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
|
-
*
|
|
78
|
+
* Creates a reactive store synchronized with a multiplayer server for real-time collaboration.
|
|
50
79
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
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
|
|
386
|
+
* The WebSocket URI of the multiplayer server for real-time synchronization.
|
|
276
387
|
*
|
|
277
|
-
*
|
|
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
|
-
*
|
|
280
|
-
*
|
|
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
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
297
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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
|
+
})
|
package/src/useSyncDemo.ts
CHANGED
|
@@ -44,12 +44,19 @@ export interface UseSyncDemoOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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 {
|