@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
520 lines (519 loc) • 18.7 kB
JavaScript
import { react, transact } from "@tldraw/state";
import {
reverseRecordsDiff,
squashRecordDiffs
} from "@tldraw/store";
import {
exhaustiveSwitchError,
fpsThrottle,
isEqual,
objectMapEntries,
uniqueId
} from "@tldraw/utils";
import { RecordOpType, applyObjectDiff, diffRecord, getNetworkDiff } from "./diff.mjs";
import { interval } from "./interval.mjs";
import {
getTlsyncProtocolVersion
} from "./protocol.mjs";
const TLSyncErrorCloseEventCode = 4099;
const TLSyncErrorCloseEventReason = {
/** Room or resource not found */
NOT_FOUND: "NOT_FOUND",
/** User lacks permission to access the room */
FORBIDDEN: "FORBIDDEN",
/** User authentication required or invalid */
NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
/** Unexpected server error occurred */
UNKNOWN_ERROR: "UNKNOWN_ERROR",
/** Client protocol version too old */
CLIENT_TOO_OLD: "CLIENT_TOO_OLD",
/** Server protocol version too old */
SERVER_TOO_OLD: "SERVER_TOO_OLD",
/** Client sent invalid or corrupted record data */
INVALID_RECORD: "INVALID_RECORD",
/** Client exceeded rate limits */
RATE_LIMITED: "RATE_LIMITED",
/** Room has reached maximum capacity */
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 = -1;
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 = [];
/** @internal */
store;
/** @internal */
socket;
/** @internal */
presenceState;
/** @internal */
presenceMode;
// 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)
/** @internal */
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;
/**
* Callback executed immediately after successful connection to sync room.
* Use this to perform any post-connection setup required for your application,
* such as initializing default content or updating UI state.
*
* @param self - The TLSyncClient instance that connected
* @param details - Connection details
* - isReadonly - Whether the connection is in read-only mode
*/
onAfterConnect;
onCustomMessageReceived;
isDebugging = false;
debug(...args) {
if (this.isDebugging) {
console.debug(...args);
}
}
presenceType;
didCancel;
/**
* Creates a new TLSyncClient instance to manage synchronization with a remote server.
*
* @param config - Configuration object for the sync client
* - store - The local tldraw store to synchronize
* - socket - WebSocket adapter for server communication
* - presence - Reactive signal containing current user's presence data
* - presenceMode - Optional signal controlling presence sharing (defaults to 'full')
* - onLoad - Callback fired when initial sync completes successfully
* - onSyncError - Callback fired when sync fails with error reason
* - onCustomMessageReceived - Optional handler for custom messages
* - onAfterConnect - Optional callback fired after successful connection
* - self - The TLSyncClient instance
* - details - Connection details including readonly status
* - didCancel - Optional function to check if sync should be cancelled
*/
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;
this.onCustomMessageReceived = config.onCustomMessageReceived;
let didLoad = false;
this.presenceState = config.presence;
this.presenceMode = config.presenceMode;
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
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
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(
react("pushPresence", () => {
if (this.didCancel?.()) return this.close();
const mode = this.presenceMode?.get();
if (mode !== "full") return;
this.pushPresence(this.presenceState.get());
})
);
}
if (this.socket.connectionStatus === "online") {
this.sendConnectMessage();
}
}
/** @internal */
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 = uniqueId();
this.socket.sendMessage({
type: "connect",
connectRequestId: this.latestConnectRequestId,
schema: this.store.schema.serialize(),
protocolVersion: getTlsyncProtocolVersion(),
lastServerClock: this.lastServerClock
});
}
/** Switch to offline mode */
resetConnection(hard = false) {
this.debug("resetting connection");
if (hard) {
this.lastServerClock = 0;
}
const keys = Object.keys(this.store.serialize("presence"));
if (keys.length > 0) {
this.store.mergeRemoteChanges(() => {
this.store.remove(keys);
});
}
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;
}
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(reverseRecordsDiff(stashedChanges), { runCallbacks: false });
}
for (const [id, record] of objectMapEntries(this.store.serialize("all"))) {
if (wipeAll && this.store.scopedTypes.document.has(record.typeName) || record.typeName === this.presenceType) {
wipeDiff[id] = [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 });
const presence = this.presenceState?.get();
if (presence) {
this.pushPresence(presence);
}
});
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;
case "custom":
this.onCustomMessageReceived?.call(null, event.data);
break;
default:
exhaustiveSwitchError(event);
}
}
/**
* Closes the sync client and cleans up all resources.
*
* Call this method when you no longer need the sync client to prevent
* memory leaks and close the WebSocket connection. After calling close(),
* the client cannot be reused.
*
* @example
* ```ts
* // Clean shutdown
* syncClient.close()
* ```
*/
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 = [RecordOpType.Put, nextPresence];
} else if (this.lastPushedPresenceState && nextPresence) {
const diff = diffRecord(this.lastPushedPresenceState, nextPresence);
if (diff) {
presence = [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 = getNetworkDiff(change);
if (!diff) return;
this.speculativeChanges = 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 = 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 objectMapEntries(diff)) {
if (op[0] === RecordOpType.Put) {
const existing = this.store.get(id);
if (existing && !isEqual(existing, op[1])) {
hasChanges = true;
changes.updated[id] = [existing, op[1]];
} else {
hasChanges = true;
changes.added[id] = op[1];
}
} else if (op[0] === RecordOpType.Patch) {
const record = this.store.get(id);
if (!record) {
continue;
}
const patched = applyObjectDiff(record, op[1]);
hasChanges = true;
changes.updated[id] = [record, patched];
} else if (op[0] === 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(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 = fpsThrottle(this.rebase);
}
export {
TLSyncClient,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason
};
//# sourceMappingURL=TLSyncClient.mjs.map