@tldraw/sync-core 5.2.4 → 5.3.0-canary.00f14de1f81d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist-cjs/index.js CHANGED
@@ -61,7 +61,7 @@ var import_TLSyncRoom = require("./lib/TLSyncRoom");
61
61
  var import_TLSyncStorage = require("./lib/TLSyncStorage");
62
62
  (0, import_utils.registerTldrawLibraryVersion)(
63
63
  "@tldraw/sync-core",
64
- "5.2.4",
64
+ "5.3.0-canary.00f14de1f81d",
65
65
  "cjs"
66
66
  );
67
67
  //# sourceMappingURL=index.js.map
@@ -160,7 +160,8 @@ class ClientWebSocketAdapter {
160
160
  try {
161
161
  parsed = JSON.parse(ev.data.toString());
162
162
  } catch {
163
- (0, import_utils.warnOnce)("Received malformed WebSocket message. Dropping message.");
163
+ (0, import_utils.warnOnce)("Received malformed WebSocket message. Restarting the connection.");
164
+ this.restart();
164
165
  return;
165
166
  }
166
167
  this.messageListeners.forEach((cb) => cb(parsed));
@@ -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// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\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\twarnOnce('Received malformed WebSocket message. Dropping message.')\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// TLPersistentClientSocket stuff\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 scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\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\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": "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",
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// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\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// TLPersistentClientSocket stuff\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 scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\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\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": "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",
6
6
  "names": []
7
7
  }
@@ -36,7 +36,7 @@ import {
36
36
  } from "./lib/TLSyncStorage.mjs";
37
37
  registerTldrawLibraryVersion(
38
38
  "@tldraw/sync-core",
39
- "5.2.4",
39
+ "5.3.0-canary.00f14de1f81d",
40
40
  "esm"
41
41
  );
