UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

715 lines (648 loc) • 23 kB
import { atom, Atom } from '@tldraw/state' import { TLRecord } from '@tldraw/tlschema' import { assert, warnOnce } from '@tldraw/utils' import { chunk } from './chunk' import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol' import { TLPersistentClientSocket, TLPersistentClientSocketStatus, TLSocketStatusListener, TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason, } from './TLSyncClient' function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) { target.addEventListener(event, handler) return () => { target.removeEventListener(event, handler) } } function debug(...args: any[]) { // @ts-ignore if (typeof window !== 'undefined' && window.__tldraw_socket_debug) { const now = new Date() // eslint-disable-next-line no-console console.log( `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`, ...args //, new Error().stack ) } } // NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss // detection, for example by regularly pinging the server and .restart()ing // the connection when a number of pings goes unanswered. Without this mechanism, // we might not be able to detect the websocket connection going down in a timely manner // (it will probably time out on outgoing data packets at some point). // // This is by design. While the Websocket protocol specifies protocol-level pings, // they don't seem to be surfaced in browser APIs and can't be relied on. Therefore, // pings need to be implemented one level up, on the application API side, which for our // codebase means whatever code that uses ClientWebSocketAdapter. /** * A WebSocket adapter that provides persistent connection management for tldraw synchronization. * This adapter handles connection establishment, reconnection logic, and message routing between * the sync client and server. It implements automatic reconnection with exponential backoff * and supports connection loss detection. * * Note: This adapter requires users to implement their own connection loss detection (e.g., pings) * as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames. * * @internal * @example * ```ts * // Create a WebSocket adapter with connection URI * const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync') * * // Listen for connection status changes * adapter.onStatusChange((status) => { * console.log('Connection status:', status) * }) * * // Listen for incoming messages * adapter.onReceiveMessage((message) => { * console.log('Received:', message) * }) * * // Send a message when connected * if (adapter.connectionStatus === 'online') { * adapter.sendMessage({ type: 'ping' }) * } * ``` */ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLSocketClientSentEvent<TLRecord>, TLSocketServerSentEvent<TLRecord>> { _ws: WebSocket | null = null isDisposed = false /** @internal */ readonly _reconnectManager: ReconnectManager /** * Permanently closes the WebSocket adapter and disposes of all resources. * Once closed, the adapter cannot be reused and should be discarded. * This method is idempotent - calling it multiple times has no additional effect. */ // TODO: .close should be a project-wide interface with a common contract (.close()d thing // can only be garbage collected, and can't be used anymore) close() { this.isDisposed = true this._reconnectManager.close() // WebSocket.close() is idempotent this._ws?.close() } /** * Creates a new ClientWebSocketAdapter instance. * * @param getUri - Function that returns the WebSocket URI to connect to. * Can return a string directly or a Promise that resolves to a string. * This function is called each time a connection attempt is made, * allowing for dynamic URI generation (e.g., for authentication tokens). */ constructor(getUri: () => Promise<string> | string) { this._reconnectManager = new ReconnectManager(this, getUri) } private _handleConnect() { debug('handleConnect') this._connectionStatus.set('online') this.statusListeners.forEach((cb) => cb({ status: 'online' })) this._reconnectManager.connected() } private _handleDisconnect( reason: 'closed' | 'manual', closeCode?: number, didOpen?: boolean, closeReason?: string ) { closeReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR debug('handleDisconnect', { currentStatus: this.connectionStatus, closeCode, reason, }) let newStatus: 'offline' | 'error' switch (reason) { case 'closed': if (closeCode === TLSyncErrorCloseEventCode) { newStatus = 'error' } else { newStatus = 'offline' } break case 'manual': newStatus = 'offline' break } if (closeCode === 1006 && !didOpen) { warnOnce( "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." ) } if ( // it the status changed this.connectionStatus !== newStatus && // ignore errors if we're already in the offline state !(newStatus === 'error' && this.connectionStatus === 'offline') ) { this._connectionStatus.set(newStatus) this.statusListeners.forEach((cb) => cb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus }) ) } this._reconnectManager.disconnected() } _setNewSocket(ws: WebSocket) { assert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket') assert( this._ws === null || this._ws.readyState === WebSocket.CLOSED || this._ws.readyState === WebSocket.CLOSING, `Tried to set a new websocket in when the existing one was ${this._ws?.readyState}` ) let didOpen = false // NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition // between CLOSING and CLOSED happens either after the closing handshake, or after a // timeout, but in either case those sockets don't need any special handling, the browser // will close them eventually. We just "orphan" such sockets and ignore their onclose/onerror. ws.onopen = () => { debug('ws.onopen') assert( this._ws === ws, "sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open" ) didOpen = true this._handleConnect() } ws.onclose = (event: CloseEvent) => { debug('ws.onclose', event) if (this._ws === ws) { this._handleDisconnect('closed', event.code, didOpen, event.reason) } else { debug('ignoring onclose for an orphaned socket') } } ws.onerror = (event) => { debug('ws.onerror', event) if (this._ws === ws) { this._handleDisconnect('closed') } else { debug('ignoring onerror for an orphaned socket') } } ws.onmessage = (ev) => { assert( this._ws === ws, "sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages" ) const parsed = JSON.parse(ev.data.toString()) this.messageListeners.forEach((cb) => cb(parsed)) } this._ws = ws } _closeSocket() { if (this._ws === null) return this._ws.close() // explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed this._ws = null this._handleDisconnect('manual') } // TLPersistentClientSocket stuff _connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom( 'websocket connection status', 'initial' ) /** * Gets the current connection status of the WebSocket. * * @returns The current connection status: 'online', 'offline', or 'error' */ // eslint-disable-next-line no-restricted-syntax get connectionStatus(): TLPersistentClientSocketStatus { const status = this._connectionStatus.get() return status === 'initial' ? 'offline' : status } /** * Sends a message to the server through the WebSocket connection. * Messages are automatically chunked if they exceed size limits. * * @param msg - The message to send to the server * * @example * ```ts * adapter.sendMessage({ * type: 'push', * diff: { 'shape:abc123': [2, { x: [1, 150] }] } * }) * ``` */ sendMessage(msg: TLSocketClientSentEvent<TLRecord>) { assert(!this.isDisposed, 'Tried to send message on a disposed socket') if (!this._ws) return if (this.connectionStatus === 'online') { const chunks = chunk(JSON.stringify(msg)) for (const part of chunks) { this._ws.send(part) } } else { console.warn('Tried to send message while ' + this.connectionStatus) } } private messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>() /** * Registers a callback to handle incoming messages from the server. * * @param cb - Callback function that will be called with each received message * @returns A cleanup function to remove the message listener * * @example * ```ts * const unsubscribe = adapter.onReceiveMessage((message) => { * switch (message.type) { * case 'connect': * console.log('Connected to room') * break * case 'data': * console.log('Received data:', message.diff) * break * } * }) * * // Later, remove the listener * unsubscribe() * ``` */ onReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) { assert(!this.isDisposed, 'Tried to add message listener on a disposed socket') this.messageListeners.add(cb) return () => { this.messageListeners.delete(cb) } } private statusListeners = new Set<TLSocketStatusListener>() /** * Registers a callback to handle connection status changes. * * @param cb - Callback function that will be called when the connection status changes * @returns A cleanup function to remove the status listener * * @example * ```ts * const unsubscribe = adapter.onStatusChange((status) => { * if (status.status === 'error') { * console.error('Connection error:', status.reason) * } else { * console.log('Status changed to:', status.status) * } * }) * * // Later, remove the listener * unsubscribe() * ``` */ onStatusChange(cb: TLSocketStatusListener) { assert(!this.isDisposed, 'Tried to add status listener on a disposed socket') this.statusListeners.add(cb) return () => { this.statusListeners.delete(cb) } } /** * Manually restarts the WebSocket connection. * This closes the current connection (if any) and attempts to establish a new one. * Useful for implementing connection loss detection and recovery. * * @example * ```ts * // Restart connection after detecting it's stale * if (lastPongTime < Date.now() - 30000) { * adapter.restart() * } * ``` */ restart() { assert(!this.isDisposed, 'Tried to restart a disposed socket') debug('restarting') this._closeSocket() this._reconnectManager.maybeReconnected() } } /** * Minimum reconnection delay in milliseconds when the browser tab is active and focused. * * @internal */ export const ACTIVE_MIN_DELAY = 500 /** * Maximum reconnection delay in milliseconds when the browser tab is active and focused. * * @internal */ export const ACTIVE_MAX_DELAY = 2000 /** * Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden. * This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab. * * @internal */ export const INACTIVE_MIN_DELAY = 1000 /** * Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden. * Set to 5 minutes to balance between maintaining sync and conserving resources. * * @internal */ export const INACTIVE_MAX_DELAY = 1000 * 60 * 5 /** * Exponential backoff multiplier for calculating reconnection delays. * Each failed connection attempt increases the delay by this factor until max delay is reached. * * @internal */ export const DELAY_EXPONENT = 1.5 /** * Maximum time in milliseconds to wait for a connection attempt before considering it failed. * This helps detect connections stuck in the CONNECTING state and retry with fresh attempts. * * @internal */ export const ATTEMPT_TIMEOUT = 1000 /** * Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies. * This class handles connection attempts, tracks connection state, and implements exponential backoff * with different delays based on whether the browser tab is active or inactive. * * The ReconnectManager responds to various browser events like network status changes, * tab visibility changes, and connection events to optimize reconnection timing and * minimize unnecessary connection attempts. * * @internal * * @example * ```ts * const manager = new ReconnectManager( * socketAdapter, * () => 'ws://localhost:3000/sync' * ) * * // Manager automatically handles: * // - Initial connection * // - Reconnection on disconnect * // - Exponential backoff on failures * // - Tab visibility-aware delays * // - Network status change responses * ``` */ export class ReconnectManager { private isDisposed = false private disposables: (() => void)[] = [ () => { if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout) if (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout) }, ] private reconnectTimeout: ReturnType<typeof setTimeout> | null = null private recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null private lastAttemptStart: number | null = null intendedDelay: number = ACTIVE_MIN_DELAY private state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected' /** * Creates a new ReconnectManager instance. * * socketAdapter - The ClientWebSocketAdapter instance to manage * getUri - Function that returns the WebSocket URI for connection attempts */ constructor( private socketAdapter: ClientWebSocketAdapter, private getUri: () => Promise<string> | string ) { this.subscribeToReconnectHints() this.disposables.push( listenTo(window, 'offline', () => { debug('window went offline') // On the one hand, 'offline' event is not really reliable; on the other, the only // alternative is to wait for pings not being delivered, which takes more than 20 seconds, // which means we won't see the ClientWebSocketAdapter status change for more than // 20 seconds after the tab goes offline. Our application layer must be resistent to // connection restart anyway, so we can just try to reconnect and see if // we're truly offline. this.socketAdapter._closeSocket() }) ) this.state = 'pendingAttempt' this.intendedDelay = ACTIVE_MIN_DELAY this.scheduleAttempt() } private subscribeToReconnectHints() { this.disposables.push( listenTo(window, 'online', () => { debug('window went online') this.maybeReconnected() }), listenTo(document, 'visibilitychange', () => { if (!document.hidden) { debug('document became visible') this.maybeReconnected() } }) ) if (Object.prototype.hasOwnProperty.call(navigator, 'connection')) { const connection = (navigator as any)['connection'] as EventTarget this.disposables.push( listenTo(connection, 'change', () => { debug('navigator.connection change') this.maybeReconnected() }) ) } } private scheduleAttempt() { assert(this.state === 'pendingAttempt') debug('scheduling a connection attempt') Promise.resolve(this.getUri()).then((uri) => { // this can happen if the promise gets resolved too late if (this.state !== 'pendingAttempt' || this.isDisposed) return assert( this.socketAdapter._ws?.readyState !== WebSocket.OPEN, 'There should be no connection attempts while already connected' ) this.lastAttemptStart = Date.now() this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri))) this.state = 'pendingAttemptResult' }) } private getMaxDelay() { return document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY } private getMinDelay() { return document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY } private clearReconnectTimeout() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout) this.reconnectTimeout = null } } private clearRecheckConnectingTimeout() { if (this.recheckConnectingTimeout) { clearTimeout(this.recheckConnectingTimeout) this.recheckConnectingTimeout = null } } /** * Checks if reconnection should be attempted and initiates it if appropriate. * This method is called in response to network events, tab visibility changes, * and other hints that connectivity may have been restored. * * The method intelligently handles various connection states: * - Already connected: no action needed * - Currently connecting: waits or retries based on attempt age * - Disconnected: initiates immediate reconnection attempt * * @example * ```ts * // Called automatically on network/visibility events, but can be called manually * manager.maybeReconnected() * ``` */ maybeReconnected() { debug('ReconnectManager.maybeReconnected') // It doesn't make sense to have another check scheduled if we're already checking it now. // If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway this.clearRecheckConnectingTimeout() // readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending) if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) { debug('ReconnectManager.maybeReconnected: already connected') // nothing to do, we're already OK return } if (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) { debug('ReconnectManager.maybeReconnected: connecting') // We might be waiting for a TCP connection that sent SYN out and will never get it back, // while a new connection appeared. On the other hand, we might have just started connecting // and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew // if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent // new connections as long as `maybeReconnected` is not looped itself assert( this.lastAttemptStart, 'ReadyState=CONNECTING without lastAttemptStart should be impossible' ) const sinceLastStart = Date.now() - this.lastAttemptStart if (sinceLastStart < ATTEMPT_TIMEOUT) { debug('ReconnectManager.maybeReconnected: connecting, rechecking later') this.recheckConnectingTimeout = setTimeout( () => this.maybeReconnected(), ATTEMPT_TIMEOUT - sinceLastStart ) } else { debug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now') // Last connection attempt was started a while ago, it's possible that network conditions // changed, and it's worth retrying to connect. `disconnected` will handle reconnection // // NOTE: The danger here is looping in connection attemps if connections are slow. // Make sure that `maybeReconnected` is not called in the `disconnected` codepath! this.clearRecheckConnectingTimeout() this.socketAdapter._closeSocket() } return } debug('ReconnectManager.maybeReconnected: closing/closed/null, retry now') // readyState is CLOSING or CLOSED, or the websocket is null // Restart the backoff and retry ASAP (honouring the min delay) // this.state doesn't really matter, because disconnected() will handle any state correctly this.intendedDelay = ACTIVE_MIN_DELAY this.disconnected() } /** * Handles disconnection events and schedules reconnection attempts with exponential backoff. * This method is called when the WebSocket connection is lost or fails to establish. * * It implements intelligent delay calculation based on: * - Previous attempt timing * - Current tab visibility (active vs inactive delays) * - Exponential backoff for repeated failures * * @example * ```ts * // Called automatically when connection is lost * // Schedules reconnection with appropriate delay * manager.disconnected() * ``` */ disconnected() { debug('ReconnectManager.disconnected') // This either means we're freshly disconnected, or the last connection attempt failed; // either way, time to try again. // Guard against delayed notifications and recheck synchronously if ( this.socketAdapter._ws?.readyState !== WebSocket.OPEN && this.socketAdapter._ws?.readyState !== WebSocket.CONNECTING ) { debug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING') this.clearReconnectTimeout() let delayLeft if (this.state === 'connected') { // it's the first sign that we got disconnected; the state will be updated below, // just set the appropriate delay for now this.intendedDelay = this.getMinDelay() delayLeft = this.intendedDelay } else { delayLeft = this.lastAttemptStart !== null ? this.lastAttemptStart + this.intendedDelay - Date.now() : 0 } if (delayLeft > 0) { debug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft) // try again later this.state = 'delay' this.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft) } else { // not connected and not delayed, time to retry this.state = 'pendingAttempt' this.intendedDelay = Math.min( this.getMaxDelay(), Math.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT ) debug( 'ReconnectManager.disconnected: attempting a connection, next delay', this.intendedDelay ) this.scheduleAttempt() } } } /** * Handles successful connection events and resets reconnection state. * This method is called when the WebSocket successfully connects to the server. * * It clears any pending reconnection attempts and resets the delay back to minimum * for future connection attempts. * * @example * ```ts * // Called automatically when WebSocket opens successfully * manager.connected() * ``` */ connected() { debug('ReconnectManager.connected') // this notification could've been delayed, recheck synchronously if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) { debug('ReconnectManager.connected: websocket is OPEN') this.state = 'connected' this.clearReconnectTimeout() this.intendedDelay = ACTIVE_MIN_DELAY } } /** * Permanently closes the reconnection manager and cleans up all resources. * This stops all pending reconnection attempts and removes event listeners. * Once closed, the manager cannot be reused. */ close() { this.disposables.forEach((d) => d()) this.isDisposed = true } } function httpToWs(url: string) { return url.replace(/^http(s)?:/, 'ws$1:') }