@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
352 lines (351 loc) • 11.5 kB
JavaScript
import { atom } from "@tldraw/state";
import { assert, warnOnce } from "@tldraw/utils";
import { chunk } from "./chunk.mjs";
import {
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason
} from "./TLSyncClient.mjs";
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 || TLSyncErrorCloseEventReason.UNKNOWN_ERROR;
debug("handleDisconnect", {
currentStatus: this.connectionStatus,
closeCode,
reason
});
let newStatus;
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) {
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;
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) => {
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();
this._ws = null;
this._handleDisconnect("manual");
}
// TLPersistentClientSocket stuff
_connectionStatus = 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) {
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);
}
}
messageListeners = /* @__PURE__ */ new Set();
onReceiveMessage(cb) {
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) {
assert(!this.isDisposed, "Tried to add status listener on a disposed socket");
this.statusListeners.add(cb);
return () => {
this.statusListeners.delete(cb);
};
}
restart() {
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() {
assert(this.state === "pendingAttempt");
debug("scheduling a connection attempt");
Promise.resolve(this.getUri()).then((uri) => {
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";
});
}
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");
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:");
}
export {
ACTIVE_MAX_DELAY,
ACTIVE_MIN_DELAY,
ATTEMPT_TIMEOUT,
ClientWebSocketAdapter,
DELAY_EXPONENT,
INACTIVE_MAX_DELAY,
INACTIVE_MIN_DELAY,
ReconnectManager
};
//# sourceMappingURL=ClientWebSocketAdapter.mjs.map