UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

464 lines (463 loc) • 17.4 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 TLSyncClient_exports = {}; __export(TLSyncClient_exports, { TLSyncClient: () => TLSyncClient, TLSyncErrorCloseEventCode: () => TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason: () => TLSyncErrorCloseEventReason }); module.exports = __toCommonJS(TLSyncClient_exports); var import_state = require("@tldraw/state"); var import_store = require("@tldraw/store"); var import_utils = require("@tldraw/utils"); var import_diff = require("./diff"); var import_interval = require("./interval"); var import_protocol = require("./protocol"); const TLSyncErrorCloseEventCode = 4099; const TLSyncErrorCloseEventReason = { NOT_FOUND: "NOT_FOUND", FORBIDDEN: "FORBIDDEN", NOT_AUTHENTICATED: "NOT_AUTHENTICATED", UNKNOWN_ERROR: "UNKNOWN_ERROR", CLIENT_TOO_OLD: "CLIENT_TOO_OLD", SERVER_TOO_OLD: "SERVER_TOO_OLD", INVALID_RECORD: "INVALID_RECORD", RATE_LIMITED: "RATE_LIMITED", ROOM_FULL: "ROOM_FULL" }; const PING_INTERVAL = 5e3; const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2; class TLSyncClient { /** The last clock time from the most recent server update */ lastServerClock = 0; lastServerInteractionTimestamp = Date.now(); /** The queue of in-flight push requests that have not yet been acknowledged by the server */ pendingPushRequests = []; /** * The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we * take this diff, reverse it, and apply that to the store, our store will match exactly the most * recent state of the server that we know about */ speculativeChanges = { added: {}, updated: {}, removed: {} }; disposables = []; store; socket; presenceState; // isOnline is true when we have an open socket connection and we have // established a connection with the server room (i.e. we have received a 'connect' message) isConnectedToRoom = false; /** * The client clock is essentially a counter for push requests Each time a push request is created * the clock is incremented. This clock is sent with the push request to the server, and the * server returns it with the response so that we can match up the response with the request. * * The clock may also be used at one point in the future to allow the client to re-send push * requests idempotently (i.e. the server will keep track of each client's clock and not execute * requests it has already handled), but at the time of writing this is neither needed nor * implemented. */ clientClock = 0; /** * Called immediately after a connect acceptance has been received and processed Use this to make * any changes to the store that are required to keep it operational */ onAfterConnect; isDebugging = false; debug(...args) { if (this.isDebugging) { console.debug(...args); } } presenceType; didCancel; constructor(config) { this.didCancel = config.didCancel; this.presenceType = config.store.scopedTypes.presence.values().next().value ?? null; if (typeof window !== "undefined") { ; window.tlsync = this; } this.store = config.store; this.socket = config.socket; this.onAfterConnect = config.onAfterConnect; let didLoad = false; this.presenceState = config.presence; this.disposables.push( // when local 'user' changes are made, send them to the server // or stash them locally in offline mode this.store.listen( ({ changes }) => { if (this.didCancel?.()) return this.close(); this.debug("received store changes", { changes }); this.push(changes); }, { source: "user", scope: "document" } ), // when the server sends us events, handle them this.socket.onReceiveMessage((msg) => { if (this.didCancel?.()) return this.close(); this.debug("received message from server", msg); this.handleServerEvent(msg); if (!didLoad) { didLoad = true; config.onLoad(this); } }), // handle switching between online and offline this.socket.onStatusChange((ev) => { if (this.didCancel?.()) return this.close(); this.debug("socket status changed", ev.status); if (ev.status === "online") { this.sendConnectMessage(); } else { this.resetConnection(); if (ev.status === "error") { didLoad = true; config.onSyncError(ev.reason); this.close(); } } }), // Send a ping every PING_INTERVAL ms while online (0, import_interval.interval)(() => { if (this.didCancel?.()) return this.close(); this.debug("ping loop", { isConnectedToRoom: this.isConnectedToRoom }); if (!this.isConnectedToRoom) return; try { this.socket.sendMessage({ type: "ping" }); } catch (error) { console.warn("ping failed, resetting", error); this.resetConnection(); } }, PING_INTERVAL), // Check the server connection health, reset the connection if needed (0, import_interval.interval)(() => { if (this.didCancel?.()) return this.close(); this.debug("health check loop", { isConnectedToRoom: this.isConnectedToRoom }); if (!this.isConnectedToRoom) return; const timeSinceLastServerInteraction = Date.now() - this.lastServerInteractionTimestamp; if (timeSinceLastServerInteraction < MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION) { this.debug("health check passed", { timeSinceLastServerInteraction }); return; } console.warn(`Haven't heard from the server in a while, resetting connection...`); this.resetConnection(); }, PING_INTERVAL * 2) ); if (this.presenceState) { this.disposables.push( (0, import_state.react)("pushPresence", () => { if (this.didCancel?.()) return this.close(); this.pushPresence(this.presenceState.get()); }) ); } if (this.socket.connectionStatus === "online") { this.sendConnectMessage(); } } latestConnectRequestId = null; /** * This is the first message that is sent over a newly established socket connection. And we need * to wait for the response before this client can be used. */ sendConnectMessage() { if (this.isConnectedToRoom) { console.error("sendConnectMessage called while already connected"); return; } this.debug("sending connect message"); this.latestConnectRequestId = (0, import_utils.uniqueId)(); this.socket.sendMessage({ type: "connect", connectRequestId: this.latestConnectRequestId, schema: this.store.schema.serialize(), protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(), lastServerClock: this.lastServerClock }); } /** Switch to offline mode */ resetConnection(hard = false) { this.debug("resetting connection"); if (hard) { this.lastServerClock = 0; } this.store.mergeRemoteChanges(() => { this.store.remove(Object.keys(this.store.serialize("presence"))); }); this.lastPushedPresenceState = null; this.isConnectedToRoom = false; this.pendingPushRequests = []; this.incomingDiffBuffer = []; if (this.socket.connectionStatus === "online") { this.socket.restart(); } } /** * Invoked when the socket connection comes online, either for the first time or as the result of * a reconnect. The goal is to rebase on the server's state and fire off a new push request for * any local changes that were made while offline. */ didReconnect(event) { this.debug("did reconnect", event); if (event.connectRequestId !== this.latestConnectRequestId) { return; } this.latestConnectRequestId = null; if (this.isConnectedToRoom) { console.error("didReconnect called while already connected"); this.resetConnection(true); return; } if (this.pendingPushRequests.length > 0) { console.error("pendingPushRequests should already be empty when we reconnect"); this.resetConnection(true); return; } (0, import_state.transact)(() => { const stashedChanges = this.speculativeChanges; this.speculativeChanges = { added: {}, updated: {}, removed: {} }; this.store.mergeRemoteChanges(() => { const wipeDiff = {}; const wipeAll = event.hydrationType === "wipe_all"; if (!wipeAll) { this.store.applyDiff((0, import_store.reverseRecordsDiff)(stashedChanges), { runCallbacks: false }); } for (const [id, record] of (0, import_utils.objectMapEntries)(this.store.serialize("all"))) { if (wipeAll && this.store.scopedTypes.document.has(record.typeName) || record.typeName === this.presenceType) { wipeDiff[id] = [import_diff.RecordOpType.Remove]; } } this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true); this.isConnectedToRoom = true; const speculativeChanges = this.store.filterChangesByScope( this.store.extractingChanges(() => { this.store.applyDiff(stashedChanges); }), "document" ); if (speculativeChanges) this.push(speculativeChanges); }); this.onAfterConnect?.(this, { isReadonly: event.isReadonly }); }); this.lastServerClock = event.serverClock; } incomingDiffBuffer = []; /** Handle events received from the server */ handleServerEvent(event) { this.debug("received server event", event); this.lastServerInteractionTimestamp = Date.now(); switch (event.type) { case "connect": this.didReconnect(event); break; // legacy v4 events case "patch": case "push_result": if (!this.isConnectedToRoom) break; this.incomingDiffBuffer.push(event); this.scheduleRebase(); break; case "data": if (!this.isConnectedToRoom) break; this.incomingDiffBuffer.push(...event.data); this.scheduleRebase(); break; case "incompatibility_error": console.error("incompatibility error is legacy and should no longer be sent by the server"); break; case "pong": break; default: (0, import_utils.exhaustiveSwitchError)(event); } } close() { this.debug("closing"); this.disposables.forEach((dispose) => dispose()); this.flushPendingPushRequests.cancel?.(); this.scheduleRebase.cancel?.(); } lastPushedPresenceState = null; pushPresence(nextPresence) { this.store._flushHistory(); if (!this.isConnectedToRoom) { return; } let presence = void 0; if (!this.lastPushedPresenceState && nextPresence) { presence = [import_diff.RecordOpType.Put, nextPresence]; } else if (this.lastPushedPresenceState && nextPresence) { const diff = (0, import_diff.diffRecord)(this.lastPushedPresenceState, nextPresence); if (diff) { presence = [import_diff.RecordOpType.Patch, diff]; } } if (!presence) return; this.lastPushedPresenceState = nextPresence; const lastPush = this.pendingPushRequests.at(-1); if (lastPush && !lastPush.sent && !lastPush.request.presence) { lastPush.request.presence = presence; return; } const req = { type: "push", clientClock: this.clientClock++, presence }; if (req) { this.pendingPushRequests.push({ request: req, sent: false }); this.flushPendingPushRequests(); } } /** Push a change to the server, or stash it locally if we're offline */ push(change) { this.debug("push", change); const diff = (0, import_diff.getNetworkDiff)(change); if (!diff) return; this.speculativeChanges = (0, import_store.squashRecordDiffs)([this.speculativeChanges, change]); if (!this.isConnectedToRoom) { return; } const pushRequest = { type: "push", diff, clientClock: this.clientClock++ }; this.pendingPushRequests.push({ request: pushRequest, sent: false }); this.flushPendingPushRequests(); } /** Send any unsent push requests to the server */ flushPendingPushRequests = (0, import_utils.fpsThrottle)(() => { this.debug("flushing pending push requests", { isConnectedToRoom: this.isConnectedToRoom, pendingPushRequests: this.pendingPushRequests }); if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) { return; } for (const pendingPushRequest of this.pendingPushRequests) { if (!pendingPushRequest.sent) { if (this.socket.connectionStatus !== "online") { return; } this.socket.sendMessage(pendingPushRequest.request); pendingPushRequest.sent = true; } } }); /** * Applies a 'network' diff to the store this does value-based equality checking so that if the * data is the same (as opposed to merely identical with ===), then no change is made and no * changes will be propagated back to store listeners */ applyNetworkDiff(diff, runCallbacks) { this.debug("applyNetworkDiff", diff); const changes = { added: {}, updated: {}, removed: {} }; let hasChanges = false; for (const [id, op] of (0, import_utils.objectMapEntries)(diff)) { if (op[0] === import_diff.RecordOpType.Put) { const existing = this.store.get(id); if (existing && !(0, import_utils.isEqual)(existing, op[1])) { hasChanges = true; changes.updated[id] = [existing, op[1]]; } else { hasChanges = true; changes.added[id] = op[1]; } } else if (op[0] === import_diff.RecordOpType.Patch) { const record = this.store.get(id); if (!record) { continue; } const patched = (0, import_diff.applyObjectDiff)(record, op[1]); hasChanges = true; changes.updated[id] = [record, patched]; } else if (op[0] === import_diff.RecordOpType.Remove) { if (this.store.has(id)) { hasChanges = true; changes.removed[id] = this.store.get(id); } } } if (hasChanges) { this.store.applyDiff(changes, { runCallbacks }); } } // eslint-disable-next-line local/prefer-class-methods rebase = () => { this.store._flushHistory(); if (this.incomingDiffBuffer.length === 0) return; const diffs = this.incomingDiffBuffer; this.incomingDiffBuffer = []; try { this.store.mergeRemoteChanges(() => { this.store.applyDiff((0, import_store.reverseRecordsDiff)(this.speculativeChanges), { runCallbacks: false }); for (const diff of diffs) { if (diff.type === "patch") { this.applyNetworkDiff(diff.diff, true); continue; } if (this.pendingPushRequests.length === 0) { throw new Error("Received push_result but there are no pending push requests"); } if (this.pendingPushRequests[0].request.clientClock !== diff.clientClock) { throw new Error( "Received push_result for a push request that is not at the front of the queue" ); } if (diff.action === "discard") { this.pendingPushRequests.shift(); } else if (diff.action === "commit") { const { request } = this.pendingPushRequests.shift(); if ("diff" in request && request.diff) { this.applyNetworkDiff(request.diff, true); } } else { this.applyNetworkDiff(diff.action.rebaseWithDiff, true); this.pendingPushRequests.shift(); } } try { this.speculativeChanges = this.store.extractingChanges(() => { for (const { request } of this.pendingPushRequests) { if (!("diff" in request) || !request.diff) continue; this.applyNetworkDiff(request.diff, true); } }); } catch (e) { console.error(e); this.speculativeChanges = { added: {}, updated: {}, removed: {} }; this.resetConnection(); } }); this.lastServerClock = diffs.at(-1)?.serverClock ?? this.lastServerClock; } catch (e) { console.error(e); this.store.ensureStoreIsUsable(); this.resetConnection(); } }; scheduleRebase = (0, import_utils.fpsThrottle)(this.rebase); } //# sourceMappingURL=TLSyncClient.js.map