@tldraw/sync 5.3.0-next.299378752aaf → 5.3.0-next.2fa9c61a8de6

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.
@@ -1,5 +1,6 @@
1
1
  import { Editor } from 'tldraw';
2
2
  import { TLAssetStore } from 'tldraw';
3
+ import { TLObjectStoreAccess } from '@tldraw/sync-core';
3
4
  import { TLPersistentClientSocket } from '@tldraw/sync-core';
4
5
  import { TLPresenceStateInfo } from 'tldraw';
5
6
  import { TLStore } from 'tldraw';
@@ -39,10 +40,21 @@ import { TLUserStore } from 'tldraw';
39
40
  *
40
41
  * @public
41
42
  */
42
- export declare type RemoteTLStoreWithStatus = Exclude<TLStoreWithStatus, {
43
+ export declare type RemoteTLStoreWithStatus = (Extract<TLStoreWithStatus, {
44
+ status: 'synced-remote';
45
+ }> & {
46
+ /**
47
+ * Write access for object-store lane record types (e.g. comments), as granted by the
48
+ * server for this session. Independent of the canvas read-only state, so a session can
49
+ * be allowed to comment without being allowed to edit. Defaults to `'write'`.
50
+ */
51
+ readonly objectAccess: TLObjectStoreAccess;
52
+ }) | Exclude<TLStoreWithStatus, {
43
53
  status: 'not-synced';
44
54
  } | {
45
55
  status: 'synced-local';
56
+ } | {
57
+ status: 'synced-remote';
46
58
  }>;
47
59
 
48
60
  /**
package/dist-cjs/index.js CHANGED
@@ -29,7 +29,7 @@ var import_useSync = require("./useSync");
29
29
  var import_useSyncDemo = require("./useSyncDemo");
30
30
  (0, import_utils.registerTldrawLibraryVersion)(
31
31
  "@tldraw/sync",
32
- "5.3.0-next.299378752aaf",
32
+ "5.3.0-next.2fa9c61a8de6",
33
33
  "cjs"
34
34
  );
35
35
  //# sourceMappingURL=index.js.map
@@ -153,7 +153,7 @@ function useSync(opts) {
153
153
  didCancel: () => didCancel,
154
154
  onLoad(client2) {
155
155
  track?.(MULTIPLAYER_EVENT_NAME, { name: "load", roomId });
156
- setState({ readyClient: client2 });
156
+ setState((prev) => ({ ...prev, readyClient: client2 }));
157
157
  },
158
158
  onSyncError(reason) {
159
159
  console.error("sync error", reason);
@@ -177,7 +177,8 @@ function useSync(opts) {
177
177
  setState({ error: new import_sync_core.TLRemoteSyncError(reason) });
178
178
  socket.close();
179
179
  },
180
- onAfterConnect(_, { isReadonly }) {
180
+ onAfterConnect(_, { isReadonly, objectAccess }) {
181
+ setState((prev) => ({ ...prev, objectAccess }));
181
182
  (0, import_state.transact)(() => {
182
183
  syncMode.set(isReadonly ? "readonly" : "readwrite");
183
184
  store.ensureStoreIsUsable();
@@ -216,7 +217,8 @@ function useSync(opts) {
216
217
  return {
217
218
  status: "synced-remote",
218
219
  connectionStatus: connectionStatus === "error" ? "offline" : connectionStatus,
219
- store: state.readyClient.store
220
+ store: state.readyClient.store,
221
+ objectAccess: state.objectAccess ?? "write"
220
222
  };
221
223
  },
222
224
  [state]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/useSync.ts"],
4
- "sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLObjectStoreAccess,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus =\n\t| Exclude<\n\t\t\tTLStoreWithStatus,\n\t\t\t{ status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' }\n\t >\n\t| (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & {\n\t\t\t/**\n\t\t\t * Write access for object-store lane record types (e.g. comments), as granted by the\n\t\t\t * server for this session. Independent of the canvas read-only state, so a session can\n\t\t\t * be allowed to comment without being allowed to edit. Defaults to `'write'`.\n\t\t\t */\n\t\t\treadonly objectAccess: TLObjectStoreAccess\n\t })\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t\tobjectAccess?: TLObjectStoreAccess\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\t// Merge so we don't clobber objectAccess if onAfterConnect ran first.\n\t\t\t\tsetState((prev) => ({ ...prev, readyClient: client }))\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly, objectAccess }) {\n\t\t\t\t// Object-lane (comment) write access is decided per session by the server and can\n\t\t\t\t// change on reconnect (e.g. when the file's share tier changes), so surface it on\n\t\t\t\t// the returned status for consumers that gate comment UI. This fires before the\n\t\t\t\t// first onLoad, hence the merge in both directions.\n\t\t\t\tsetState((prev) => ({ ...prev, objectAccess }))\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t\tobjectAccess: state.objectAccess ?? 'write',\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA+B;AAC/B,uBAWO;AACP,mBAA0B;AAC1B,oBA+BO;AAEP,MAAM,yBAAyB;AAE/B,MAAM,8BAAsD,MAAM;AAAC;AAiI5D,SAAS,QAAQ,MAAsE;AAC7F,QAAM,CAAC,OAAO,QAAQ,QAAI,2BAIhB,IAAI;AACd,QAAM;AAAA,IACL;AAAA,IACA,SAAS;AAAA,IACT;AAAA,IACA,OAAO;AAAA,IACP;AAAA,IACA;AAAA,IACA,qBAAqB;AAAA,IACrB,iBAAiB;AAAA,IACjB,yBAAyB;AAAA,IACzB;AAAA,IACA,GAAG;AAAA,EACJ,IAAI;AAKJ,QAAM,YAAmB;AAEzB,QAAM,qBAAiB,6BAAc,MAAM;AAC3C,8CAAyB,cAAc;AACvC,6CAAwB,cAAc;AACtC,QAAM,aAAS,oCAAqB,UAAU;AAE9C,QAAM,sBAAkB;AAAA,IACtB,oBAAoB;AAAA,EACtB;AACA,QAAM,8BAA0B,wBAAS,4BAA4B,2BAA2B;AAEhG,8BAAU,MAAM;AACf,UAAM,cAAU,wBAAS;AAEzB,UAAM,QAA+B,SAClC;AAAA,MACA,aAAa,OAAO;AAAA,MACpB,SACC,OAAO,eACP,uCAAwB,CAAC,WAAW;AACnC,cAAM,UAAU,OAAO,YAAY,IAAI;AACvC,eAAO,WAAW,QAAQ,WAAO,4BAAa,MAAM,IAAI,UAAU;AAAA,MACnE,CAAC;AAAA,IACH,IACC;AAAA,MACA,aAAa,+BAAiB;AAAA,MAC9B,aAAS,uCAAwB,CAAC,WAAW;AAC5C,cAAM,UAAU,+BAAiB,YAAY,IAAI;AACjD,YAAI,WAAW,QAAQ,WAAO,4BAAa,MAAM,EAAG,QAAO;AAC3D,cAAM,YAAY,MAAM,MAAM,QAAQ,mBAAmB,EAAE,IAAI;AAC/D,cAAM,QAAQ,UAAU,KAAK,CAAC,MAAM,EAAE,eAAW,4BAAa,MAAM,CAAC;AACrE,YAAI,OAAO;AACV,iBAAO,6BAAe,OAAO;AAAA,YAC5B,QAAI,4BAAa,MAAM;AAAA,YACvB,MAAM,MAAM;AAAA,YACZ,OAAO,MAAM;AAAA,UACd,CAAC;AAAA,QACF;AACA,eAAO;AAAA,MACR,CAAC;AAAA,IACF;AAMF,UAAM,kBAAc,wBAAiB,eAAe,MAAM;AACzD,YAAM,OAAO,MAAM,YAAY,IAAI;AACnC,UAAI,KAAM,QAAO;AACjB,YAAM,YAAQ,kCAAmB;AACjC,aAAO,6BAAe,OAAO;AAAA,QAC5B,QAAI,4BAAa,MAAM,EAAE;AAAA,QACzB,MAAM,MAAM,QAAQ;AAAA,QACpB,OAAO,MAAM,SAAS,qCAAuB;AAAA,MAC9C,CAAC;AAAA,IACF,CAAC;AAED,QAAI;AAIJ,QAAI,SAAS;AACZ,UAAI,KAAK;AACR,cAAM,IAAI,MAAM,yCAAyC;AAAA,MAC1D;AAEA,eAAS,QAAQ;AAAA,QAChB,WAAW;AAAA,QACX;AAAA,MACD,CAAC;AAAA,IAIF,WAAW,KAAK;AACf,UAAI,SAAS;AACZ,cAAM,IAAI,MAAM,yCAAyC;AAAA,MAC1D;AAEA,eAAS,IAAI,wCAAuB,YAAY;AAC/C,cAAM,YAAY,OAAO,QAAQ,WAAW,MAAM,MAAM,IAAI;AAG5D,cAAM,aAAa,IAAI,IAAI,SAAS;AACpC,YAAI,WAAW,aAAa,IAAI,WAAW,GAAG;AAC7C,gBAAM,IAAI;AAAA,YACT;AAAA,UACD;AAAA,QACD;AACA,YAAI,WAAW,aAAa,IAAI,SAAS,GAAG;AAC3C,gBAAM,IAAI;AAAA,YACT;AAAA,UACD;AAAA,QACD;AAEA,mBAAW,aAAa,IAAI,aAAa,oBAAM;AAC/C,mBAAW,aAAa,IAAI,WAAW,OAAO;AAC9C,eAAO,WAAW,SAAS;AAAA,MAC5B,CAAC;AAAA,IACF,OAAO;AACN,YAAM,IAAI,MAAM,iCAAiC;AAAA,IAClD;AAEA,QAAI,YAAY;AAEhB,aAAS,sBAAsB;AAC9B,aAAO,OAAO,qBAAqB,UAAU,YAAY,OAAO;AAAA,IACjE;AACA,UAAM,gCAA4B,mBAAK,wBAAwB,oBAAoB,CAAC;AACpF,UAAM,kCAAkC,OAAO,eAAe,MAAM;AACnE,gCAA0B,IAAI,oBAAoB,CAAC;AAAA,IACpD,CAAC;AAED,UAAM,eAAW,mBAAK,aAAa,WAAuC;AAE1E,UAAM,YAAQ,6BAAc;AAAA,MAC3B,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,eAAe;AAAA,QACd,QAAQ;AAAA,QACR,MAAM;AAAA,MACP;AAAA,IACD,CAAC;AAED,UAAM,eAAW,6CAA8B,aAAa;AAAA,MAC3D;AAAA,IACD,CAAC,EAAE,KAAK;AASR,UAAM,gBAAgB,MAAM,MAAM,IAAI,mBAAmB;AAEzD,UAAM,mBAAe,wBAAyB,gBAAgB,MAAM;AACnE,aAAO,cAAc,IAAI,EAAE,SAAS,IAAI,SAAS;AAAA,IAClD,CAAC;AAED,UAAM,SAAS,IAAI,8BAAgC;AAAA,MAClD;AAAA,MACA;AAAA,MACA,WAAW,MAAM;AAAA,MACjB,OAAOA,SAAQ;AACd,gBAAQ,wBAAwB,EAAE,MAAM,QAAQ,OAAO,CAAC;AAExD,iBAAS,CAAC,UAAU,EAAE,GAAG,MAAM,aAAaA,QAAO,EAAE;AAAA,MACtD;AAAA,MACA,YAAY,QAAQ;AACnB,gBAAQ,MAAM,cAAc,MAAM;AAElC,gBAAQ,QAAQ;AAAA,UACf,KAAK,6CAA4B;AAChC,oBAAQ,wBAAwB,EAAE,MAAM,kBAAkB,OAAO,CAAC;AAClE;AAAA,UACD,KAAK,6CAA4B;AAChC,oBAAQ,wBAAwB,EAAE,MAAM,aAAa,OAAO,CAAC;AAC7D;AAAA,UACD,KAAK,6CAA4B;AAChC,oBAAQ,wBAAwB,EAAE,MAAM,qBAAqB,OAAO,CAAC;AACrE;AAAA,UACD,KAAK,6CAA4B;AAChC,oBAAQ,wBAAwB,EAAE,MAAM,gBAAgB,OAAO,CAAC;AAChE;AAAA,UACD;AACC,oBAAQ,wBAAwB,EAAE,MAAM,gBAAgB,QAAQ,OAAO,CAAC;AACxE;AAAA,QACF;AAEA,iBAAS,EAAE,OAAO,IAAI,mCAAkB,MAAM,EAAE,CAAC;AACjD,eAAO,MAAM;AAAA,MACd;AAAA,MACA,eAAe,GAAG,EAAE,YAAY,aAAa,GAAG;AAK/C,iBAAS,CAAC,UAAU,EAAE,GAAG,MAAM,aAAa,EAAE;AAC9C,mCAAS,MAAM;AACd,mBAAS,IAAI,aAAa,aAAa,WAAW;AAOlD,gBAAM,oBAAoB;AAAA,QAC3B,CAAC;AAAA,MACF;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACD,CAAC;AAED,WAAO,MAAM;AACZ,kBAAY;AACZ,sCAAgC;AAChC,aAAO,MAAM;AACb,aAAO,MAAM;AAAA,IACd;AAAA,EACD,GAAG;AAAA,IACF;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACD,CAAC;AAED,aAAO;AAAA,IACN;AAAA,IACA,MAAM;AACL,UAAI,CAAC,MAAO,QAAO,EAAE,QAAQ,UAAU;AACvC,UAAI,MAAM,MAAO,QAAO,EAAE,QAAQ,SAAS,OAAO,MAAM,MAAM;AAC9D,UAAI,CAAC,MAAM,YAAa,QAAO,EAAE,QAAQ,UAAU;AACnD,YAAM,mBAAmB,MAAM,YAAY,OAAO;AAClD,aAAO;AAAA,QACN,QAAQ;AAAA,QACR,kBAAkB,qBAAqB,UAAU,YAAY;AAAA,QAC7D,OAAO,MAAM,YAAY;AAAA,QACzB,cAAc,MAAM,gBAAgB;AAAA,MACrC;AAAA,IACD;AAAA,IACA,CAAC,KAAK;AAAA,EACP;AACD;",
6
6
  "names": ["client"]
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { Editor } from 'tldraw';
2
2
  import { TLAssetStore } from 'tldraw';
