UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

513 lines (512 loc) • 17.7 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var ClientWebSocketAdapter_exports = {}; __export(ClientWebSocketAdapter_exports, { ACTIVE_MAX_DELAY: () => ACTIVE_MAX_DELAY, ACTIVE_MIN_DELAY: () => ACTIVE_MIN_DELAY, ATTEMPT_TIMEOUT: () => ATTEMPT_TIMEOUT, ClientWebSocketAdapter: () => ClientWebSocketAdapter, DELAY_EXPONENT: () => DELAY_EXPONENT, INACTIVE_MAX_DELAY: () => INACTIVE_MAX_DELAY, INACTIVE_MIN_DELAY: () => INACTIVE_MIN_DELAY, ReconnectManager: () => ReconnectManager }); module.exports = __toCommonJS(ClientWebSocketAdapter_exports); var import_state = require("@tldraw/state"); var import_utils = require("@tldraw/utils"); var import_chunk = require("./chunk"); var import_TLSyncClient = require("./TLSyncClient"); function listenTo(target, event, handler) { target.addEventListener(event, handler); return () => { target.removeEventListener(event, handler); }; } function debug(...args) { if (typeof window !== "undefined" && window.__tldraw_socket_debug) { const now = /* @__PURE__ */ new Date(); console.log( `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`, ...args //, new Error().stack ); } } class ClientWebSocketAdapter { _ws = null; isDisposed = false; /** @internal */ _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(); 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) { this._reconnectManager = new ReconnectManager(this, getUri); } _handleConnect() { debug("handleConnect"); this._connectionStatus.set("online"); this.statusListeners.forEach((cb) => cb({ status: "online" })); this._reconnectManager.connected(); } _handleDisconnect(reason, closeCode, didOpen, closeReason) { closeReason = closeReason || import_TLSyncClient.TLSyncErrorCloseEventReason.UNKNOWN_ERROR; debug("handleDisconnect", { currentStatus: this.connectionStatus, closeCode, reason }); let newStatus; switch (reason) { case "closed": if (closeCode === import_TLSyncClient.TLSyncErrorCloseEventCode) { newStatus = "error"; } else { newStatus = "offline"; } break; case "manual": newStatus = "offline"; break; } if (closeCode === 1006 && !didOpen) { (0, import_utils.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) { (0, import_utils.assert)(!this.isDisposed, "Tried to set a new websocket on a disposed socket"); (0, import_utils.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; ws.onopen = () => { debug("ws.onopen"); (0, import_utils.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) => { 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) => { (0, import_utils.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(); this._ws = null; this._handleDisconnect("manual"); } // TLPersistentClientSocket stuff _connectionStatus = (0, import_state.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() { 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) { (0, import_utils.assert)(!this.isDisposed, "Tried to send message on a disposed socket"); if (!this._ws) return; if (this.connectionStatus === "online") { const chunks = (0, import_chunk.chunk)(JSON.stringify(msg)); for (const part of chunks) { this._ws.send(part); } } else { console.warn("Tried to send message while " + this.connectionStatus); } } messageListeners = /* @__PURE__ */ new Set(); /** * 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) { (0, import_utils.assert)(!this.isDisposed, "Tried to add message listener on a disposed socket"); this.messageListeners.add(cb); return () => { this.messageListeners.delete(cb); }; } statusListeners = /* @__PURE__ */ new Set(); /** * 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) { (0, import_utils.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() { (0, import_utils.assert)(!this.isDisposed, "Tried to restart a disposed socket"); debug("restarting"); this._closeSocket(); this._reconnectManager.maybeReconnected(); } } const ACTIVE_MIN_DELAY = 500; const ACTIVE_MAX_DELAY = 2e3; const INACTIVE_MIN_DELAY = 1e3; const INACTIVE_MAX_DELAY = 1e3 * 60 * 5; const DELAY_EXPONENT = 1.5; const ATTEMPT_TIMEOUT = 1e3; class ReconnectManager { /** * Creates a new ReconnectManager instance. * * socketAdapter - The ClientWebSocketAdapter instance to manage * getUri - Function that returns the WebSocket URI for connection attempts */ constructor(socketAdapter, getUri) { this.socketAdapter = socketAdapter; this.getUri = getUri; this.subscribeToReconnectHints(); this.disposables.push( listenTo(window, "offline", () => { debug("window went offline"); this.socketAdapter._closeSocket(); }) ); this.state = "pendingAttempt"; this.intendedDelay = ACTIVE_MIN_DELAY; this.scheduleAttempt(); } isDisposed = false; disposables = [ () => { if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); if (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout); } ]; reconnectTimeout = null; recheckConnectingTimeout = null; lastAttemptStart = null; intendedDelay = ACTIVE_MIN_DELAY; state; 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["connection"]; this.disposables.push( listenTo(connection, "change", () => { debug("navigator.connection change"); this.maybeReconnected(); }) ); } } scheduleAttempt() { (0, import_utils.assert)(this.state === "pendingAttempt"); debug("scheduling a connection attempt"); Promise.resolve(this.getUri()).then((uri) => { if (this.state !== "pendingAttempt" || this.isDisposed) return; (0, import_utils.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"; }); } getMaxDelay() { return document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY; } getMinDelay() { return document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY; } clearReconnectTimeout() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } } 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"); this.clearRecheckConnectingTimeout(); if (this.socketAdapter._ws?.readyState === WebSocket.OPEN) { debug("ReconnectManager.maybeReconnected: already connected"); return; } if (this.socketAdapter._ws?.readyState === WebSocket.CONNECTING) { debug("ReconnectManager.maybeReconnected: connecting"); (0, import_utils.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"); this.clearRecheckConnectingTimeout(); this.socketAdapter._closeSocket(); } return; } debug("ReconnectManager.maybeReconnected: closing/closed/null, retry now"); 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"); 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") { 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); this.state = "delay"; this.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft); } else { 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"); 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) { return url.replace(/^http(s)?:/, "ws$1:"); } //# sourceMappingURL=ClientWebSocketAdapter.js.map