42
42
  export {
@@ -133,7 +133,8 @@ class ClientWebSocketAdapter {
133
133
  try {
134
134
  parsed = JSON.parse(ev.data.toString());
135
135
  } catch {
136
- warnOnce("Received malformed WebSocket message. Dropping message.");
136
+ warnOnce("Received malformed WebSocket message. Restarting the connection.");
137
+ this.restart();
137
138
  return;
138
139
  }
139
140
  this.messageListeners.forEach((cb) => cb(parsed));
@@ -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// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\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\twarnOnce('Received malformed WebSocket message. Dropping message.')\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// TLPersistentClientSocket stuff\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 scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\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\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;AAE7B,SAAK,KAAK,MAAM;AAAA,EACjB;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;AACP,iBAAS,yDAAyD;AAClE;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;AAAA,EAIA,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,EAEQ,kBAAkB;AACzB,WAAO,KAAK,UAAU,gBAAgB;AACtC,UAAM,iCAAiC;AACvC,YAAQ,QAAQ,KAAK,OAAO,CAAC,EAAE,KAAK,CAAC,QAAQ;AAE5C,UAAI,KAAK,UAAU,oBAAoB,KAAK,WAAY;AACxD;AAAA,QACC,KAAK,cAAc,KAAK,eAAe,UAAU;AAAA,QACjD;AAAA,MACD;AAEA,WAAK,mBAAmB,KAAK,IAAI;AACjC,WAAK,cAAc,cAAc,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC;AAC7D,WAAK,QAAQ;AAAA,IACd,CAAC;AAAA,EACF;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;AAKrC,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 } 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// WebSocket.close() is idempotent\n\t\tthis._ws?.close()\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// TLPersistentClientSocket stuff\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 scheduleAttempt() {\n\t\tassert(this.state === 'pendingAttempt')\n\t\tdebug('scheduling a connection attempt')\n\t\tPromise.resolve(this.getUri()).then((uri) => {\n\t\t\t// this can happen if the promise gets resolved too late\n\t\t\tif (this.state !== 'pendingAttempt' || this.isDisposed) return\n\t\t\tassert(\n\t\t\t\tthis.socketAdapter._ws?.readyState !== WebSocket.OPEN,\n\t\t\t\t'There should be no connection attempts while already connected'\n\t\t\t)\n\n\t\t\tthis.lastAttemptStart = Date.now()\n\t\t\tthis.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))\n\t\t\tthis.state = 'pendingAttemptResult'\n\t\t})\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\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": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/sync-core",
3
3
  "description": "tldraw infinite canvas SDK (multiplayer sync).",
4
- "version": "5.2.4",
4
+ "version": "5.3.0-canary.00f14de1f81d",
5
5
  "author": {
6
6
  "name": "tldraw GB Ltd.",
7
7
  "email": "hello@tldraw.com"
@@ -49,17 +49,17 @@
49
49
  "@types/uuid-readable": "^0.0.3",
50
50
  "react": "^19.2.1",
51
51
  "react-dom": "^19.2.1",
52
- "tldraw": "5.2.4",
52
+ "tldraw": "5.3.0-canary.00f14de1f81d",
53
53
  "typescript": "^5.8.3",
54
54
  "uuid-by-string": "^4.0.0",
55
55
  "uuid-readable": "^0.0.2",
56
56
  "vitest": "^4.1.7"
57
57
  },
58
58
  "dependencies": {
59
- "@tldraw/state": "5.2.4",
60
- "@tldraw/store": "5.2.4",
61
- "@tldraw/tlschema": "5.2.4",
62
- "@tldraw/utils": "5.2.4",
59
+ "@tldraw/state": "5.3.0-canary.00f14de1f81d",
60
+ "@tldraw/store": "5.3.0-canary.00f14de1f81d",
61
+ "@tldraw/tlschema": "5.3.0-canary.00f14de1f81d",
62
+ "@tldraw/utils": "5.3.0-canary.00f14de1f81d",
63
63
  "nanoevents": "^7.0.1",
64
64
  "ws": "^8.18.0"
65
65
  },
@@ -410,23 +410,35 @@ describe('ClientWebSocketAdapter', () => {
410
410
  expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
411
411
  })
412
412
 
413
- it('[CW7] drops malformed JSON messages without closing the socket', async () => {
413
+ it('[CW7] restarts the connection on a malformed JSON message instead of forwarding it', async () => {
414
414
  const warnOnceMock = vi.mocked(warnOnce)
415
415
  const onMessage = vi.fn()
416
+ const onStatusChange = vi.fn()
416
417
  adapter.onReceiveMessage(onMessage)
418
+ adapter.onStatusChange(onStatusChange)
417
419
 
418
420
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
421
+ const prevWs = adapter._ws
419
422
  warnOnceMock.mockClear()
420
423
 
421
424
  connectedServerSocket.send('{ "type": "message",')
422
- connectedServerSocket.send('{ "type": "message", "data": "hello" }')
423
425
 
424
- await waitFor(() => onMessage.mock.calls.length === 1)
425
- expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
426
- expect(adapter.connectionStatus).toBe('online')
426
+ // the malformed message is never forwarded to listeners, and the connection restarts so
427
+ // the client re-hydrates from the last known server clock rather than silently desyncing
428
+ await waitFor(() => adapter._ws !== prevWs && adapter._ws?.readyState === WebSocket.OPEN)
429
+ expect(onMessage).not.toHaveBeenCalled()
427
430
  expect(warnOnceMock).toHaveBeenCalledWith(
428
- 'Received malformed WebSocket message. Dropping message.'
431
+ 'Received malformed WebSocket message. Restarting the connection.'
429
432
  )
433
+ expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
434
+ expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
435
+ expect(adapter.connectionStatus).toBe('online')
436
+
437
+ // the restarted connection is fully functional: a well-formed message on the new socket
438
+ // is delivered to listeners as usual
439
+ connectedServerSocket.send('{ "type": "message", "data": "hello" }')
440
+ await waitFor(() => onMessage.mock.calls.length === 1)
441
+ expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
430
442
  })
431
443
 
432
444
  it('[CW7] stops delivering messages after unsubscribe', async () => {
@@ -216,7 +216,13 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<
216
216
  try {
217
217
  parsed = JSON.parse(ev.data.toString())
218
218
  } catch {
219
- warnOnce('Received malformed WebSocket message. Dropping message.')
219
+ // A malformed message means the connection delivered corrupt data, so we can't trust
220
+ // that we're still in sync. Dropping the message can silently desync the client (e.g. a
221
+ // missed 'patch' is applied as if it never happened), so instead we restart the
222
+ // connection. Reconnecting re-hydrates the store from the last known server clock and
223
+ // brings us fully back in sync.
224
+ warnOnce('Received malformed WebSocket message. Restarting the connection.')
225
+ this.restart()
220
226
  return
221
227
  }
222
228
  this.messageListeners.forEach((cb) => cb(parsed))
@@ -0,0 +1,285 @@
1
+ import { computed } from '@tldraw/state'
2
+ import {
3
+ BaseRecord,
4
+ RecordId,
5
+ RecordsDiff,
6
+ Store,
7
+ StoreSchema,
8
+ createRecordType,
9
+ } from '@tldraw/store'
10
+ import { isEqual } from '@tldraw/utils'
11
+ import { afterEach, describe, expect, it } from 'vitest'
12
+ import { ValueOpType } from '../lib/diff'
13
+ import { TLPushRequest } from '../lib/protocol'
14
+ import { TLSyncClient } from '../lib/TLSyncClient'
15
+ import { TestServer } from './TestServer'
16
+ import { TestSocketPair } from './TestSocketPair'
17
+
18
+ // These tests pin two invariants of TLSyncClient.rebase that hold *emergently* — they fall out of
19
+ // the tldraw Store's mergeRemoteChanges / history squashing rather than being asserted anywhere.
20
+ // They only manifest on STAGGERED commit acks: when the client has several pending (unacked) pushes
21
+ // and the server commits them one at a time, each ack triggering its own rebase() while the later
22
+ // pushes are still in flight.
23
+ //
24
+ // Invariant 1 — a staggered commit ack emits no net change to store listeners. rebase() rewinds the
25
+ // speculative changes, applies the committed push, then re-applies the still-pending pushes, all
26
+ // inside a single store.mergeRemoteChanges. The rewind and re-apply must cancel, so a listener
27
+ // registered before the ack observes no net document change (either no history entry, or a
28
+ // net-zero 'remote' entry whose before/after are value-equal), while the store still holds the full
29
+ // optimistic state.
30
+ //
31
+ // Invariant 2 — after such an ack, speculativeChanges must span *from the committed (server-acked)
32
+ // state*, not from a mid-flight state. reverseRecordsDiff(speculativeChanges) is used as the rewind
33
+ // on the next incoming message; if its `from` were a mid-flight state, base-relative ops (string
34
+ // Append ops, per-index array patches — see diffArray in ../lib/diff.ts) would be applied to the
35
+ // wrong base and silently corrupt records, because the apply guards skip mismatched appends rather
36
+ // than erroring. To exercise those base-relative ops, every change here is append-shaped.
37
+ //
38
+ // Harness: a real TestServer / TestSocketPair (as in TLSyncRoom.test.ts). Because the throttles are
39
+ // pass-through under NODE_ENV=test, every store change, push, and rebase runs synchronously — no
40
+ // fake timers needed. We deliver each client push to the server individually so the server emits one
41
+ // push_result at a time, letting us drive genuinely staggered acks.
42
+
43
+ interface TestDoc extends BaseRecord<'doc', RecordId<TestDoc>> {
44
+ text: string
45
+ items: number[]
46
+ }
47
+ const TestDocType = createRecordType<TestDoc>('doc', {
48
+ scope: 'document',
49
+ validator: { validate: (value) => value as TestDoc },
50
+ })
51
+
52
+ interface TestPresence extends BaseRecord<'presence', RecordId<TestPresence>> {
53
+ name: string
54
+ }
55
+ const TestPresenceType = createRecordType<TestPresence>('presence', {
56
+ scope: 'presence',
57
+ validator: { validate: (value) => value as TestPresence },
58
+ })
59
+
60
+ const testSchema = StoreSchema.create<TestDoc | TestPresence>({
61
+ doc: TestDocType,
62
+ presence: TestPresenceType,
63
+ })
64
+
65
+ type R = TestDoc | TestPresence
66
+
67
+ interface Instance {
68
+ server: TestServer<R>
69
+ socketPair: TestSocketPair<R>
70
+ client: TLSyncClient<R>
71
+ store: Store<R>
72
+ docId: RecordId<TestDoc>
73
+ }
74
+
75
+ const disposables: Array<() => void> = []
76
+ afterEach(() => {
77
+ while (disposables.length) disposables.pop()!()
78
+ })
79
+
80
+ /**
81
+ * Stand up a connected client + server whose single confirmed (server-acked) document is `doc0`.
82
+ * Pre-seeding the server means the connect hydration installs `doc0` as a synced record, so the
83
+ * subsequent appends are updates against a known server base rather than fresh creations.
84
+ */
85
+ function makeInstance(doc0: TestDoc): Instance {
86
+ const server = new TestServer<R>(testSchema, {
87
+ documents: [{ state: doc0, lastChangedClock: 0 }],
88
+ clock: 0,
89
+ documentClock: 0,
90
+ schema: testSchema.serialize(),
91
+ })
92
+ const socketPair = new TestSocketPair<R>('rebase-test', server)
93
+ socketPair.connect()
94
+
95
+ const store = new Store<R>({ schema: testSchema, props: {} })
96
+ const client = new TLSyncClient<R>({
97
+ store,
98
+ socket: socketPair.clientSocket,
99
+ presence: computed('presence', () => null),
100
+ onLoad: () => {},
101
+ onSyncError: (reason) => {
102
+ throw new Error(`unexpected sync error: ${reason}`)
103
+ },
104
+ })
105
+ disposables.push(() => client.close())
106
+
107
+ // complete the connect handshake (no pushes are in flight yet, so this only hydrates)
108
+ while (socketPair.getNeedsFlushing()) {
109
+ socketPair.flushClientSentEvents()
110
+ socketPair.flushServerSentEvents()
111
+ }
112
+ expect(store.get(doc0.id)).toEqual(doc0)
113
+
114
+ return { server, socketPair, client, store, docId: doc0.id }
115
+ }
116
+
117
+ function getQueuedPushes(inst: Instance): TLPushRequest<R>[] {
118
+ return inst.socketPair.clientSentEventQueue.filter(
119
+ (m): m is TLPushRequest<R> => m.type === 'push'
120
+ )
121
+ }
122
+
123
+ function pendingPushCount(inst: Instance): number {
124
+ return (inst.client as any).pendingPushRequests.length
125
+ }
126
+
127
+ function speculativeChanges(inst: Instance): RecordsDiff<R> {
128
+ return (inst.client as any).speculativeChanges
129
+ }
130
+
131
+ /**
132
+ * Deliver exactly one queued client push to the server, then hand the server's resulting
133
+ * push_result(s) back to the client — driving a single rebase() while the later pushes stay
134
+ * pending. Returns the push_results the client received. flushDebouncingMessages resets the
135
+ * server's data-message debounce so each push_result is emitted immediately in its own message.
136
+ */
137
+ function ackNextPush(inst: Instance) {
138
+ const { server, socketPair } = inst
139
+ server.flushDebouncingMessages()
140
+
141
+ const clientMsg = socketPair.clientSentEventQueue.shift()
142
+ if (!clientMsg || clientMsg.type !== 'push') {
143
+ throw new Error(`expected a queued push, got ${clientMsg?.type ?? 'nothing'}`)
144
+ }
145
+ socketPair.didReceiveFromClient!(clientMsg)
146
+
147
+ server.flushDebouncingMessages()
148
+ const serverMsgs = socketPair.serverSentEventQueue.splice(0)
149
+ for (const msg of serverMsgs) socketPair.callbacks.onReceiveMessage!(msg)
150
+
151
+ const results: any[] = []
152
+ for (const msg of serverMsgs) {
153
+ if (msg.type === 'data') {
154
+ for (const d of msg.data) if (d.type === 'push_result') results.push(d)
155
+ }
156
+ }
157
+ return results
158
+ }
159
+
160
+ /** True when a diff represents no net change: no adds, no removes, and every update is value-equal. */
161
+ function isNetZero(diff: RecordsDiff<R>): boolean {
162
+ if (Object.keys(diff.added).length > 0) return false
163
+ if (Object.keys(diff.removed).length > 0) return false
164
+ return Object.values(diff.updated).every(([from, to]) => isEqual(from, to))
165
+ }
166
+
167
+ describe('TLSyncClient staggered rebase invariants', () => {
168
+ it('invariant 1: a staggered commit ack for an in-flight push emits no net document change', () => {
169
+ const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
170
+ const { store, docId } = inst
171
+
172
+ // three sequential appends to the same record — three in-flight pushes
173
+ store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
174
+ store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
175
+ store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
176
+
177
+ const pushes = getQueuedPushes(inst)
178
+ expect(pushes.map((p) => p.clientClock)).toEqual([0, 1, 2])
179
+ // the pushes carry base-relative Append ops (not base-independent Puts)
180
+ expect(pushes[0].diff![docId][0]).toBe('patch')
181
+ expect((pushes[0].diff![docId][1] as any).text).toEqual([ValueOpType.Append, 'A', 0])
182
+ expect(pendingPushCount(inst)).toBe(3)
183
+
184
+ const before = store.get(docId)
185
+ expect((before as TestDoc).text).toBe('ABC')
186
+
187
+ // register a listener AFTER the optimistic state is in place, then commit only the first push
188
+ const received: Array<{ changes: RecordsDiff<R>; source: string }> = []
189
+ const unlisten = store.listen((entry) => received.push(entry as any), {
190
+ scope: 'document',
191
+ source: 'all',
192
+ })
193
+
194
+ const results = ackNextPush(inst)
195
+ expect(results).toHaveLength(1)
196
+ expect(results[0]).toMatchObject({ type: 'push_result', clientClock: 0, action: 'commit' })
197
+
198
+ // (a) the ack produced no net change for listeners — any entry seen is a net-zero remote entry
199
+ for (const entry of received) {
200
+ expect(entry.source).toBe('remote')
201
+ expect(isNetZero(entry.changes)).toBe(true)
202
+ }
203
+ // (b) the store still holds the full optimistic state after all three local changes
204
+ expect(store.get(docId)).toEqual(before)
205
+ expect((store.get(docId) as TestDoc).text).toBe('ABC')
206
+ // the ack drained exactly one pending push; the other two remain in flight
207
+ expect(pendingPushCount(inst)).toBe(2)
208
+
209
+ unlisten()
210
+ })
211
+
212
+ it('invariant 2: after a staggered commit, speculativeChanges spans from the committed state, not a mid-flight one', () => {
213
+ const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
214
+ const { store, docId } = inst
215
+
216
+ store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
217
+ store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
218
+ store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
219
+ expect((store.get(docId) as TestDoc).text).toBe('ABC')
220
+
221
+ // commit push 1 only (the other two stay pending)
222
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
223
+ expect(pendingPushCount(inst)).toBe(2)
224
+
225
+ // speculativeChanges must now be the squash of the two REMAINING pushes, with `from` equal to
226
+ // the committed (server-acked) state — the state after push 1 ('A'), NOT after push 2 ('AB').
227
+ const spec1 = speculativeChanges(inst)
228
+ expect((spec1.updated[docId][0] as TestDoc).text).toBe('A')
229
+ expect((spec1.updated[docId][1] as TestDoc).text).toBe('ABC')
230
+ // the optimistic state is untouched by the ack
231
+ expect((store.get(docId) as TestDoc).text).toBe('ABC')
232
+
233
+ // commit push 2: `from` advances to the new committed state ('AB'), still base-correct
234
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
235
+ expect(pendingPushCount(inst)).toBe(1)
236
+ const spec2 = speculativeChanges(inst)
237
+ expect((spec2.updated[docId][0] as TestDoc).text).toBe('AB')
238
+ expect((spec2.updated[docId][1] as TestDoc).text).toBe('ABC')
239
+ expect((store.get(docId) as TestDoc).text).toBe('ABC')
240
+
241
+ // commit push 3: nothing pending, so speculativeChanges is empty and the value has converged
242
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
243
+ expect(pendingPushCount(inst)).toBe(0)
244
+ expect(speculativeChanges(inst).updated[docId]).toBeUndefined()
245
+ expect((store.get(docId) as TestDoc).text).toBe('ABC')
246
+
247
+ // the server converged to the exact final value across the staggered commits
248
+ const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
249
+ ?.state as TestDoc
250
+ expect(serverDoc.text).toBe('ABC')
251
+ })
252
+
253
+ it('invariant 2: base-relative array appends converge across staggered commit acks', () => {
254
+ const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
255
+ const { store, docId } = inst
256
+
257
+ store.update(docId, (d) => ({ ...d, items: [...d.items, 1] }))
258
+ store.update(docId, (d) => ({ ...d, items: [...d.items, 2] }))
259
+ store.update(docId, (d) => ({ ...d, items: [...d.items, 3] }))
260
+ expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
261
+
262
+ // the array pushes carry base-relative Append ops (offset-relative to the diff base)
263
+ const pushes = getQueuedPushes(inst)
264
+ expect((pushes[0].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [1], 0])
265
+ expect((pushes[1].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [2], 1])
266
+
267
+ // commit push 1: `from` of the remaining speculative diff is the committed array state [1]
268
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
269
+ const spec1 = speculativeChanges(inst)
270
+ expect((spec1.updated[docId][0] as TestDoc).items).toEqual([1])
271
+ expect((spec1.updated[docId][1] as TestDoc).items).toEqual([1, 2, 3])
272
+ expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
273
+
274
+ // commit the rest, one staggered ack at a time; base-relative appends stay aligned
275
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
276
+ expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
277
+ expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
278
+ expect(pendingPushCount(inst)).toBe(0)
279
+ expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
280
+
281
+ const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
282
+ ?.state as TestDoc
283
+ expect(serverDoc.items).toEqual([1, 2, 3])
284
+ })
285
+ })