UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

369 lines (368 loc) • 12.8 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; // 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(); } 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" ); // eslint-disable-next-line no-restricted-syntax get connectionStatus() { const status = this._connectionStatus.get(); return status === "initial" ? "offline" : status; } 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(); 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(); 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); }; } 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 { 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; } } 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(); } 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(); } } } 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; } } close() { this.disposables.forEach((d) => d()); this.isDisposed = true; } } function httpToWs(url) { return url.replace(/^http(s)?:/, "ws$1:"); } //# sourceMappingURL=ClientWebSocketAdapter.js.map