3
+ import { TLObjectStoreAccess } from '@tldraw/sync-core';
3
4
  import { TLPersistentClientSocket } from '@tldraw/sync-core';
4
5
  import { TLPresenceStateInfo } from 'tldraw';
5
6
  import { TLStore } from 'tldraw';
@@ -39,10 +40,21 @@ import { TLUserStore } from 'tldraw';
39
40
  *
40
41
  * @public
41
42
  */
42
- export declare type RemoteTLStoreWithStatus = Exclude<TLStoreWithStatus, {
43
+ export declare type RemoteTLStoreWithStatus = (Extract<TLStoreWithStatus, {
44
+ status: 'synced-remote';
45
+ }> & {
46
+ /**
47
+ * Write access for object-store lane record types (e.g. comments), as granted by the
48
+ * server for this session. Independent of the canvas read-only state, so a session can
49
+ * be allowed to comment without being allowed to edit. Defaults to `'write'`.
50
+ */
51
+ readonly objectAccess: TLObjectStoreAccess;
52
+ }) | Exclude<TLStoreWithStatus, {
43
53
  status: 'not-synced';
44
54
  } | {
45
55
  status: 'synced-local';
56
+ } | {
57
+ status: 'synced-remote';
46
58
  }>;
47
59
 
48
60
  /**
@@ -6,7 +6,7 @@ import {
6
6
  import { useSyncDemo } from "./useSyncDemo.mjs";
7
7
  registerTldrawLibraryVersion(
8
8
  "@tldraw/sync",
9
- "5.3.0-next.299378752aaf",
9
+ "5.3.0-next.2fa9c61a8de6",
10
10
  "esm"
11
11
  );
12
12
  export {
@@ -156,7 +156,7 @@ function useSync(opts) {
156
156
  didCancel: () => didCancel,
157
157
  onLoad(client2) {
158
158
  track?.(MULTIPLAYER_EVENT_NAME, { name: "load", roomId });
159
- setState({ readyClient: client2 });
159
+ setState((prev) => ({ ...prev, readyClient: client2 }));
160
160
  },
161
161
  onSyncError(reason) {
162
162
  console.error("sync error", reason);
@@ -180,7 +180,8 @@ function useSync(opts) {
180
180
  setState({ error: new TLRemoteSyncError(reason) });
181
181
  socket.close();
182
182
  },
183
- onAfterConnect(_, { isReadonly }) {
183
+ onAfterConnect(_, { isReadonly, objectAccess }) {
184
+ setState((prev) => ({ ...prev, objectAccess }));
184
185
  transact(() => {
185
186
  syncMode.set(isReadonly ? "readonly" : "readwrite");
186
187
  store.ensureStoreIsUsable();
@@ -219,7 +220,8 @@ function useSync(opts) {
219
220
  return {
220
221
  status: "synced-remote",
221
222
  connectionStatus: connectionStatus === "error" ? "offline" : connectionStatus,
222
- store: state.readyClient.store
223
+ store: state.readyClient.store,
224
+ objectAccess: state.objectAccess ?? "write"
223
225
  };
224
226
  },
225
227
  [state]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/useSync.ts"],
4
- "sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus = Exclude<\n\tTLStoreWithStatus,\n\t{ status: 'synced-local' } | { status: 'not-synced' }\n>\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\tsetState({ readyClient: client })\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly }) {\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { atom, transact } from '@tldraw/state'\nimport {\n\tClientWebSocketAdapter,\n\tTLCustomMessageHandler,\n\tTLObjectStoreAccess,\n\tTLPersistentClientSocket,\n\tTLPresenceMode,\n\tTLRemoteSyncError,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentEvent,\n\tTLSyncClient,\n\tTLSyncErrorCloseEventReason,\n} from '@tldraw/sync-core'\nimport { useEffect } from 'react'\nimport {\n\tEditor,\n\tTAB_ID,\n\tTLAssetStore,\n\tTLPresenceStateInfo,\n\tTLRecord,\n\tTLStore,\n\tTLStoreSchemaOptions,\n\tTLStoreWithStatus,\n\tTLThemes,\n\tTLUser,\n\tTLUserStore,\n\tUserRecordType,\n\tcomputed,\n\tcreateCachedUserResolve,\n\tcreatePresenceStateDerivation,\n\tregisterColorsFromThemes,\n\tregisterFontsFromThemes,\n\tresolveThemes,\n\tcreateTLStore,\n\tcreateUserId,\n\tdefaultUserPreferences,\n\tdefaultUserStore,\n\tgetDefaultUserPresence,\n\tgetUserPreferences,\n\tuniqueId,\n\tuseEvent,\n\tuseReactiveEvent,\n\tuseRefState,\n\tuseTLSchemaFromUtils,\n\tuseValue,\n} from 'tldraw'\n\nconst MULTIPLAYER_EVENT_NAME = 'multiplayer.client'\n\nconst defaultCustomMessageHandler: TLCustomMessageHandler = () => {}\n\n/**\n * A store wrapper specifically for remote collaboration that excludes local-only states.\n * This type represents a tldraw store that is synchronized with a remote multiplayer server.\n *\n * Unlike the base TLStoreWithStatus, this excludes 'synced-local' and 'not-synced' states\n * since remote stores are always either loading, connected to a server, or in an error state.\n *\n * @example\n * ```tsx\n * function MyCollaborativeApp() {\n * const store: RemoteTLStoreWithStatus = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to multiplayer session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Connection failed: {store.error.message}</div>\n * }\n *\n * // store.status === 'synced-remote'\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport type RemoteTLStoreWithStatus =\n\t| Exclude<\n\t\t\tTLStoreWithStatus,\n\t\t\t{ status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' }\n\t >\n\t| (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & {\n\t\t\t/**\n\t\t\t * Write access for object-store lane record types (e.g. comments), as granted by the\n\t\t\t * server for this session. Independent of the canvas read-only state, so a session can\n\t\t\t * be allowed to comment without being allowed to edit. Defaults to `'write'`.\n\t\t\t */\n\t\t\treadonly objectAccess: TLObjectStoreAccess\n\t })\n\n/**\n * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.\n *\n * This hook manages the complete lifecycle of a collaborative tldraw session, including\n * WebSocket connection establishment, state synchronization, user presence, and error handling.\n * The returned store can be passed directly to the Tldraw component to enable multiplayer features.\n *\n * The store progresses through multiple states:\n * - `loading`: Establishing connection and synchronizing initial state\n * - `synced-remote`: Successfully connected and actively synchronizing changes\n * - `error`: Connection failed or synchronization error occurred\n *\n * For optimal performance with media assets, provide an `assets` store that implements\n * external blob storage. Without this, large images and videos will be stored inline\n * as base64, causing performance issues during serialization.\n *\n * @param opts - Configuration options for multiplayer synchronization\n * - `uri` - WebSocket server URI (string or async function returning URI)\n * - `assets` - Asset store for blob storage (required for production use)\n * - `users` - User store for identity, presence and attribution\n * - `getUserPresence` - Optional function to customize presence data\n * - `onCustomMessageReceived` - Handler for custom socket messages\n * - `roomId` - Room identifier for analytics (internal use)\n * - `onMount` - Callback when editor mounts (internal use)\n * - `trackAnalyticsEvent` - Analytics event tracker (internal use)\n *\n * @returns A reactive store wrapper with connection status and synchronized store\n *\n * @example\n * ```tsx\n * // Basic multiplayer setup\n * function CollaborativeApp() {\n * const store = useSync({\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: computed('current-user', () => ({\n * id: createUserId('user-1'),\n * name: 'Alice',\n * color: '#ff0000',\n * meta: {},\n * })),\n * }\n * })\n *\n * if (store.status === 'loading') {\n * return <div>Connecting to collaboration session...</div>\n * }\n *\n * if (store.status === 'error') {\n * return <div>Failed to connect: {store.error.message}</div>\n * }\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Dynamic authentication with user store\n * function AuthenticatedApp() {\n * const store = useSync({\n * uri: async () => {\n * const token = await getAuthToken()\n * return `wss://myserver.com/sync/room-123?token=${token}`\n * },\n * assets: authenticatedAssetStore,\n * users: myUserStore,\n * getUserPresence: (store, user) => {\n * return {\n * userId: user.id,\n * userName: user.name,\n * cursor: getCurrentCursor(store)\n * }\n * }\n * })\n *\n * return <Tldraw store={store.store} />\n * }\n * ```\n *\n * @public\n */\nexport function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLStoreWithStatus {\n\tconst [state, setState] = useRefState<{\n\t\treadyClient?: TLSyncClient<TLRecord, TLStore>\n\t\terror?: Error\n\t\tobjectAccess?: TLObjectStoreAccess\n\t} | null>(null)\n\tconst {\n\t\turi,\n\t\troomId = 'default',\n\t\tassets,\n\t\tusers: _users,\n\t\tonMount,\n\t\tconnect,\n\t\ttrackAnalyticsEvent: track,\n\t\tgetUserPresence: _getUserPresence,\n\t\tonCustomMessageReceived: _onCustomMessageReceived,\n\t\tthemes,\n\t\t...schemaOpts\n\t} = opts\n\n\t// This line will throw a type error if we add any new options to the useSync hook but we don't destructure them\n\t// This is required because otherwise the useTLSchemaFromUtils might return a new schema on every render if the newly-added option\n\t// is allowed to be unstable\n\tconst __never__: never = 0 as any as keyof Omit<typeof schemaOpts, keyof TLStoreSchemaOptions>\n\n\tconst resolvedThemes = resolveThemes(themes)\n\tregisterColorsFromThemes(resolvedThemes)\n\tregisterFontsFromThemes(resolvedThemes)\n\tconst schema = useTLSchemaFromUtils(schemaOpts)\n\n\tconst getUserPresence = useReactiveEvent(\n\t\t(_getUserPresence ?? getDefaultUserPresence) as typeof getDefaultUserPresence\n\t)\n\tconst onCustomMessageReceived = useEvent(_onCustomMessageReceived ?? defaultCustomMessageHandler)\n\n\tuseEffect(() => {\n\t\tconst storeId = uniqueId()\n\n\t\tconst users: Required<TLUserStore> = _users\n\t\t\t? {\n\t\t\t\t\tcurrentUser: _users.currentUser,\n\t\t\t\t\tresolve:\n\t\t\t\t\t\t_users.resolve ??\n\t\t\t\t\t\tcreateCachedUserResolve((userId) => {\n\t\t\t\t\t\t\tconst current = _users.currentUser.get()\n\t\t\t\t\t\t\treturn current && current.id === createUserId(userId) ? current : null\n\t\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tcurrentUser: defaultUserStore.currentUser,\n\t\t\t\t\tresolve: createCachedUserResolve((userId) => {\n\t\t\t\t\t\tconst current = defaultUserStore.currentUser.get()\n\t\t\t\t\t\tif (current && current.id === createUserId(userId)) return current\n\t\t\t\t\t\tconst presences = store.query.records('instance_presence').get()\n\t\t\t\t\t\tconst match = presences.find((p) => p.userId === createUserId(userId))\n\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\treturn UserRecordType.create({\n\t\t\t\t\t\t\t\tid: createUserId(userId),\n\t\t\t\t\t\t\t\tname: match.userName,\n\t\t\t\t\t\t\t\tcolor: match.color,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn null\n\t\t\t\t\t}),\n\t\t\t\t}\n\n\t\t// This always returns a non-null user for presence display, falling back\n\t\t// to anonymous user preferences. The store receives the raw `users` object\n\t\t// (where currentUser may return null), so attribution via\n\t\t// getAttributionUserId() correctly returns null for anonymous sessions.\n\t\tconst currentUser = computed<TLUser>('currentUser', () => {\n\t\t\tconst user = users.currentUser.get()\n\t\t\tif (user) return user\n\t\t\tconst prefs = getUserPreferences()\n\t\t\treturn UserRecordType.create({\n\t\t\t\tid: createUserId(prefs.id),\n\t\t\t\tname: prefs.name ?? '',\n\t\t\t\tcolor: prefs.color ?? defaultUserPreferences.color,\n\t\t\t})\n\t\t})\n\n\t\tlet socket: TLPersistentClientSocket<\n\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t>\n\t\tif (connect) {\n\t\t\tif (uri) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = connect({\n\t\t\t\tsessionId: TAB_ID,\n\t\t\t\tstoreId,\n\t\t\t}) as TLPersistentClientSocket<\n\t\t\t\tTLSocketClientSentEvent<TLRecord>,\n\t\t\t\tTLSocketServerSentEvent<TLRecord>\n\t\t\t>\n\t\t} else if (uri) {\n\t\t\tif (connect) {\n\t\t\t\tthrow new Error('uri and connect cannot be used together')\n\t\t\t}\n\n\t\t\tsocket = new ClientWebSocketAdapter(async () => {\n\t\t\t\tconst uriString = typeof uri === 'string' ? uri : await uri()\n\n\t\t\t\t// set sessionId as a query param on the uri\n\t\t\t\tconst withParams = new URL(uriString)\n\t\t\t\tif (withParams.searchParams.has('sessionId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"sessionId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (withParams.searchParams.has('storeId')) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t'useSync. \"storeId\" is a reserved query param name. Please use a different name'\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\twithParams.searchParams.set('sessionId', TAB_ID)\n\t\t\t\twithParams.searchParams.set('storeId', storeId)\n\t\t\t\treturn withParams.toString()\n\t\t\t})\n\t\t} else {\n\t\t\tthrow new Error('uri or connect must be provided')\n\t\t}\n\n\t\tlet didCancel = false\n\n\t\tfunction getConnectionStatus() {\n\t\t\treturn socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus\n\t\t}\n\t\tconst collaborationStatusSignal = atom('collaboration status', getConnectionStatus())\n\t\tconst unsubscribeFromConnectionStatus = socket.onStatusChange(() => {\n\t\t\tcollaborationStatusSignal.set(getConnectionStatus())\n\t\t})\n\n\t\tconst syncMode = atom('sync mode', 'readwrite' as 'readonly' | 'readwrite')\n\n\t\tconst store = createTLStore({\n\t\t\tid: storeId,\n\t\t\tschema,\n\t\t\tassets,\n\t\t\tusers,\n\t\t\tonMount,\n\t\t\tcollaboration: {\n\t\t\t\tstatus: collaborationStatusSignal,\n\t\t\t\tmode: syncMode,\n\t\t\t},\n\t\t})\n\n\t\tconst presence = createPresenceStateDerivation(currentUser, {\n\t\t\tgetUserPresence,\n\t\t})(store)\n\n\t\t// Every connected session \u2014 each tab, window, or device \u2014 pushes its\n\t\t// presence on connect, so the store holds one instance_presence record per\n\t\t// *other* session in the room, including the user's own other tabs. (The\n\t\t// server never echoes a session its own record.) So an empty set means\n\t\t// we're genuinely the only session and can throttle to solo; any other\n\t\t// session \u2014 another user, or just another tab of our own \u2014 keeps us at the\n\t\t// full sync rate so edits propagate without the solo-mode lag.\n\t\tconst otherSessions = store.query.ids('instance_presence')\n\n\t\tconst presenceMode = computed<TLPresenceMode>('presenceMode', () => {\n\t\t\treturn otherSessions.get().size === 0 ? 'solo' : 'full'\n\t\t})\n\n\t\tconst client = new TLSyncClient<TLRecord, TLStore>({\n\t\t\tstore,\n\t\t\tsocket,\n\t\t\tdidCancel: () => didCancel,\n\t\t\tonLoad(client) {\n\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })\n\t\t\t\t// Merge so we don't clobber objectAccess if onAfterConnect ran first.\n\t\t\t\tsetState((prev) => ({ ...prev, readyClient: client }))\n\t\t\t},\n\t\t\tonSyncError(reason) {\n\t\t\t\tconsole.error('sync error', reason)\n\n\t\t\t\tswitch (reason) {\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_FOUND:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.FORBIDDEN:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'forbidden', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'not-authenticated', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tcase TLSyncErrorCloseEventReason.RATE_LIMITED:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'rate-limited', roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t\tdefault:\n\t\t\t\t\t\ttrack?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error:' + reason, roomId })\n\t\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tsetState({ error: new TLRemoteSyncError(reason) })\n\t\t\t\tsocket.close()\n\t\t\t},\n\t\t\tonAfterConnect(_, { isReadonly, objectAccess }) {\n\t\t\t\t// Object-lane (comment) write access is decided per session by the server and can\n\t\t\t\t// change on reconnect (e.g. when the file's share tier changes), so surface it on\n\t\t\t\t// the returned status for consumers that gate comment UI. This fires before the\n\t\t\t\t// first onLoad, hence the merge in both directions.\n\t\t\t\tsetState((prev) => ({ ...prev, objectAccess }))\n\t\t\t\ttransact(() => {\n\t\t\t\t\tsyncMode.set(isReadonly ? 'readonly' : 'readwrite')\n\t\t\t\t\t// if the server crashes and loses all data it can return an empty document\n\t\t\t\t\t// when it comes back up. This is a safety check to make sure that if something like\n\t\t\t\t\t// that happens, it won't render the app broken and require a restart. The user will\n\t\t\t\t\t// most likely lose all their changes though since they'll have been working with pages\n\t\t\t\t\t// that won't exist. There's certainly something we can do to make this better.\n\t\t\t\t\t// but the likelihood of this happening is very low and maybe not worth caring about beyond this.\n\t\t\t\t\tstore.ensureStoreIsUsable()\n\t\t\t\t})\n\t\t\t},\n\t\t\tonCustomMessageReceived,\n\t\t\tpresence,\n\t\t\tpresenceMode,\n\t\t})\n\n\t\treturn () => {\n\t\t\tdidCancel = true\n\t\t\tunsubscribeFromConnectionStatus()\n\t\t\tclient.close()\n\t\t\tsocket.close()\n\t\t}\n\t}, [\n\t\tassets,\n\t\tonMount,\n\t\tconnect,\n\t\t_users,\n\t\troomId,\n\t\tschema,\n\t\tsetState,\n\t\ttrack,\n\t\turi,\n\t\tgetUserPresence,\n\t\tonCustomMessageReceived,\n\t])\n\n\treturn useValue<RemoteTLStoreWithStatus>(\n\t\t'remote synced store',\n\t\t() => {\n\t\t\tif (!state) return { status: 'loading' }\n\t\t\tif (state.error) return { status: 'error', error: state.error }\n\t\t\tif (!state.readyClient) return { status: 'loading' }\n\t\t\tconst connectionStatus = state.readyClient.socket.connectionStatus\n\t\t\treturn {\n\t\t\t\tstatus: 'synced-remote',\n\t\t\t\tconnectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,\n\t\t\t\tstore: state.readyClient.store,\n\t\t\t\tobjectAccess: state.objectAccess ?? 'write',\n\t\t\t}\n\t\t},\n\t\t[state]\n\t)\n}\n\n/**\n * Configuration options for the {@link useSync} hook to establish multiplayer collaboration.\n *\n * This interface defines the required and optional settings for connecting to a multiplayer\n * server, managing user presence, handling assets, and customizing the collaboration experience.\n *\n * @example\n * ```tsx\n * const syncOptions: UseSyncOptions = {\n * uri: 'wss://myserver.com/sync/room-123',\n * assets: myAssetStore,\n * users: {\n * currentUser: myCurrentUserSignal,\n * },\n * getUserPresence: (store, user) => ({\n * userId: user.id,\n * userName: user.name,\n * cursor: getCursorPosition()\n * })\n * }\n * ```\n *\n * @public\n */\nexport interface UseSyncOptionsBase {\n\t/**\n\t * Named theme definitions. When provided, custom color names are automatically\n\t * registered before the store is constructed so persisted data with those\n\t * colors passes validation on load.\n\t */\n\tthemes?: Partial<TLThemes>\n\n\t/**\n\t * Asset store implementation for handling file uploads and storage.\n\t *\n\t * Required for production applications to handle images, videos, and other\n\t * media efficiently. Without an asset store, files are stored inline as\n\t * base64, which causes performance issues with large files.\n\t *\n\t * The asset store must implement upload (for new files) and resolve\n\t * (for displaying existing files) methods. For prototyping, you can use\n\t * {@link @tldraw/editor#inlineBase64AssetStore} but this is not recommended for production.\n\t *\n\t * @example\n\t * ```ts\n\t * const myAssetStore: TLAssetStore = {\n\t * upload: async (asset, file) => {\n\t * const url = await uploadToCloudStorage(file)\n\t * return { src: url }\n\t * },\n\t * resolve: (asset, context) => {\n\t * return getOptimizedUrl(asset.src, context)\n\t * }\n\t * }\n\t * ```\n\t */\n\tassets: TLAssetStore\n\n\t/**\n\t * User store for identity, presence and attribution.\n\t *\n\t * Both methods return reactive {@link @tldraw/state#Signal | Signals}.\n\t * `currentUser` provides the current user's identity (used for\n\t * both presence broadcasting and shape attribution) and optionally\n\t * `resolve(userId)` looks up other users by ID. If not provided,\n\t * a default implementation backed by localStorage user preferences is\n\t * used, with `resolve` falling back to presence records in the store.\n\t */\n\tusers?: TLUserStore\n\n\t/**\n\t * Handler for receiving custom messages sent through the multiplayer connection.\n\t *\n\t * Use this to implement custom communication channels between clients beyond\n\t * the standard shape and presence synchronization. Messages are sent using\n\t * the TLSyncClient's sendMessage method.\n\t *\n\t * @param data - The custom message data received from another client\n\t *\n\t * @example\n\t * ```ts\n\t * onCustomMessageReceived: (data) => {\n\t * if (data.type === 'chat') {\n\t * displayChatMessage(data.message, data.userId)\n\t * }\n\t * }\n\t * ```\n\t */\n\tonCustomMessageReceived?(data: any): void\n\n\t/** @internal */\n\tonMount?(editor: Editor): void\n\t/** @internal used for analytics only, we should refactor this away */\n\troomId?: string\n\t/** @internal */\n\ttrackAnalyticsEvent?(name: string, data: { [key: string]: any }): void\n\n\t/**\n\t * A reactive function that returns a {@link @tldraw/tlschema#TLInstancePresence} object.\n\t *\n\t * This function is called reactively whenever the store state changes and\n\t * determines what presence information to broadcast to other clients. The\n\t * result is synchronized across all connected clients for displaying cursors,\n\t * selections, and other collaborative indicators.\n\t *\n\t * If not provided, uses the default implementation which includes standard\n\t * cursor position and selection state. Custom implementations allow you to\n\t * add additional presence data like current tool, view state, or custom status.\n\t *\n\t * See {@link @tldraw/tlschema#getDefaultUserPresence} for\n\t * the default implementation of this function.\n\t *\n\t * @param store - The current TLStore\n\t * @param user - The current user information\n\t * @returns Presence state to broadcast to other clients, or null to hide presence\n\t *\n\t * @example\n\t * ```ts\n\t * getUserPresence: (store, user) => {\n\t * return {\n\t * userId: user.id,\n\t * userName: user.name,\n\t * cursor: { x: 100, y: 200 },\n\t * currentTool: 'select',\n\t * isActive: true\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetUserPresence?(store: TLStore, user: TLUser): TLPresenceStateInfo | null\n}\n\n/** @public */\nexport interface UseSyncOptionsWithUri extends UseSyncOptionsBase {\n\t/**\n\t * The WebSocket URI of the multiplayer server for real-time synchronization.\n\t *\n\t * Must include the protocol (wss:// for secure, ws:// for local development).\n\t * HTTP/HTTPS URLs will be automatically upgraded to WebSocket connections.\n\t *\n\t * Can be a static string or a function that returns a URI (useful for dynamic\n\t * authentication tokens or room routing). The function is called on each\n\t * connection attempt, allowing for token refresh and dynamic routing.\n\t *\n\t * Reserved query parameters `sessionId` and `storeId` are automatically added\n\t * by the sync system and should not be included in your URI.\n\t *\n\t * @example\n\t * ```ts\n\t * // Static URI\n\t * uri: 'wss://myserver.com/sync/room-123'\n\t *\n\t * // Dynamic URI with authentication\n\t * uri: async () => {\n\t * const token = await getAuthToken()\n\t * return `wss://myserver.com/sync/room-123?token=${token}`\n\t * }\n\t * ```\n\t */\n\turi: string | (() => string | Promise<string>)\n\tconnect?: never\n}\n\n/** @public */\nexport interface UseSyncOptionsWithConnectFn extends UseSyncOptionsBase {\n\t/**\n\t * Create a connection to the server. Mostly you should use {@link UseSyncOptionsWithUri.uri}\n\t * instead, but this is useful if you want to use a custom transport to connect to the server,\n\t * instead of our default websocket-based transport.\n\t */\n\tconnect: UseSyncConnectFn\n\turi?: never\n}\n\n/** @public */\nexport type UseSyncConnectFn = (query: {\n\tsessionId: string\n\tstoreId: string\n}) => TLPersistentClientSocket\n\n/**\n * Options for the {@link useSync} hook.\n * @public\n */\nexport type UseSyncOptions = UseSyncOptionsWithUri | UseSyncOptionsWithConnectFn\n"],
5
+ "mappings": "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",
6
6
  "names": ["client"]
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/sync",
3
3
  "description": "tldraw infinite canvas SDK (multiplayer sync react bindings).",
4
- "version": "5.3.0-next.299378752aaf",
4
+ "version": "5.3.0-next.2fa9c61a8de6",
5
5
  "author": {
6
6
  "name": "tldraw GB Ltd.",
7
7
  "email": "hello@tldraw.com"
@@ -54,12 +54,12 @@
54
54
  "vitest": "^4.1.7"
55
55
  },
56
56
  "dependencies": {
57
- "@tldraw/state": "5.3.0-next.299378752aaf",
58
- "@tldraw/state-react": "5.3.0-next.299378752aaf",
59
- "@tldraw/sync-core": "5.3.0-next.299378752aaf",
60
- "@tldraw/utils": "5.3.0-next.299378752aaf",
57
+ "@tldraw/state": "5.3.0-next.2fa9c61a8de6",
58
+ "@tldraw/state-react": "5.3.0-next.2fa9c61a8de6",
59
+ "@tldraw/sync-core": "5.3.0-next.2fa9c61a8de6",
60
+ "@tldraw/utils": "5.3.0-next.2fa9c61a8de6",
61
61
  "nanoevents": "^7.0.1",
62
- "tldraw": "5.3.0-next.299378752aaf",
62
+ "tldraw": "5.3.0-next.2fa9c61a8de6",
63
63
  "ws": "^8.18.0"
64
64
  },
65
65
  "peerDependencies": {
package/src/useSync.ts CHANGED
@@ -2,6 +2,7 @@ import { atom, transact } from '@tldraw/state'
2
2
  import {
3
3
  ClientWebSocketAdapter,
4
4
  TLCustomMessageHandler,
5
+ TLObjectStoreAccess,
5
6
  TLPersistentClientSocket,
6
7
  TLPresenceMode,
7
8
  TLRemoteSyncError,
@@ -78,10 +79,19 @@ const defaultCustomMessageHandler: TLCustomMessageHandler = () => {}
78
79
  *
79
80
  * @public
80
81
  */
81
- export type RemoteTLStoreWithStatus = Exclude<
82
- TLStoreWithStatus,
83
- { status: 'synced-local' } | { status: 'not-synced' }
84
- >
82
+ export type RemoteTLStoreWithStatus =
83
+ | Exclude<
84
+ TLStoreWithStatus,
85
+ { status: 'synced-local' } | { status: 'not-synced' } | { status: 'synced-remote' }
86
+ >
87
+ | (Extract<TLStoreWithStatus, { status: 'synced-remote' }> & {
88
+ /**
89
+ * Write access for object-store lane record types (e.g. comments), as granted by the
90
+ * server for this session. Independent of the canvas read-only state, so a session can
91
+ * be allowed to comment without being allowed to edit. Defaults to `'write'`.
92
+ */
93
+ readonly objectAccess: TLObjectStoreAccess
94
+ })
85
95
 
86
96
  /**
87
97
  * Creates a reactive store synchronized with a multiplayer server for real-time collaboration.
@@ -170,6 +180,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
170
180
  const [state, setState] = useRefState<{
171
181
  readyClient?: TLSyncClient<TLRecord, TLStore>
172
182
  error?: Error
183
+ objectAccess?: TLObjectStoreAccess
173
184
  } | null>(null)
174
185
  const {
175
186
  uri,
@@ -338,7 +349,8 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
338
349
  didCancel: () => didCancel,
339
350
  onLoad(client) {
340
351
  track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
341
- setState({ readyClient: client })
352
+ // Merge so we don't clobber objectAccess if onAfterConnect ran first.
353
+ setState((prev) => ({ ...prev, readyClient: client }))
342
354
  },
343
355
  onSyncError(reason) {
344
356
  console.error('sync error', reason)
@@ -364,7 +376,12 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
364
376
  setState({ error: new TLRemoteSyncError(reason) })
365
377
  socket.close()
366
378
  },
367
- onAfterConnect(_, { isReadonly }) {
379
+ onAfterConnect(_, { isReadonly, objectAccess }) {
380
+ // Object-lane (comment) write access is decided per session by the server and can
381
+ // change on reconnect (e.g. when the file's share tier changes), so surface it on
382
+ // the returned status for consumers that gate comment UI. This fires before the
383
+ // first onLoad, hence the merge in both directions.
384
+ setState((prev) => ({ ...prev, objectAccess }))
368
385
  transact(() => {
369
386
  syncMode.set(isReadonly ? 'readonly' : 'readwrite')
370
387
  // if the server crashes and loses all data it can return an empty document
@@ -412,6 +429,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt
412
429
  status: 'synced-remote',
413
430
  connectionStatus: connectionStatus === 'error' ? 'offline' : connectionStatus,
414
431
  store: state.readyClient.store,
432
+ objectAccess: state.objectAccess ?? 'write',
415
433
  }
416
434
  },
417
435
  [state]