@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
369 lines (368 loc) • 12.8 kB
JavaScript
"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