@tldraw/sync-core 5.5.0-canary.4fbbdcf5e86c → 5.5.0-canary.5176de035caa

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,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/TLSyncClient.ts"],
4
- "sourcesContent": ["import { Signal, react, transact } from '@tldraw/state'\nimport {\n\tRecordId,\n\tRecordsDiff,\n\tStore,\n\tUnknownRecord,\n\treverseRecordsDiff,\n\tsquashRecordDiffsMutable,\n} from '@tldraw/store'\nimport {\n\tFpsScheduler,\n\texhaustiveSwitchError,\n\tisEqual,\n\tobjectMapEntries,\n\tuniqueId,\n} from '@tldraw/utils'\nimport {\n\tNetworkDiff,\n\tObjectDiff,\n\tRecordOpType,\n\tapplyObjectDiff,\n\tdiffRecord,\n\tgetNetworkDiff,\n} from './diff'\nimport { interval } from './interval'\nimport {\n\tTLObjectStoreAccess,\n\tTLPushRequest,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentDataEvent,\n\tTLSocketServerSentEvent,\n\tgetTlsyncProtocolVersion,\n} from './protocol'\n\n/**\n * Function type for subscribing to events with a callback.\n * Returns an unsubscribe function to clean up the listener.\n *\n * @param cb - Callback function that receives the event value\n * @returns Function to call when you want to unsubscribe from the events\n *\n * @public\n */\nexport type SubscribingFn<T> = (cb: (val: T) => void) => () => void\n\n/** Network sync frame rate when in solo mode (no collaborators) @internal */\nconst SOLO_MODE_FPS = 1\n\n/** Network sync frame rate when in collaborative mode (with collaborators) @internal */\nconst COLLABORATIVE_MODE_FPS = 30\n\n/**\n * WebSocket close code used by the server to signal a non-recoverable sync error.\n * This close code indicates that the connection is being terminated due to an error\n * that cannot be automatically recovered from, such as authentication failures,\n * incompatible client versions, or invalid data.\n *\n * @example\n * ```ts\n * // Server-side: Close connection with specific error reason\n * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.NOT_FOUND)\n *\n * // Client-side: Handle the error in your sync error handler\n * const syncClient = new TLSyncClient({\n * // ... other config\n * onSyncError: (reason) => {\n * console.error('Sync failed:', reason) // Will receive 'NOT_FOUND'\n * }\n * })\n * ```\n *\n * @public\n */\nexport const TLSyncErrorCloseEventCode = 4099 as const\n\n/**\n * Predefined reasons for server-initiated connection closures.\n * These constants represent different error conditions that can cause\n * the sync server to terminate a WebSocket connection.\n *\n * @example\n * ```ts\n * // Server usage\n * if (!user.hasPermission(roomId)) {\n * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.FORBIDDEN)\n * }\n *\n * // Client error handling\n * syncClient.onSyncError((reason) => {\n * switch (reason) {\n * case TLSyncErrorCloseEventReason.NOT_FOUND:\n * showError('Room does not exist')\n * break\n * case TLSyncErrorCloseEventReason.FORBIDDEN:\n * showError('Access denied')\n * break\n * case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:\n * showError('Please update your app')\n * break\n * }\n * })\n * ```\n *\n * @public\n */\nexport const TLSyncErrorCloseEventReason = {\n\t/** Room or resource not found */\n\tNOT_FOUND: 'NOT_FOUND',\n\t/** User lacks permission to access the room */\n\tFORBIDDEN: 'FORBIDDEN',\n\t/** User authentication required or invalid */\n\tNOT_AUTHENTICATED: 'NOT_AUTHENTICATED',\n\t/** Unexpected server error occurred */\n\tUNKNOWN_ERROR: 'UNKNOWN_ERROR',\n\t/** Client protocol version too old */\n\tCLIENT_TOO_OLD: 'CLIENT_TOO_OLD',\n\t/** Server protocol version too old */\n\tSERVER_TOO_OLD: 'SERVER_TOO_OLD',\n\t/** Client sent invalid or corrupted record data */\n\tINVALID_RECORD: 'INVALID_RECORD',\n\t/** Client exceeded rate limits */\n\tRATE_LIMITED: 'RATE_LIMITED',\n\t/** Room has reached maximum capacity */\n\tROOM_FULL: 'ROOM_FULL',\n} as const\n\n/**\n * @internal\n */\nexport class TLSyncError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic reason: TLSyncErrorCloseEventReason\n\t) {\n\t\tsuper(message)\n\t}\n}\n/**\n * Union type of all possible server connection close reasons.\n * Represents the string values that can be passed when a server closes\n * a sync connection due to an error condition.\n *\n * @public\n */\nexport type TLSyncErrorCloseEventReason =\n\t(typeof TLSyncErrorCloseEventReason)[keyof typeof TLSyncErrorCloseEventReason]\n\n/**\n * Handler function for custom application messages sent through the sync protocol.\n * These are user-defined messages that can be sent between clients via the sync server,\n * separate from the standard document synchronization messages.\n *\n * @param data - Custom message payload (application-defined structure)\n *\n * @example\n * ```ts\n * const customMessageHandler: TLCustomMessageHandler = (data) => {\n * if (data.type === 'user_joined') {\n * console.log(`${data.username} joined the session`)\n * showToast(`${data.username} is now collaborating`)\n * }\n * }\n *\n * const syncClient = new TLSyncClient({\n * // ... other config\n * onCustomMessageReceived: customMessageHandler\n * })\n * ```\n *\n * @public\n */\nexport type TLCustomMessageHandler = (this: null, data: any) => void\n\n/**\n * Event object describing changes in socket connection status.\n * Contains either a basic status change or an error with details.\n *\n * @public\n */\nexport type TLSocketStatusChangeEvent =\n\t| {\n\t\t\t/** Connection came online or went offline */\n\t\t\tstatus: 'online' | 'offline'\n\t }\n\t| {\n\t\t\t/** Connection encountered an error */\n\t\t\tstatus: 'error'\n\t\t\t/** Description of the error that occurred */\n\t\t\treason: string\n\t }\n/**\n * Callback function type for listening to socket status changes.\n *\n * @param params - Event object containing the new status and optional error details\n *\n * @internal\n */\nexport type TLSocketStatusListener = (params: TLSocketStatusChangeEvent) => void\n\n/**\n * Possible connection states for a persistent client socket.\n * Represents the current connectivity status between client and server.\n *\n * @internal\n */\nexport type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'\n\n/**\n * Mode for handling presence information in sync sessions.\n * Controls whether presence data (cursors, selections) is shared with other clients.\n *\n * @public\n */\nexport type TLPresenceMode =\n\t/** No presence sharing - client operates independently */\n\t| 'solo'\n\t/** Full presence sharing - cursors and selections visible to others */\n\t| 'full'\n/**\n * Interface for persistent WebSocket-like connections used by TLSyncClient.\n * Handles automatic reconnection and provides event-based communication with the sync server.\n * Implementations should maintain connection resilience and handle network interruptions gracefully.\n *\n * @example\n * ```ts\n * class MySocketAdapter implements TLPersistentClientSocket {\n * connectionStatus: 'offline' | 'online' | 'error' = 'offline'\n *\n * sendMessage(msg: TLSocketClientSentEvent) {\n * if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n * this.ws.send(JSON.stringify(msg))\n * }\n * }\n *\n * onReceiveMessage = (callback) => {\n * // Set up message listener and return cleanup function\n * }\n *\n * restart() {\n * this.disconnect()\n * this.connect()\n * }\n * }\n * ```\n *\n * @public\n */\nexport interface TLPersistentClientSocket<\n\tClientSentMessage extends object = object,\n\tServerSentMessage extends object = object,\n> {\n\t/** Current connection state - online means actively connected and ready */\n\tconnectionStatus: 'online' | 'offline' | 'error'\n\n\t/**\n\t * Send a protocol message to the sync server\n\t * @param msg - Message to send (connect, push, ping, etc.)\n\t */\n\tsendMessage(msg: ClientSentMessage): void\n\n\t/**\n\t * Subscribe to messages received from the server\n\t * @param callback - Function called for each received message\n\t * @returns Cleanup function to remove the listener\n\t */\n\tonReceiveMessage: SubscribingFn<ServerSentMessage>\n\n\t/**\n\t * Subscribe to connection status changes\n\t * @param callback - Function called when connection status changes\n\t * @returns Cleanup function to remove the listener\n\t */\n\tonStatusChange: SubscribingFn<TLSocketStatusChangeEvent>\n\n\t/**\n\t * Force a connection restart (disconnect then reconnect)\n\t * Used for error recovery or when connection health checks fail\n\t */\n\trestart(): void\n\n\t/**\n\t * Close the connection\n\t */\n\tclose(): void\n}\n\nconst PING_INTERVAL = 5000\nconst MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2\n/**\n * How long an unanswered ping may stay outstanding before the connection is considered dead.\n * Tuned together with MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION:\n * a reset needs both stale interaction and a ping overdue by this much.\n */\nconst PONG_TIMEOUT = PING_INTERVAL * 2\n\n// Should connect support chunking the response to allow for large payloads?\n\nfunction getPresenceOp<R extends UnknownRecord>(\n\tlastPushedPresenceState: R | null,\n\tnextPresence: R | null\n): [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R] | undefined {\n\tif (!lastPushedPresenceState && nextPresence) {\n\t\treturn [RecordOpType.Put, nextPresence]\n\t}\n\tif (lastPushedPresenceState && nextPresence) {\n\t\tconst diff = diffRecord(lastPushedPresenceState, nextPresence)\n\t\tif (!diff) return undefined\n\t\treturn [RecordOpType.Patch, diff]\n\t}\n\treturn undefined\n}\n\n/**\n * Main client-side synchronization engine for collaborative tldraw applications.\n *\n * TLSyncClient manages bidirectional synchronization between a local tldraw Store\n * and a remote sync server. It uses an optimistic update model where local changes\n * are immediately applied for responsive UI, then sent to the server for validation\n * and distribution to other clients.\n *\n * The synchronization follows a git-like push/pull/rebase model:\n * - **Push**: Local changes are sent to server as diff operations\n * - **Pull**: Server changes are received and applied locally\n * - **Rebase**: Conflicting changes are resolved by undoing local changes,\n * applying server changes, then re-applying local changes on top\n *\n * @example\n * ```ts\n * import { TLSyncClient, ClientWebSocketAdapter } from '@tldraw/sync-core'\n * import { createTLStore } from '@tldraw/store'\n *\n * // Create store and socket\n * const store = createTLStore({ schema: mySchema })\n * const socket = new ClientWebSocketAdapter('ws://localhost:3000/sync')\n *\n * // Create sync client\n * const syncClient = new TLSyncClient({\n * store,\n * socket,\n * presence: atom(null),\n * onLoad: () => console.log('Connected and loaded'),\n * onSyncError: (reason) => console.error('Sync failed:', reason)\n * })\n *\n * // Changes to store are now automatically synchronized\n * store.put([{ id: 'shape1', type: 'geo', x: 100, y: 100 }])\n * ```\n *\n * @example\n * ```ts\n * // Advanced usage with presence and custom messages\n * const syncClient = new TLSyncClient({\n * store,\n * socket,\n * presence: atom({ cursor: { x: 0, y: 0 }, userName: 'Alice' }),\n * presenceMode: atom('full'),\n * onCustomMessageReceived: (data) => {\n * if (data.type === 'chat') {\n * showChatMessage(data.message, data.from)\n * }\n * },\n * onAfterConnect: (client, { isReadonly }) => {\n * if (isReadonly) {\n * showNotification('Connected in read-only mode')\n * }\n * }\n * })\n * ```\n *\n * @public\n */\nexport class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>> {\n\t/** The last clock time from the most recent server update */\n\tprivate lastServerClock = -1\n\tprivate lastServerInteractionTimestamp = Date.now()\n\t/**\n\t * Send time of the oldest outstanding ping; answered iff `lastServerInteractionTimestamp >= it`\n\t * (any server message counts). Null until the first ping after (re)connect.\n\t */\n\tprivate firstUnansweredPingAt: number | null = null\n\n\t/** The queue of in-flight push requests that have not yet been acknowledged by the server */\n\tprivate pendingPushRequests: TLPushRequest<R>[] = []\n\tprivate unsentChanges: {\n\t\tnextDiff?: RecordsDiff<R>\n\t\tnextPresence?: R | null\n\t} = { nextDiff: undefined, nextPresence: undefined }\n\n\t/**\n\t * The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we\n\t * take this diff, reverse it, and apply that to the store, our store will match exactly the most\n\t * recent state of the server that we know about\n\t */\n\tprivate speculativeChanges: RecordsDiff<R> = {\n\t\tadded: {} as any,\n\t\tupdated: {} as any,\n\t\tremoved: {} as any,\n\t}\n\n\tprivate disposables: Array<() => void> = []\n\n\t/** Separate scheduler instance for network sync operations */\n\tprivate readonly fpsScheduler: FpsScheduler\n\n\t/** Send any unsent push requests to the server */\n\tprivate readonly sendUnsentChanges: {\n\t\t(): void\n\t\tcancel?(): void\n\t}\n\n\t/** Schedule a rebase operation */\n\tprivate readonly scheduleRebase: {\n\t\t(): void\n\t\tcancel?(): void\n\t}\n\n\t/** @internal */\n\treadonly store: S\n\t/** @internal */\n\treadonly socket: TLPersistentClientSocket<TLSocketClientSentEvent<R>, TLSocketServerSentEvent<R>>\n\n\t/** @internal */\n\treadonly presenceState: Signal<R | null> | undefined\n\t/** @internal */\n\treadonly presenceMode: Signal<TLPresenceMode> | undefined\n\n\t// isOnline is true when we have an open socket connection and we have\n\t// established a connection with the server room (i.e. we have received a 'connect' message)\n\t/** @internal */\n\tisConnectedToRoom = false\n\n\t/**\n\t * The client clock is essentially a counter for push requests Each time a push request is created\n\t * the clock is incremented. This clock is sent with the push request to the server, and the\n\t * server returns it with the response so that we can match up the response with the request.\n\t *\n\t * The clock may also be used at one point in the future to allow the client to re-send push\n\t * requests idempotently (i.e. the server will keep track of each client's clock and not execute\n\t * requests it has already handled), but at the time of writing this is neither needed nor\n\t * implemented.\n\t */\n\tprivate clientClock = 0\n\n\t/**\n\t * Callback executed immediately after successful connection to sync room.\n\t * Use this to perform any post-connection setup required for your application,\n\t * such as initializing default content or updating UI state.\n\t *\n\t * @param self - The TLSyncClient instance that connected\n\t * @param details - Connection details\n\t * - isReadonly - Whether the connection is in read-only mode\n\t * - objectAccess - Write access for object-store lane record types (defaults to 'write'\n\t * when the server doesn't send it, e.g. older servers or rooms with no object lane)\n\t */\n\tprivate readonly onAfterConnect?: (\n\t\tself: this,\n\t\tdetails: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }\n\t) => void\n\n\tprivate readonly onCustomMessageReceived?: TLCustomMessageHandler\n\n\tprivate isDebugging = false\n\tprivate debug(...args: any[]) {\n\t\tif (this.isDebugging) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.debug(...args)\n\t\t}\n\t}\n\n\tprivate readonly presenceType: R['typeName'] | null\n\n\tprivate didCancel?: () => boolean\n\n\t/**\n\t * Creates a new TLSyncClient instance to manage synchronization with a remote server.\n\t *\n\t * @param config - Configuration object for the sync client\n\t * - store - The local tldraw store to synchronize\n\t * - socket - WebSocket adapter for server communication\n\t * - presence - Reactive signal containing current user's presence data\n\t * - presenceMode - Optional signal controlling presence sharing (defaults to 'full')\n\t * - onLoad - Callback fired when initial sync completes successfully\n\t * - onSyncError - Callback fired when sync fails with error reason\n\t * - onCustomMessageReceived - Optional handler for custom messages\n\t * - onAfterConnect - Optional callback fired after successful connection\n\t * - self - The TLSyncClient instance\n\t * - details - Connection details including readonly status\n\t * - didCancel - Optional function to check if sync should be cancelled\n\t */\n\tconstructor(config: {\n\t\tstore: S\n\t\tsocket: TLPersistentClientSocket<any, any>\n\t\tpresence: Signal<R | null>\n\t\tpresenceMode?: Signal<TLPresenceMode>\n\t\tonLoad(self: TLSyncClient<R, S>): void\n\t\tonSyncError(reason: string): void\n\t\tonCustomMessageReceived?: TLCustomMessageHandler\n\t\tonAfterConnect?(\n\t\t\tself: TLSyncClient<R, S>,\n\t\t\tdetails: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }\n\t\t): void\n\t\tdidCancel?(): boolean\n\t}) {\n\t\tthis.didCancel = config.didCancel\n\n\t\tthis.presenceType = config.store.scopedTypes.presence.values().next().value ?? null\n\n\t\t// Create a separate throttle instance for network sync operations\n\t\t// This ensures sync operations have their own queue separate from UI operations\n\t\tthis.fpsScheduler = new FpsScheduler(COLLABORATIVE_MODE_FPS)\n\n\t\t// Initialize throttled methods after throttle instance is created\n\t\tthis.sendUnsentChanges = this.fpsScheduler.fpsThrottle(() => {\n\t\t\tthis.debug('sending unsent changes', {\n\t\t\t\tisConnectedToRoom: this.isConnectedToRoom,\n\t\t\t\tunsentChanges: this.unsentChanges,\n\t\t\t})\n\t\t\tif (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!this.unsentChanges.nextDiff && !this.unsentChanges.nextPresence) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst diff = this.unsentChanges.nextDiff\n\t\t\t\t? (getNetworkDiff(this.unsentChanges.nextDiff) ?? undefined)\n\t\t\t\t: undefined\n\t\t\tconst presence = this.unsentChanges.nextPresence\n\t\t\t\t? getPresenceOp<R>(this.lastPushedPresenceState, this.unsentChanges.nextPresence)\n\t\t\t\t: undefined\n\n\t\t\tif (!diff && !presence) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst pushRequest: TLPushRequest<R> = {\n\t\t\t\ttype: 'push',\n\t\t\t\tclientClock: this.clientClock,\n\t\t\t\tdiff,\n\t\t\t\tpresence,\n\t\t\t}\n\n\t\t\tthis.debug('sending push request', pushRequest)\n\t\t\tthis.socket.sendMessage(pushRequest)\n\n\t\t\tif (this.unsentChanges.nextPresence) {\n\t\t\t\tthis.lastPushedPresenceState = this.unsentChanges.nextPresence\n\t\t\t}\n\t\t\tthis.clientClock++\n\t\t\tthis.pendingPushRequests.push(pushRequest)\n\t\t\tthis.unsentChanges.nextDiff = undefined\n\t\t\tthis.unsentChanges.nextPresence = undefined\n\t\t})\n\n\t\tthis.scheduleRebase = this.fpsScheduler.fpsThrottle(this.rebase)\n\n\t\tif (typeof window !== 'undefined') {\n\t\t\t;(window as any).tlsync = this\n\t\t}\n\t\tthis.store = config.store\n\t\tthis.socket = config.socket\n\t\tthis.onAfterConnect = config.onAfterConnect\n\t\tthis.onCustomMessageReceived = config.onCustomMessageReceived\n\n\t\tlet didLoad = false\n\n\t\tthis.presenceState = config.presence\n\t\tthis.presenceMode = config.presenceMode\n\n\t\tthis.disposables.push(\n\t\t\t// when local 'user' changes are made, send them to the server\n\t\t\t// or stash them locally in offline mode\n\t\t\tthis.store.listen(\n\t\t\t\t({ changes }) => {\n\t\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\t\tthis.debug('received store changes', { changes })\n\t\t\t\t\tthis.push(changes)\n\t\t\t\t},\n\t\t\t\t{ source: 'user', scope: 'document' }\n\t\t\t),\n\t\t\t// when the server sends us events, handle them\n\t\t\tthis.socket.onReceiveMessage((msg) => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('received message from server', msg)\n\t\t\t\tthis.handleServerEvent(msg)\n\t\t\t\t// the first time we receive a message from the server, we should trigger\n\n\t\t\t\t// one of the load callbacks\n\t\t\t\tif (!didLoad) {\n\t\t\t\t\tdidLoad = true\n\t\t\t\t\tconfig.onLoad(this)\n\t\t\t\t}\n\t\t\t}),\n\t\t\t// handle switching between online and offline\n\t\t\tthis.socket.onStatusChange((ev) => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('socket status changed', ev.status)\n\t\t\t\tif (ev.status === 'online') {\n\t\t\t\t\tthis.sendConnectMessage()\n\t\t\t\t} else {\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t\tif (ev.status === 'error') {\n\t\t\t\t\t\tdidLoad = true\n\t\t\t\t\t\tconfig.onSyncError(ev.reason)\n\t\t\t\t\t\tthis.close()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}),\n\t\t\t// Send a ping every PING_INTERVAL ms while online\n\t\t\tinterval(() => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('ping loop', { isConnectedToRoom: this.isConnectedToRoom })\n\t\t\t\tif (!this.isConnectedToRoom) return\n\t\t\t\ttry {\n\t\t\t\t\t// advance the marker only when the previous ping was answered: a half-open socket\n\t\t\t\t\t// that accepts sends but returns nothing must not keep refreshing its own alibi\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.firstUnansweredPingAt === null ||\n\t\t\t\t\t\tthis.lastServerInteractionTimestamp >= this.firstUnansweredPingAt\n\t\t\t\t\t) {\n\t\t\t\t\t\tthis.firstUnansweredPingAt = Date.now()\n\t\t\t\t\t}\n\t\t\t\t\tthis.socket.sendMessage({ type: 'ping' })\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.warn('ping failed, resetting', error)\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t}\n\t\t\t}, PING_INTERVAL),\n\t\t\t// Check the server connection health, reset the connection if needed\n\t\t\tinterval(() => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('health check loop', { isConnectedToRoom: this.isConnectedToRoom })\n\t\t\t\tif (!this.isConnectedToRoom) return\n\t\t\t\tconst timeSinceLastServerInteraction = Date.now() - this.lastServerInteractionTimestamp\n\n\t\t\t\tif (\n\t\t\t\t\ttimeSinceLastServerInteraction <\n\t\t\t\t\tMAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION\n\t\t\t\t) {\n\t\t\t\t\tthis.debug('health check passed', { timeSinceLastServerInteraction })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Silence alone is not evidence of a dead socket: in a throttled hidden tab our own ping\n\t\t\t\t// loop stops, so the server had nothing to answer. Only reset on an unanswered ping.\n\t\t\t\tconst firstUnansweredPingAt = this.firstUnansweredPingAt\n\t\t\t\tconst pingOverdue =\n\t\t\t\t\tfirstUnansweredPingAt !== null &&\n\t\t\t\t\tthis.lastServerInteractionTimestamp < firstUnansweredPingAt &&\n\t\t\t\t\tDate.now() - firstUnansweredPingAt >= PONG_TIMEOUT\n\t\t\t\tif (!pingOverdue) {\n\t\t\t\t\tthis.debug('health check stale but no overdue ping', { timeSinceLastServerInteraction })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// A frozen tab can deliver a queued pong only after these catch-up ticks run, so one\n\t\t\t\t// spurious reset on unfreeze is still possible \u2014 rare, and the reconnect is clean.\n\t\t\t\tconsole.warn(`Haven't heard from the server in a while, resetting connection...`, {\n\t\t\t\t\ttimeSinceLastServerInteraction,\n\t\t\t\t\tpingOutstandingMs: Date.now() - firstUnansweredPingAt!,\n\t\t\t\t})\n\t\t\t\tthis.resetConnection()\n\t\t\t}, PING_INTERVAL * 2)\n\t\t)\n\n\t\tif (this.presenceState) {\n\t\t\tthis.disposables.push(\n\t\t\t\treact('pushPresence', () => {\n\t\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\t\tconst mode = this.presenceMode?.get()\n\t\t\t\t\tthis.fpsScheduler.updateTargetFps(this.getSyncFps())\n\t\t\t\t\tif (mode !== 'full') return\n\t\t\t\t\tthis.pushPresence(this.presenceState!.get())\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\n\t\t// if the socket is already online before this client was instantiated\n\t\t// then we should send a connect message right away\n\t\tif (this.socket.connectionStatus === 'online') {\n\t\t\tthis.sendConnectMessage()\n\t\t}\n\t}\n\n\t/** @internal */\n\tlatestConnectRequestId: string | null = null\n\n\t/**\n\t * This is the first message that is sent over a newly established socket connection. And we need\n\t * to wait for the response before this client can be used.\n\t */\n\tprivate sendConnectMessage() {\n\t\tif (this.isConnectedToRoom) {\n\t\t\tconsole.error('sendConnectMessage called while already connected')\n\t\t\treturn\n\t\t}\n\t\tthis.debug('sending connect message')\n\t\tthis.latestConnectRequestId = uniqueId()\n\t\tthis.socket.sendMessage({\n\t\t\ttype: 'connect',\n\t\t\tconnectRequestId: this.latestConnectRequestId,\n\t\t\tschema: this.store.schema.serialize(),\n\t\t\tprotocolVersion: getTlsyncProtocolVersion(),\n\t\t\tlastServerClock: this.lastServerClock,\n\t\t})\n\t}\n\n\t/** Switch to offline mode */\n\tprivate resetConnection(hard = false) {\n\t\tthis.debug('resetting connection')\n\t\tif (hard) {\n\t\t\tthis.lastServerClock = 0\n\t\t}\n\t\t// kill all presence state\n\t\tconst keys = Object.keys(this.store.serialize('presence')) as any\n\t\tif (keys.length > 0) {\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\tthis.store.remove(keys)\n\t\t\t})\n\t\t}\n\t\tthis.lastPushedPresenceState = null\n\t\tthis.isConnectedToRoom = false\n\t\tthis.firstUnansweredPingAt = null\n\t\tthis.pendingPushRequests = []\n\t\tthis.incomingDiffBuffer = []\n\t\tthis.unsentChanges.nextDiff = undefined\n\t\tthis.unsentChanges.nextPresence = undefined\n\t\tif (this.socket.connectionStatus === 'online') {\n\t\t\tthis.socket.restart()\n\t\t}\n\t}\n\n\t/**\n\t * Invoked when the socket connection comes online, either for the first time or as the result of\n\t * a reconnect. The goal is to rebase on the server's state and fire off a new push request for\n\t * any local changes that were made while offline.\n\t */\n\tprivate didReconnect(event: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) {\n\t\tthis.debug('did reconnect', event)\n\t\tif (event.connectRequestId !== this.latestConnectRequestId) {\n\t\t\t// ignore connect events for old connect requests\n\t\t\treturn\n\t\t}\n\t\tthis.latestConnectRequestId = null\n\n\t\tif (this.isConnectedToRoom) {\n\t\t\tconsole.error('didReconnect called while already connected')\n\t\t\tthis.resetConnection(true)\n\t\t\treturn\n\t\t}\n\t\tif (this.pendingPushRequests.length > 0) {\n\t\t\tconsole.error('pendingPushRequests should already be empty when we reconnect')\n\t\t\tthis.resetConnection(true)\n\t\t\treturn\n\t\t}\n\t\t// at the end of this process we want to have at most one pending push request\n\t\t// based on anything inside this.speculativeChanges\n\t\ttransact(() => {\n\t\t\t// Now our goal is to rebase on the server's state.\n\t\t\t// This means wiping away any peer presence data, which the server will replace in full on every connect.\n\t\t\t// If the server does not have enough history to give us a partial document state hydration we will\n\t\t\t// also need to wipe away all of our document state before hydrating with the server's state from scratch.\n\t\t\tconst stashedChanges = this.speculativeChanges\n\t\t\tthis.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any }\n\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\t// gather records to delete in a NetworkDiff\n\t\t\t\tconst wipeDiff: NetworkDiff<R> = {}\n\t\t\t\tconst wipeAll = event.hydrationType === 'wipe_all'\n\t\t\t\tif (!wipeAll) {\n\t\t\t\t\t// if we're only wiping presence data, undo the speculative changes first\n\t\t\t\t\tthis.store.applyDiff(reverseRecordsDiff(stashedChanges), { runCallbacks: false })\n\t\t\t\t}\n\n\t\t\t\t// now wipe all presence data and, if needed, all document data\n\t\t\t\tfor (const [id, record] of objectMapEntries(this.store.serialize('all'))) {\n\t\t\t\t\tif (\n\t\t\t\t\t\t(wipeAll && this.store.scopedTypes.document.has(record.typeName)) ||\n\t\t\t\t\t\trecord.typeName === this.presenceType\n\t\t\t\t\t) {\n\t\t\t\t\t\twipeDiff[id] = [RecordOpType.Remove]\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// then apply the upstream changes\n\t\t\t\tthis.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true)\n\n\t\t\t\tthis.isConnectedToRoom = true\n\n\t\t\t\t// now re-apply the speculative changes creating a new push request with the\n\t\t\t\t// appropriate diff\n\t\t\t\tconst networkDiff = getNetworkDiff(stashedChanges)\n\t\t\t\tif (!networkDiff) return\n\t\t\t\tconst speculativeChanges = this.store.filterChangesByScope(\n\t\t\t\t\tthis.store.extractingChanges(() => {\n\t\t\t\t\t\tthis.applyNetworkDiff(networkDiff, true)\n\t\t\t\t\t}),\n\t\t\t\t\t'document'\n\t\t\t\t)\n\t\t\t\tif (speculativeChanges) this.push(speculativeChanges)\n\t\t\t})\n\n\t\t\t// this.isConnectedToRoom = true\n\t\t\t// this.store.applyDiff(stashedChanges, false)\n\n\t\t\tthis.onAfterConnect?.(this, {\n\t\t\t\tisReadonly: event.isReadonly,\n\t\t\t\tobjectAccess: event.objectAccess ?? 'write',\n\t\t\t})\n\t\t\tconst presence = this.presenceState?.get()\n\t\t\tif (presence) {\n\t\t\t\tthis.pushPresence(presence)\n\t\t\t}\n\t\t})\n\n\t\tthis.lastServerClock = event.serverClock\n\t}\n\n\tprivate incomingDiffBuffer: TLSocketServerSentDataEvent<R>[] = []\n\n\t/** Handle events received from the server */\n\tprivate handleServerEvent(event: TLSocketServerSentEvent<R>) {\n\t\tthis.debug('received server event', event)\n\t\tthis.lastServerInteractionTimestamp = Date.now()\n\t\t// always update the lastServerClock when it is present\n\t\tswitch (event.type) {\n\t\t\tcase 'connect':\n\t\t\t\tthis.didReconnect(event)\n\t\t\t\tbreak\n\t\t\t// legacy v4 events\n\t\t\tcase 'patch':\n\t\t\tcase 'push_result':\n\t\t\t\tif (!this.isConnectedToRoom) break\n\t\t\t\tthis.incomingDiffBuffer.push(event)\n\t\t\t\tthis.scheduleRebase()\n\t\t\t\tbreak\n\t\t\tcase 'data':\n\t\t\t\t// wait for a connect to succeed before processing more events\n\t\t\t\tif (!this.isConnectedToRoom) break\n\t\t\t\tthis.incomingDiffBuffer.push(...event.data)\n\t\t\t\tthis.scheduleRebase()\n\t\t\t\tbreak\n\t\t\tcase 'incompatibility_error':\n\t\t\t\t// legacy unrecoverable errors\n\t\t\t\tconsole.error('incompatibility error is legacy and should no longer be sent by the server')\n\t\t\t\tbreak\n\t\t\tcase 'pong':\n\t\t\t\t// noop, we only use ping/pong to set lastSeverInteractionTimestamp\n\t\t\t\tbreak\n\t\t\tcase 'custom':\n\t\t\t\tthis.onCustomMessageReceived?.call(null, event.data)\n\t\t\t\tbreak\n\n\t\t\tdefault:\n\t\t\t\texhaustiveSwitchError(event)\n\t\t}\n\t}\n\n\t/**\n\t * Closes the sync client and cleans up all resources.\n\t *\n\t * Call this method when you no longer need the sync client to prevent\n\t * memory leaks and close the WebSocket connection. After calling close(),\n\t * the client cannot be reused.\n\t *\n\t * @example\n\t * ```ts\n\t * // Clean shutdown\n\t * syncClient.close()\n\t * ```\n\t */\n\tclose() {\n\t\tthis.debug('closing')\n\t\tthis.disposables.forEach((dispose) => dispose())\n\t\tthis.sendUnsentChanges.cancel?.()\n\t\tthis.scheduleRebase.cancel?.()\n\t\tif (typeof window !== 'undefined' && (window as any).tlsync === this) {\n\t\t\tdelete (window as any).tlsync\n\t\t}\n\t}\n\n\tprivate lastPushedPresenceState: R | null = null\n\n\tprivate pushPresence(nextPresence: R | null) {\n\t\t// make sure we push any document changes first\n\t\tthis.store._flushHistory()\n\n\t\tif (!this.isConnectedToRoom) {\n\t\t\t// if we're offline, don't do anything\n\t\t\treturn\n\t\t}\n\n\t\tthis.unsentChanges.nextPresence = nextPresence\n\t\tthis.sendUnsentChanges()\n\t}\n\n\t/** Push a change to the server, or stash it locally if we're offline */\n\tprivate push(change: RecordsDiff<any>) {\n\t\tthis.debug('push', change)\n\t\tsquashRecordDiffsMutable(this.speculativeChanges, [change])\n\t\t// in offline mode, we only accumulate in speculativeChanges\n\t\tif (!this.isConnectedToRoom) return\n\t\tif (!this.unsentChanges.nextDiff) {\n\t\t\tthis.unsentChanges.nextDiff = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\t}\n\t\t// records are immutable, so sharing their references with `change` is fine \u2014 the\n\t\t// squash gives nextDiff its own containers and tuples without deep-cloning records\n\t\tsquashRecordDiffsMutable(this.unsentChanges.nextDiff, [change])\n\t\tthis.sendUnsentChanges()\n\t}\n\n\t/** Get the target FPS for network operations based on presence mode */\n\tprivate getSyncFps(): number {\n\t\treturn this.presenceMode?.get() === 'solo' ? SOLO_MODE_FPS : COLLABORATIVE_MODE_FPS\n\t}\n\n\t/**\n\t * Applies a 'network' diff to the store this does value-based equality checking so that if the\n\t * data is the same (as opposed to merely identical with ===), then no change is made and no\n\t * changes will be propagated back to store listeners\n\t */\n\tprivate applyNetworkDiff(diff: NetworkDiff<R>, runCallbacks: boolean) {\n\t\tthis.debug('applyNetworkDiff', diff)\n\t\tconst changes: RecordsDiff<R> = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\ttype k = keyof typeof changes.updated\n\t\tlet hasChanges = false\n\t\tfor (const [id, op] of objectMapEntries(diff)) {\n\t\t\tif (op[0] === RecordOpType.Put) {\n\t\t\t\tconst existing = this.store.get(id as RecordId<any>)\n\t\t\t\tif (existing && !isEqual(existing, op[1])) {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.updated[id as k] = [existing, op[1]]\n\t\t\t\t} else {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.added[id as k] = op[1]\n\t\t\t\t}\n\t\t\t} else if (op[0] === RecordOpType.Patch) {\n\t\t\t\tconst record = this.store.get(id as RecordId<any>)\n\t\t\t\tif (!record) {\n\t\t\t\t\t// the record was removed upstream\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tconst patched = applyObjectDiff(record, op[1])\n\t\t\t\thasChanges = true\n\t\t\t\tchanges.updated[id as k] = [record, patched]\n\t\t\t} else if (op[0] === RecordOpType.Remove) {\n\t\t\t\tif (this.store.has(id as RecordId<any>)) {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.removed[id as k] = this.store.get(id as RecordId<any>)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (hasChanges) {\n\t\t\tthis.store.applyDiff(changes, { runCallbacks })\n\t\t}\n\t}\n\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\tprivate rebase = () => {\n\t\t// need to make sure that our speculative changes are in sync with the actual store instance before\n\t\t// proceeding, to avoid inconsistency bugs.\n\t\tthis.store._flushHistory()\n\t\tif (this.incomingDiffBuffer.length === 0) return\n\n\t\tconst diffs = this.incomingDiffBuffer\n\t\tthis.incomingDiffBuffer = []\n\n\t\ttry {\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\t// first undo speculative changes\n\t\t\t\tthis.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), { runCallbacks: false })\n\n\t\t\t\t// then apply network diffs on top of known-to-be-synced data\n\t\t\t\tfor (const diff of diffs) {\n\t\t\t\t\tif (diff.type === 'patch') {\n\t\t\t\t\t\tthis.applyNetworkDiff(diff.diff, true)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// handling push_result\n\t\t\t\t\tif (this.pendingPushRequests.length === 0) {\n\t\t\t\t\t\tthrow new Error('Received push_result but there are no pending push requests')\n\t\t\t\t\t}\n\t\t\t\t\tif (this.pendingPushRequests[0].clientClock !== diff.clientClock) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'Received push_result for a push request that is not at the front of the queue'\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tif (diff.action === 'discard') {\n\t\t\t\t\t\tthis.pendingPushRequests.shift()\n\t\t\t\t\t} else if (diff.action === 'commit') {\n\t\t\t\t\t\tconst request = this.pendingPushRequests.shift()!\n\t\t\t\t\t\tif ('diff' in request && request.diff) {\n\t\t\t\t\t\t\tthis.applyNetworkDiff(request.diff, true)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.applyNetworkDiff(diff.action.rebaseWithDiff, true)\n\t\t\t\t\t\tthis.pendingPushRequests.shift()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// update the speculative diff while re-applying pending changes\n\t\t\t\ttry {\n\t\t\t\t\tthis.speculativeChanges = this.store.extractingChanges(() => {\n\t\t\t\t\t\tfor (const request of this.pendingPushRequests) {\n\t\t\t\t\t\t\tif (!('diff' in request) || !request.diff) continue\n\t\t\t\t\t\t\tthis.applyNetworkDiff(request.diff, true)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!this.unsentChanges.nextDiff) return\n\t\t\t\t\t\tconst diff = getNetworkDiff(this.unsentChanges.nextDiff)\n\t\t\t\t\t\tif (!diff) return\n\t\t\t\t\t\tthis.applyNetworkDiff(diff, true)\n\t\t\t\t\t})\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(e)\n\t\t\t\t\t// throw away the speculative changes and start over\n\t\t\t\t\tthis.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t}\n\t\t\t})\n\t\t\tthis.lastServerClock = diffs.at(-1)?.serverClock ?? this.lastServerClock\n\t\t} catch (e) {\n\t\t\tconsole.error(e)\n\t\t\tthis.store.ensureStoreIsUsable()\n\t\t\tthis.resetConnection()\n\t\t}\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { Signal, react, transact } from '@tldraw/state'\nimport {\n\tRecordId,\n\tRecordsDiff,\n\tStore,\n\tUnknownRecord,\n\treverseRecordsDiff,\n\tsquashRecordDiffsMutable,\n} from '@tldraw/store'\nimport {\n\tFpsScheduler,\n\texhaustiveSwitchError,\n\tisEqual,\n\tobjectMapEntries,\n\tuniqueId,\n} from '@tldraw/utils'\nimport {\n\tNetworkDiff,\n\tObjectDiff,\n\tRecordOpType,\n\tapplyObjectDiff,\n\tdiffRecord,\n\tgetNetworkDiff,\n} from './diff'\nimport { interval } from './interval'\nimport {\n\tTLObjectStoreAccess,\n\tTLPushRequest,\n\tTLSocketClientSentEvent,\n\tTLSocketServerSentDataEvent,\n\tTLSocketServerSentEvent,\n\tgetTlsyncProtocolVersion,\n} from './protocol'\n\n/**\n * Function type for subscribing to events with a callback.\n * Returns an unsubscribe function to clean up the listener.\n *\n * @param cb - Callback function that receives the event value\n * @returns Function to call when you want to unsubscribe from the events\n *\n * @public\n */\nexport type SubscribingFn<T> = (cb: (val: T) => void) => () => void\n\n/** Network sync frame rate when in solo mode (no collaborators) @internal */\nconst SOLO_MODE_FPS = 1\n\n/** Network sync frame rate when in collaborative mode (with collaborators) @internal */\nconst COLLABORATIVE_MODE_FPS = 30\n\n/**\n * WebSocket close code used by the server to signal a non-recoverable sync error.\n * This close code indicates that the connection is being terminated due to an error\n * that cannot be automatically recovered from, such as authentication failures,\n * incompatible client versions, or invalid data.\n *\n * @example\n * ```ts\n * // Server-side: Close connection with specific error reason\n * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.NOT_FOUND)\n *\n * // Client-side: Handle the error in your sync error handler\n * const syncClient = new TLSyncClient({\n * // ... other config\n * onSyncError: (reason) => {\n * console.error('Sync failed:', reason) // Will receive 'NOT_FOUND'\n * }\n * })\n * ```\n *\n * @public\n */\nexport const TLSyncErrorCloseEventCode = 4099 as const\n\n/**\n * Predefined reasons for server-initiated connection closures.\n * These constants represent different error conditions that can cause\n * the sync server to terminate a WebSocket connection.\n *\n * @example\n * ```ts\n * // Server usage\n * if (!user.hasPermission(roomId)) {\n * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.FORBIDDEN)\n * }\n *\n * // Client error handling\n * syncClient.onSyncError((reason) => {\n * switch (reason) {\n * case TLSyncErrorCloseEventReason.NOT_FOUND:\n * showError('Room does not exist')\n * break\n * case TLSyncErrorCloseEventReason.FORBIDDEN:\n * showError('Access denied')\n * break\n * case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:\n * showError('Please update your app')\n * break\n * }\n * })\n * ```\n *\n * @public\n */\nexport const TLSyncErrorCloseEventReason = {\n\t/** Room or resource not found */\n\tNOT_FOUND: 'NOT_FOUND',\n\t/** User lacks permission to access the room */\n\tFORBIDDEN: 'FORBIDDEN',\n\t/** User authentication required or invalid */\n\tNOT_AUTHENTICATED: 'NOT_AUTHENTICATED',\n\t/** Unexpected server error occurred */\n\tUNKNOWN_ERROR: 'UNKNOWN_ERROR',\n\t/** Client protocol version too old */\n\tCLIENT_TOO_OLD: 'CLIENT_TOO_OLD',\n\t/** Server protocol version too old */\n\tSERVER_TOO_OLD: 'SERVER_TOO_OLD',\n\t/** Client sent invalid or corrupted record data */\n\tINVALID_RECORD: 'INVALID_RECORD',\n\t/** Client exceeded rate limits */\n\tRATE_LIMITED: 'RATE_LIMITED',\n\t/** Room has reached maximum capacity */\n\tROOM_FULL: 'ROOM_FULL',\n\t/** A single message exceeded the size the server will assemble */\n\tMESSAGE_TOO_LARGE: 'MESSAGE_TOO_LARGE',\n} as const\n\n/**\n * @internal\n */\nexport class TLSyncError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic reason: TLSyncErrorCloseEventReason\n\t) {\n\t\tsuper(message)\n\t}\n}\n/**\n * Union type of all possible server connection close reasons.\n * Represents the string values that can be passed when a server closes\n * a sync connection due to an error condition.\n *\n * @public\n */\nexport type TLSyncErrorCloseEventReason =\n\t(typeof TLSyncErrorCloseEventReason)[keyof typeof TLSyncErrorCloseEventReason]\n\n/**\n * Handler function for custom application messages sent through the sync protocol.\n * These are user-defined messages that can be sent between clients via the sync server,\n * separate from the standard document synchronization messages.\n *\n * @param data - Custom message payload (application-defined structure)\n *\n * @example\n * ```ts\n * const customMessageHandler: TLCustomMessageHandler = (data) => {\n * if (data.type === 'user_joined') {\n * console.log(`${data.username} joined the session`)\n * showToast(`${data.username} is now collaborating`)\n * }\n * }\n *\n * const syncClient = new TLSyncClient({\n * // ... other config\n * onCustomMessageReceived: customMessageHandler\n * })\n * ```\n *\n * @public\n */\nexport type TLCustomMessageHandler = (this: null, data: any) => void\n\n/**\n * Event object describing changes in socket connection status.\n * Contains either a basic status change or an error with details.\n *\n * @public\n */\nexport type TLSocketStatusChangeEvent =\n\t| {\n\t\t\t/** Connection came online or went offline */\n\t\t\tstatus: 'online' | 'offline'\n\t }\n\t| {\n\t\t\t/** Connection encountered an error */\n\t\t\tstatus: 'error'\n\t\t\t/** Description of the error that occurred */\n\t\t\treason: string\n\t }\n/**\n * Callback function type for listening to socket status changes.\n *\n * @param params - Event object containing the new status and optional error details\n *\n * @internal\n */\nexport type TLSocketStatusListener = (params: TLSocketStatusChangeEvent) => void\n\n/**\n * Possible connection states for a persistent client socket.\n * Represents the current connectivity status between client and server.\n *\n * @internal\n */\nexport type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'\n\n/**\n * Mode for handling presence information in sync sessions.\n * Controls whether presence data (cursors, selections) is shared with other clients.\n *\n * @public\n */\nexport type TLPresenceMode =\n\t/** No presence sharing - client operates independently */\n\t| 'solo'\n\t/** Full presence sharing - cursors and selections visible to others */\n\t| 'full'\n/**\n * Interface for persistent WebSocket-like connections used by TLSyncClient.\n * Handles automatic reconnection and provides event-based communication with the sync server.\n * Implementations should maintain connection resilience and handle network interruptions gracefully.\n *\n * @example\n * ```ts\n * class MySocketAdapter implements TLPersistentClientSocket {\n * connectionStatus: 'offline' | 'online' | 'error' = 'offline'\n *\n * sendMessage(msg: TLSocketClientSentEvent) {\n * if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n * this.ws.send(JSON.stringify(msg))\n * }\n * }\n *\n * onReceiveMessage = (callback) => {\n * // Set up message listener and return cleanup function\n * }\n *\n * restart() {\n * this.disconnect()\n * this.connect()\n * }\n * }\n * ```\n *\n * @public\n */\nexport interface TLPersistentClientSocket<\n\tClientSentMessage extends object = object,\n\tServerSentMessage extends object = object,\n> {\n\t/** Current connection state - online means actively connected and ready */\n\tconnectionStatus: 'online' | 'offline' | 'error'\n\n\t/**\n\t * Send a protocol message to the sync server\n\t * @param msg - Message to send (connect, push, ping, etc.)\n\t */\n\tsendMessage(msg: ClientSentMessage): void\n\n\t/**\n\t * Subscribe to messages received from the server\n\t * @param callback - Function called for each received message\n\t * @returns Cleanup function to remove the listener\n\t */\n\tonReceiveMessage: SubscribingFn<ServerSentMessage>\n\n\t/**\n\t * Subscribe to connection status changes\n\t * @param callback - Function called when connection status changes\n\t * @returns Cleanup function to remove the listener\n\t */\n\tonStatusChange: SubscribingFn<TLSocketStatusChangeEvent>\n\n\t/**\n\t * Force a connection restart (disconnect then reconnect)\n\t * Used for error recovery or when connection health checks fail\n\t */\n\trestart(): void\n\n\t/**\n\t * Close the connection\n\t */\n\tclose(): void\n}\n\nconst PING_INTERVAL = 5000\nconst MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2\n/**\n * How long an unanswered ping may stay outstanding before the connection is considered dead.\n * Tuned together with MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION:\n * a reset needs both stale interaction and a ping overdue by this much.\n */\nconst PONG_TIMEOUT = PING_INTERVAL * 2\n\n// Should connect support chunking the response to allow for large payloads?\n\nfunction getPresenceOp<R extends UnknownRecord>(\n\tlastPushedPresenceState: R | null,\n\tnextPresence: R | null\n): [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R] | undefined {\n\tif (!lastPushedPresenceState && nextPresence) {\n\t\treturn [RecordOpType.Put, nextPresence]\n\t}\n\tif (lastPushedPresenceState && nextPresence) {\n\t\tconst diff = diffRecord(lastPushedPresenceState, nextPresence)\n\t\tif (!diff) return undefined\n\t\treturn [RecordOpType.Patch, diff]\n\t}\n\treturn undefined\n}\n\n/**\n * Main client-side synchronization engine for collaborative tldraw applications.\n *\n * TLSyncClient manages bidirectional synchronization between a local tldraw Store\n * and a remote sync server. It uses an optimistic update model where local changes\n * are immediately applied for responsive UI, then sent to the server for validation\n * and distribution to other clients.\n *\n * The synchronization follows a git-like push/pull/rebase model:\n * - **Push**: Local changes are sent to server as diff operations\n * - **Pull**: Server changes are received and applied locally\n * - **Rebase**: Conflicting changes are resolved by undoing local changes,\n * applying server changes, then re-applying local changes on top\n *\n * @example\n * ```ts\n * import { TLSyncClient, ClientWebSocketAdapter } from '@tldraw/sync-core'\n * import { createTLStore } from '@tldraw/store'\n *\n * // Create store and socket\n * const store = createTLStore({ schema: mySchema })\n * const socket = new ClientWebSocketAdapter('ws://localhost:3000/sync')\n *\n * // Create sync client\n * const syncClient = new TLSyncClient({\n * store,\n * socket,\n * presence: atom(null),\n * onLoad: () => console.log('Connected and loaded'),\n * onSyncError: (reason) => console.error('Sync failed:', reason)\n * })\n *\n * // Changes to store are now automatically synchronized\n * store.put([{ id: 'shape1', type: 'geo', x: 100, y: 100 }])\n * ```\n *\n * @example\n * ```ts\n * // Advanced usage with presence and custom messages\n * const syncClient = new TLSyncClient({\n * store,\n * socket,\n * presence: atom({ cursor: { x: 0, y: 0 }, userName: 'Alice' }),\n * presenceMode: atom('full'),\n * onCustomMessageReceived: (data) => {\n * if (data.type === 'chat') {\n * showChatMessage(data.message, data.from)\n * }\n * },\n * onAfterConnect: (client, { isReadonly }) => {\n * if (isReadonly) {\n * showNotification('Connected in read-only mode')\n * }\n * }\n * })\n * ```\n *\n * @public\n */\nexport class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>> {\n\t/** The last clock time from the most recent server update */\n\tprivate lastServerClock = -1\n\tprivate lastServerInteractionTimestamp = Date.now()\n\t/**\n\t * Send time of the oldest outstanding ping; answered iff `lastServerInteractionTimestamp >= it`\n\t * (any server message counts). Null until the first ping after (re)connect.\n\t */\n\tprivate firstUnansweredPingAt: number | null = null\n\n\t/** The queue of in-flight push requests that have not yet been acknowledged by the server */\n\tprivate pendingPushRequests: TLPushRequest<R>[] = []\n\tprivate unsentChanges: {\n\t\tnextDiff?: RecordsDiff<R>\n\t\tnextPresence?: R | null\n\t} = { nextDiff: undefined, nextPresence: undefined }\n\n\t/**\n\t * The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we\n\t * take this diff, reverse it, and apply that to the store, our store will match exactly the most\n\t * recent state of the server that we know about\n\t */\n\tprivate speculativeChanges: RecordsDiff<R> = {\n\t\tadded: {} as any,\n\t\tupdated: {} as any,\n\t\tremoved: {} as any,\n\t}\n\n\tprivate disposables: Array<() => void> = []\n\n\t/** Separate scheduler instance for network sync operations */\n\tprivate readonly fpsScheduler: FpsScheduler\n\n\t/** Send any unsent push requests to the server */\n\tprivate readonly sendUnsentChanges: {\n\t\t(): void\n\t\tcancel?(): void\n\t}\n\n\t/** Schedule a rebase operation */\n\tprivate readonly scheduleRebase: {\n\t\t(): void\n\t\tcancel?(): void\n\t}\n\n\t/** @internal */\n\treadonly store: S\n\t/** @internal */\n\treadonly socket: TLPersistentClientSocket<TLSocketClientSentEvent<R>, TLSocketServerSentEvent<R>>\n\n\t/** @internal */\n\treadonly presenceState: Signal<R | null> | undefined\n\t/** @internal */\n\treadonly presenceMode: Signal<TLPresenceMode> | undefined\n\n\t// isOnline is true when we have an open socket connection and we have\n\t// established a connection with the server room (i.e. we have received a 'connect' message)\n\t/** @internal */\n\tisConnectedToRoom = false\n\n\t/**\n\t * The client clock is essentially a counter for push requests Each time a push request is created\n\t * the clock is incremented. This clock is sent with the push request to the server, and the\n\t * server returns it with the response so that we can match up the response with the request.\n\t *\n\t * The clock may also be used at one point in the future to allow the client to re-send push\n\t * requests idempotently (i.e. the server will keep track of each client's clock and not execute\n\t * requests it has already handled), but at the time of writing this is neither needed nor\n\t * implemented.\n\t */\n\tprivate clientClock = 0\n\n\t/**\n\t * Callback executed immediately after successful connection to sync room.\n\t * Use this to perform any post-connection setup required for your application,\n\t * such as initializing default content or updating UI state.\n\t *\n\t * @param self - The TLSyncClient instance that connected\n\t * @param details - Connection details\n\t * - isReadonly - Whether the connection is in read-only mode\n\t * - objectAccess - Write access for object-store lane record types (defaults to 'write'\n\t * when the server doesn't send it, e.g. older servers or rooms with no object lane)\n\t */\n\tprivate readonly onAfterConnect?: (\n\t\tself: this,\n\t\tdetails: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }\n\t) => void\n\n\tprivate readonly onCustomMessageReceived?: TLCustomMessageHandler\n\n\tprivate isDebugging = false\n\tprivate debug(...args: any[]) {\n\t\tif (this.isDebugging) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.debug(...args)\n\t\t}\n\t}\n\n\tprivate readonly presenceType: R['typeName'] | null\n\n\tprivate didCancel?: () => boolean\n\n\t/**\n\t * Creates a new TLSyncClient instance to manage synchronization with a remote server.\n\t *\n\t * @param config - Configuration object for the sync client\n\t * - store - The local tldraw store to synchronize\n\t * - socket - WebSocket adapter for server communication\n\t * - presence - Reactive signal containing current user's presence data\n\t * - presenceMode - Optional signal controlling presence sharing (defaults to 'full')\n\t * - onLoad - Callback fired when initial sync completes successfully\n\t * - onSyncError - Callback fired when sync fails with error reason\n\t * - onCustomMessageReceived - Optional handler for custom messages\n\t * - onAfterConnect - Optional callback fired after successful connection\n\t * - self - The TLSyncClient instance\n\t * - details - Connection details including readonly status\n\t * - didCancel - Optional function to check if sync should be cancelled\n\t */\n\tconstructor(config: {\n\t\tstore: S\n\t\tsocket: TLPersistentClientSocket<any, any>\n\t\tpresence: Signal<R | null>\n\t\tpresenceMode?: Signal<TLPresenceMode>\n\t\tonLoad(self: TLSyncClient<R, S>): void\n\t\tonSyncError(reason: string): void\n\t\tonCustomMessageReceived?: TLCustomMessageHandler\n\t\tonAfterConnect?(\n\t\t\tself: TLSyncClient<R, S>,\n\t\t\tdetails: { isReadonly: boolean; objectAccess: TLObjectStoreAccess }\n\t\t): void\n\t\tdidCancel?(): boolean\n\t}) {\n\t\tthis.didCancel = config.didCancel\n\n\t\tthis.presenceType = config.store.scopedTypes.presence.values().next().value ?? null\n\n\t\t// Create a separate throttle instance for network sync operations\n\t\t// This ensures sync operations have their own queue separate from UI operations\n\t\tthis.fpsScheduler = new FpsScheduler(COLLABORATIVE_MODE_FPS)\n\n\t\t// Initialize throttled methods after throttle instance is created\n\t\tthis.sendUnsentChanges = this.fpsScheduler.fpsThrottle(() => {\n\t\t\tthis.debug('sending unsent changes', {\n\t\t\t\tisConnectedToRoom: this.isConnectedToRoom,\n\t\t\t\tunsentChanges: this.unsentChanges,\n\t\t\t})\n\t\t\tif (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!this.unsentChanges.nextDiff && !this.unsentChanges.nextPresence) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst diff = this.unsentChanges.nextDiff\n\t\t\t\t? (getNetworkDiff(this.unsentChanges.nextDiff) ?? undefined)\n\t\t\t\t: undefined\n\t\t\tconst presence = this.unsentChanges.nextPresence\n\t\t\t\t? getPresenceOp<R>(this.lastPushedPresenceState, this.unsentChanges.nextPresence)\n\t\t\t\t: undefined\n\n\t\t\tif (!diff && !presence) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst pushRequest: TLPushRequest<R> = {\n\t\t\t\ttype: 'push',\n\t\t\t\tclientClock: this.clientClock,\n\t\t\t\tdiff,\n\t\t\t\tpresence,\n\t\t\t}\n\n\t\t\tthis.debug('sending push request', pushRequest)\n\t\t\tthis.socket.sendMessage(pushRequest)\n\n\t\t\tif (this.unsentChanges.nextPresence) {\n\t\t\t\tthis.lastPushedPresenceState = this.unsentChanges.nextPresence\n\t\t\t}\n\t\t\tthis.clientClock++\n\t\t\tthis.pendingPushRequests.push(pushRequest)\n\t\t\tthis.unsentChanges.nextDiff = undefined\n\t\t\tthis.unsentChanges.nextPresence = undefined\n\t\t})\n\n\t\tthis.scheduleRebase = this.fpsScheduler.fpsThrottle(this.rebase)\n\n\t\tif (typeof window !== 'undefined') {\n\t\t\t;(window as any).tlsync = this\n\t\t}\n\t\tthis.store = config.store\n\t\tthis.socket = config.socket\n\t\tthis.onAfterConnect = config.onAfterConnect\n\t\tthis.onCustomMessageReceived = config.onCustomMessageReceived\n\n\t\tlet didLoad = false\n\n\t\tthis.presenceState = config.presence\n\t\tthis.presenceMode = config.presenceMode\n\n\t\tthis.disposables.push(\n\t\t\t// when local 'user' changes are made, send them to the server\n\t\t\t// or stash them locally in offline mode\n\t\t\tthis.store.listen(\n\t\t\t\t({ changes }) => {\n\t\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\t\tthis.debug('received store changes', { changes })\n\t\t\t\t\tthis.push(changes)\n\t\t\t\t},\n\t\t\t\t{ source: 'user', scope: 'document' }\n\t\t\t),\n\t\t\t// when the server sends us events, handle them\n\t\t\tthis.socket.onReceiveMessage((msg) => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('received message from server', msg)\n\t\t\t\tthis.handleServerEvent(msg)\n\t\t\t\t// the first time we receive a message from the server, we should trigger\n\n\t\t\t\t// one of the load callbacks\n\t\t\t\tif (!didLoad) {\n\t\t\t\t\tdidLoad = true\n\t\t\t\t\tconfig.onLoad(this)\n\t\t\t\t}\n\t\t\t}),\n\t\t\t// handle switching between online and offline\n\t\t\tthis.socket.onStatusChange((ev) => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('socket status changed', ev.status)\n\t\t\t\tif (ev.status === 'online') {\n\t\t\t\t\tthis.sendConnectMessage()\n\t\t\t\t} else {\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t\tif (ev.status === 'error') {\n\t\t\t\t\t\tdidLoad = true\n\t\t\t\t\t\tconfig.onSyncError(ev.reason)\n\t\t\t\t\t\tthis.close()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}),\n\t\t\t// Send a ping every PING_INTERVAL ms while online\n\t\t\tinterval(() => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('ping loop', { isConnectedToRoom: this.isConnectedToRoom })\n\t\t\t\tif (!this.isConnectedToRoom) return\n\t\t\t\ttry {\n\t\t\t\t\t// advance the marker only when the previous ping was answered: a half-open socket\n\t\t\t\t\t// that accepts sends but returns nothing must not keep refreshing its own alibi\n\t\t\t\t\tif (\n\t\t\t\t\t\tthis.firstUnansweredPingAt === null ||\n\t\t\t\t\t\tthis.lastServerInteractionTimestamp >= this.firstUnansweredPingAt\n\t\t\t\t\t) {\n\t\t\t\t\t\tthis.firstUnansweredPingAt = Date.now()\n\t\t\t\t\t}\n\t\t\t\t\tthis.socket.sendMessage({ type: 'ping' })\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.warn('ping failed, resetting', error)\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t}\n\t\t\t}, PING_INTERVAL),\n\t\t\t// Check the server connection health, reset the connection if needed\n\t\t\tinterval(() => {\n\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\tthis.debug('health check loop', { isConnectedToRoom: this.isConnectedToRoom })\n\t\t\t\tif (!this.isConnectedToRoom) return\n\t\t\t\tconst timeSinceLastServerInteraction = Date.now() - this.lastServerInteractionTimestamp\n\n\t\t\t\tif (\n\t\t\t\t\ttimeSinceLastServerInteraction <\n\t\t\t\t\tMAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION\n\t\t\t\t) {\n\t\t\t\t\tthis.debug('health check passed', { timeSinceLastServerInteraction })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Silence alone is not evidence of a dead socket: in a throttled hidden tab our own ping\n\t\t\t\t// loop stops, so the server had nothing to answer. Only reset on an unanswered ping.\n\t\t\t\tconst firstUnansweredPingAt = this.firstUnansweredPingAt\n\t\t\t\tconst pingOverdue =\n\t\t\t\t\tfirstUnansweredPingAt !== null &&\n\t\t\t\t\tthis.lastServerInteractionTimestamp < firstUnansweredPingAt &&\n\t\t\t\t\tDate.now() - firstUnansweredPingAt >= PONG_TIMEOUT\n\t\t\t\tif (!pingOverdue) {\n\t\t\t\t\tthis.debug('health check stale but no overdue ping', { timeSinceLastServerInteraction })\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// A frozen tab can deliver a queued pong only after these catch-up ticks run, so one\n\t\t\t\t// spurious reset on unfreeze is still possible \u2014 rare, and the reconnect is clean.\n\t\t\t\tconsole.warn(`Haven't heard from the server in a while, resetting connection...`, {\n\t\t\t\t\ttimeSinceLastServerInteraction,\n\t\t\t\t\tpingOutstandingMs: Date.now() - firstUnansweredPingAt!,\n\t\t\t\t})\n\t\t\t\tthis.resetConnection()\n\t\t\t}, PING_INTERVAL * 2)\n\t\t)\n\n\t\tif (this.presenceState) {\n\t\t\tthis.disposables.push(\n\t\t\t\treact('pushPresence', () => {\n\t\t\t\t\tif (this.didCancel?.()) return this.close()\n\t\t\t\t\tconst mode = this.presenceMode?.get()\n\t\t\t\t\tthis.fpsScheduler.updateTargetFps(this.getSyncFps())\n\t\t\t\t\tif (mode !== 'full') return\n\t\t\t\t\tthis.pushPresence(this.presenceState!.get())\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\n\t\t// if the socket is already online before this client was instantiated\n\t\t// then we should send a connect message right away\n\t\tif (this.socket.connectionStatus === 'online') {\n\t\t\tthis.sendConnectMessage()\n\t\t}\n\t}\n\n\t/** @internal */\n\tlatestConnectRequestId: string | null = null\n\n\t/**\n\t * This is the first message that is sent over a newly established socket connection. And we need\n\t * to wait for the response before this client can be used.\n\t */\n\tprivate sendConnectMessage() {\n\t\tif (this.isConnectedToRoom) {\n\t\t\tconsole.error('sendConnectMessage called while already connected')\n\t\t\treturn\n\t\t}\n\t\tthis.debug('sending connect message')\n\t\tthis.latestConnectRequestId = uniqueId()\n\t\tthis.socket.sendMessage({\n\t\t\ttype: 'connect',\n\t\t\tconnectRequestId: this.latestConnectRequestId,\n\t\t\tschema: this.store.schema.serialize(),\n\t\t\tprotocolVersion: getTlsyncProtocolVersion(),\n\t\t\tlastServerClock: this.lastServerClock,\n\t\t})\n\t}\n\n\t/** Switch to offline mode */\n\tprivate resetConnection(hard = false) {\n\t\tthis.debug('resetting connection')\n\t\tif (hard) {\n\t\t\tthis.lastServerClock = 0\n\t\t}\n\t\t// kill all presence state\n\t\tconst keys = Object.keys(this.store.serialize('presence')) as any\n\t\tif (keys.length > 0) {\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\tthis.store.remove(keys)\n\t\t\t})\n\t\t}\n\t\tthis.lastPushedPresenceState = null\n\t\tthis.isConnectedToRoom = false\n\t\tthis.firstUnansweredPingAt = null\n\t\tthis.pendingPushRequests = []\n\t\tthis.incomingDiffBuffer = []\n\t\tthis.unsentChanges.nextDiff = undefined\n\t\tthis.unsentChanges.nextPresence = undefined\n\t\tif (this.socket.connectionStatus === 'online') {\n\t\t\tthis.socket.restart()\n\t\t}\n\t}\n\n\t/**\n\t * Invoked when the socket connection comes online, either for the first time or as the result of\n\t * a reconnect. The goal is to rebase on the server's state and fire off a new push request for\n\t * any local changes that were made while offline.\n\t */\n\tprivate didReconnect(event: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) {\n\t\tthis.debug('did reconnect', event)\n\t\tif (event.connectRequestId !== this.latestConnectRequestId) {\n\t\t\t// ignore connect events for old connect requests\n\t\t\treturn\n\t\t}\n\t\tthis.latestConnectRequestId = null\n\n\t\tif (this.isConnectedToRoom) {\n\t\t\tconsole.error('didReconnect called while already connected')\n\t\t\tthis.resetConnection(true)\n\t\t\treturn\n\t\t}\n\t\tif (this.pendingPushRequests.length > 0) {\n\t\t\tconsole.error('pendingPushRequests should already be empty when we reconnect')\n\t\t\tthis.resetConnection(true)\n\t\t\treturn\n\t\t}\n\t\t// at the end of this process we want to have at most one pending push request\n\t\t// based on anything inside this.speculativeChanges\n\t\ttransact(() => {\n\t\t\t// Now our goal is to rebase on the server's state.\n\t\t\t// This means wiping away any peer presence data, which the server will replace in full on every connect.\n\t\t\t// If the server does not have enough history to give us a partial document state hydration we will\n\t\t\t// also need to wipe away all of our document state before hydrating with the server's state from scratch.\n\t\t\tconst stashedChanges = this.speculativeChanges\n\t\t\tthis.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any }\n\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\t// gather records to delete in a NetworkDiff\n\t\t\t\tconst wipeDiff: NetworkDiff<R> = {}\n\t\t\t\tconst wipeAll = event.hydrationType === 'wipe_all'\n\t\t\t\tif (!wipeAll) {\n\t\t\t\t\t// if we're only wiping presence data, undo the speculative changes first\n\t\t\t\t\tthis.store.applyDiff(reverseRecordsDiff(stashedChanges), { runCallbacks: false })\n\t\t\t\t}\n\n\t\t\t\t// now wipe all presence data and, if needed, all document data\n\t\t\t\tfor (const [id, record] of objectMapEntries(this.store.serialize('all'))) {\n\t\t\t\t\tif (\n\t\t\t\t\t\t(wipeAll && this.store.scopedTypes.document.has(record.typeName)) ||\n\t\t\t\t\t\trecord.typeName === this.presenceType\n\t\t\t\t\t) {\n\t\t\t\t\t\twipeDiff[id] = [RecordOpType.Remove]\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// then apply the upstream changes\n\t\t\t\tthis.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true)\n\n\t\t\t\tthis.isConnectedToRoom = true\n\n\t\t\t\t// now re-apply the speculative changes creating a new push request with the\n\t\t\t\t// appropriate diff\n\t\t\t\tconst networkDiff = getNetworkDiff(stashedChanges)\n\t\t\t\tif (!networkDiff) return\n\t\t\t\tconst speculativeChanges = this.store.filterChangesByScope(\n\t\t\t\t\tthis.store.extractingChanges(() => {\n\t\t\t\t\t\tthis.applyNetworkDiff(networkDiff, true)\n\t\t\t\t\t}),\n\t\t\t\t\t'document'\n\t\t\t\t)\n\t\t\t\tif (speculativeChanges) this.push(speculativeChanges)\n\t\t\t})\n\n\t\t\t// this.isConnectedToRoom = true\n\t\t\t// this.store.applyDiff(stashedChanges, false)\n\n\t\t\tthis.onAfterConnect?.(this, {\n\t\t\t\tisReadonly: event.isReadonly,\n\t\t\t\tobjectAccess: event.objectAccess ?? 'write',\n\t\t\t})\n\t\t\tconst presence = this.presenceState?.get()\n\t\t\tif (presence) {\n\t\t\t\tthis.pushPresence(presence)\n\t\t\t}\n\t\t})\n\n\t\tthis.lastServerClock = event.serverClock\n\t}\n\n\tprivate incomingDiffBuffer: TLSocketServerSentDataEvent<R>[] = []\n\n\t/** Handle events received from the server */\n\tprivate handleServerEvent(event: TLSocketServerSentEvent<R>) {\n\t\tthis.debug('received server event', event)\n\t\tthis.lastServerInteractionTimestamp = Date.now()\n\t\t// always update the lastServerClock when it is present\n\t\tswitch (event.type) {\n\t\t\tcase 'connect':\n\t\t\t\tthis.didReconnect(event)\n\t\t\t\tbreak\n\t\t\t// legacy v4 events\n\t\t\tcase 'patch':\n\t\t\tcase 'push_result':\n\t\t\t\tif (!this.isConnectedToRoom) break\n\t\t\t\tthis.incomingDiffBuffer.push(event)\n\t\t\t\tthis.scheduleRebase()\n\t\t\t\tbreak\n\t\t\tcase 'data':\n\t\t\t\t// wait for a connect to succeed before processing more events\n\t\t\t\tif (!this.isConnectedToRoom) break\n\t\t\t\tthis.incomingDiffBuffer.push(...event.data)\n\t\t\t\tthis.scheduleRebase()\n\t\t\t\tbreak\n\t\t\tcase 'incompatibility_error':\n\t\t\t\t// legacy unrecoverable errors\n\t\t\t\tconsole.error('incompatibility error is legacy and should no longer be sent by the server')\n\t\t\t\tbreak\n\t\t\tcase 'pong':\n\t\t\t\t// noop, we only use ping/pong to set lastSeverInteractionTimestamp\n\t\t\t\tbreak\n\t\t\tcase 'custom':\n\t\t\t\tthis.onCustomMessageReceived?.call(null, event.data)\n\t\t\t\tbreak\n\n\t\t\tdefault:\n\t\t\t\texhaustiveSwitchError(event)\n\t\t}\n\t}\n\n\t/**\n\t * Closes the sync client and cleans up all resources.\n\t *\n\t * Call this method when you no longer need the sync client to prevent\n\t * memory leaks and close the WebSocket connection. After calling close(),\n\t * the client cannot be reused.\n\t *\n\t * @example\n\t * ```ts\n\t * // Clean shutdown\n\t * syncClient.close()\n\t * ```\n\t */\n\tclose() {\n\t\tthis.debug('closing')\n\t\tthis.disposables.forEach((dispose) => dispose())\n\t\tthis.sendUnsentChanges.cancel?.()\n\t\tthis.scheduleRebase.cancel?.()\n\t\tif (typeof window !== 'undefined' && (window as any).tlsync === this) {\n\t\t\tdelete (window as any).tlsync\n\t\t}\n\t}\n\n\tprivate lastPushedPresenceState: R | null = null\n\n\tprivate pushPresence(nextPresence: R | null) {\n\t\t// make sure we push any document changes first\n\t\tthis.store._flushHistory()\n\n\t\tif (!this.isConnectedToRoom) {\n\t\t\t// if we're offline, don't do anything\n\t\t\treturn\n\t\t}\n\n\t\tthis.unsentChanges.nextPresence = nextPresence\n\t\tthis.sendUnsentChanges()\n\t}\n\n\t/** Push a change to the server, or stash it locally if we're offline */\n\tprivate push(change: RecordsDiff<any>) {\n\t\tthis.debug('push', change)\n\t\tsquashRecordDiffsMutable(this.speculativeChanges, [change])\n\t\t// in offline mode, we only accumulate in speculativeChanges\n\t\tif (!this.isConnectedToRoom) return\n\t\tif (!this.unsentChanges.nextDiff) {\n\t\t\tthis.unsentChanges.nextDiff = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\t}\n\t\t// records are immutable, so sharing their references with `change` is fine \u2014 the\n\t\t// squash gives nextDiff its own containers and tuples without deep-cloning records\n\t\tsquashRecordDiffsMutable(this.unsentChanges.nextDiff, [change])\n\t\tthis.sendUnsentChanges()\n\t}\n\n\t/** Get the target FPS for network operations based on presence mode */\n\tprivate getSyncFps(): number {\n\t\treturn this.presenceMode?.get() === 'solo' ? SOLO_MODE_FPS : COLLABORATIVE_MODE_FPS\n\t}\n\n\t/**\n\t * Applies a 'network' diff to the store this does value-based equality checking so that if the\n\t * data is the same (as opposed to merely identical with ===), then no change is made and no\n\t * changes will be propagated back to store listeners\n\t */\n\tprivate applyNetworkDiff(diff: NetworkDiff<R>, runCallbacks: boolean) {\n\t\tthis.debug('applyNetworkDiff', diff)\n\t\tconst changes: RecordsDiff<R> = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\ttype k = keyof typeof changes.updated\n\t\tlet hasChanges = false\n\t\tfor (const [id, op] of objectMapEntries(diff)) {\n\t\t\tif (op[0] === RecordOpType.Put) {\n\t\t\t\tconst existing = this.store.get(id as RecordId<any>)\n\t\t\t\tif (existing && !isEqual(existing, op[1])) {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.updated[id as k] = [existing, op[1]]\n\t\t\t\t} else {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.added[id as k] = op[1]\n\t\t\t\t}\n\t\t\t} else if (op[0] === RecordOpType.Patch) {\n\t\t\t\tconst record = this.store.get(id as RecordId<any>)\n\t\t\t\tif (!record) {\n\t\t\t\t\t// the record was removed upstream\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tconst patched = applyObjectDiff(record, op[1])\n\t\t\t\thasChanges = true\n\t\t\t\tchanges.updated[id as k] = [record, patched]\n\t\t\t} else if (op[0] === RecordOpType.Remove) {\n\t\t\t\tif (this.store.has(id as RecordId<any>)) {\n\t\t\t\t\thasChanges = true\n\t\t\t\t\tchanges.removed[id as k] = this.store.get(id as RecordId<any>)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (hasChanges) {\n\t\t\tthis.store.applyDiff(changes, { runCallbacks })\n\t\t}\n\t}\n\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\tprivate rebase = () => {\n\t\t// need to make sure that our speculative changes are in sync with the actual store instance before\n\t\t// proceeding, to avoid inconsistency bugs.\n\t\tthis.store._flushHistory()\n\t\tif (this.incomingDiffBuffer.length === 0) return\n\n\t\tconst diffs = this.incomingDiffBuffer\n\t\tthis.incomingDiffBuffer = []\n\n\t\ttry {\n\t\t\tthis.store.mergeRemoteChanges(() => {\n\t\t\t\t// first undo speculative changes\n\t\t\t\tthis.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), { runCallbacks: false })\n\n\t\t\t\t// then apply network diffs on top of known-to-be-synced data\n\t\t\t\tfor (const diff of diffs) {\n\t\t\t\t\tif (diff.type === 'patch') {\n\t\t\t\t\t\tthis.applyNetworkDiff(diff.diff, true)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// handling push_result\n\t\t\t\t\tif (this.pendingPushRequests.length === 0) {\n\t\t\t\t\t\tthrow new Error('Received push_result but there are no pending push requests')\n\t\t\t\t\t}\n\t\t\t\t\tif (this.pendingPushRequests[0].clientClock !== diff.clientClock) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'Received push_result for a push request that is not at the front of the queue'\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tif (diff.action === 'discard') {\n\t\t\t\t\t\tthis.pendingPushRequests.shift()\n\t\t\t\t\t} else if (diff.action === 'commit') {\n\t\t\t\t\t\tconst request = this.pendingPushRequests.shift()!\n\t\t\t\t\t\tif ('diff' in request && request.diff) {\n\t\t\t\t\t\t\tthis.applyNetworkDiff(request.diff, true)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.applyNetworkDiff(diff.action.rebaseWithDiff, true)\n\t\t\t\t\t\tthis.pendingPushRequests.shift()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// update the speculative diff while re-applying pending changes\n\t\t\t\ttry {\n\t\t\t\t\tthis.speculativeChanges = this.store.extractingChanges(() => {\n\t\t\t\t\t\tfor (const request of this.pendingPushRequests) {\n\t\t\t\t\t\t\tif (!('diff' in request) || !request.diff) continue\n\t\t\t\t\t\t\tthis.applyNetworkDiff(request.diff, true)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!this.unsentChanges.nextDiff) return\n\t\t\t\t\t\tconst diff = getNetworkDiff(this.unsentChanges.nextDiff)\n\t\t\t\t\t\tif (!diff) return\n\t\t\t\t\t\tthis.applyNetworkDiff(diff, true)\n\t\t\t\t\t})\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.error(e)\n\t\t\t\t\t// throw away the speculative changes and start over\n\t\t\t\t\tthis.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any }\n\t\t\t\t\tthis.resetConnection()\n\t\t\t\t}\n\t\t\t})\n\t\t\tthis.lastServerClock = diffs.at(-1)?.serverClock ?? this.lastServerClock\n\t\t} catch (e) {\n\t\t\tconsole.error(e)\n\t\t\tthis.store.ensureStoreIsUsable()\n\t\t\tthis.resetConnection()\n\t\t}\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -19,12 +19,23 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  var chunk_exports = {};
20
20
  __export(chunk_exports, {
21
21
  JsonChunkAssembler: () => JsonChunkAssembler,
22
+ MAX_ASSEMBLED_MESSAGE_CHARS: () => MAX_ASSEMBLED_MESSAGE_CHARS,
23
+ MAX_CHUNK_COUNT: () => MAX_CHUNK_COUNT,
24
+ MessageTooLargeError: () => MessageTooLargeError,
22
25
  chunk: () => chunk
23
26
  });
24
27
  module.exports = __toCommonJS(chunk_exports);
25
28
  const MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024;
26
29
  const MAX_BYTES_PER_CHAR = 4;
27
30
  const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR;
31
+ const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024;
32
+ const MAX_CHUNK_COUNT = 1e5;
33
+ class MessageTooLargeError extends Error {
34
+ constructor(chars) {
35
+ super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`);
36
+ this.name = "MessageTooLargeError";
37
+ }
38
+ }
28
39
  function chunk(msg, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
29
40
  if (msg.length < maxSafeMessageSize) {
30
41
  return [msg];
@@ -65,7 +76,8 @@ class JsonChunkAssembler {
65
76
  * @param msg - The message to process, either JSON or chunk format
66
77
  * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
67
78
  * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
68
- * - `\{ error: Error \}` - Parse error or invalid chunk sequence
79
+ * - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
80
+ * MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
69
81
  * - `null` - Chunk received but more chunks expected
70
82
  *
71
83
  * @example
@@ -98,17 +110,28 @@ class JsonChunkAssembler {
98
110
  const numChunksRemaining = Number(match[1]);
99
111
  const data = match[2];
100
112
  if (this.state === "idle") {
113
+ const totalChunks = numChunksRemaining + 1;
114
+ if (totalChunks > MAX_CHUNK_COUNT) {
115
+ return { error: new Error(`Too many chunks: ${totalChunks}`) };
116
+ }
101
117
  this.state = {
102
118
  chunksReceived: [data],
103
- totalChunks: numChunksRemaining + 1
119
+ totalChunks,
120
+ charsReceived: data.length
104
121
  };
105
122
  } else {
106
123
  this.state.chunksReceived.push(data);
124
+ this.state.charsReceived += data.length;
107
125
  if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
108
126
  this.state = "idle";
109
127
  return { error: new Error(`Chunks received in wrong order`) };
110
128
  }
111
129
  }
130
+ if (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {
131
+ const charsReceived = this.state.charsReceived;
132
+ this.state = "idle";
133
+ return { error: new MessageTooLargeError(charsReceived) };
134
+ }
112
135
  if (this.state.chunksReceived.length === this.state.totalChunks) {
113
136
  try {
114
137
  const stringified = this.state.chunksReceived.join("");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/chunk.ts"],
4
- "sourcesContent": ["// quarter of a megabyte, max possible utf-8 string size\n\n// cloudflare workers only accept messages of max 1mb\nconst MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024\n// utf-8 is max 4 bytes per char\nconst MAX_BYTES_PER_CHAR = 4\n\n// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte\nconst MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR\n\n/**\n * Splits a string into smaller chunks suitable for transmission over WebSockets.\n * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).\n * Each chunk is prefixed with a number indicating how many more chunks follow.\n *\n * @param msg - The string to split into chunks\n * @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding\n * @returns Array of chunked strings, each prefixed with \"\\{number\\}_\" where number indicates remaining chunks\n *\n * @example\n * ```ts\n * // Small message - returns as single chunk\n * chunk('hello world') // ['hello world']\n *\n * // Large message - splits into multiple chunks\n * chunk('very long message...', 10)\n * // ['2_very long', '1_ message', '0_...']\n * ```\n *\n * @internal\n */\nexport function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {\n\tif (msg.length < maxSafeMessageSize) {\n\t\treturn [msg]\n\t} else {\n\t\tconst chunks = []\n\t\tlet chunkNumber = 0\n\t\tlet offset = msg.length\n\t\twhile (offset > 0) {\n\t\t\tconst prefix = `${chunkNumber}_`\n\t\t\tlet chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)\n\t\t\t// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a\n\t\t\t// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently\n\t\t\t// carries corrupted text. Shrink by one so the pair moves whole into the preceding\n\t\t\t// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).\n\t\t\tif (\n\t\t\t\tchunkSize < offset &&\n\t\t\t\tisLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&\n\t\t\t\tisHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))\n\t\t\t) {\n\t\t\t\tchunkSize += chunkSize > 1 ? -1 : 1\n\t\t\t}\n\t\t\tchunks.unshift(prefix + msg.slice(offset - chunkSize, offset))\n\t\t\toffset -= chunkSize\n\t\t\tchunkNumber++\n\t\t}\n\t\treturn chunks\n\t}\n}\n\nfunction isHighSurrogate(code: number) {\n\treturn code >= 0xd800 && code <= 0xdbff\n}\n\nfunction isLowSurrogate(code: number) {\n\treturn code >= 0xdc00 && code <= 0xdfff\n}\n\n// The 's' flag (dotAll) makes '.' match any character including line terminators\n// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word\nconst chunkRe = /^(\\d+)_(.*)$/s\n\n/**\n * Assembles chunked JSON messages back into complete objects.\n * Handles both regular JSON messages and chunked messages created by the chunk() function.\n * Maintains internal state to track partially received chunked messages.\n *\n * @example\n * ```ts\n * const assembler = new JsonChunkAssembler()\n *\n * // Handle regular JSON message\n * const result1 = assembler.handleMessage('{\"hello\": \"world\"}')\n * // Returns: { data: { hello: \"world\" }, stringified: '{\"hello\": \"world\"}' }\n *\n * // Handle chunked message\n * assembler.handleMessage('1_hello') // Returns: null (partial)\n * const result2 = assembler.handleMessage('0_ world')\n * // Returns: { data: \"hello world\", stringified: \"hello world\" }\n * ```\n *\n * @public\n */\nexport class JsonChunkAssembler {\n\t/**\n\t * Current assembly state - either 'idle' or tracking chunks being received\n\t */\n\tstate:\n\t\t| 'idle'\n\t\t| {\n\t\t\t\tchunksReceived: string[]\n\t\t\t\ttotalChunks: number\n\t\t } = 'idle'\n\n\t/**\n\t * Processes a single message, which can be either a complete JSON object or a chunk.\n\t * For complete JSON objects (starting with '\\{'), parses immediately.\n\t * For chunks (prefixed with \"\\{number\\}_\"), accumulates until all chunks received.\n\t *\n\t * @param msg - The message to process, either JSON or chunk format\n\t * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks\n\t * \t- `\\{ data: object, stringified: string \\}` - Successfully parsed complete message\n\t * \t- `\\{ error: Error \\}` - Parse error or invalid chunk sequence\n\t * \t- `null` - Chunk received but more chunks expected\n\t *\n\t * @example\n\t * ```ts\n\t * const assembler = new JsonChunkAssembler()\n\t *\n\t * // Complete JSON message\n\t * const result = assembler.handleMessage('{\"key\": \"value\"}')\n\t * if (result && 'data' in result) {\n\t * console.log(result.data) // { key: \"value\" }\n\t * }\n\t *\n\t * // Chunked message sequence\n\t * assembler.handleMessage('2_hel') // null - more chunks expected\n\t * assembler.handleMessage('1_lo ') // null - more chunks expected\n\t * assembler.handleMessage('0_wor') // { data: \"hello wor\", stringified: \"hello wor\" }\n\t * ```\n\t */\n\thandleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {\n\t\tif (msg.startsWith('{')) {\n\t\t\tconst error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')\n\t\t\tthis.state = 'idle'\n\t\t\treturn error ? { error } : { data: JSON.parse(msg), stringified: msg }\n\t\t} else {\n\t\t\tconst match = chunkRe.exec(msg)!\n\t\t\tif (!match) {\n\t\t\t\tthis.state = 'idle'\n\t\t\t\treturn { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) }\n\t\t\t}\n\t\t\tconst numChunksRemaining = Number(match[1])\n\t\t\tconst data = match[2]\n\n\t\t\tif (this.state === 'idle') {\n\t\t\t\tthis.state = {\n\t\t\t\t\tchunksReceived: [data],\n\t\t\t\t\ttotalChunks: numChunksRemaining + 1,\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.state.chunksReceived.push(data)\n\t\t\t\tif (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t\treturn { error: new Error(`Chunks received in wrong order`) }\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (this.state.chunksReceived.length === this.state.totalChunks) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stringified = this.state.chunksReceived.join('')\n\t\t\t\t\tconst data = JSON.parse(stringified)\n\t\t\t\t\treturn { data, stringified }\n\t\t\t\t} catch (e) {\n\t\t\t\t\treturn { error: e as Error }\n\t\t\t\t} finally {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null\n\t\t}\n\t}\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,MAAM,qCAAqC,OAAO;AAElD,MAAM,qBAAqB;AAG3B,MAAM,wBAAwB,qCAAqC;AAuB5D,SAAS,MAAM,KAAa,qBAAqB,uBAAuB;AAC9E,MAAI,IAAI,SAAS,oBAAoB;AACpC,WAAO,CAAC,GAAG;AAAA,EACZ,OAAO;AACN,UAAM,SAAS,CAAC;AAChB,QAAI,cAAc;AAClB,QAAI,SAAS,IAAI;AACjB,WAAO,SAAS,GAAG;AAClB,YAAM,SAAS,GAAG,WAAW;AAC7B,UAAI,YAAY,KAAK,IAAI,KAAK,IAAI,qBAAqB,OAAO,QAAQ,MAAM,GAAG,CAAC;AAKhF,UACC,YAAY,UACZ,eAAe,IAAI,WAAW,SAAS,SAAS,CAAC,KACjD,gBAAgB,IAAI,WAAW,SAAS,YAAY,CAAC,CAAC,GACrD;AACD,qBAAa,YAAY,IAAI,KAAK;AAAA,MACnC;AACA,aAAO,QAAQ,SAAS,IAAI,MAAM,SAAS,WAAW,MAAM,CAAC;AAC7D,gBAAU;AACV;AAAA,IACD;AACA,WAAO;AAAA,EACR;AACD;AAEA,SAAS,gBAAgB,MAAc;AACtC,SAAO,QAAQ,SAAU,QAAQ;AAClC;AAEA,SAAS,eAAe,MAAc;AACrC,SAAO,QAAQ,SAAU,QAAQ;AAClC;AAIA,MAAM,UAAU;AAuBT,MAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA,EAI/B,QAKO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA6BP,cAAc,KAA8E;AAC3F,QAAI,IAAI,WAAW,GAAG,GAAG;AACxB,YAAM,QAAQ,KAAK,UAAU,SAAS,SAAY,IAAI,MAAM,8BAA8B;AAC1F,WAAK,QAAQ;AACb,aAAO,QAAQ,EAAE,MAAM,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,GAAG,aAAa,IAAI;AAAA,IACtE,OAAO;AACN,YAAM,QAAQ,QAAQ,KAAK,GAAG;AAC9B,UAAI,CAAC,OAAO;AACX,aAAK,QAAQ;AACb,eAAO,EAAE,OAAO,IAAI,MAAM,oBAAoB,KAAK,UAAU,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,EAAE;AAAA,MACzF;AACA,YAAM,qBAAqB,OAAO,MAAM,CAAC,CAAC;AAC1C,YAAM,OAAO,MAAM,CAAC;AAEpB,UAAI,KAAK,UAAU,QAAQ;AAC1B,aAAK,QAAQ;AAAA,UACZ,gBAAgB,CAAC,IAAI;AAAA,UACrB,aAAa,qBAAqB;AAAA,QACnC;AAAA,MACD,OAAO;AACN,aAAK,MAAM,eAAe,KAAK,IAAI;AACnC,YAAI,uBAAuB,KAAK,MAAM,cAAc,KAAK,MAAM,eAAe,QAAQ;AACrF,eAAK,QAAQ;AACb,iBAAO,EAAE,OAAO,IAAI,MAAM,gCAAgC,EAAE;AAAA,QAC7D;AAAA,MACD;AACA,UAAI,KAAK,MAAM,eAAe,WAAW,KAAK,MAAM,aAAa;AAChE,YAAI;AACH,gBAAM,cAAc,KAAK,MAAM,eAAe,KAAK,EAAE;AACrD,gBAAMA,QAAO,KAAK,MAAM,WAAW;AACnC,iBAAO,EAAE,MAAAA,OAAM,YAAY;AAAA,QAC5B,SAAS,GAAG;AACX,iBAAO,EAAE,OAAO,EAAW;AAAA,QAC5B,UAAE;AACD,eAAK,QAAQ;AAAA,QACd;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;",
4
+ "sourcesContent": ["// quarter of a megabyte, max possible utf-8 string size\n\n// cloudflare workers only accept messages of max 1mb\nconst MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024\n// utf-8 is max 4 bytes per char\nconst MAX_BYTES_PER_CHAR = 4\n\n// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte\nconst MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR\n\n// JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender\n// decides how many that is \u2014 the count is just a prefix on the first chunk. Nothing in the\n// protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection\n// can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.\n//\n// The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the\n// 32MB of retained string data a room can afford to hold per session and stay inside a Durable\n// Object's 128MB instance limit with room for the document itself. It is far above anything\n// chunk() produces for a real document push. The character cap is the real bound; the chunk-count\n// cap only rejects an absurd declared count on arrival instead of waiting for the characters to\n// accumulate, and is loose enough not to constrain a sender that chunks more finely than the\n// default.\nexport const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024\nexport const MAX_CHUNK_COUNT = 100_000\n\n/**\n * Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.\n * Distinct from the other assembly errors because it is fatal: retrying sends the same\n * oversized message, so the session is rejected rather than reset.\n *\n * @internal\n */\nexport class MessageTooLargeError extends Error {\n\tconstructor(chars: number) {\n\t\tsuper(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)\n\t\tthis.name = 'MessageTooLargeError'\n\t}\n}\n\n/**\n * Splits a string into smaller chunks suitable for transmission over WebSockets.\n * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).\n * Each chunk is prefixed with a number indicating how many more chunks follow.\n *\n * @param msg - The string to split into chunks\n * @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding\n * @returns Array of chunked strings, each prefixed with \"\\{number\\}_\" where number indicates remaining chunks\n *\n * @example\n * ```ts\n * // Small message - returns as single chunk\n * chunk('hello world') // ['hello world']\n *\n * // Large message - splits into multiple chunks\n * chunk('very long message...', 10)\n * // ['2_very long', '1_ message', '0_...']\n * ```\n *\n * @internal\n */\nexport function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {\n\tif (msg.length < maxSafeMessageSize) {\n\t\treturn [msg]\n\t} else {\n\t\tconst chunks = []\n\t\tlet chunkNumber = 0\n\t\tlet offset = msg.length\n\t\twhile (offset > 0) {\n\t\t\tconst prefix = `${chunkNumber}_`\n\t\t\tlet chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)\n\t\t\t// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a\n\t\t\t// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently\n\t\t\t// carries corrupted text. Shrink by one so the pair moves whole into the preceding\n\t\t\t// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).\n\t\t\tif (\n\t\t\t\tchunkSize < offset &&\n\t\t\t\tisLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&\n\t\t\t\tisHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))\n\t\t\t) {\n\t\t\t\tchunkSize += chunkSize > 1 ? -1 : 1\n\t\t\t}\n\t\t\tchunks.unshift(prefix + msg.slice(offset - chunkSize, offset))\n\t\t\toffset -= chunkSize\n\t\t\tchunkNumber++\n\t\t}\n\t\treturn chunks\n\t}\n}\n\nfunction isHighSurrogate(code: number) {\n\treturn code >= 0xd800 && code <= 0xdbff\n}\n\nfunction isLowSurrogate(code: number) {\n\treturn code >= 0xdc00 && code <= 0xdfff\n}\n\n// The 's' flag (dotAll) makes '.' match any character including line terminators\n// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word\nconst chunkRe = /^(\\d+)_(.*)$/s\n\n/**\n * Assembles chunked JSON messages back into complete objects.\n * Handles both regular JSON messages and chunked messages created by the chunk() function.\n * Maintains internal state to track partially received chunked messages.\n *\n * @example\n * ```ts\n * const assembler = new JsonChunkAssembler()\n *\n * // Handle regular JSON message\n * const result1 = assembler.handleMessage('{\"hello\": \"world\"}')\n * // Returns: { data: { hello: \"world\" }, stringified: '{\"hello\": \"world\"}' }\n *\n * // Handle chunked message\n * assembler.handleMessage('1_hello') // Returns: null (partial)\n * const result2 = assembler.handleMessage('0_ world')\n * // Returns: { data: \"hello world\", stringified: \"hello world\" }\n * ```\n *\n * @public\n */\nexport class JsonChunkAssembler {\n\t/**\n\t * Current assembly state - either 'idle' or tracking chunks being received\n\t */\n\tstate:\n\t\t| 'idle'\n\t\t| {\n\t\t\t\tchunksReceived: string[]\n\t\t\t\ttotalChunks: number\n\t\t\t\tcharsReceived: number\n\t\t } = 'idle'\n\n\t/**\n\t * Processes a single message, which can be either a complete JSON object or a chunk.\n\t * For complete JSON objects (starting with '\\{'), parses immediately.\n\t * For chunks (prefixed with \"\\{number\\}_\"), accumulates until all chunks received.\n\t *\n\t * @param msg - The message to process, either JSON or chunk format\n\t * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks\n\t * \t- `\\{ data: object, stringified: string \\}` - Successfully parsed complete message\n\t * \t- `\\{ error: Error \\}` - Parse error, invalid chunk sequence, or a message exceeding\n\t * \t MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT\n\t * \t- `null` - Chunk received but more chunks expected\n\t *\n\t * @example\n\t * ```ts\n\t * const assembler = new JsonChunkAssembler()\n\t *\n\t * // Complete JSON message\n\t * const result = assembler.handleMessage('{\"key\": \"value\"}')\n\t * if (result && 'data' in result) {\n\t * console.log(result.data) // { key: \"value\" }\n\t * }\n\t *\n\t * // Chunked message sequence\n\t * assembler.handleMessage('2_hel') // null - more chunks expected\n\t * assembler.handleMessage('1_lo ') // null - more chunks expected\n\t * assembler.handleMessage('0_wor') // { data: \"hello wor\", stringified: \"hello wor\" }\n\t * ```\n\t */\n\thandleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {\n\t\tif (msg.startsWith('{')) {\n\t\t\tconst error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')\n\t\t\tthis.state = 'idle'\n\t\t\treturn error ? { error } : { data: JSON.parse(msg), stringified: msg }\n\t\t} else {\n\t\t\tconst match = chunkRe.exec(msg)!\n\t\t\tif (!match) {\n\t\t\t\tthis.state = 'idle'\n\t\t\t\treturn { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) }\n\t\t\t}\n\t\t\tconst numChunksRemaining = Number(match[1])\n\t\t\tconst data = match[2]\n\n\t\t\tif (this.state === 'idle') {\n\t\t\t\tconst totalChunks = numChunksRemaining + 1\n\t\t\t\tif (totalChunks > MAX_CHUNK_COUNT) {\n\t\t\t\t\t// Nothing has been buffered yet, so the state is already idle.\n\t\t\t\t\treturn { error: new Error(`Too many chunks: ${totalChunks}`) }\n\t\t\t\t}\n\t\t\t\tthis.state = {\n\t\t\t\t\tchunksReceived: [data],\n\t\t\t\t\ttotalChunks,\n\t\t\t\t\tcharsReceived: data.length,\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.state.chunksReceived.push(data)\n\t\t\t\tthis.state.charsReceived += data.length\n\t\t\t\tif (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t\treturn { error: new Error(`Chunks received in wrong order`) }\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {\n\t\t\t\t// Drop what was buffered rather than keeping it until the socket closes.\n\t\t\t\tconst charsReceived = this.state.charsReceived\n\t\t\t\tthis.state = 'idle'\n\t\t\t\treturn { error: new MessageTooLargeError(charsReceived) }\n\t\t\t}\n\t\t\tif (this.state.chunksReceived.length === this.state.totalChunks) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stringified = this.state.chunksReceived.join('')\n\t\t\t\t\tconst data = JSON.parse(stringified)\n\t\t\t\t\treturn { data, stringified }\n\t\t\t\t} catch (e) {\n\t\t\t\t\treturn { error: e as Error }\n\t\t\t\t} finally {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null\n\t\t}\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["data"]
7
7
  }
@@ -158,6 +158,7 @@ export declare class JsonChunkAssembler {
158
158
  * Current assembly state - either 'idle' or tracking chunks being received
159
159
  */
160
160
  state: 'idle' | {
161
+ charsReceived: number;
161
162
  chunksReceived: string[];
162
163
  totalChunks: number;
163
164
  };
@@ -169,7 +170,8 @@ export declare class JsonChunkAssembler {
169
170
  * @param msg - The message to process, either JSON or chunk format
170
171
  * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
171
172
  * - `\{ data: object, stringified: string \}` - Successfully parsed complete message
172
- * - `\{ error: Error \}` - Parse error or invalid chunk sequence
173
+ * - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
174
+ * MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
173
175
  * - `null` - Chunk received but more chunks expected
174
176
  *
175
177
  * @example
@@ -1618,6 +1620,8 @@ export declare const TLSyncErrorCloseEventCode: 4099;
1618
1620
  * @public
1619
1621
  */
1620
1622
  export declare const TLSyncErrorCloseEventReason: {
1623
+ /** A single message exceeded the size the server will assemble */
1624
+ readonly MESSAGE_TOO_LARGE: 'MESSAGE_TOO_LARGE';
1621
1625
  /** Client exceeded rate limits */
1622
1626
  readonly RATE_LIMITED: 'RATE_LIMITED';
1623
1627
  /** Client protocol version too old */
@@ -36,7 +36,7 @@ import {
36
36
  } from "./lib/TLSyncStorage.mjs";
37
37
  registerTldrawLibraryVersion(
38
38
  "@tldraw/sync-core",
39
- "5.5.0-canary.4fbbdcf5e86c",
39
+ "5.5.0-canary.5176de035caa",
40
40
  "esm"
41
41
  );
42
42
  export {
@@ -1,6 +1,6 @@
1
1
  import { atom } from "@tldraw/state";
2
2
  import { assert, warnOnce } from "@tldraw/utils";
3
- import { chunk } from "./chunk.mjs";
3
+ import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from "./chunk.mjs";
4
4
  import {
5
5
  TLSyncErrorCloseEventCode,
6
6
  TLSyncErrorCloseEventReason
@@ -182,7 +182,17 @@ class ClientWebSocketAdapter {
182
182
  assert(!this.isDisposed, "Tried to send message on a disposed socket");
183
183
  if (!this._ws) return;
184
184
  if (this.connectionStatus === "online") {
185
- const chunks = chunk(JSON.stringify(msg));
185
+ const stringified = JSON.stringify(msg);
186
+ if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
187
+ this._handleDisconnect(
188
+ "closed",
189
+ TLSyncErrorCloseEventCode,
190
+ true,
191
+ TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
192
+ );
193
+ return;
194
+ }
195
+ const chunks = chunk(stringified);
186
196
  for (const part of chunks) {
187
197
  this._ws.send(part);
188
198
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/ClientWebSocketAdapter.ts"],
4
- "sourcesContent": ["import { atom, Atom } from '@tldraw/state'\nimport { TLRecord } from '@tldraw/tlschema'\nimport { assert, warnOnce } from '@tldraw/utils'\nimport { chunk } from './chunk'\nimport { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'\nimport {\n\tTLPersistentClientSocket,\n\tTLPersistentClientSocketStatus,\n\tTLSocketStatusListener,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n} from './TLSyncClient'\n\nfunction listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {\n\ttarget.addEventListener(event, handler)\n\treturn () => {\n\t\ttarget.removeEventListener(event, handler)\n\t}\n}\n\nfunction debug(...args: any[]) {\n\t// @ts-ignore\n\tif (typeof window !== 'undefined' && window.__tldraw_socket_debug) {\n\t\tconst now = new Date()\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(\n\t\t\t`${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`,\n\t\t\t...args\n\t\t\t//, new Error().stack\n\t\t)\n\t}\n}\n\n// NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss\n// detection, for example by regularly pinging the server and .restart()ing\n// the connection when a number of pings goes unanswered. Without this mechanism,\n// we might not be able to detect the websocket connection going down in a timely manner\n// (it will probably time out on outgoing data packets at some point).\n//\n// This is by design. While the Websocket protocol specifies protocol-level pings,\n// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,\n// pings need to be implemented one level up, on the application API side, which for our\n// codebase means whatever code that uses ClientWebSocketAdapter.\n/**\n * A WebSocket adapter that provides persistent connection management for tldraw synchronization.\n * This adapter handles connection establishment, reconnection logic, and message routing between\n * the sync client and server. It implements automatic reconnection with exponential backoff\n * and supports connection loss detection.\n *\n * Note: This adapter requires users to implement their own connection loss detection (e.g., pings)\n * as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames.\n *\n * @internal\n * @example\n * ```ts\n * // Create a WebSocket adapter with connection URI\n * const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync')\n *\n * // Listen for connection status changes\n * adapter.onStatusChange((status) => {\n * console.log('Connection status:', status)\n * })\n *\n * // Listen for incoming messages\n * adapter.onReceiveMessage((message) => {\n * console.log('Received:', message)\n * })\n *\n * // Send a message when connected\n * if (adapter.connectionStatus === 'online') {\n * adapter.sendMessage({ type: 'ping' })\n * }\n * ```\n */\nexport class ClientWebSocketAdapter implements TLPersistentClientSocket<\n\tTLSocketClientSentEvent<TLRecord>,\n\tTLSocketServerSentEvent<TLRecord>\n> {\n\t_ws: WebSocket | null = null\n\n\tisDisposed = false\n\n\t/** @internal */\n\treadonly _reconnectManager: ReconnectManager\n\n\t/**\n\t * Permanently closes the WebSocket adapter and disposes of all resources.\n\t * Once closed, the adapter cannot be reused and should be discarded.\n\t * This method is idempotent - calling it multiple times has no additional effect.\n\t */\n\t// TODO: .close should be a project-wide interface with a common contract (.close()d thing\n\t// can only be garbage collected, and can't be used anymore)\n\tclose() {\n\t\tthis.isDisposed = true\n\t\tthis._reconnectManager.close()\n\t\t// orphan the socket before closing it (as _closeSocket does) so its onclose can't run\n\t\t// _handleDisconnect after disposal \u2014 that would notify listeners and schedule a reconnect\n\t\tconst ws = this._ws\n\t\tthis._ws = null\n\t\t// WebSocket.close() is idempotent\n\t\tws?.close()\n\t\tthis._connectionStatus.set('offline')\n\t}\n\n\t/**\n\t * Creates a new ClientWebSocketAdapter instance.\n\t *\n\t * @param getUri - Function that returns the WebSocket URI to connect to.\n\t * Can return a string directly or a Promise that resolves to a string.\n\t * This function is called each time a connection attempt is made,\n\t * allowing for dynamic URI generation (e.g., for authentication tokens).\n\t */\n\tconstructor(getUri: () => Promise<string> | string) {\n\t\tthis._reconnectManager = new ReconnectManager(this, getUri)\n\t}\n\n\tprivate _handleConnect() {\n\t\tdebug('handleConnect')\n\n\t\tthis._connectionStatus.set('online')\n\t\tthis.statusListeners.forEach((cb) => cb({ status: 'online' }))\n\n\t\tthis._reconnectManager.connected()\n\t}\n\n\tprivate _handleDisconnect(\n\t\treason: 'closed' | 'manual',\n\t\tcloseCode?: number,\n\t\tdidOpen?: boolean,\n\t\tcloseReason?: string\n\t) {\n\t\tcloseReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR\n\n\t\tdebug('handleDisconnect', {\n\t\t\tcurrentStatus: this.connectionStatus,\n\t\t\tcloseCode,\n\t\t\treason,\n\t\t})\n\n\t\tlet newStatus: 'offline' | 'error'\n\t\tswitch (reason) {\n\t\t\tcase 'closed':\n\t\t\t\tif (closeCode === TLSyncErrorCloseEventCode) {\n\t\t\t\t\tnewStatus = 'error'\n\t\t\t\t} else {\n\t\t\t\t\tnewStatus = 'offline'\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tcase 'manual':\n\t\t\t\tnewStatus = 'offline'\n\t\t\t\tbreak\n\t\t}\n\n\t\tif (closeCode === 1006 && !didOpen) {\n\t\t\twarnOnce(\n\t\t\t\t\"Could not open WebSocket connection. This might be because you're trying to load a URL that doesn't support websockets. Check the URL you're trying to connect to.\"\n\t\t\t)\n\t\t}\n\n\t\tif (\n\t\t\t// it the status changed\n\t\t\tthis.connectionStatus !== newStatus &&\n\t\t\t// ignore errors if we're already in the offline state\n\t\t\t!(newStatus === 'error' && this.connectionStatus === 'offline')\n\t\t) {\n\t\t\tthis._connectionStatus.set(newStatus)\n\t\t\tthis.statusListeners.forEach((cb) =>\n\t\t\t\tcb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus })\n\t\t\t)\n\t\t}\n\n\t\tthis._reconnectManager.disconnected()\n\t}\n\n\t_setNewSocket(ws: WebSocket) {\n\t\tassert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')\n\t\tassert(\n\t\t\tthis._ws === null ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSED ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSING,\n\t\t\t`Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`\n\t\t)\n\n\t\tlet didOpen = false\n\n\t\t// NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition\n\t\t// between CLOSING and CLOSED happens either after the closing handshake, or after a\n\t\t// timeout, but in either case those sockets don't need any special handling, the browser\n\t\t// will close them eventually. We just \"orphan\" such sockets and ignore their onclose/onerror.\n\t\tws.onopen = () => {\n\t\t\tdebug('ws.onopen')\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open\"\n\t\t\t)\n\t\t\tdidOpen = true\n\t\t\tthis._handleConnect()\n\t\t}\n\t\tws.onclose = (event: CloseEvent) => {\n\t\t\tdebug('ws.onclose', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed', event.code, didOpen, event.reason)\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onclose for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onerror = (event) => {\n\t\t\tdebug('ws.onerror', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed')\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onerror for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onmessage = (ev) => {\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages\"\n\t\t\t)\n\t\t\tlet parsed: TLSocketServerSentEvent<TLRecord>\n\t\t\ttry {\n\t\t\t\tparsed = JSON.parse(ev.data.toString())\n\t\t\t} catch {\n\t\t\t\t// A malformed message means the connection delivered corrupt data, so we can't trust\n\t\t\t\t// that we're still in sync. Dropping the message can silently desync the client (e.g. a\n\t\t\t\t// missed 'patch' is applied as if it never happened), so instead we restart the\n\t\t\t\t// connection. Reconnecting re-hydrates the store from the last known server clock and\n\t\t\t\t// brings us fully back in sync.\n\t\t\t\twarnOnce('Received malformed WebSocket message. Restarting the connection.')\n\t\t\t\tthis.restart()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.messageListeners.forEach((cb) => cb(parsed))\n\t\t}\n\n\t\tthis._ws = ws\n\t}\n\n\t_closeSocket() {\n\t\tif (this._ws === null) return\n\n\t\tthis._ws.close()\n\t\t// explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed\n\t\tthis._ws = null\n\t\tthis._handleDisconnect('manual')\n\t}\n\n\t_connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(\n\t\t'websocket connection status',\n\t\t'initial'\n\t)\n\n\t/**\n\t * Gets the current connection status of the WebSocket.\n\t *\n\t * @returns The current connection status: 'online', 'offline', or 'error'\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget connectionStatus(): TLPersistentClientSocketStatus {\n\t\tconst status = this._connectionStatus.get()\n\t\treturn status === 'initial' ? 'offline' : status\n\t}\n\n\t/**\n\t * Sends a message to the server through the WebSocket connection.\n\t * Messages are automatically chunked if they exceed size limits.\n\t *\n\t * @param msg - The message to send to the server\n\t *\n\t * @example\n\t * ```ts\n\t * adapter.sendMessage({\n\t * type: 'push',\n\t * diff: { 'shape:abc123': [2, { x: [1, 150] }] }\n\t * })\n\t * ```\n\t */\n\tsendMessage(msg: TLSocketClientSentEvent<TLRecord>) {\n\t\tassert(!this.isDisposed, 'Tried to send message on a disposed socket')\n\n\t\tif (!this._ws) return\n\t\tif (this.connectionStatus === 'online') {\n\t\t\tconst chunks = chunk(JSON.stringify(msg))\n\t\t\tfor (const part of chunks) {\n\t\t\t\tthis._ws.send(part)\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn('Tried to send message while ' + this.connectionStatus)\n\t\t}\n\t}\n\n\tprivate messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()\n\t/**\n\t * Registers a callback to handle incoming messages from the server.\n\t *\n\t * @param cb - Callback function that will be called with each received message\n\t * @returns A cleanup function to remove the message listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onReceiveMessage((message) => {\n\t * switch (message.type) {\n\t * case 'connect':\n\t * console.log('Connected to room')\n\t * break\n\t * case 'data':\n\t * console.log('Received data:', message.diff)\n\t * break\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {\n\t\tassert(!this.isDisposed, 'Tried to add message listener on a disposed socket')\n\n\t\tthis.messageListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.messageListeners.delete(cb)\n\t\t}\n\t}\n\n\tprivate statusListeners = new Set<TLSocketStatusListener>()\n\t/**\n\t * Registers a callback to handle connection status changes.\n\t *\n\t * @param cb - Callback function that will be called when the connection status changes\n\t * @returns A cleanup function to remove the status listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onStatusChange((status) => {\n\t * if (status.status === 'error') {\n\t * console.error('Connection error:', status.reason)\n\t * } else {\n\t * console.log('Status changed to:', status.status)\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonStatusChange(cb: TLSocketStatusListener) {\n\t\tassert(!this.isDisposed, 'Tried to add status listener on a disposed socket')\n\n\t\tthis.statusListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.statusListeners.delete(cb)\n\t\t}\n\t}\n\n\t/**\n\t * Manually restarts the WebSocket connection.\n\t * This closes the current connection (if any) and attempts to establish a new one.\n\t * Useful for implementing connection loss detection and recovery.\n\t *\n\t * @example\n\t * ```ts\n\t * // Restart connection after detecting it's stale\n\t * if (lastPongTime < Date.now() - 30000) {\n\t * adapter.restart()\n\t * }\n\t * ```\n\t */\n\trestart() {\n\t\tassert(!this.isDisposed, 'Tried to restart a disposed socket')\n\t\tdebug('restarting')\n\n\t\tthis._closeSocket()\n\t\tthis._reconnectManager.maybeReconnected()\n\t}\n}\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MIN_DELAY = 500\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MAX_DELAY = 2000\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab.\n *\n * @internal\n */\nexport const INACTIVE_MIN_DELAY = 1000\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * Set to 5 minutes to balance between maintaining sync and conserving resources.\n *\n * @internal\n */\nexport const INACTIVE_MAX_DELAY = 1000 * 60 * 5\n\n/**\n * Exponential backoff multiplier for calculating reconnection delays.\n * Each failed connection attempt increases the delay by this factor until max delay is reached.\n *\n * @internal\n */\nexport const DELAY_EXPONENT = 1.5\n\n/**\n * Maximum time in milliseconds to wait for a connection attempt before considering it failed.\n * This helps detect connections stuck in the CONNECTING state and retry with fresh attempts.\n *\n * @internal\n */\nexport const ATTEMPT_TIMEOUT = 1000\n\n/**\n * Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies.\n * This class handles connection attempts, tracks connection state, and implements exponential backoff\n * with different delays based on whether the browser tab is active or inactive.\n *\n * The ReconnectManager responds to various browser events like network status changes,\n * tab visibility changes, and connection events to optimize reconnection timing and\n * minimize unnecessary connection attempts.\n *\n * @internal\n *\n * @example\n * ```ts\n * const manager = new ReconnectManager(\n * socketAdapter,\n * () => 'ws://localhost:3000/sync'\n * )\n *\n * // Manager automatically handles:\n * // - Initial connection\n * // - Reconnection on disconnect\n * // - Exponential backoff on failures\n * // - Tab visibility-aware delays\n * // - Network status change responses\n * ```\n */\nexport class ReconnectManager {\n\tprivate isDisposed = false\n\tprivate disposables: (() => void)[] = [\n\t\t() => {\n\t\t\tif (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)\n\t\t\tif (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout)\n\t\t},\n\t]\n\tprivate reconnectTimeout: ReturnType<typeof setTimeout> | null = null\n\tprivate recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null\n\n\tprivate lastAttemptStart: number | null = null\n\tintendedDelay: number = ACTIVE_MIN_DELAY\n\tprivate state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'\n\n\t/**\n\t * Creates a new ReconnectManager instance.\n\t *\n\t * socketAdapter - The ClientWebSocketAdapter instance to manage\n\t * getUri - Function that returns the WebSocket URI for connection attempts\n\t */\n\tconstructor(\n\t\tprivate socketAdapter: ClientWebSocketAdapter,\n\t\tprivate getUri: () => Promise<string> | string\n\t) {\n\t\tthis.subscribeToReconnectHints()\n\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'offline', () => {\n\t\t\t\tdebug('window went offline')\n\t\t\t\t// On the one hand, 'offline' event is not really reliable; on the other, the only\n\t\t\t\t// alternative is to wait for pings not being delivered, which takes more than 20 seconds,\n\t\t\t\t// which means we won't see the ClientWebSocketAdapter status change for more than\n\t\t\t\t// 20 seconds after the tab goes offline. Our application layer must be resistent to\n\t\t\t\t// connection restart anyway, so we can just try to reconnect and see if\n\t\t\t\t// we're truly offline.\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t})\n\t\t)\n\n\t\tthis.state = 'pendingAttempt'\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.scheduleAttempt()\n\t}\n\n\tprivate subscribeToReconnectHints() {\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'online', () => {\n\t\t\t\tdebug('window went online')\n\t\t\t\tthis.maybeReconnected()\n\t\t\t}),\n\t\t\tlistenTo(document, 'visibilitychange', () => {\n\t\t\t\tif (!document.hidden) {\n\t\t\t\t\tdebug('document became visible')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t}\n\t\t\t})\n\t\t)\n\n\t\tif (Object.prototype.hasOwnProperty.call(navigator, 'connection')) {\n\t\t\tconst connection = (navigator as any)['connection'] as EventTarget\n\t\t\tthis.disposables.push(\n\t\t\t\tlistenTo(connection, 'change', () => {\n\t\t\t\t\tdebug('navigator.connection change')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate async scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tif (this.isDisposed) return\n\t\tdebug('scheduling a connection attempt')\n\n\t\tlet uri: string\n\t\ttry {\n\t\t\turi = await this.getUri()\n\t\t} catch (error) {\n\t\t\t// A failed auth-token fetch must retry instead of leaving us with no socket or timer.\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tconsole.error('Failed to get the websocket URI, retrying', error)\n\t\t\tthis.state = 'delay'\n\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)\n\t\t\treturn\n\t\t}\n\n\t\t// this can happen if the promise gets resolved too late\n\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\tassert(\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t'There should be no connection attempts while already connected'\n\t\t)\n\n\t\tthis.lastAttemptStart = Date.now()\n\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\tthis.state = 'pendingAttemptResult'\n\t}\n\n\tprivate getMaxDelay() {\n\t\treturn document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY\n\t}\n\n\tprivate getMinDelay() {\n\t\treturn document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY\n\t}\n\n\tprivate clearReconnectTimeout() {\n\t\tif (this.reconnectTimeout) {\n\t\t\tclearTimeout(this.reconnectTimeout)\n\t\t\tthis.reconnectTimeout = null\n\t\t}\n\t}\n\n\tprivate clearRecheckConnectingTimeout() {\n\t\tif (this.recheckConnectingTimeout) {\n\t\t\tclearTimeout(this.recheckConnectingTimeout)\n\t\t\tthis.recheckConnectingTimeout = null\n\t\t}\n\t}\n\n\t/**\n\t * Checks if reconnection should be attempted and initiates it if appropriate.\n\t * This method is called in response to network events, tab visibility changes,\n\t * and other hints that connectivity may have been restored.\n\t *\n\t * The method intelligently handles various connection states:\n\t * - Already connected: no action needed\n\t * - Currently connecting: waits or retries based on attempt age\n\t * - Disconnected: initiates immediate reconnection attempt\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically on network/visibility events, but can be called manually\n\t * manager.maybeReconnected()\n\t * ```\n\t */\n\tmaybeReconnected() {\n\t\tdebug('ReconnectManager.maybeReconnected')\n\t\t// It doesn't make sense to have another check scheduled if we're already checking it now.\n\t\t// If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway\n\t\tthis.clearRecheckConnectingTimeout()\n\n\t\t// readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: already connected')\n\t\t\t// nothing to do, we're already OK\n\t\t\treturn\n\t\t}\n\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: connecting')\n\t\t\t// We might be waiting for a TCP connection that sent SYN out and will never get it back,\n\t\t\t// while a new connection appeared. On the other hand, we might have just started connecting\n\t\t\t// and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew\n\t\t\t// if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent\n\t\t\t// new connections as long as `maybeReconnected` is not looped itself\n\t\t\tassert(\n\t\t\t\tthis.lastAttemptStart,\n\t\t\t\t'ReadyState=CONNECTING without lastAttemptStart should be impossible'\n\t\t\t)\n\t\t\tconst sinceLastStart = Date.now() - this.lastAttemptStart\n\t\t\tif (sinceLastStart < ATTEMPT_TIMEOUT) {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, rechecking later')\n\t\t\t\tthis.recheckConnectingTimeout = setTimeout(\n\t\t\t\t\t() => this.maybeReconnected(),\n\t\t\t\t\tATTEMPT_TIMEOUT - sinceLastStart\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now')\n\t\t\t\t// Last connection attempt was started a while ago, it's possible that network conditions\n\t\t\t\t// changed, and it's worth retrying to connect. `disconnected` will handle reconnection\n\t\t\t\t//\n\t\t\t\t// NOTE: The danger here is looping in connection attemps if connections are slow.\n\t\t\t\t// Make sure that `maybeReconnected` is not called in the `disconnected` codepath!\n\t\t\t\tthis.clearRecheckConnectingTimeout()\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t}\n\n\t\t\treturn\n\t\t}\n\n\t\tdebug('ReconnectManager.maybeReconnected: closing/closed/null, retry now')\n\t\t// readyState is CLOSING or CLOSED, or the websocket is null\n\t\t// Restart the backoff and retry ASAP (honouring the min delay)\n\t\t// this.state doesn't really matter, because disconnected() will handle any state correctly\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.disconnected()\n\t}\n\n\t/**\n\t * Handles disconnection events and schedules reconnection attempts with exponential backoff.\n\t * This method is called when the WebSocket connection is lost or fails to establish.\n\t *\n\t * It implements intelligent delay calculation based on:\n\t * - Previous attempt timing\n\t * - Current tab visibility (active vs inactive delays)\n\t * - Exponential backoff for repeated failures\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when connection is lost\n\t * // Schedules reconnection with appropriate delay\n\t * manager.disconnected()\n\t * ```\n\t */\n\tdisconnected() {\n\t\tdebug('ReconnectManager.disconnected')\n\t\t// This either means we're freshly disconnected, or the last connection attempt failed;\n\t\t// either way, time to try again.\n\t\tif (this.isDisposed) return\n\n\t\t// Guard against delayed notifications and recheck synchronously\n\t\tif (\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN &&\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.CONNECTING\n\t\t) {\n\t\t\tdebug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')\n\t\t\tthis.clearReconnectTimeout()\n\n\t\t\tlet delayLeft\n\t\t\tif (this.state === 'connected') {\n\t\t\t\t// it's the first sign that we got disconnected; the state will be updated below,\n\t\t\t\t// just set the appropriate delay for now\n\t\t\t\tthis.intendedDelay = this.getMinDelay()\n\t\t\t\tdelayLeft = this.intendedDelay\n\t\t\t} else {\n\t\t\t\tdelayLeft =\n\t\t\t\t\tthis.lastAttemptStart !== null\n\t\t\t\t\t\t? this.lastAttemptStart + this.intendedDelay - Date.now()\n\t\t\t\t\t\t: 0\n\t\t\t}\n\n\t\t\tif (delayLeft > 0) {\n\t\t\t\tdebug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft)\n\t\t\t\t// try again later\n\t\t\t\tthis.state = 'delay'\n\n\t\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft)\n\t\t\t} else {\n\t\t\t\t// not connected and not delayed, time to retry\n\t\t\t\tthis.state = 'pendingAttempt'\n\n\t\t\t\tthis.intendedDelay = Math.min(\n\t\t\t\t\tthis.getMaxDelay(),\n\t\t\t\t\tMath.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT\n\t\t\t\t)\n\t\t\t\tdebug(\n\t\t\t\t\t'ReconnectManager.disconnected: attempting a connection, next delay',\n\t\t\t\t\tthis.intendedDelay\n\t\t\t\t)\n\t\t\t\tthis.scheduleAttempt()\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handles successful connection events and resets reconnection state.\n\t * This method is called when the WebSocket successfully connects to the server.\n\t *\n\t * It clears any pending reconnection attempts and resets the delay back to minimum\n\t * for future connection attempts.\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when WebSocket opens successfully\n\t * manager.connected()\n\t * ```\n\t */\n\tconnected() {\n\t\tdebug('ReconnectManager.connected')\n\t\t// this notification could've been delayed, recheck synchronously\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.connected: websocket is OPEN')\n\t\t\tthis.state = 'connected'\n\t\t\tthis.clearReconnectTimeout()\n\t\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\t}\n\t}\n\n\t/**\n\t * Permanently closes the reconnection manager and cleans up all resources.\n\t * This stops all pending reconnection attempts and removes event listeners.\n\t * Once closed, the manager cannot be reused.\n\t */\n\tclose() {\n\t\tthis.disposables.forEach((d) => d())\n\t\tthis.isDisposed = true\n\t}\n}\n\nfunction httpToWs(url: string) {\n\treturn url.replace(/^http(s)?:/, 'ws$1:')\n}\n"],
5
- "mappings": "AAAA,SAAS,YAAkB;AAE3B,SAAS,QAAQ,gBAAgB;AACjC,SAAS,aAAa;AAEtB;AAAA,EAIC;AAAA,EACA;AAAA,OACM;AAEP,SAAS,SAAgC,QAAW,OAAe,SAAqB;AACvF,SAAO,iBAAiB,OAAO,OAAO;AACtC,SAAO,MAAM;AACZ,WAAO,oBAAoB,OAAO,OAAO;AAAA,EAC1C;AACD;AAEA,SAAS,SAAS,MAAa;AAE9B,MAAI,OAAO,WAAW,eAAe,OAAO,uBAAuB;AAClE,UAAM,MAAM,oBAAI,KAAK;AAErB,YAAQ;AAAA,MACP,GAAG,IAAI,SAAS,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,gBAAgB,CAAC;AAAA,MAClF,GAAG;AAAA;AAAA,IAEJ;AAAA,EACD;AACD;AA2CO,MAAM,uBAGX;AAAA,EACD,MAAwB;AAAA,EAExB,aAAa;AAAA;AAAA,EAGJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAST,QAAQ;AACP,SAAK,aAAa;AAClB,SAAK,kBAAkB,MAAM;AAG7B,UAAM,KAAK,KAAK;AAChB,SAAK,MAAM;AAEX,QAAI,MAAM;AACV,SAAK,kBAAkB,IAAI,SAAS;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,YAAY,QAAwC;AACnD,SAAK,oBAAoB,IAAI,iBAAiB,MAAM,MAAM;AAAA,EAC3D;AAAA,EAEQ,iBAAiB;AACxB,UAAM,eAAe;AAErB,SAAK,kBAAkB,IAAI,QAAQ;AACnC,SAAK,gBAAgB,QAAQ,CAAC,OAAO,GAAG,EAAE,QAAQ,SAAS,CAAC,CAAC;AAE7D,SAAK,kBAAkB,UAAU;AAAA,EAClC;AAAA,EAEQ,kBACP,QACA,WACA,SACA,aACC;AACD,kBAAc,eAAe,4BAA4B;AAEzD,UAAM,oBAAoB;AAAA,MACzB,eAAe,KAAK;AAAA,MACpB;AAAA,MACA;AAAA,IACD,CAAC;AAED,QAAI;AACJ,YAAQ,QAAQ;AAAA,MACf,KAAK;AACJ,YAAI,cAAc,2BAA2B;AAC5C,sBAAY;AAAA,QACb,OAAO;AACN,sBAAY;AAAA,QACb;AACA;AAAA,MACD,KAAK;AACJ,oBAAY;AACZ;AAAA,IACF;AAEA,QAAI,cAAc,QAAQ,CAAC,SAAS;AACnC;AAAA,QACC;AAAA,MACD;AAAA,IACD;AAEA;AAAA;AAAA,MAEC,KAAK,qBAAqB;AAAA,MAE1B,EAAE,cAAc,WAAW,KAAK,qBAAqB;AAAA,MACpD;AACD,WAAK,kBAAkB,IAAI,SAAS;AACpC,WAAK,gBAAgB;AAAA,QAAQ,CAAC,OAC7B,GAAG,cAAc,UAAU,EAAE,QAAQ,SAAS,QAAQ,YAAY,IAAI,EAAE,QAAQ,UAAU,CAAC;AAAA,MAC5F;AAAA,IACD;AAEA,SAAK,kBAAkB,aAAa;AAAA,EACrC;AAAA,EAEA,cAAc,IAAe;AAC5B,WAAO,CAAC,KAAK,YAAY,mDAAmD;AAC5E;AAAA,MACC,KAAK,QAAQ,QACZ,KAAK,IAAI,eAAe,UAAU,UAClC,KAAK,IAAI,eAAe,UAAU;AAAA,MACnC,6DAA6D,KAAK,KAAK,UAAU;AAAA,IAClF;AAEA,QAAI,UAAU;AAMd,OAAG,SAAS,MAAM;AACjB,YAAM,WAAW;AACjB;AAAA,QACC,KAAK,QAAQ;AAAA,QACb;AAAA,MACD;AACA,gBAAU;AACV,WAAK,eAAe;AAAA,IACrB;AACA,OAAG,UAAU,CAAC,UAAsB;AACnC,YAAM,cAAc,KAAK;AACzB,UAAI,KAAK,QAAQ,IAAI;AACpB,aAAK,kBAAkB,UAAU,MAAM,MAAM,SAAS,MAAM,MAAM;AAAA,MACnE,OAAO;AACN,cAAM,yCAAyC;AAAA,MAChD;AAAA,IACD;AACA,OAAG,UAAU,CAAC,UAAU;AACvB,YAAM,cAAc,KAAK;AACzB,UAAI,KAAK,QAAQ,IAAI;AACpB,aAAK,kBAAkB,QAAQ;AAAA,MAChC,OAAO;AACN,cAAM,yCAAyC;AAAA,MAChD;AAAA,IACD;AACA,OAAG,YAAY,CAAC,OAAO;AACtB;AAAA,QACC,KAAK,QAAQ;AAAA,QACb;AAAA,MACD;AACA,UAAI;AACJ,UAAI;AACH,iBAAS,KAAK,MAAM,GAAG,KAAK,SAAS,CAAC;AAAA,MACvC,QAAQ;AAMP,iBAAS,kEAAkE;AAC3E,aAAK,QAAQ;AACb;AAAA,MACD;AACA,WAAK,iBAAiB,QAAQ,CAAC,OAAO,GAAG,MAAM,CAAC;AAAA,IACjD;AAEA,SAAK,MAAM;AAAA,EACZ;AAAA,EAEA,eAAe;AACd,QAAI,KAAK,QAAQ,KAAM;AAEvB,SAAK,IAAI,MAAM;AAEf,SAAK,MAAM;AACX,SAAK,kBAAkB,QAAQ;AAAA,EAChC;AAAA,EAEA,oBAAsE;AAAA,IACrE;AAAA,IACA;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,IAAI,mBAAmD;AACtD,UAAM,SAAS,KAAK,kBAAkB,IAAI;AAC1C,WAAO,WAAW,YAAY,YAAY;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,YAAY,KAAwC;AACnD,WAAO,CAAC,KAAK,YAAY,4CAA4C;AAErE,QAAI,CAAC,KAAK,IAAK;AACf,QAAI,KAAK,qBAAqB,UAAU;AACvC,YAAM,SAAS,MAAM,KAAK,UAAU,GAAG,CAAC;AACxC,iBAAW,QAAQ,QAAQ;AAC1B,aAAK,IAAI,KAAK,IAAI;AAAA,MACnB;AAAA,IACD,OAAO;AACN,cAAQ,KAAK,iCAAiC,KAAK,gBAAgB;AAAA,IACpE;AAAA,EACD;AAAA,EAEQ,mBAAmB,oBAAI,IAAsD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAwBrF,iBAAiB,IAAsD;AACtE,WAAO,CAAC,KAAK,YAAY,oDAAoD;AAE7E,SAAK,iBAAiB,IAAI,EAAE;AAC5B,WAAO,MAAM;AACZ,WAAK,iBAAiB,OAAO,EAAE;AAAA,IAChC;AAAA,EACD;AAAA,EAEQ,kBAAkB,oBAAI,IAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqB1D,eAAe,IAA4B;AAC1C,WAAO,CAAC,KAAK,YAAY,mDAAmD;AAE5E,SAAK,gBAAgB,IAAI,EAAE;AAC3B,WAAO,MAAM;AACZ,WAAK,gBAAgB,OAAO,EAAE;AAAA,IAC/B;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,UAAU;AACT,WAAO,CAAC,KAAK,YAAY,oCAAoC;AAC7D,UAAM,YAAY;AAElB,SAAK,aAAa;AAClB,SAAK,kBAAkB,iBAAiB;AAAA,EACzC;AACD;AAOO,MAAM,mBAAmB;AAOzB,MAAM,mBAAmB;AAQzB,MAAM,qBAAqB;AAQ3B,MAAM,qBAAqB,MAAO,KAAK;AAQvC,MAAM,iBAAiB;AAQvB,MAAM,kBAAkB;AA4BxB,MAAM,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqB7B,YACS,eACA,QACP;AAFO;AACA;AAER,SAAK,0BAA0B;AAE/B,SAAK,YAAY;AAAA,MAChB,SAAS,QAAQ,WAAW,MAAM;AACjC,cAAM,qBAAqB;AAO3B,aAAK,cAAc,aAAa;AAAA,MACjC,CAAC;AAAA,IACF;AAEA,SAAK,QAAQ;AACb,SAAK,gBAAgB;AACrB,SAAK,gBAAgB;AAAA,EACtB;AAAA,EArBS;AAAA,EACA;AAAA,EAtBD,aAAa;AAAA,EACb,cAA8B;AAAA,IACrC,MAAM;AACL,UAAI,KAAK,iBAAkB,cAAa,KAAK,gBAAgB;AAC7D,UAAI,KAAK,yBAA0B,cAAa,KAAK,wBAAwB;AAAA,IAC9E;AAAA,EACD;AAAA,EACQ,mBAAyD;AAAA,EACzD,2BAAiE;AAAA,EAEjE,mBAAkC;AAAA,EAC1C,gBAAwB;AAAA,EAChB;AAAA,EAgCA,4BAA4B;AACnC,SAAK,YAAY;AAAA,MAChB,SAAS,QAAQ,UAAU,MAAM;AAChC,cAAM,oBAAoB;AAC1B,aAAK,iBAAiB;AAAA,MACvB,CAAC;AAAA,MACD,SAAS,UAAU,oBAAoB,MAAM;AAC5C,YAAI,CAAC,SAAS,QAAQ;AACrB,gBAAM,yBAAyB;AAC/B,eAAK,iBAAiB;AAAA,QACvB;AAAA,MACD,CAAC;AAAA,IACF;AAEA,QAAI,OAAO,UAAU,eAAe,KAAK,WAAW,YAAY,GAAG;AAClE,YAAM,aAAc,UAAkB,YAAY;AAClD,WAAK,YAAY;AAAA,QAChB,SAAS,YAAY,UAAU,MAAM;AACpC,gBAAM,6BAA6B;AACnC,eAAK,iBAAiB;AAAA,QACvB,CAAC;AAAA,MACF;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAc,kBAAkB;AAC/B,WAAO,KAAK,UAAU,gBAAgB;AACtC,QAAI,KAAK,WAAY;AACrB,UAAM,iCAAiC;AAEvC,QAAI;AACJ,QAAI;AACH,YAAM,MAAM,KAAK,OAAO;AAAA,IACzB,SAAS,OAAO;AAEf,UAAI,KAAK,UAAU,oBAAoB,KAAK,WAAY;AACxD,cAAQ,MAAM,6CAA6C,KAAK;AAChE,WAAK,QAAQ;AACb,WAAK,mBAAmB,WAAW,MAAM,KAAK,aAAa,GAAG,KAAK,aAAa;AAChF;AAAA,IACD;AAGA,QAAI,KAAK,UAAU,oBAAoB,KAAK,WAAY;AACxD;AAAA,MACC,KAAK,cAAc,KAAK,eAAe,UAAU;AAAA,MACjD;AAAA,IACD;AAEA,SAAK,mBAAmB,KAAK,IAAI;AACjC,SAAK,cAAc,cAAc,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC;AAC7D,SAAK,QAAQ;AAAA,EACd;AAAA,EAEQ,cAAc;AACrB,WAAO,SAAS,SAAS,qBAAqB;AAAA,EAC/C;AAAA,EAEQ,cAAc;AACrB,WAAO,SAAS,SAAS,qBAAqB;AAAA,EAC/C;AAAA,EAEQ,wBAAwB;AAC/B,QAAI,KAAK,kBAAkB;AAC1B,mBAAa,KAAK,gBAAgB;AAClC,WAAK,mBAAmB;AAAA,IACzB;AAAA,EACD;AAAA,EAEQ,gCAAgC;AACvC,QAAI,KAAK,0BAA0B;AAClC,mBAAa,KAAK,wBAAwB;AAC1C,WAAK,2BAA2B;AAAA,IACjC;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,mBAAmB;AAClB,UAAM,mCAAmC;AAGzC,SAAK,8BAA8B;AAGnC,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,MAAM;AAC1D,YAAM,sDAAsD;AAE5D;AAAA,IACD;AAEA,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,YAAY;AAChE,YAAM,+CAA+C;AAMrD;AAAA,QACC,KAAK;AAAA,QACL;AAAA,MACD;AACA,YAAM,iBAAiB,KAAK,IAAI,IAAI,KAAK;AACzC,UAAI,iBAAiB,iBAAiB;AACrC,cAAM,iEAAiE;AACvE,aAAK,2BAA2B;AAAA,UAC/B,MAAM,KAAK,iBAAiB;AAAA,UAC5B,kBAAkB;AAAA,QACnB;AAAA,MACD,OAAO;AACN,cAAM,4EAA4E;AAMlF,aAAK,8BAA8B;AACnC,aAAK,cAAc,aAAa;AAAA,MACjC;AAEA;AAAA,IACD;AAEA,UAAM,mEAAmE;AAIzE,SAAK,gBAAgB;AACrB,SAAK,aAAa;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,eAAe;AACd,UAAM,+BAA+B;AAGrC,QAAI,KAAK,WAAY;AAGrB,QACC,KAAK,cAAc,KAAK,eAAe,UAAU,QACjD,KAAK,cAAc,KAAK,eAAe,UAAU,YAChD;AACD,YAAM,oEAAoE;AAC1E,WAAK,sBAAsB;AAE3B,UAAI;AACJ,UAAI,KAAK,UAAU,aAAa;AAG/B,aAAK,gBAAgB,KAAK,YAAY;AACtC,oBAAY,KAAK;AAAA,MAClB,OAAO;AACN,oBACC,KAAK,qBAAqB,OACvB,KAAK,mBAAmB,KAAK,gBAAgB,KAAK,IAAI,IACtD;AAAA,MACL;AAEA,UAAI,YAAY,GAAG;AAClB,cAAM,sDAAsD,SAAS;AAErE,aAAK,QAAQ;AAEb,aAAK,mBAAmB,WAAW,MAAM,KAAK,aAAa,GAAG,SAAS;AAAA,MACxE,OAAO;AAEN,aAAK,QAAQ;AAEb,aAAK,gBAAgB,KAAK;AAAA,UACzB,KAAK,YAAY;AAAA,UACjB,KAAK,IAAI,KAAK,YAAY,GAAG,KAAK,aAAa,IAAI;AAAA,QACpD;AACA;AAAA,UACC;AAAA,UACA,KAAK;AAAA,QACN;AACA,aAAK,gBAAgB;AAAA,MACtB;AAAA,IACD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,YAAY;AACX,UAAM,4BAA4B;AAElC,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,MAAM;AAC1D,YAAM,+CAA+C;AACrD,WAAK,QAAQ;AACb,WAAK,sBAAsB;AAC3B,WAAK,gBAAgB;AAAA,IACtB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QAAQ;AACP,SAAK,YAAY,QAAQ,CAAC,MAAM,EAAE,CAAC;AACnC,SAAK,aAAa;AAAA,EACnB;AACD;AAEA,SAAS,SAAS,KAAa;AAC9B,SAAO,IAAI,QAAQ,cAAc,OAAO;AACzC;",
4
+ "sourcesContent": ["import { atom, Atom } from '@tldraw/state'\nimport { TLRecord } from '@tldraw/tlschema'\nimport { assert, warnOnce } from '@tldraw/utils'\nimport { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'\nimport { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'\nimport {\n\tTLPersistentClientSocket,\n\tTLPersistentClientSocketStatus,\n\tTLSocketStatusListener,\n\tTLSyncErrorCloseEventCode,\n\tTLSyncErrorCloseEventReason,\n} from './TLSyncClient'\n\nfunction listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {\n\ttarget.addEventListener(event, handler)\n\treturn () => {\n\t\ttarget.removeEventListener(event, handler)\n\t}\n}\n\nfunction debug(...args: any[]) {\n\t// @ts-ignore\n\tif (typeof window !== 'undefined' && window.__tldraw_socket_debug) {\n\t\tconst now = new Date()\n\t\t// eslint-disable-next-line no-console\n\t\tconsole.log(\n\t\t\t`${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`,\n\t\t\t...args\n\t\t\t//, new Error().stack\n\t\t)\n\t}\n}\n\n// NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss\n// detection, for example by regularly pinging the server and .restart()ing\n// the connection when a number of pings goes unanswered. Without this mechanism,\n// we might not be able to detect the websocket connection going down in a timely manner\n// (it will probably time out on outgoing data packets at some point).\n//\n// This is by design. While the Websocket protocol specifies protocol-level pings,\n// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,\n// pings need to be implemented one level up, on the application API side, which for our\n// codebase means whatever code that uses ClientWebSocketAdapter.\n/**\n * A WebSocket adapter that provides persistent connection management for tldraw synchronization.\n * This adapter handles connection establishment, reconnection logic, and message routing between\n * the sync client and server. It implements automatic reconnection with exponential backoff\n * and supports connection loss detection.\n *\n * Note: This adapter requires users to implement their own connection loss detection (e.g., pings)\n * as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames.\n *\n * @internal\n * @example\n * ```ts\n * // Create a WebSocket adapter with connection URI\n * const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync')\n *\n * // Listen for connection status changes\n * adapter.onStatusChange((status) => {\n * console.log('Connection status:', status)\n * })\n *\n * // Listen for incoming messages\n * adapter.onReceiveMessage((message) => {\n * console.log('Received:', message)\n * })\n *\n * // Send a message when connected\n * if (adapter.connectionStatus === 'online') {\n * adapter.sendMessage({ type: 'ping' })\n * }\n * ```\n */\nexport class ClientWebSocketAdapter implements TLPersistentClientSocket<\n\tTLSocketClientSentEvent<TLRecord>,\n\tTLSocketServerSentEvent<TLRecord>\n> {\n\t_ws: WebSocket | null = null\n\n\tisDisposed = false\n\n\t/** @internal */\n\treadonly _reconnectManager: ReconnectManager\n\n\t/**\n\t * Permanently closes the WebSocket adapter and disposes of all resources.\n\t * Once closed, the adapter cannot be reused and should be discarded.\n\t * This method is idempotent - calling it multiple times has no additional effect.\n\t */\n\t// TODO: .close should be a project-wide interface with a common contract (.close()d thing\n\t// can only be garbage collected, and can't be used anymore)\n\tclose() {\n\t\tthis.isDisposed = true\n\t\tthis._reconnectManager.close()\n\t\t// orphan the socket before closing it (as _closeSocket does) so its onclose can't run\n\t\t// _handleDisconnect after disposal \u2014 that would notify listeners and schedule a reconnect\n\t\tconst ws = this._ws\n\t\tthis._ws = null\n\t\t// WebSocket.close() is idempotent\n\t\tws?.close()\n\t\tthis._connectionStatus.set('offline')\n\t}\n\n\t/**\n\t * Creates a new ClientWebSocketAdapter instance.\n\t *\n\t * @param getUri - Function that returns the WebSocket URI to connect to.\n\t * Can return a string directly or a Promise that resolves to a string.\n\t * This function is called each time a connection attempt is made,\n\t * allowing for dynamic URI generation (e.g., for authentication tokens).\n\t */\n\tconstructor(getUri: () => Promise<string> | string) {\n\t\tthis._reconnectManager = new ReconnectManager(this, getUri)\n\t}\n\n\tprivate _handleConnect() {\n\t\tdebug('handleConnect')\n\n\t\tthis._connectionStatus.set('online')\n\t\tthis.statusListeners.forEach((cb) => cb({ status: 'online' }))\n\n\t\tthis._reconnectManager.connected()\n\t}\n\n\tprivate _handleDisconnect(\n\t\treason: 'closed' | 'manual',\n\t\tcloseCode?: number,\n\t\tdidOpen?: boolean,\n\t\tcloseReason?: string\n\t) {\n\t\tcloseReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR\n\n\t\tdebug('handleDisconnect', {\n\t\t\tcurrentStatus: this.connectionStatus,\n\t\t\tcloseCode,\n\t\t\treason,\n\t\t})\n\n\t\tlet newStatus: 'offline' | 'error'\n\t\tswitch (reason) {\n\t\t\tcase 'closed':\n\t\t\t\tif (closeCode === TLSyncErrorCloseEventCode) {\n\t\t\t\t\tnewStatus = 'error'\n\t\t\t\t} else {\n\t\t\t\t\tnewStatus = 'offline'\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\tcase 'manual':\n\t\t\t\tnewStatus = 'offline'\n\t\t\t\tbreak\n\t\t}\n\n\t\tif (closeCode === 1006 && !didOpen) {\n\t\t\twarnOnce(\n\t\t\t\t\"Could not open WebSocket connection. This might be because you're trying to load a URL that doesn't support websockets. Check the URL you're trying to connect to.\"\n\t\t\t)\n\t\t}\n\n\t\tif (\n\t\t\t// it the status changed\n\t\t\tthis.connectionStatus !== newStatus &&\n\t\t\t// ignore errors if we're already in the offline state\n\t\t\t!(newStatus === 'error' && this.connectionStatus === 'offline')\n\t\t) {\n\t\t\tthis._connectionStatus.set(newStatus)\n\t\t\tthis.statusListeners.forEach((cb) =>\n\t\t\t\tcb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus })\n\t\t\t)\n\t\t}\n\n\t\tthis._reconnectManager.disconnected()\n\t}\n\n\t_setNewSocket(ws: WebSocket) {\n\t\tassert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')\n\t\tassert(\n\t\t\tthis._ws === null ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSED ||\n\t\t\t\tthis._ws.readyState === WebSocket.CLOSING,\n\t\t\t`Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`\n\t\t)\n\n\t\tlet didOpen = false\n\n\t\t// NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition\n\t\t// between CLOSING and CLOSED happens either after the closing handshake, or after a\n\t\t// timeout, but in either case those sockets don't need any special handling, the browser\n\t\t// will close them eventually. We just \"orphan\" such sockets and ignore their onclose/onerror.\n\t\tws.onopen = () => {\n\t\t\tdebug('ws.onopen')\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open\"\n\t\t\t)\n\t\t\tdidOpen = true\n\t\t\tthis._handleConnect()\n\t\t}\n\t\tws.onclose = (event: CloseEvent) => {\n\t\t\tdebug('ws.onclose', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed', event.code, didOpen, event.reason)\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onclose for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onerror = (event) => {\n\t\t\tdebug('ws.onerror', event)\n\t\t\tif (this._ws === ws) {\n\t\t\t\tthis._handleDisconnect('closed')\n\t\t\t} else {\n\t\t\t\tdebug('ignoring onerror for an orphaned socket')\n\t\t\t}\n\t\t}\n\t\tws.onmessage = (ev) => {\n\t\t\tassert(\n\t\t\t\tthis._ws === ws,\n\t\t\t\t\"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages\"\n\t\t\t)\n\t\t\tlet parsed: TLSocketServerSentEvent<TLRecord>\n\t\t\ttry {\n\t\t\t\tparsed = JSON.parse(ev.data.toString())\n\t\t\t} catch {\n\t\t\t\t// A malformed message means the connection delivered corrupt data, so we can't trust\n\t\t\t\t// that we're still in sync. Dropping the message can silently desync the client (e.g. a\n\t\t\t\t// missed 'patch' is applied as if it never happened), so instead we restart the\n\t\t\t\t// connection. Reconnecting re-hydrates the store from the last known server clock and\n\t\t\t\t// brings us fully back in sync.\n\t\t\t\twarnOnce('Received malformed WebSocket message. Restarting the connection.')\n\t\t\t\tthis.restart()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.messageListeners.forEach((cb) => cb(parsed))\n\t\t}\n\n\t\tthis._ws = ws\n\t}\n\n\t_closeSocket() {\n\t\tif (this._ws === null) return\n\n\t\tthis._ws.close()\n\t\t// explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed\n\t\tthis._ws = null\n\t\tthis._handleDisconnect('manual')\n\t}\n\n\t_connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(\n\t\t'websocket connection status',\n\t\t'initial'\n\t)\n\n\t/**\n\t * Gets the current connection status of the WebSocket.\n\t *\n\t * @returns The current connection status: 'online', 'offline', or 'error'\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget connectionStatus(): TLPersistentClientSocketStatus {\n\t\tconst status = this._connectionStatus.get()\n\t\treturn status === 'initial' ? 'offline' : status\n\t}\n\n\t/**\n\t * Sends a message to the server through the WebSocket connection.\n\t * Messages are automatically chunked if they exceed size limits.\n\t *\n\t * @param msg - The message to send to the server\n\t *\n\t * @example\n\t * ```ts\n\t * adapter.sendMessage({\n\t * type: 'push',\n\t * diff: { 'shape:abc123': [2, { x: [1, 150] }] }\n\t * })\n\t * ```\n\t */\n\tsendMessage(msg: TLSocketClientSentEvent<TLRecord>) {\n\t\tassert(!this.isDisposed, 'Tried to send message on a disposed socket')\n\n\t\tif (!this._ws) return\n\t\tif (this.connectionStatus === 'online') {\n\t\t\tconst stringified = JSON.stringify(msg)\n\t\t\t// The server rejects a session whose assembled message exceeds this, so sending would\n\t\t\t// only earn a close and a reconnect that re-sends the same message. Fail here the way\n\t\t\t// the server would, so onSyncError runs once instead of the client looping forever.\n\t\t\tif (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {\n\t\t\t\tthis._handleDisconnect(\n\t\t\t\t\t'closed',\n\t\t\t\t\tTLSyncErrorCloseEventCode,\n\t\t\t\t\ttrue,\n\t\t\t\t\tTLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst chunks = chunk(stringified)\n\t\t\tfor (const part of chunks) {\n\t\t\t\tthis._ws.send(part)\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn('Tried to send message while ' + this.connectionStatus)\n\t\t}\n\t}\n\n\tprivate messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()\n\t/**\n\t * Registers a callback to handle incoming messages from the server.\n\t *\n\t * @param cb - Callback function that will be called with each received message\n\t * @returns A cleanup function to remove the message listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onReceiveMessage((message) => {\n\t * switch (message.type) {\n\t * case 'connect':\n\t * console.log('Connected to room')\n\t * break\n\t * case 'data':\n\t * console.log('Received data:', message.diff)\n\t * break\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {\n\t\tassert(!this.isDisposed, 'Tried to add message listener on a disposed socket')\n\n\t\tthis.messageListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.messageListeners.delete(cb)\n\t\t}\n\t}\n\n\tprivate statusListeners = new Set<TLSocketStatusListener>()\n\t/**\n\t * Registers a callback to handle connection status changes.\n\t *\n\t * @param cb - Callback function that will be called when the connection status changes\n\t * @returns A cleanup function to remove the status listener\n\t *\n\t * @example\n\t * ```ts\n\t * const unsubscribe = adapter.onStatusChange((status) => {\n\t * if (status.status === 'error') {\n\t * console.error('Connection error:', status.reason)\n\t * } else {\n\t * console.log('Status changed to:', status.status)\n\t * }\n\t * })\n\t *\n\t * // Later, remove the listener\n\t * unsubscribe()\n\t * ```\n\t */\n\tonStatusChange(cb: TLSocketStatusListener) {\n\t\tassert(!this.isDisposed, 'Tried to add status listener on a disposed socket')\n\n\t\tthis.statusListeners.add(cb)\n\t\treturn () => {\n\t\t\tthis.statusListeners.delete(cb)\n\t\t}\n\t}\n\n\t/**\n\t * Manually restarts the WebSocket connection.\n\t * This closes the current connection (if any) and attempts to establish a new one.\n\t * Useful for implementing connection loss detection and recovery.\n\t *\n\t * @example\n\t * ```ts\n\t * // Restart connection after detecting it's stale\n\t * if (lastPongTime < Date.now() - 30000) {\n\t * adapter.restart()\n\t * }\n\t * ```\n\t */\n\trestart() {\n\t\tassert(!this.isDisposed, 'Tried to restart a disposed socket')\n\t\tdebug('restarting')\n\n\t\tthis._closeSocket()\n\t\tthis._reconnectManager.maybeReconnected()\n\t}\n}\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MIN_DELAY = 500\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is active and focused.\n *\n * @internal\n */\nexport const ACTIVE_MAX_DELAY = 2000\n\n/**\n * Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab.\n *\n * @internal\n */\nexport const INACTIVE_MIN_DELAY = 1000\n\n/**\n * Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden.\n * Set to 5 minutes to balance between maintaining sync and conserving resources.\n *\n * @internal\n */\nexport const INACTIVE_MAX_DELAY = 1000 * 60 * 5\n\n/**\n * Exponential backoff multiplier for calculating reconnection delays.\n * Each failed connection attempt increases the delay by this factor until max delay is reached.\n *\n * @internal\n */\nexport const DELAY_EXPONENT = 1.5\n\n/**\n * Maximum time in milliseconds to wait for a connection attempt before considering it failed.\n * This helps detect connections stuck in the CONNECTING state and retry with fresh attempts.\n *\n * @internal\n */\nexport const ATTEMPT_TIMEOUT = 1000\n\n/**\n * Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies.\n * This class handles connection attempts, tracks connection state, and implements exponential backoff\n * with different delays based on whether the browser tab is active or inactive.\n *\n * The ReconnectManager responds to various browser events like network status changes,\n * tab visibility changes, and connection events to optimize reconnection timing and\n * minimize unnecessary connection attempts.\n *\n * @internal\n *\n * @example\n * ```ts\n * const manager = new ReconnectManager(\n * socketAdapter,\n * () => 'ws://localhost:3000/sync'\n * )\n *\n * // Manager automatically handles:\n * // - Initial connection\n * // - Reconnection on disconnect\n * // - Exponential backoff on failures\n * // - Tab visibility-aware delays\n * // - Network status change responses\n * ```\n */\nexport class ReconnectManager {\n\tprivate isDisposed = false\n\tprivate disposables: (() => void)[] = [\n\t\t() => {\n\t\t\tif (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)\n\t\t\tif (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout)\n\t\t},\n\t]\n\tprivate reconnectTimeout: ReturnType<typeof setTimeout> | null = null\n\tprivate recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null\n\n\tprivate lastAttemptStart: number | null = null\n\tintendedDelay: number = ACTIVE_MIN_DELAY\n\tprivate state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'\n\n\t/**\n\t * Creates a new ReconnectManager instance.\n\t *\n\t * socketAdapter - The ClientWebSocketAdapter instance to manage\n\t * getUri - Function that returns the WebSocket URI for connection attempts\n\t */\n\tconstructor(\n\t\tprivate socketAdapter: ClientWebSocketAdapter,\n\t\tprivate getUri: () => Promise<string> | string\n\t) {\n\t\tthis.subscribeToReconnectHints()\n\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'offline', () => {\n\t\t\t\tdebug('window went offline')\n\t\t\t\t// On the one hand, 'offline' event is not really reliable; on the other, the only\n\t\t\t\t// alternative is to wait for pings not being delivered, which takes more than 20 seconds,\n\t\t\t\t// which means we won't see the ClientWebSocketAdapter status change for more than\n\t\t\t\t// 20 seconds after the tab goes offline. Our application layer must be resistent to\n\t\t\t\t// connection restart anyway, so we can just try to reconnect and see if\n\t\t\t\t// we're truly offline.\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t})\n\t\t)\n\n\t\tthis.state = 'pendingAttempt'\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.scheduleAttempt()\n\t}\n\n\tprivate subscribeToReconnectHints() {\n\t\tthis.disposables.push(\n\t\t\tlistenTo(window, 'online', () => {\n\t\t\t\tdebug('window went online')\n\t\t\t\tthis.maybeReconnected()\n\t\t\t}),\n\t\t\tlistenTo(document, 'visibilitychange', () => {\n\t\t\t\tif (!document.hidden) {\n\t\t\t\t\tdebug('document became visible')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t}\n\t\t\t})\n\t\t)\n\n\t\tif (Object.prototype.hasOwnProperty.call(navigator, 'connection')) {\n\t\t\tconst connection = (navigator as any)['connection'] as EventTarget\n\t\t\tthis.disposables.push(\n\t\t\t\tlistenTo(connection, 'change', () => {\n\t\t\t\t\tdebug('navigator.connection change')\n\t\t\t\t\tthis.maybeReconnected()\n\t\t\t\t})\n\t\t\t)\n\t\t}\n\t}\n\n\tprivate async scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tif (this.isDisposed) return\n\t\tdebug('scheduling a connection attempt')\n\n\t\tlet uri: string\n\t\ttry {\n\t\t\turi = await this.getUri()\n\t\t} catch (error) {\n\t\t\t// A failed auth-token fetch must retry instead of leaving us with no socket or timer.\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tconsole.error('Failed to get the websocket URI, retrying', error)\n\t\t\tthis.state = 'delay'\n\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)\n\t\t\treturn\n\t\t}\n\n\t\t// this can happen if the promise gets resolved too late\n\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\tassert(\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t'There should be no connection attempts while already connected'\n\t\t)\n\n\t\tthis.lastAttemptStart = Date.now()\n\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\tthis.state = 'pendingAttemptResult'\n\t}\n\n\tprivate getMaxDelay() {\n\t\treturn document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY\n\t}\n\n\tprivate getMinDelay() {\n\t\treturn document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY\n\t}\n\n\tprivate clearReconnectTimeout() {\n\t\tif (this.reconnectTimeout) {\n\t\t\tclearTimeout(this.reconnectTimeout)\n\t\t\tthis.reconnectTimeout = null\n\t\t}\n\t}\n\n\tprivate clearRecheckConnectingTimeout() {\n\t\tif (this.recheckConnectingTimeout) {\n\t\t\tclearTimeout(this.recheckConnectingTimeout)\n\t\t\tthis.recheckConnectingTimeout = null\n\t\t}\n\t}\n\n\t/**\n\t * Checks if reconnection should be attempted and initiates it if appropriate.\n\t * This method is called in response to network events, tab visibility changes,\n\t * and other hints that connectivity may have been restored.\n\t *\n\t * The method intelligently handles various connection states:\n\t * - Already connected: no action needed\n\t * - Currently connecting: waits or retries based on attempt age\n\t * - Disconnected: initiates immediate reconnection attempt\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically on network/visibility events, but can be called manually\n\t * manager.maybeReconnected()\n\t * ```\n\t */\n\tmaybeReconnected() {\n\t\tdebug('ReconnectManager.maybeReconnected')\n\t\t// It doesn't make sense to have another check scheduled if we're already checking it now.\n\t\t// If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway\n\t\tthis.clearRecheckConnectingTimeout()\n\n\t\t// readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: already connected')\n\t\t\t// nothing to do, we're already OK\n\t\t\treturn\n\t\t}\n\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) {\n\t\t\tdebug('ReconnectManager.maybeReconnected: connecting')\n\t\t\t// We might be waiting for a TCP connection that sent SYN out and will never get it back,\n\t\t\t// while a new connection appeared. On the other hand, we might have just started connecting\n\t\t\t// and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew\n\t\t\t// if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent\n\t\t\t// new connections as long as `maybeReconnected` is not looped itself\n\t\t\tassert(\n\t\t\t\tthis.lastAttemptStart,\n\t\t\t\t'ReadyState=CONNECTING without lastAttemptStart should be impossible'\n\t\t\t)\n\t\t\tconst sinceLastStart = Date.now() - this.lastAttemptStart\n\t\t\tif (sinceLastStart < ATTEMPT_TIMEOUT) {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, rechecking later')\n\t\t\t\tthis.recheckConnectingTimeout = setTimeout(\n\t\t\t\t\t() => this.maybeReconnected(),\n\t\t\t\t\tATTEMPT_TIMEOUT - sinceLastStart\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\tdebug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now')\n\t\t\t\t// Last connection attempt was started a while ago, it's possible that network conditions\n\t\t\t\t// changed, and it's worth retrying to connect. `disconnected` will handle reconnection\n\t\t\t\t//\n\t\t\t\t// NOTE: The danger here is looping in connection attemps if connections are slow.\n\t\t\t\t// Make sure that `maybeReconnected` is not called in the `disconnected` codepath!\n\t\t\t\tthis.clearRecheckConnectingTimeout()\n\t\t\t\tthis.socketAdapter._closeSocket()\n\t\t\t}\n\n\t\t\treturn\n\t\t}\n\n\t\tdebug('ReconnectManager.maybeReconnected: closing/closed/null, retry now')\n\t\t// readyState is CLOSING or CLOSED, or the websocket is null\n\t\t// Restart the backoff and retry ASAP (honouring the min delay)\n\t\t// this.state doesn't really matter, because disconnected() will handle any state correctly\n\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\tthis.disconnected()\n\t}\n\n\t/**\n\t * Handles disconnection events and schedules reconnection attempts with exponential backoff.\n\t * This method is called when the WebSocket connection is lost or fails to establish.\n\t *\n\t * It implements intelligent delay calculation based on:\n\t * - Previous attempt timing\n\t * - Current tab visibility (active vs inactive delays)\n\t * - Exponential backoff for repeated failures\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when connection is lost\n\t * // Schedules reconnection with appropriate delay\n\t * manager.disconnected()\n\t * ```\n\t */\n\tdisconnected() {\n\t\tdebug('ReconnectManager.disconnected')\n\t\t// This either means we're freshly disconnected, or the last connection attempt failed;\n\t\t// either way, time to try again.\n\t\tif (this.isDisposed) return\n\n\t\t// Guard against delayed notifications and recheck synchronously\n\t\tif (\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN &&\n\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.CONNECTING\n\t\t) {\n\t\t\tdebug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')\n\t\t\tthis.clearReconnectTimeout()\n\n\t\t\tlet delayLeft\n\t\t\tif (this.state === 'connected') {\n\t\t\t\t// it's the first sign that we got disconnected; the state will be updated below,\n\t\t\t\t// just set the appropriate delay for now\n\t\t\t\tthis.intendedDelay = this.getMinDelay()\n\t\t\t\tdelayLeft = this.intendedDelay\n\t\t\t} else {\n\t\t\t\tdelayLeft =\n\t\t\t\t\tthis.lastAttemptStart !== null\n\t\t\t\t\t\t? this.lastAttemptStart + this.intendedDelay - Date.now()\n\t\t\t\t\t\t: 0\n\t\t\t}\n\n\t\t\tif (delayLeft > 0) {\n\t\t\t\tdebug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft)\n\t\t\t\t// try again later\n\t\t\t\tthis.state = 'delay'\n\n\t\t\t\tthis.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft)\n\t\t\t} else {\n\t\t\t\t// not connected and not delayed, time to retry\n\t\t\t\tthis.state = 'pendingAttempt'\n\n\t\t\t\tthis.intendedDelay = Math.min(\n\t\t\t\t\tthis.getMaxDelay(),\n\t\t\t\t\tMath.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT\n\t\t\t\t)\n\t\t\t\tdebug(\n\t\t\t\t\t'ReconnectManager.disconnected: attempting a connection, next delay',\n\t\t\t\t\tthis.intendedDelay\n\t\t\t\t)\n\t\t\t\tthis.scheduleAttempt()\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Handles successful connection events and resets reconnection state.\n\t * This method is called when the WebSocket successfully connects to the server.\n\t *\n\t * It clears any pending reconnection attempts and resets the delay back to minimum\n\t * for future connection attempts.\n\t *\n\t * @example\n\t * ```ts\n\t * // Called automatically when WebSocket opens successfully\n\t * manager.connected()\n\t * ```\n\t */\n\tconnected() {\n\t\tdebug('ReconnectManager.connected')\n\t\t// this notification could've been delayed, recheck synchronously\n\t\tif (this.socketAdapter._ws?.readyState === WebSocket.OPEN) {\n\t\t\tdebug('ReconnectManager.connected: websocket is OPEN')\n\t\t\tthis.state = 'connected'\n\t\t\tthis.clearReconnectTimeout()\n\t\t\tthis.intendedDelay = ACTIVE_MIN_DELAY\n\t\t}\n\t}\n\n\t/**\n\t * Permanently closes the reconnection manager and cleans up all resources.\n\t * This stops all pending reconnection attempts and removes event listeners.\n\t * Once closed, the manager cannot be reused.\n\t */\n\tclose() {\n\t\tthis.disposables.forEach((d) => d())\n\t\tthis.isDisposed = true\n\t}\n}\n\nfunction httpToWs(url: string) {\n\treturn url.replace(/^http(s)?:/, 'ws$1:')\n}\n"],
5
+ "mappings": "AAAA,SAAS,YAAkB;AAE3B,SAAS,QAAQ,gBAAgB;AACjC,SAAS,OAAO,mCAAmC;AAEnD;AAAA,EAIC;AAAA,EACA;AAAA,OACM;AAEP,SAAS,SAAgC,QAAW,OAAe,SAAqB;AACvF,SAAO,iBAAiB,OAAO,OAAO;AACtC,SAAO,MAAM;AACZ,WAAO,oBAAoB,OAAO,OAAO;AAAA,EAC1C;AACD;AAEA,SAAS,SAAS,MAAa;AAE9B,MAAI,OAAO,WAAW,eAAe,OAAO,uBAAuB;AAClE,UAAM,MAAM,oBAAI,KAAK;AAErB,YAAQ;AAAA,MACP,GAAG,IAAI,SAAS,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,gBAAgB,CAAC;AAAA,MAClF,GAAG;AAAA;AAAA,IAEJ;AAAA,EACD;AACD;AA2CO,MAAM,uBAGX;AAAA,EACD,MAAwB;AAAA,EAExB,aAAa;AAAA;AAAA,EAGJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAST,QAAQ;AACP,SAAK,aAAa;AAClB,SAAK,kBAAkB,MAAM;AAG7B,UAAM,KAAK,KAAK;AAChB,SAAK,MAAM;AAEX,QAAI,MAAM;AACV,SAAK,kBAAkB,IAAI,SAAS;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,YAAY,QAAwC;AACnD,SAAK,oBAAoB,IAAI,iBAAiB,MAAM,MAAM;AAAA,EAC3D;AAAA,EAEQ,iBAAiB;AACxB,UAAM,eAAe;AAErB,SAAK,kBAAkB,IAAI,QAAQ;AACnC,SAAK,gBAAgB,QAAQ,CAAC,OAAO,GAAG,EAAE,QAAQ,SAAS,CAAC,CAAC;AAE7D,SAAK,kBAAkB,UAAU;AAAA,EAClC;AAAA,EAEQ,kBACP,QACA,WACA,SACA,aACC;AACD,kBAAc,eAAe,4BAA4B;AAEzD,UAAM,oBAAoB;AAAA,MACzB,eAAe,KAAK;AAAA,MACpB;AAAA,MACA;AAAA,IACD,CAAC;AAED,QAAI;AACJ,YAAQ,QAAQ;AAAA,MACf,KAAK;AACJ,YAAI,cAAc,2BAA2B;AAC5C,sBAAY;AAAA,QACb,OAAO;AACN,sBAAY;AAAA,QACb;AACA;AAAA,MACD,KAAK;AACJ,oBAAY;AACZ;AAAA,IACF;AAEA,QAAI,cAAc,QAAQ,CAAC,SAAS;AACnC;AAAA,QACC;AAAA,MACD;AAAA,IACD;AAEA;AAAA;AAAA,MAEC,KAAK,qBAAqB;AAAA,MAE1B,EAAE,cAAc,WAAW,KAAK,qBAAqB;AAAA,MACpD;AACD,WAAK,kBAAkB,IAAI,SAAS;AACpC,WAAK,gBAAgB;AAAA,QAAQ,CAAC,OAC7B,GAAG,cAAc,UAAU,EAAE,QAAQ,SAAS,QAAQ,YAAY,IAAI,EAAE,QAAQ,UAAU,CAAC;AAAA,MAC5F;AAAA,IACD;AAEA,SAAK,kBAAkB,aAAa;AAAA,EACrC;AAAA,EAEA,cAAc,IAAe;AAC5B,WAAO,CAAC,KAAK,YAAY,mDAAmD;AAC5E;AAAA,MACC,KAAK,QAAQ,QACZ,KAAK,IAAI,eAAe,UAAU,UAClC,KAAK,IAAI,eAAe,UAAU;AAAA,MACnC,6DAA6D,KAAK,KAAK,UAAU;AAAA,IAClF;AAEA,QAAI,UAAU;AAMd,OAAG,SAAS,MAAM;AACjB,YAAM,WAAW;AACjB;AAAA,QACC,KAAK,QAAQ;AAAA,QACb;AAAA,MACD;AACA,gBAAU;AACV,WAAK,eAAe;AAAA,IACrB;AACA,OAAG,UAAU,CAAC,UAAsB;AACnC,YAAM,cAAc,KAAK;AACzB,UAAI,KAAK,QAAQ,IAAI;AACpB,aAAK,kBAAkB,UAAU,MAAM,MAAM,SAAS,MAAM,MAAM;AAAA,MACnE,OAAO;AACN,cAAM,yCAAyC;AAAA,MAChD;AAAA,IACD;AACA,OAAG,UAAU,CAAC,UAAU;AACvB,YAAM,cAAc,KAAK;AACzB,UAAI,KAAK,QAAQ,IAAI;AACpB,aAAK,kBAAkB,QAAQ;AAAA,MAChC,OAAO;AACN,cAAM,yCAAyC;AAAA,MAChD;AAAA,IACD;AACA,OAAG,YAAY,CAAC,OAAO;AACtB;AAAA,QACC,KAAK,QAAQ;AAAA,QACb;AAAA,MACD;AACA,UAAI;AACJ,UAAI;AACH,iBAAS,KAAK,MAAM,GAAG,KAAK,SAAS,CAAC;AAAA,MACvC,QAAQ;AAMP,iBAAS,kEAAkE;AAC3E,aAAK,QAAQ;AACb;AAAA,MACD;AACA,WAAK,iBAAiB,QAAQ,CAAC,OAAO,GAAG,MAAM,CAAC;AAAA,IACjD;AAEA,SAAK,MAAM;AAAA,EACZ;AAAA,EAEA,eAAe;AACd,QAAI,KAAK,QAAQ,KAAM;AAEvB,SAAK,IAAI,MAAM;AAEf,SAAK,MAAM;AACX,SAAK,kBAAkB,QAAQ;AAAA,EAChC;AAAA,EAEA,oBAAsE;AAAA,IACrE;AAAA,IACA;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,IAAI,mBAAmD;AACtD,UAAM,SAAS,KAAK,kBAAkB,IAAI;AAC1C,WAAO,WAAW,YAAY,YAAY;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,YAAY,KAAwC;AACnD,WAAO,CAAC,KAAK,YAAY,4CAA4C;AAErE,QAAI,CAAC,KAAK,IAAK;AACf,QAAI,KAAK,qBAAqB,UAAU;AACvC,YAAM,cAAc,KAAK,UAAU,GAAG;AAItC,UAAI,YAAY,SAAS,6BAA6B;AACrD,aAAK;AAAA,UACJ;AAAA,UACA;AAAA,UACA;AAAA,UACA,4BAA4B;AAAA,QAC7B;AACA;AAAA,MACD;AACA,YAAM,SAAS,MAAM,WAAW;AAChC,iBAAW,QAAQ,QAAQ;AAC1B,aAAK,IAAI,KAAK,IAAI;AAAA,MACnB;AAAA,IACD,OAAO;AACN,cAAQ,KAAK,iCAAiC,KAAK,gBAAgB;AAAA,IACpE;AAAA,EACD;AAAA,EAEQ,mBAAmB,oBAAI,IAAsD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAwBrF,iBAAiB,IAAsD;AACtE,WAAO,CAAC,KAAK,YAAY,oDAAoD;AAE7E,SAAK,iBAAiB,IAAI,EAAE;AAC5B,WAAO,MAAM;AACZ,WAAK,iBAAiB,OAAO,EAAE;AAAA,IAChC;AAAA,EACD;AAAA,EAEQ,kBAAkB,oBAAI,IAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqB1D,eAAe,IAA4B;AAC1C,WAAO,CAAC,KAAK,YAAY,mDAAmD;AAE5E,SAAK,gBAAgB,IAAI,EAAE;AAC3B,WAAO,MAAM;AACZ,WAAK,gBAAgB,OAAO,EAAE;AAAA,IAC/B;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,UAAU;AACT,WAAO,CAAC,KAAK,YAAY,oCAAoC;AAC7D,UAAM,YAAY;AAElB,SAAK,aAAa;AAClB,SAAK,kBAAkB,iBAAiB;AAAA,EACzC;AACD;AAOO,MAAM,mBAAmB;AAOzB,MAAM,mBAAmB;AAQzB,MAAM,qBAAqB;AAQ3B,MAAM,qBAAqB,MAAO,KAAK;AAQvC,MAAM,iBAAiB;AAQvB,MAAM,kBAAkB;AA4BxB,MAAM,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqB7B,YACS,eACA,QACP;AAFO;AACA;AAER,SAAK,0BAA0B;AAE/B,SAAK,YAAY;AAAA,MAChB,SAAS,QAAQ,WAAW,MAAM;AACjC,cAAM,qBAAqB;AAO3B,aAAK,cAAc,aAAa;AAAA,MACjC,CAAC;AAAA,IACF;AAEA,SAAK,QAAQ;AACb,SAAK,gBAAgB;AACrB,SAAK,gBAAgB;AAAA,EACtB;AAAA,EArBS;AAAA,EACA;AAAA,EAtBD,aAAa;AAAA,EACb,cAA8B;AAAA,IACrC,MAAM;AACL,UAAI,KAAK,iBAAkB,cAAa,KAAK,gBAAgB;AAC7D,UAAI,KAAK,yBAA0B,cAAa,KAAK,wBAAwB;AAAA,IAC9E;AAAA,EACD;AAAA,EACQ,mBAAyD;AAAA,EACzD,2BAAiE;AAAA,EAEjE,mBAAkC;AAAA,EAC1C,gBAAwB;AAAA,EAChB;AAAA,EAgCA,4BAA4B;AACnC,SAAK,YAAY;AAAA,MAChB,SAAS,QAAQ,UAAU,MAAM;AAChC,cAAM,oBAAoB;AAC1B,aAAK,iBAAiB;AAAA,MACvB,CAAC;AAAA,MACD,SAAS,UAAU,oBAAoB,MAAM;AAC5C,YAAI,CAAC,SAAS,QAAQ;AACrB,gBAAM,yBAAyB;AAC/B,eAAK,iBAAiB;AAAA,QACvB;AAAA,MACD,CAAC;AAAA,IACF;AAEA,QAAI,OAAO,UAAU,eAAe,KAAK,WAAW,YAAY,GAAG;AAClE,YAAM,aAAc,UAAkB,YAAY;AAClD,WAAK,YAAY;AAAA,QAChB,SAAS,YAAY,UAAU,MAAM;AACpC,gBAAM,6BAA6B;AACnC,eAAK,iBAAiB;AAAA,QACvB,CAAC;AAAA,MACF;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAc,kBAAkB;AAC/B,WAAO,KAAK,UAAU,gBAAgB;AACtC,QAAI,KAAK,WAAY;AACrB,UAAM,iCAAiC;AAEvC,QAAI;AACJ,QAAI;AACH,YAAM,MAAM,KAAK,OAAO;AAAA,IACzB,SAAS,OAAO;AAEf,UAAI,KAAK,UAAU,oBAAoB,KAAK,WAAY;AACxD,cAAQ,MAAM,6CAA6C,KAAK;AAChE,WAAK,QAAQ;AACb,WAAK,mBAAmB,WAAW,MAAM,KAAK,aAAa,GAAG,KAAK,aAAa;AAChF;AAAA,IACD;AAGA,QAAI,KAAK,UAAU,oBAAoB,KAAK,WAAY;AACxD;AAAA,MACC,KAAK,cAAc,KAAK,eAAe,UAAU;AAAA,MACjD;AAAA,IACD;AAEA,SAAK,mBAAmB,KAAK,IAAI;AACjC,SAAK,cAAc,cAAc,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC;AAC7D,SAAK,QAAQ;AAAA,EACd;AAAA,EAEQ,cAAc;AACrB,WAAO,SAAS,SAAS,qBAAqB;AAAA,EAC/C;AAAA,EAEQ,cAAc;AACrB,WAAO,SAAS,SAAS,qBAAqB;AAAA,EAC/C;AAAA,EAEQ,wBAAwB;AAC/B,QAAI,KAAK,kBAAkB;AAC1B,mBAAa,KAAK,gBAAgB;AAClC,WAAK,mBAAmB;AAAA,IACzB;AAAA,EACD;AAAA,EAEQ,gCAAgC;AACvC,QAAI,KAAK,0BAA0B;AAClC,mBAAa,KAAK,wBAAwB;AAC1C,WAAK,2BAA2B;AAAA,IACjC;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,mBAAmB;AAClB,UAAM,mCAAmC;AAGzC,SAAK,8BAA8B;AAGnC,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,MAAM;AAC1D,YAAM,sDAAsD;AAE5D;AAAA,IACD;AAEA,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,YAAY;AAChE,YAAM,+CAA+C;AAMrD;AAAA,QACC,KAAK;AAAA,QACL;AAAA,MACD;AACA,YAAM,iBAAiB,KAAK,IAAI,IAAI,KAAK;AACzC,UAAI,iBAAiB,iBAAiB;AACrC,cAAM,iEAAiE;AACvE,aAAK,2BAA2B;AAAA,UAC/B,MAAM,KAAK,iBAAiB;AAAA,UAC5B,kBAAkB;AAAA,QACnB;AAAA,MACD,OAAO;AACN,cAAM,4EAA4E;AAMlF,aAAK,8BAA8B;AACnC,aAAK,cAAc,aAAa;AAAA,MACjC;AAEA;AAAA,IACD;AAEA,UAAM,mEAAmE;AAIzE,SAAK,gBAAgB;AACrB,SAAK,aAAa;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,eAAe;AACd,UAAM,+BAA+B;AAGrC,QAAI,KAAK,WAAY;AAGrB,QACC,KAAK,cAAc,KAAK,eAAe,UAAU,QACjD,KAAK,cAAc,KAAK,eAAe,UAAU,YAChD;AACD,YAAM,oEAAoE;AAC1E,WAAK,sBAAsB;AAE3B,UAAI;AACJ,UAAI,KAAK,UAAU,aAAa;AAG/B,aAAK,gBAAgB,KAAK,YAAY;AACtC,oBAAY,KAAK;AAAA,MAClB,OAAO;AACN,oBACC,KAAK,qBAAqB,OACvB,KAAK,mBAAmB,KAAK,gBAAgB,KAAK,IAAI,IACtD;AAAA,MACL;AAEA,UAAI,YAAY,GAAG;AAClB,cAAM,sDAAsD,SAAS;AAErE,aAAK,QAAQ;AAEb,aAAK,mBAAmB,WAAW,MAAM,KAAK,aAAa,GAAG,SAAS;AAAA,MACxE,OAAO;AAEN,aAAK,QAAQ;AAEb,aAAK,gBAAgB,KAAK;AAAA,UACzB,KAAK,YAAY;AAAA,UACjB,KAAK,IAAI,KAAK,YAAY,GAAG,KAAK,aAAa,IAAI;AAAA,QACpD;AACA;AAAA,UACC;AAAA,UACA,KAAK;AAAA,QACN;AACA,aAAK,gBAAgB;AAAA,MACtB;AAAA,IACD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,YAAY;AACX,UAAM,4BAA4B;AAElC,QAAI,KAAK,cAAc,KAAK,eAAe,UAAU,MAAM;AAC1D,YAAM,+CAA+C;AACrD,WAAK,QAAQ;AACb,WAAK,sBAAsB;AAC3B,WAAK,gBAAgB;AAAA,IACtB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QAAQ;AACP,SAAK,YAAY,QAAQ,CAAC,MAAM,EAAE,CAAC;AACnC,SAAK,aAAa;AAAA,EACnB;AACD;AAEA,SAAS,SAAS,KAAa;AAC9B,SAAO,IAAI,QAAQ,cAAc,OAAO;AACzC;",
6
6
  "names": []
7
7
  }