snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

637
packages/sync/DOCS.md Normal file
View File

@@ -0,0 +1,637 @@
# @tldraw/sync
The `@tldraw/sync` package provides React hooks and utilities for integrating real-time collaboration into your tldraw applications. Built on top of `@tldraw/sync-core`, it offers a developer-friendly API that enables multiplayer functionality with minimal configuration.
## 1. Introduction
Real-time collaboration transforms single-user drawing applications into shared creative spaces where multiple users can work together simultaneously. The sync package handles the complex coordination required for multiplayer experiences: synchronizing changes, managing user presence, handling network interruptions, and resolving conflicts.
You create collaborative tldraw applications using two main hooks:
- `useSync` - For production applications with custom servers
- `useSyncDemo` - For prototypes and demos using tldraw's hosted demo server
Both hooks return a store wrapped with connection status, allowing you to build responsive UIs that gracefully handle loading states, connection issues, and real-time updates.
## 2. Core Concepts
### Multiplayer Store State
The foundation of sync integration is the **RemoteTLStoreWithStatus**, an enhanced store wrapper that tracks both your drawing data and connection state:
```ts
import { useSync } from '@tldraw/sync'
function MyApp() {
const store = useSync({
uri: 'wss://myserver.com/sync/room-123',
assets: myAssetStore,
userInfo: { id: 'user-1', name: 'Alice', color: '#ff0000' }
})
// Store progresses through these states:
if (store.status === 'loading') return <div>Connecting...</div>
if (store.status === 'error') return <div>Connection failed: {store.error.message}</div>
// store.status === 'synced-remote'
return <Tldraw store={store.store} />
}
```
The store moves through three distinct states as it establishes and maintains connection:
1. **loading** - Initial connection and synchronization with the server
2. **synced-remote** - Successfully connected and actively synchronizing changes
3. **error** - Connection failed or a synchronization error occurred
### User Presence
**/User presence** encompasses the real-time information about other users in your collaborative session. This includes cursor positions, current selections, and any custom presence data you want to share. The `useSync` hook handles this automatically, but you can provide a custom `getUserPresence` function to send additional data.
```ts
const store = useSync({
uri: wsUri,
assets: myAssets,
})
```
The presence system automatically optimizes itself based on room occupancy, switching between 'solo' mode when you're alone and 'full' mode when collaborating with others.
### Asset Management
**Assets** are files like images, videos, and other media that users embed in their drawings. The sync package requires an asset store implementation that handles uploading files and resolving them for display:
```ts
const myAssetStore = {
upload: async (asset, file) => {
// Upload file to your storage service
const url = await uploadToStorage(file)
return { src: url }
},
resolve: (asset, context) => {
// Return optimized URLs based on context
// (screen DPI, network quality, display size)
return getOptimizedUrl(asset.src, context)
},
}
```
## 3. Basic Usage
### Quick Start with Demo Server
The fastest way to add collaboration to your application is using the demo server. This requires no backend setup and works immediately:
```ts
import { useSyncDemo } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
function CollaborativeApp() {
const store = useSyncDemo({
roomId: 'my-prototype-room-123',
userInfo: {
id: 'user-' + Math.random(),
name: 'Anonymous User',
color: '#' + Math.floor(Math.random()*16777215).toString(16)
}
})
if (store.status === 'loading') {
return <div>Connecting to room...</div>
}
if (store.status === 'error') {
return <div>Failed to connect: {store.error.message}</div>
}
return <Tldraw store={store.store} />
}
```
> Tip: The demo server is perfect for prototyping and testing, but data is automatically deleted after ~24 hours and rooms are publicly accessible to anyone with the room ID.
### Production Integration
For production applications, use `useSync` with your own WebSocket server:
```ts
import { useSync } from '@tldraw/sync'
import { Tldraw } from 'tldraw'
function ProductionApp() {
const store = useSync({
uri: 'wss://myserver.com/sync/project-collaboration-session',
userInfo: getCurrentUser(), // Your user system integration
assets: productionAssetStore, // Your asset storage integration
})
return (
<div>
{store.status === 'loading' && <LoadingSpinner />}
{store.status === 'error' && <ErrorMessage error={store.error} />}
{store.status === 'synced-remote' && (
<>
<ConnectionIndicator status={store.connectionStatus} />
<Tldraw store={store.store} />
</>
)}
</div>
)
}
```
## 4. Advanced Configuration
### Dynamic Connection URIs
You can provide connection URIs dynamically, which is essential for authentication and room-specific routing:
```ts
const store = useSync({
uri: async () => {
const token = await getAuthToken()
const roomId = getCurrentRoomId()
return `wss://myserver.com/sync/${roomId}?token=${token}`
},
assets: authenticatedAssetStore,
userInfo: userSignal, // Can be a reactive signal that updates
})
```
When the URI function is async, the sync system waits for it to resolve before attempting connection. This ensures your authentication flow completes before establishing the WebSocket connection.
### Reactive User Information
User information can be static or reactive. Using reactive signals allows the presence system to automatically update when user details change:
```ts
import { atom } from '@tldraw/state'
const currentUser = atom('currentUser', {
id: 'user-123',
name: 'Alice',
color: '#ff0000',
})
const store = useSync({
uri: wsUri,
assets: myAssets,
userInfo: currentUser, // Reactive signal
})
// Later, when user updates their profile:
currentUser.set({
id: 'user-123',
name: 'Alice Cooper', // Updated name
color: '#00ff00', // New color
})
// Presence automatically updates for all connected users
```
### Custom Presence Data
The `getUserPresence` function allows you to include custom presence information beyond the standard cursor and selection data. The function receives the `store` and the current `user` info. It should return an object that conforms to the `TLPresenceStateInfo` type.
Note that the `store` object passed to this function is a `TLStore` instance, and does not have an `editor` property. To access editor-specific state like the current tool or cursor position, you will need to find a way to access the `Editor` instance from your component.
```ts
const store = useSync({
uri: wsUri,
assets: myAssets,
getUserPresence: (store, user) => {
// This function is called whenever the store changes.
// You can use it to derive presence information from the store.
// To get information like cursor position, you may need to
// find a way to access your <Tldraw /> component's editor instance.
return {
userId: user.id,
userName: user.name,
// ... and other properties from TLPresenceStateInfo
}
},
})
```
### Asset Store Implementation
A complete asset store handles both uploading new files and resolving existing assets for optimal display:
```ts
const productionAssetStore = {
upload: async (asset, file) => {
// Generate unique filename
const filename = `${Date.now()}-${file.name}`
// Upload to your storage service
const formData = new FormData()
formData.append('file', file)
formData.append('filename', filename)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
},
})
const { url } = await response.json()
return { src: url }
},
resolve: (asset, context) => {
const baseUrl = asset.src
// Return different resolutions based on context
if (context.shouldResolveToOriginal) {
return baseUrl // Full quality for printing/export
}
// Optimize based on display size and screen density
const targetWidth = Math.ceil(context.screenScale * context.imageSize.w)
const targetHeight = Math.ceil(context.screenScale * context.imageSize.h)
return `${baseUrl}?w=${targetWidth}&h=${targetHeight}&q=${context.networkQuality}`
},
}
```
## 5. Connection Management
### Understanding Connection States
The sync system provides granular connection status information to help you build responsive UIs:
```ts
function ConnectionAwareApp() {
const store = useSync({ /* ... */ })
if (store.status === 'loading') {
return <div>Establishing connection...</div>
}
if (store.status === 'error') {
return (
<div>
<h3>Connection Error</h3>
<p>{store.error.message}</p>
<button onClick={() => window.location.reload()}>
Retry Connection
</button>
</div>
)
}
// store.status === 'synced-remote'
return (
<div>
<NetworkIndicator status={store.connectionStatus} />
<Tldraw store={store.store} />
</div>
)
}
function NetworkIndicator({ status }) {
if (status === 'offline') {
return <div className="warning">Working offline - changes will sync when reconnected</div>
}
if (status === 'online') {
return <div className="success">Connected and syncing</div>
}
return null
}
```
The connection status operates independently of the sync status. Even when `store.status` is 'synced-remote', the `connectionStatus` can be 'offline' if network connectivity is lost, allowing the application to continue working locally.
### Automatic Reconnection
The sync system handles network interruptions gracefully with automatic reconnection and state recovery:
```ts
// No additional code needed - reconnection is automatic
const store = useSync({
uri: 'wss://myserver.com/sync/room-123',
assets: myAssets,
userInfo: currentUser,
})
// The system automatically:
// 1. Detects network disconnection
// 2. Queues local changes while offline
// 3. Attempts reconnection with exponential backoff
// 4. Reconciles state when connection is restored
// 5. Handles conflicts between local and server changes
```
### Error Handling and Recovery
Different types of connection errors require different handling strategies:
```ts
function ErrorHandlingApp() {
const store = useSync({ /* ... */ })
if (store.status === 'error') {
const error = store.error
// Check specific error types for appropriate responses
if (error.reason === 'NOT_FOUND') {
return <div>Room not found. Please check the room ID.</div>
}
if (error.reason === 'FORBIDDEN') {
return <div>Access denied. Please check your permissions.</div>
}
if (error.reason === 'NOT_AUTHENTICATED') {
return <div>Authentication required. <button onClick={login}>Login</button></div>
}
if (error.reason === 'RATE_LIMITED') {
return <div>Too many requests. Please wait before retrying.</div>
}
// Generic network or server error
return (
<div>
<h3>Connection Error</h3>
<p>Unable to connect to collaboration server.</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
)
}
return <Tldraw store={store.store} />
}
```
## 6. Debugging
### Understanding Connection Behavior
The sync package provides several tools for understanding and debugging connection behavior in your application.
#### Connection Status Monitoring
You can monitor connection events by logging the status changes:
```ts
import { useEffect } from 'react'
function DebuggableApp() {
const store = useSync({
uri: 'wss://myserver.com/sync/room-123',
assets: myAssets,
userInfo: currentUser,
trackAnalyticsEvent: (name, data) => {
console.log('Sync Event:', name, data)
}
})
useEffect(() => {
console.log('Store status changed:', store.status)
if (store.status === 'synced-remote') {
console.log('Connection status:', store.connectionStatus)
}
}, [store.status, store.status === 'synced-remote' ? store.connectionStatus : null])
return <Tldraw store={store.store} />
}
```
This will log events like:
```
Sync Event: room-not-found { roomId: "room-123" }
Sync Event: connected { isReadonly: false }
Store status changed: synced-remote
Connection status: online
```
#### Network Quality Detection
The demo asset store includes network quality detection that affects image resolution:
```ts
// In the demo environment, you can observe network adaptation:
const store = useSyncDemo({
roomId: 'debug-room',
userInfo: currentUser,
})
// Images automatically adjust quality based on:
// - Connection speed (detected from WebSocket latency)
// - Screen pixel density
// - Actual display size
// - File size thresholds
```
#### Presence System Debugging
You can debug presence updates by monitoring presence mode changes:
```ts
import { getDefaultUserPresence } from 'tldraw'
function PresenceDebuggingApp() {
const store = useSync({
uri: wsUri,
assets: myAssets,
getUserPresence: (store, user) => {
// See the "Custom Presence Data" section for details
// on how to implement this function.
const presence = getDefaultUserPresence(store, user)
console.log('Updating presence:', presence)
return presence
},
})
useEffect(() => {
if (store.status === 'synced-remote') {
// Monitor presence mode changes
const unsubscribe = store.store.listen(() => {
const presences = store.store.allRecords().filter(r => r.typeName === 'instance_presence')
console.log('Active users:', presences.length)
console.log('Presence mode:', presences.length > 1 ? 'collaborative' : 'solo')
})
return unsubscribe
}
}, [store])
return <Tldraw store={store.store} />
}
```
#### Common Connection Issues
**WebSocket Connection Failures**
If connections consistently fail, verify the WebSocket URL format:
```ts
// ✅ Correct formats:
'wss://myserver.com/sync'
'wss://myserver.com/sync/room-123'
'ws://localhost:3001/sync' // Development only
// ❌ Common mistakes:
'https://myserver.com/sync' // Wrong protocol
'wss://myserver.com/sync/' // Trailing slash may cause issues
'myserver.com/sync' // Missing protocol
```
**Authentication Token Issues**
When using token authentication, ensure tokens remain valid throughout the session:
```ts
const store = useSync({
uri: async () => {
const token = await refreshTokenIfNeeded() // Ensure token is fresh
return `wss://myserver.com/sync?token=${token}`
},
assets: myAssets,
userInfo: currentUser,
})
```
**Asset Upload Problems**
Asset upload failures often relate to CORS configuration or authentication:
```ts
const debugAssetStore = {
upload: async (asset, file) => {
console.log('Uploading asset:', {
name: file.name,
size: file.size,
type: file.type,
})
try {
const result = await uploadToServer(file)
console.log('Upload successful:', result)
return result
} catch (error) {
console.error('Upload failed:', error)
throw error
}
},
resolve: (asset, context) => {
console.log('Resolving asset:', asset.src, 'with context:', context)
return asset.src
},
}
```
## 7. Integration with Authentication
### Token-Based Authentication
Most production applications require authentication. The sync package supports token-based auth through dynamic URI generation:
```ts
import { useAuth } from './auth-system'
function AuthenticatedApp() {
const { user, getToken } = useAuth()
const store = useSync({
uri: async () => {
if (!user) throw new Error('Not authenticated')
const token = await getToken()
return `wss://myserver.com/sync/room-123?token=${token}&userId=${user.id}`
},
userInfo: {
id: user.id,
name: user.displayName,
color: user.preferredColor
},
assets: createAuthenticatedAssetStore(getToken),
})
if (!user) {
return <LoginPrompt />
}
return <Tldraw store={store.store} />
}
```
### Session Management
Handle authentication state changes gracefully by recreating the sync connection:
```ts
function SessionManagedApp() {
const { user, sessionId } = useAuth()
// Recreate sync connection when session changes
const store = useSync({
uri: `wss://myserver.com/sync?session=${sessionId}`,
userInfo: user ? {
id: user.id,
name: user.name,
color: user.color
} : null,
assets: myAssetStore,
})
// Handle logout
const handleLogout = () => {
// Sync connection will automatically clean up
// when component unmounts or deps change
logout()
}
return (
<div>
{user && <button onClick={handleLogout}>Logout</button>}
<Tldraw store={store.store} />
</div>
)
}
```
### Role-Based Permissions
Integrate with permission systems by handling readonly mode:
```ts
function PermissionAwareApp() {
const store = useSync({
uri: wsUri,
assets: myAssets,
userInfo: currentUser,
})
if (store.status === 'synced-remote') {
// Server can set readonly mode based on user permissions
const isReadonly = store.store.collaboration?.mode === 'readonly'
if (isReadonly) {
return (
<div>
<div className="notice">You have view-only access to this room</div>
<Tldraw store={store.store} />
</div>
)
}
}
return <Tldraw store={store.store} />
}
```
The sync system automatically handles permission enforcement when the server sets readonly mode, preventing local changes from being applied or synchronized.
This comprehensive guide covers the essential concepts, practical implementation patterns, and advanced features of the `@tldraw/sync` package. The reactive nature of the sync system, combined with robust error handling and flexible configuration options, enables you to build reliable collaborative experiences that gracefully handle the complexities of real-time multiplayer interaction.

1
packages/sync/LICENSE.md Normal file
View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

37
packages/sync/README.md Normal file
View File

@@ -0,0 +1,37 @@
# @tldraw/sync
This project contains source code for [tldraw sync](https://tldraw.dev/docs/sync). [Click here](https://tldraw.dev/blog/product/announcing-tldraw-sync) to learn more.
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
## License
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
You can use the tldraw SDK in commercial or non-commercial projects so long as you preserve the "Made with tldraw" watermark on the canvas. To remove the watermark, you can purchase a [business license](https://tldraw.dev/pricing). Visit [tldraw.dev](https://tldraw.dev) to learn more.
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,95 @@
## API Report File for "@tldraw/sync"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Editor } from 'tldraw';
import { TLAssetStore } from 'tldraw';
import { TLObjectStoreAccess } from '@tldraw/sync-core';
import { TLPersistentClientSocket } from '@tldraw/sync-core';
import { TLPresenceStateInfo } from 'tldraw';
import { TLStore } from 'tldraw';
import { TLStoreSchemaOptions } from 'tldraw';
import { TLStoreWithStatus } from 'tldraw';
import { TLThemes } from 'tldraw';
import { TLUser } from 'tldraw';
import { TLUserStore } from 'tldraw';
// @public
export type RemoteTLStoreWithStatus = (Extract<TLStoreWithStatus, {
status: 'synced-remote';
}> & {
readonly objectAccess: TLObjectStoreAccess;
}) | Exclude<TLStoreWithStatus, {
status: 'not-synced';
} | {
status: 'synced-local';
} | {
status: 'synced-remote';
}>;
// @public
export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented)
export type UseSyncConnectFn = (query: {
sessionId: string;
storeId: string;
}) => TLPersistentClientSocket;
// @public
export function useSyncDemo(options: UseSyncDemoOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus;
// @public (undocumented)
export interface UseSyncDemoOptions {
// Warning: (ae-unresolved-inheritdoc-reference) The @inheritDoc reference could not be resolved: No member was found with name "getUserPresence"
//
// (undocumented)
getUserPresence?(store: TLStore, user: TLUser): null | TLPresenceStateInfo;
// @internal (undocumented)
host?: string;
roomId: string;
users?: TLUserStore;
}
// @public
export type UseSyncOptions = UseSyncOptionsWithConnectFn | UseSyncOptionsWithUri;
// @public
export interface UseSyncOptionsBase {
assets: TLAssetStore;
getUserPresence?(store: TLStore, user: TLUser): null | TLPresenceStateInfo;
onCustomMessageReceived?(data: any): void;
// @internal (undocumented)
onMount?(editor: Editor): void;
// @internal
roomId?: string;
themes?: Partial<TLThemes>;
// @internal (undocumented)
trackAnalyticsEvent?(name: string, data: {
[key: string]: any;
}): void;
users?: TLUserStore;
}
// @public (undocumented)
export interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {
connect: UseSyncConnectFn;
// (undocumented)
uri?: never;
}
// @public (undocumented)
export interface UseSyncOptionsWithUri extends UseSyncOptionsBase {
// (undocumented)
connect?: never;
uri: (() => Promise<string> | string) | string;
}
export * from "@tldraw/sync-core";
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,78 @@
{
"name": "@tldraw/sync",
"description": "tldraw infinite canvas SDK (multiplayer sync react bindings).",
"version": "5.4.2",
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:sync",
"name": "Sync",
"type": "feature",
"parent": "tldraw:sdk-core",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"typescript": "^7.0.2",
"uuid-by-string": "^4.0.0",
"uuid-readable": "^0.0.2",
"vitest": "^5.0.0"
},
"dependencies": {
"@tldraw/state": "workspace:*",
"@tldraw/state-react": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/utils": "workspace:*",
"nanoevents": "^7.0.1",
"tldraw": "workspace:*",
"ws": "^8.18.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1,3 @@
// Text encoding/decoding polyfills
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder

View File

@@ -0,0 +1,3 @@
test('make ci pass with empty test', () => {
// empty
})

View File

@@ -0,0 +1,20 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/sync-core'
export {
useSync,
type RemoteTLStoreWithStatus,
type UseSyncConnectFn,
type UseSyncOptions,
type UseSyncOptionsBase,
type UseSyncOptionsWithConnectFn,
type UseSyncOptionsWithUri,
} from './useSync'
export { useSyncDemo, type UseSyncDemoOptions } from './useSyncDemo'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

View File

@@ -0,0 +1,95 @@
import { TLSyncClient } from '@tldraw/sync-core'
import { act, createElement } from 'react'
import { createRoot, Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RemoteTLStoreWithStatus, useSync, UseSyncOptions } from './useSync'
vi.mock('@tldraw/sync-core', async () => {
const actual = await vi.importActual<typeof import('@tldraw/sync-core')>('@tldraw/sync-core')
// capture each client's callbacks so the test can drive the handshake outcome directly
class MockTLSyncClient {
static instances: MockTLSyncClient[] = []
store: any
socket: any
constructor(public opts: any) {
this.store = opts.store
this.socket = opts.socket
MockTLSyncClient.instances.push(this)
}
close = vi.fn()
}
return { ...actual, TLSyncClient: MockTLSyncClient }
})
const Mock = TLSyncClient as unknown as {
instances: Array<{ opts: any; store: any; socket: any }>
}
const assets = { upload: vi.fn(), resolve: vi.fn() } as any
function makeSocket() {
return {
connectionStatus: 'online' as const,
sendMessage: vi.fn(),
onReceiveMessage: vi.fn(() => () => {}),
onStatusChange: vi.fn(() => () => {}),
restart: vi.fn(),
close: vi.fn(),
}
}
describe('useSync', () => {
let root: Root
let container: HTMLDivElement
let latest: RemoteTLStoreWithStatus
function Harness(props: { opts: UseSyncOptions }) {
latest = useSync(props.opts)
return null
}
function render(opts: UseSyncOptions) {
act(() => root.render(createElement(Harness, { opts })))
}
beforeEach(() => {
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
Mock.instances.length = 0
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.restoreAllMocks()
})
it("clears a previous client's error once a new room loads", () => {
const socketA = makeSocket()
render({ connect: () => socketA as any, roomId: 'a', assets })
expect(latest.status).toBe('loading')
// the first room is missing: the hook reports the error
act(() => Mock.instances[0].opts.onSyncError('NOT_FOUND'))
expect(latest.status).toBe('error')
// the caller switches to another room without remounting; a fresh client is created
const socketB = makeSocket()
render({ connect: () => socketB as any, roomId: 'b', assets })
expect(Mock.instances).toHaveLength(2)
const clientB = Mock.instances[1]
act(() => {
clientB.opts.onAfterConnect(clientB, { isReadonly: false, objectAccess: 'write' })
clientB.opts.onLoad(clientB)
})
// the healthy room must not inherit the old room's NOT_FOUND
expect(latest).toMatchObject({
status: 'synced-remote',
connectionStatus: 'online',
objectAccess: 'write',
})
})
})

View File

@@ -0,0 +1,604 @@
import { atom, transact } from '@tldraw/state'
import {
ClientWebSocketAdapter,
TLCustomMessageHandler,
TLObjectStoreAccess,
TLPersistentClientSocket,
TLPresenceMode,
TLRemoteSyncError,
TLSocketClientSentEvent,
TLSocketServerSentEvent,
TLSyncClient,
TLSyncErrorCloseEventReason,
} from '@tldraw/sync-core'
import { useEffect } from 'react'
import {
Editor,
TAB_ID,
TLAssetStore,
TLPresenceStateInfo,
TLRecord,
TLStore,
TLStoreSchemaOptions,
TLStoreWithStatus,
TLThemes,
TLUser,
TLUserStore,
UserRecordType,
computed,
createCachedUserResolve,
createPresenceStateDerivation,
registerColorsFromThemes,
registerFontsFromThemes,
resolveThemes,
createTLStore,
createUserId,
defaultUserPreferences,
defaultUserStore,
getDefaultUserPresence,
getUserPreferences,
uniqueId,
useEvent,
useReactiveEvent,
useRefState,
useTLSchemaFromUtils,
useValue,
} from 'tldraw'
const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
// Keyed by `reason`, which is an arbitrary string off the server's close event, so a Map rather
// than an object literal: a reason of 'constructor' or '__proto__' would find a prototype member.
const SYNC_ERROR_EVENT_NAMES = new Map<string, string>([
[TLSyncErrorCloseEventReason.NOT_FOUND, 'room-not-found'],
[TLSyncErrorCloseEventReason.FORBIDDEN, 'forbidden'],
[TLSyncErrorCloseEventReason.NOT_AUTHENTICATED, 'not-authenticated'],
[TLSyncErrorCloseEventReason.RATE_LIMITED, 'rate-limited'],
])
type SyncSocket = TLPersistentClientSocket<
TLSocketClientSentEvent<TLRecord>,
TLSocketServerSentEvent<TLRecord>
>
// The socket reports 'error' but the collaboration status only knows 'offline'.
function toCollaborationStatus(status: SyncSocket['connectionStatus']) {
return status === 'error' ? 'offline' : status
}
/**
* A store wrapper specifically for remote collaboration that excludes local-only states.
* This type represents a tldraw store that is synchronized with a remote multiplayer server.
*
* Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states
* since remote stores are always either loading, connected to a server, or in an error state.
*
* @example
* ```tsx
* function MyCollaborativeApp() {
* const store: RemoteTLStoreWithStatus = useSync({
* uri: 'wss://myserver.com/sync/room-123',
* assets: myAssetStore
* })
*
* if (store.status === 'loading') {
* return <div>Connecting to multiplayer session...</div>
* }
*
* if (store.status === 'error') {
* return <div>Connection failed: {store.error.message}</div>
* }
*
* // store.status === 'synced-remote'
* return <Tldraw store={store.store} />
* }
* ```
*
* @public
*/
export type RemoteTLStoreWithStatus =
| Exclude<
TLStoreWithStatus,
{ status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' }
>
| (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & {
/**
* Write access for object-store lane record types (e.g. comments), as granted by the
* server for this session. Independent of the canvas read-only state, so a session can
* be allowed to comment without being allowed to edit. Defaults to `'write'`.
*/
readonly objectAccess: TLObjectStoreAccess
})
/**
* Creates a reactive store synchronized with a multiplayer server for real-time collaboration.
*
* This hook manages the complete lifecycle of a collaborative tldraw session, including
* WebSocket connection establishment, state synchronization, user presence, and error handling.
* The returned store can be passed directly to the Tldraw component to enable multiplayer features.
*
* The store progresses through multiple states:
* - `loading`: Establishing connection and synchronizing initial state
* - `synced-remote`: Successfully connected and actively synchronizing changes
* - `error`: Connection failed or synchronization error occurred
*
* For optimal performance with media assets, provide an `assets` store that implements
* external blob storage. Without this, large images and videos will be stored inline
* as base64, causing performance issues during serialization.
*
* @param opts - Configuration options for multiplayer synchronization
* - `uri` - WebSocket server URI (string or async function returning URI)
* - `assets` - Asset store for blob storage (required for production use)
* - `users` - User store for identity, presence and attribution
* - `getUserPresence` - Optional function to customize presence data
* - `onCustomMessageReceived` - Handler for custom socket messages
* - `roomId` - Room identifier for analytics (internal use)
* - `onMount` - Callback when editor mounts (internal use)
* - `trackAnalyticsEvent` - Analytics event tracker (internal use)
*
* @returns A reactive store wrapper with connection status and synchronized store
*
* @example
* ```tsx
* // Basic multiplayer setup
* function CollaborativeApp() {
* const store = useSync({
* uri: 'wss://myserver.com/sync/room-123',
* assets: myAssetStore,
* users: {
* currentUser: computed('current-user', () => ({
* id: createUserId('user-1'),
* name: 'Alice',
* color: '#ff0000',
* meta: {},
* })),
* }
* })
*
* if (store.status === 'loading') {
* return <div>Connecting to collaboration session...</div>
* }
*
* if (store.status === 'error') {
* return <div>Failed to connect: {store.error.message}</div>
* }
*
* return <Tldraw store={store.store} />
* }
* ```
*
* @example
* ```tsx
* // Dynamic authentication with user store
* function AuthenticatedApp() {
* const store = useSync({
* uri: async () => {
* const token = await getAuthToken()
* return `wss://myserver.com/sync/room-123?token=${token}`
* },
* assets: authenticatedAssetStore,
* users: myUserStore,
* getUserPresence: (store, user) => {
* return {
* userId: user.id,
* userName: user.name,
* cursor: getCurrentCursor(store)
* }
* }
* })
*
* return <Tldraw store={store.store} />
* }
* ```
*
* @public
*/
export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {
const [state, setState] = useRefState<{
readyClient?: TLSyncClient<TLRecord, TLStore>
error?: Error
objectAccess?: TLObjectStoreAccess
} | null>(null)
const {
uri,
roomId = 'default',
assets,
users: _users,
onMount,
connect,
trackAnalyticsEvent: track,
getUserPresence: _getUserPresence,
onCustomMessageReceived: _onCustomMessageReceived,
themes,
...schemaOpts
} = opts
// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them
// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option
// is allowed to be unstable
const __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>
const resolvedThemes = resolveThemes(themes)
registerColorsFromThemes(resolvedThemes)
registerFontsFromThemes(resolvedThemes)
const schema = useTLSchemaFromUtils(schemaOpts)
const getUserPresence = useReactiveEvent(
(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence
)
const onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)
useEffect(() => {
const storeId = uniqueId()
const currentUserSignal = _users?.currentUser ?? defaultUserStore.currentUser
const users: Required<TLUserStore> = {
currentUser: currentUserSignal,
resolve:
_users?.resolve ??
createCachedUserResolve((userId) => {
const id = createUserId(userId)
const current = currentUserSignal.get()
if (current && current.id === id) return current
// Only the default user store falls back to presence records for other users.
if (_users) return null
const match = store.query
.records('instance_presence')
.get()
.find((p) => p.userId === id)
return match
? UserRecordType.create({ id, name: match.userName, color: match.color })
: null
}),
}
// This always returns a non-null user for presence display, falling back
// to anonymous user preferences. The store receives the raw `users` object
// (where currentUser may return null), so attribution via
// getAttributionUserId() correctly returns null for anonymous sessions.
const currentUser = computed<TLUser>('currentUser', () => {
const user = users.currentUser.get()
if (user) return user
const prefs = getUserPreferences()
return UserRecordType.create({
id: createUserId(prefs.id),
name: prefs.name ?? '',
color: prefs.color ?? defaultUserPreferences.color,
})
})
let socket: SyncSocket
if (connect) {
if (uri) {
throw new Error('uri and connect cannot be used together')
}
socket = connect({ sessionId: TAB_ID, storeId }) as SyncSocket
} else if (uri) {
socket = new ClientWebSocketAdapter(async () => {
// set sessionId as a query param on the uri
const withParams = new URL(typeof uri === 'string' ? uri : await uri())
for (const param of ['sessionId', 'storeId']) {
if (withParams.searchParams.has(param)) {
throw new Error(
`useSync. "${param}" is a reserved query param name. Please use a different name`
)
}
}
withParams.searchParams.set('sessionId', TAB_ID)
withParams.searchParams.set('storeId', storeId)
return withParams.toString()
})
} else {
throw new Error('uri or connect must be provided')
}
let didCancel = false
const collaborationStatusSignal = atom(
'collaboration status',
toCollaborationStatus(socket.connectionStatus)
)
const unsubscribeFromConnectionStatus = socket.onStatusChange(() => {
collaborationStatusSignal.set(toCollaborationStatus(socket.connectionStatus))
})
const syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')
const store = createTLStore({
id: storeId,
schema,
assets,
users,
onMount,
collaboration: {
status: collaborationStatusSignal,
mode: syncMode,
},
})
const presence = createPresenceStateDerivation(currentUser, {
getUserPresence,
})(store)
// Every connected session — each tab, window, or device — pushes its
// presence on connect, so the store holds one instance_presence record per
// *other* session in the room, including the user's own other tabs. (The
// server never echoes a session its own record.) So an empty set means
// we're genuinely the only session and can throttle to solo; any other
// session — another user, or just another tab of our own — keeps us at the
// full sync rate so edits propagate without the solo-mode lag.
const otherSessions = store.query.ids('instance_presence')
const presenceMode = computed<TLPresenceMode>('presenceMode', () => {
return otherSessions.get().size === 0 ? 'solo' : 'full'
})
const client = new TLSyncClient<TLRecord, TLStore>({
store,
socket,
didCancel: () => didCancel,
onLoad(client) {
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
// Keep objectAccess (onAfterConnect always runs first for this client) but drop the
// rest: an `error` left behind by a previous client (e.g. NOT_FOUND on the old uri)
// would otherwise keep the hook reporting status 'error' for the new, healthy room.
setState((prev) => ({ readyClient: client, objectAccess: prev?.objectAccess }))
},
onSyncError(reason) {
console.error('sync error', reason)
track?.(MULTIPLAYER_EVENT_NAME, {
name: SYNC_ERROR_EVENT_NAMES.get(reason) ?? 'sync-error:' + reason,
roomId,
})
setState({ error: new TLRemoteSyncError(reason) })
socket.close()
},
onAfterConnect(_, { isReadonly, objectAccess }) {
// Object-lane (comment) write access is decided per session by the server and can
// change on reconnect (e.g. when the file's share tier changes), so surface it on
// the returned status for consumers that gate comment UI. This fires before the
// first onLoad, hence the merge in both directions.
setState((prev) => ({ ...prev, objectAccess }))
transact(() => {
syncMode.set(isReadonly ? 'readonly' : 'readwrite')
// if the server crashes and loses all data it can return an empty document
// when it comes back up. This is a safety check to make sure that if something like
// that happens, it won't render the app broken and require a restart. The user will
// most likely lose all their changes though since they'll have been working with pages
// that won't exist. There's certainly something we can do to make this better.
// but the likelihood of this happening is very low and maybe not worth caring about beyond this.
store.ensureStoreIsUsable()
})
},
onCustomMessageReceived,
presence,
presenceMode,
})
return () => {
didCancel = true
unsubscribeFromConnectionStatus()
client.close()
socket.close()
}
}, [
assets,
onMount,
connect,
_users,
roomId,
schema,
setState,
track,
uri,
getUserPresence,
onCustomMessageReceived,
])
return useValue<RemoteTLStoreWithStatus>(
'remote synced store',
() => {
if (!state) return { status: 'loading' }
if (state.error) return { status: 'error', error: state.error }
if (!state.readyClient) return { status: 'loading' }
return {
status: 'synced-remote',
connectionStatus: toCollaborationStatus(state.readyClient.socket.connectionStatus),
store: state.readyClient.store,
objectAccess: state.objectAccess ?? 'write',
}
},
[state]
)
}
/**
* Configuration options for the {@link useSync} hook to establish multiplayer collaboration.
*
* This interface defines the required and optional settings for connecting to a multiplayer
* server, managing user presence, handling assets, and customizing the collaboration experience.
*
* @example
* ```tsx
* const syncOptions: UseSyncOptions = {
* uri: 'wss://myserver.com/sync/room-123',
* assets: myAssetStore,
* users: {
* currentUser: myCurrentUserSignal,
* },
* getUserPresence: (store, user) => ({
* userId: user.id,
* userName: user.name,
* cursor: getCursorPosition()
* })
* }
* ```
*
* @public
*/
export interface UseSyncOptionsBase {
/**
* Named theme definitions. When provided, custom color names are automatically
* registered before the store is constructed so persisted data with those
* colors passes validation on load.
*/
themes?: Partial<TLThemes>
/**
* Asset store implementation for handling file uploads and storage.
*
* Required for production applications to handle images, videos, and other
* media efficiently. Without an asset store, files are stored inline as
* base64, which causes performance issues with large files.
*
* The asset store must implement upload (for new files) and resolve
* (for displaying existing files) methods. For prototyping, you can use
* {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.
*
* @example
* ```ts
* const myAssetStore: TLAssetStore = {
* upload: async (asset, file) => {
* const url = await uploadToCloudStorage(file)
* return { src: url }
* },
* resolve: (asset, context) => {
* return getOptimizedUrl(asset.src, context)
* }
* }
* ```
*/
assets: TLAssetStore
/**
* User store for identity, presence and attribution.
*
* Both methods return reactive {@link @tldraw/state#Signal | Signals}.
* `currentUser` provides the current user's identity (used for
* both presence broadcasting and shape attribution) and optionally
* `resolve(userId)` looks up other users by ID. If not provided,
* a default implementation backed by localStorage user preferences is
* used, with `resolve` falling back to presence records in the store.
*/
users?: TLUserStore
/**
* Handler for receiving custom messages sent through the multiplayer connection.
*
* Use this to implement custom communication channels between clients beyond
* the standard shape and presence synchronization. Messages are sent using
* the TLSyncClient's sendMessage method.
*
* @param data - The custom message data received from another client
*
* @example
* ```ts
* onCustomMessageReceived: (data) => {
* if (data.type === 'chat') {
* displayChatMessage(data.message, data.userId)
* }
* }
* ```
*/
onCustomMessageReceived?(data: any): void
/** @internal */
onMount?(editor: Editor): void
/** @internal used for analytics only, we should refactor this away */
roomId?: string
/** @internal */
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
/**
* A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.
*
* This function is called reactively whenever the store state changes and
* determines what presence information to broadcast to other clients. The
* result is synchronized across all connected clients for displaying cursors,
* selections, and other collaborative indicators.
*
* If not provided, uses the default implementation which includes standard
* cursor position and selection state. Custom implementations allow you to
* add additional presence data like current tool, view state, or custom status.
*
* See {@link @tldraw/tlschema#getDefaultUserPresence} for
* the default implementation of this function.
*
* @param store - The current TLStore
* @param user - The current user information
* @returns Presence state to broadcast to other clients, or null to hide presence
*
* @example
* ```ts
* getUserPresence: (store, user) => {
* return {
* userId: user.id,
* userName: user.name,
* cursor: { x: 100, y: 200 },
* currentTool: 'select',
* isActive: true
* }
* }
* ```
*/
getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
}
/** @public */
export interface UseSyncOptionsWithUri extends UseSyncOptionsBase {
/**
* The WebSocket URI of the multiplayer server for real-time synchronization.
*
* Must include the protocol (wss:// for secure, ws:// for local development).
* HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.
*
* Can be a static string or a function that returns a URI (useful for dynamic
* authentication tokens or room routing). The function is called on each
* connection attempt, allowing for token refresh and dynamic routing.
*
* Reserved query parameters `sessionId` and `storeId` are automatically added
* by the sync system and should not be included in your URI.
*
* @example
* ```ts
* // Static URI
* uri: 'wss://myserver.com/sync/room-123'
*
* // Dynamic URI with authentication
* uri: async () => {
* const token = await getAuthToken()
* return `wss://myserver.com/sync/room-123?token=${token}`
* }
* ```
*/
uri: string | (() => string | Promise<string>)
connect?: never
}
/** @public */
export interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {
/**
* Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}
* instead, but this is useful if you want to use a custom transport to connect to the server,
* instead of our default websocket-based transport.
*/
connect: UseSyncConnectFn
uri?: never
}
/** @public */
export type UseSyncConnectFn = (query: {
sessionId: string
storeId: string
}) => TLPersistentClientSocket
/**
* Options for the {@link useSync} hook.
* @public
*/
export type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn

View File

@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock modules before imports
vi.mock('./useSync')
vi.mock('react', async () => {
const actual = await vi.importActual('react')
return {
...actual,
useMemo: vi.fn((fn, _deps) => fn()),
useCallback: vi.fn((fn, _deps) => fn),
}
})
vi.mock('tldraw', async () => {
const actual = await vi.importActual('tldraw')
return {
...actual,
useShallowObjectIdentity: vi.fn((obj) => obj),
uniqueId: vi.fn(() => 'mock-unique-id'),
getHashForString: vi.fn((str) => `hash-${str}`),
}
})
import {
TLAsset,
defaultBindingUtils,
defaultShapeUtils,
getHashForString,
uniqueId,
useShallowObjectIdentity,
} from 'tldraw'
import { useSync } from './useSync'
import { useSyncDemo } from './useSyncDemo'
// Mock fetch globally
const mockFetch = vi.fn() as any
global.fetch = mockFetch
// Mock console.error and alert to avoid noise in tests
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
describe('useSyncDemo', () => {
beforeEach(() => {
vi.clearAllMocks()
consoleSpy.mockClear()
alertSpy.mockClear()
vi.mocked(useSync).mockReturnValue({ status: 'loading' } as any)
vi.mocked(useShallowObjectIdentity).mockImplementation((obj) => obj)
vi.mocked(uniqueId).mockReturnValue('mock-unique-id')
vi.mocked(getHashForString).mockImplementation((str) => `hash-${str}`)
})
describe('core functionality', () => {
it('should construct correct URI with room ID encoding', () => {
const roomIdWithSpecialChars = 'room with spaces & symbols!'
useSyncDemo({ roomId: roomIdWithSpecialChars })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
expect(useSyncCall.uri).toContain(encodeURIComponent(roomIdWithSpecialChars))
expect(useSyncCall.roomId).toBe(roomIdWithSpecialChars)
})
it('should use custom host when provided', () => {
const customHost = 'https://custom.server.com'
useSyncDemo({ roomId: 'test-room', host: customHost })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
expect(useSyncCall.uri).toBe(`${customHost}/connect/test-room`)
})
it('should merge custom shape utils with defaults', () => {
const customShapeUtils = [{ type: 'custom-shape' } as any]
useSyncDemo({ roomId: 'test-room', shapeUtils: customShapeUtils } as any)
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
expect((useSyncCall as any).shapeUtils).toEqual([...defaultShapeUtils, ...customShapeUtils])
})
it('should merge custom binding utils with defaults', () => {
const customBindingUtils = [{ type: 'custom-binding' } as any]
useSyncDemo({ roomId: 'test-room', bindingUtils: customBindingUtils } as any)
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
expect((useSyncCall as any).bindingUtils).toEqual([
...defaultBindingUtils,
...customBindingUtils,
])
})
})
describe('asset upload restrictions', () => {
it('should block uploads for tldraw domains', async () => {
useSyncDemo({ roomId: 'test-room', host: 'https://demo.tldraw.xyz' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
const assetStore = useSyncCall.assets
const file = new File(['test content'], 'test-file.jpg')
await expect(assetStore.upload({} as TLAsset, file)).rejects.toThrow(
'Uploading images is disabled in this demo.'
)
expect(alertSpy).toHaveBeenCalledWith('Uploading images is disabled in this demo.')
})
it('should allow uploads for non-tldraw domains', async () => {
useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
const assetStore = useSyncCall.assets
const file = new File(['test content'], 'test-file.jpg')
vi.mocked(mockFetch).mockResolvedValueOnce(new Response())
vi.mocked(uniqueId).mockReturnValueOnce('unique-123')
const result = await assetStore.upload({} as TLAsset, file)
expect(mockFetch).toHaveBeenCalledWith(
'https://demo.server.com/uploads/unique-123-test-file-jpg',
expect.objectContaining({ method: 'POST', body: file })
)
expect(result).toEqual({ src: 'https://demo.server.com/uploads/unique-123-test-file-jpg' })
})
it('should reject the upload when the server responds with an error status', async () => {
useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
const assetStore = useSyncCall.assets
const file = new File(['test content'], 'test-file.jpg')
// a rejected upload resolves the fetch; the asset must not point at a URL that was never stored
vi.mocked(mockFetch).mockResolvedValueOnce(new Response(null, { status: 409 }))
await expect(assetStore.upload({} as TLAsset, file)).rejects.toThrow('409')
})
})
describe('bookmark asset creation', () => {
it('should create bookmark assets with metadata when successful', async () => {
const mockEditor = { registerExternalAssetHandler: vi.fn() } as any
useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
useSyncCall.onMount!(mockEditor)
const handlerFunction = mockEditor.registerExternalAssetHandler.mock.calls[0][1]
const testUrl = 'https://example.com'
const mockMeta = { title: 'Example', description: 'Test' }
vi.mocked(mockFetch).mockResolvedValueOnce({
json: () => Promise.resolve(mockMeta),
} as Response)
vi.mocked(getHashForString).mockReturnValueOnce('url-hash-123')
const result = await handlerFunction({ url: testUrl })
expect(result.type).toBe('bookmark')
expect(result.props.src).toBe(testUrl)
expect(result.props.title).toBe('Example')
})
it('should handle bookmark creation errors gracefully', async () => {
const mockEditor = { registerExternalAssetHandler: vi.fn() } as any
useSyncDemo({ roomId: 'test-room' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
useSyncCall.onMount!(mockEditor)
const handlerFunction = mockEditor.registerExternalAssetHandler.mock.calls[0][1]
const testUrl = 'https://example.com'
vi.mocked(mockFetch).mockRejectedValueOnce(new Error('Network error'))
vi.mocked(getHashForString).mockReturnValueOnce('url-hash-123')
const result = await handlerFunction({ url: testUrl })
expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error))
expect(result.props.title).toBe('')
expect(result.props.description).toBe('')
})
})
describe('file name sanitization', () => {
it('should sanitize file names in upload URLs', async () => {
useSyncDemo({ roomId: 'test-room', host: 'https://demo.server.com' })
const useSyncCall = vi.mocked(useSync).mock.calls[0][0]
const assetStore = useSyncCall.assets
const file = new File(['test'], 'file with spaces & symbols!.jpg')
vi.mocked(mockFetch).mockResolvedValueOnce(new Response())
vi.mocked(uniqueId).mockReturnValueOnce('unique-123')
await assetStore.upload({} as TLAsset, file)
expect(mockFetch).toHaveBeenCalledWith(
'https://demo.server.com/uploads/unique-123-file-with-spaces---symbols--jpg',
expect.any(Object)
)
})
})
})

View File

@@ -0,0 +1,309 @@
import { useCallback, useMemo } from 'react'
import {
AssetRecordType,
Editor,
MediaHelpers,
TLAsset,
TLAssetStore,
TLPresenceStateInfo,
TLStore,
TLStoreSchemaOptions,
TLUser,
TLUserStore,
clamp,
defaultBindingUtils,
defaultShapeUtils,
getHashForString,
uniqueId,
useShallowObjectIdentity,
} from 'tldraw'
import { RemoteTLStoreWithStatus, useSync } from './useSync'
/** @public */
export interface UseSyncDemoOptions {
/**
* The room ID to sync with. Make sure the room ID is unique. The namespace is shared by
* everyone using the demo server. Consider prefixing it with your company or project name.
*/
roomId: string
/**
* User store for identity, presence and attribution.
* If not provided, a default implementation based on localStorage will be used.
*/
users?: TLUserStore
/** @internal */
host?: string
/**
* {@inheritdoc UseSyncOptions.getUserPresence}
* @public
*/
getUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null
}
/**
* Safely accesses environment variables across different bundling environments.
*
* Depending on the environment this package is used in, process.env may not be available. This function
* wraps `process.env` accesses in a try/catch to prevent runtime errors in environments where process
* is not defined.
*
* The reason that this is just a try/catch and not a dynamic check e.g. `process &&
* process.env[key]` is that many bundlers implement `process.env.WHATEVER` using compile-time
* string replacement, rather than actually creating a runtime implementation of a `process` object.
*
* @param cb - Callback function that accesses an environment variable
* @returns The environment variable value if available, otherwise undefined
* @internal
*/
function getEnv(cb: () => string | undefined): string | undefined {
try {
return cb()
} catch {
return undefined
}
}
const DEMO_WORKER = getEnv(() => process.env.TLDRAW_BEMO_URL) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.TLDRAW_IMAGE_URL) ?? 'https://images.tldraw.xyz'
/**
* Creates a tldraw store synced with a multiplayer room hosted on tldraw's demo server `https://demo.tldraw.xyz`.
*
* The store can be passed directly into the `<Tldraw />` component to enable multiplayer features.
* It will handle loading states, and enable multiplayer UX like user cursors and following.
*
* All data on the demo server is
*
* - Deleted after a day or so.
* - Publicly accessible to anyone who knows the room ID. Use your company name as a prefix to help avoid collisions, or generate UUIDs for maximum privacy.
*
* @example
* ```tsx
* function MyApp() {
* const store = useSyncDemo({roomId: 'my-app-test-room'})
* return <Tldraw store={store} />
* }
* ```
*
* @param options - Options for the multiplayer demo sync store. See {@link UseSyncDemoOptions} and {@link @tldraw/editor#TLStoreSchemaOptions}.
*
* @public
*/
export function useSyncDemo(
options: UseSyncDemoOptions & TLStoreSchemaOptions
): RemoteTLStoreWithStatus {
const { roomId, host = DEMO_WORKER, ..._syncOpts } = options
const assets = useMemo(() => createDemoAssetStore(host), [host])
const syncOpts = useShallowObjectIdentity(_syncOpts)
const syncOptsWithDefaults = useMemo(() => {
if ('schema' in syncOpts && syncOpts.schema) return syncOpts
return {
...syncOpts,
shapeUtils:
'shapeUtils' in syncOpts
? [...defaultShapeUtils, ...(syncOpts.shapeUtils ?? [])]
: defaultShapeUtils,
bindingUtils:
'bindingUtils' in syncOpts
? [...defaultBindingUtils, ...(syncOpts.bindingUtils ?? [])]
: defaultBindingUtils,
}
}, [syncOpts])
return useSync({
uri: `${host}/connect/${encodeURIComponent(roomId)}`,
roomId,
assets,
onMount: useCallback(
(editor: Editor) => {
editor.registerExternalAssetHandler('url', ({ url }) =>
createAssetFromUrlUsingDemoServer(host, url)
)
},
[host]
),
...syncOptsWithDefaults,
})
}
/**
* Determines whether file uploads should be disabled for a given host.
*
* Uploads are disabled for production tldraw domains to prevent abuse of the demo server
* infrastructure. This includes tldraw.com and tldraw.xyz domains and their subdomains.
*
* @param host - The host URL to check for upload restrictions
* @returns True if uploads should be disabled, false otherwise
* @internal
*/
function shouldDisallowUploads(host: string) {
const disallowedHosts = ['tldraw.com', 'tldraw.xyz']
return disallowedHosts.some(
(disallowedHost) => host === disallowedHost || host.endsWith(`.${disallowedHost}`)
)
}
/**
* Creates an asset store implementation optimized for the tldraw demo server.
*
* This asset store handles file uploads to the demo server and provides intelligent
* asset resolution with automatic image optimization based on network conditions,
* screen density, and display size. It includes safeguards to prevent uploads to
* production domains and optimizes images through the tldraw image processing service.
*
* @param host - The demo server host URL for file uploads and asset resolution
* @returns A TLAssetStore implementation with upload and resolve capabilities
* @example
* ```ts
* const assetStore = createDemoAssetStore('https://demo.tldraw.xyz')
*
* // Upload a file
* const result = await assetStore.upload(asset, file)
* console.log('Uploaded to:', result.src)
*
* // Resolve optimized asset URL
* const optimizedUrl = assetStore.resolve(imageAsset, {
* steppedScreenScale: 1.5,
* dpr: 2,
* networkEffectiveType: '4g'
* })
* ```
* @internal
*/
function createDemoAssetStore(host: string): TLAssetStore {
return {
upload: async (_asset, file) => {
if (shouldDisallowUploads(host)) {
alert('Uploading images is disabled in this demo.')
throw new Error('Uploading images is disabled in this demo.')
}
const objectName = `${uniqueId()}-${file.name}`.replace(/\W/g, '-')
const url = `${host}/uploads/${objectName}`
const response = await fetch(url, {
method: 'POST',
body: file,
})
// a rejected upload (invalid name, duplicate, server error) resolves the fetch; without
// this the asset would point at a URL that was never stored
if (!response.ok) {
throw new Error(`Failed to upload asset (${response.status})`)
}
return { src: url }
},
resolve(asset, context) {
const { src } = asset.props
if (!src) return null
// We don't deal with videos at the moment.
if (asset.type === 'video') return src
// Assert it's an image to make TS happy.
if (asset.type !== 'image') return null
// Don't try to transform data: URLs, yikes.
if (!src.startsWith('http:') && !src.startsWith('https:')) return src
if (context.shouldResolveToOriginal) return src
// Don't try to transform animated images.
if (MediaHelpers.isAnimatedImageType(asset.props.mimeType) || asset.props.isAnimated)
return src
// Don't try to transform vector images.
if (MediaHelpers.isVectorImageType(asset.props.mimeType)) return src
const url = new URL(src)
// we only transform images that are hosted on domains we control
const isTldrawImage =
url.origin === host || /\.tldraw\.(?:com|xyz|dev|workers\.dev)$/.test(url.host)
if (!isTldrawImage) return src
// Assets that are under a certain file size aren't worth transforming (and incurring cost).
// We still send them through the image worker to get them optimized though.
const { fileSize = 0 } = asset.props
const isWorthResizing = fileSize >= 1024 * 1024 * 1.5
if (isWorthResizing) {
// N.B. navigator.connection is only available in certain browsers (mainly Blink-based browsers)
// 4g is as high the 'effectiveType' goes and we can pick a lower effective image quality for slower connections.
const networkCompensation =
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
const pixelRatio = asset.props.pixelRatio ?? 1
const trueWidth = asset.props.w * pixelRatio
const width = Math.ceil(
Math.min(
trueWidth *
clamp(context.steppedScreenScale, 1 / 32, 1) *
networkCompensation *
context.dpr,
trueWidth
)
)
url.searchParams.set('w', width.toString())
}
return `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
},
}
}
/**
* Creates a bookmark asset by fetching metadata from a URL using the demo server.
*
* This function uses the demo server's bookmark unfurling service to extract metadata
* like title, description, favicon, and preview image from a given URL. If the metadata
* fetch fails, it returns a blank bookmark asset with just the URL.
*
* @param host - The demo server host URL to use for bookmark unfurling
* @param url - The URL to create a bookmark asset from
* @returns A promise that resolves to a TLAsset of type 'bookmark' with extracted metadata
* @example
* ```ts
* const asset = await createAssetFromUrlUsingDemoServer(
* 'https://demo.tldraw.xyz',
* 'https://example.com'
* )
*
* console.log(asset.props.title) // "Example Domain"
* console.log(asset.props.description) // "This domain is for use in illustrative examples..."
* ```
* @internal
*/
async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
let meta: { description?: string; image?: string; favicon?: string; title?: string } | null = null
try {
// First, try to get the meta data from our endpoint
const fetchUrl = new URL(`${host}/bookmarks/unfurl`)
fetchUrl.searchParams.set('url', url)
meta = await (await fetch(fetchUrl, { method: 'POST' })).json()
} catch (error) {
// Otherwise, fall back to a blank bookmark
console.error(error)
}
return {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta?.description ?? '',
image: meta?.image ?? '',
favicon: meta?.favicon ?? '',
title: meta?.title ?? '',
},
meta: {},
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "docs", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [
{ "path": "../state" },
{ "path": "../state-react" },
{ "path": "../sync-core" },
{ "path": "../tldraw" },
{ "path": "../utils" }
]
}

View File

@@ -0,0 +1,10 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// sync package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {
test: {
setupFiles: ['../../internal/config/vitest/setup.ts', './setupVitest.js'],
},
})