UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

1,185 lines (1,184 loc) • 43.9 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 TLSyncRoom_exports = {}; __export(TLSyncRoom_exports, { DATA_MESSAGE_DEBOUNCE_INTERVAL: () => DATA_MESSAGE_DEBOUNCE_INTERVAL, DocumentState: () => DocumentState, MAX_TOMBSTONES: () => MAX_TOMBSTONES, TLSyncRoom: () => TLSyncRoom, TOMBSTONE_PRUNE_BUFFER_SIZE: () => TOMBSTONE_PRUNE_BUFFER_SIZE }); module.exports = __toCommonJS(TLSyncRoom_exports); var import_state = require("@tldraw/state"); var import_store = require("@tldraw/store"); var import_tlschema = require("@tldraw/tlschema"); var import_utils = require("@tldraw/utils"); var import_nanoevents = require("nanoevents"); var import_RoomSession = require("./RoomSession"); var import_TLSyncClient = require("./TLSyncClient"); var import_diff = require("./diff"); var import_findMin = require("./findMin"); var import_interval = require("./interval"); var import_protocol = require("./protocol"); const MAX_TOMBSTONES = 3e3; const TOMBSTONE_PRUNE_BUFFER_SIZE = 300; const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1e3 / 60; const timeSince = (time) => Date.now() - time; class DocumentState { constructor(state, lastChangedClock, recordType) { this.state = state; this.lastChangedClock = lastChangedClock; this.recordType = recordType; } /** * Create a DocumentState instance without validating the record data. * Used for performance when validation has already been performed. * * @param state - The record data * @param lastChangedClock - Clock value when this record was last modified * @param recordType - The record type definition for validation * @returns A new DocumentState instance */ static createWithoutValidating(state, lastChangedClock, recordType) { return new DocumentState(state, lastChangedClock, recordType); } /** * Create a DocumentState instance with validation of the record data. * * @param state - The record data to validate * @param lastChangedClock - Clock value when this record was last modified * @param recordType - The record type definition for validation * @returns Result containing the DocumentState or validation error */ static createAndValidate(state, lastChangedClock, recordType) { try { recordType.validate(state); } catch (error) { return import_utils.Result.err(error); } return import_utils.Result.ok(new DocumentState(state, lastChangedClock, recordType)); } /** * Replace the current state with new state and calculate the diff. * * @param state - The new record state * @param clock - The new clock value * @param legacyAppendMode - If true, string append operations will be converted to Put operations * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error */ replaceState(state, clock, legacyAppendMode = false) { const diff = (0, import_diff.diffRecord)(this.state, state, legacyAppendMode); if (!diff) return import_utils.Result.ok(null); try { this.recordType.validate(state); } catch (error) { return import_utils.Result.err(error); } return import_utils.Result.ok([diff, new DocumentState(state, clock, this.recordType)]); } /** * Apply a diff to the current state and return the resulting changes. * * @param diff - The object diff to apply * @param clock - The new clock value * @param legacyAppendMode - If true, string append operations will be converted to Put operations * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error */ mergeDiff(diff, clock, legacyAppendMode = false) { const newState = (0, import_diff.applyObjectDiff)(this.state, diff); return this.replaceState(newState, clock, legacyAppendMode); } } function getDocumentClock(snapshot) { if (typeof snapshot.documentClock === "number") { return snapshot.documentClock; } let max = 0; for (const doc of snapshot.documents) { max = Math.max(max, doc.lastChangedClock); } for (const tombstone of Object.values(snapshot.tombstones ?? {})) { max = Math.max(max, tombstone); } return max; } class TLSyncRoom { // A table of connected clients sessions = /* @__PURE__ */ new Map(); // eslint-disable-next-line local/prefer-class-methods pruneSessions = () => { for (const client of this.sessions.values()) { switch (client.state) { case import_RoomSession.RoomSessionState.Connected: { const hasTimedOut = timeSince(client.lastInteractionTime) > import_RoomSession.SESSION_IDLE_TIMEOUT; if (hasTimedOut || !client.socket.isOpen) { this.cancelSession(client.sessionId); } break; } case import_RoomSession.RoomSessionState.AwaitingConnectMessage: { const hasTimedOut = timeSince(client.sessionStartTime) > import_RoomSession.SESSION_START_WAIT_TIME; if (hasTimedOut || !client.socket.isOpen) { this.removeSession(client.sessionId); } break; } case import_RoomSession.RoomSessionState.AwaitingRemoval: { const hasTimedOut = timeSince(client.cancellationTime) > import_RoomSession.SESSION_REMOVAL_WAIT_TIME; if (hasTimedOut) { this.removeSession(client.sessionId); } break; } default: { (0, import_utils.exhaustiveSwitchError)(client); } } } }; disposables = [(0, import_interval.interval)(this.pruneSessions, 2e3)]; _isClosed = false; /** * Close the room and clean up all resources. Disconnects all sessions * and stops background processes. */ close() { this.disposables.forEach((d) => d()); this.sessions.forEach((session) => { session.socket.close(); }); this._isClosed = true; } /** * Check if the room has been closed and is no longer accepting connections. * * @returns True if the room is closed */ isClosed() { return this._isClosed; } events = (0, import_nanoevents.createNanoEvents)(); // Values associated with each uid (must be serializable). /** @internal */ documents; tombstones; // this clock should start higher than the client, to make sure that clients who sync with their // initial lastServerClock value get the full state // in this case clients will start with 0, and the server will start with 1 clock; documentClock; tombstoneHistoryStartsAtClock; // map from record id to clock upon deletion serializedSchema; documentTypes; presenceType; log; schema; constructor(opts) { this.schema = opts.schema; let snapshot = opts.snapshot; this.log = opts.log; this.onDataChange = opts.onDataChange; this.onPresenceChange = opts.onPresenceChange; (0, import_utils.assert)( import_utils.isNativeStructuredClone, "TLSyncRoom is supposed to run either on Cloudflare Workersor on a 18+ version of Node.js, which both support the native structuredClone API" ); this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize())); this.documentTypes = new Set( Object.values(this.schema.types).filter((t) => t.scope === "document").map((t) => t.typeName) ); const presenceTypes = new Set( Object.values(this.schema.types).filter((t) => t.scope === "presence") ); if (presenceTypes.size > 1) { throw new Error( `TLSyncRoom: exactly zero or one presence type is expected, but found ${presenceTypes.size}` ); } this.presenceType = presenceTypes.values().next()?.value ?? null; if (!snapshot) { snapshot = { clock: 0, documentClock: 0, documents: [ { state: import_tlschema.DocumentRecordType.create({ id: import_tlschema.TLDOCUMENT_ID }), lastChangedClock: 0 }, { state: import_tlschema.PageRecordType.create({ name: "Page 1", index: "a1" }), lastChangedClock: 0 } ] }; } this.clock = snapshot.clock; let didIncrementClock = false; const ensureClockDidIncrement = (_reason) => { if (!didIncrementClock) { didIncrementClock = true; this.clock++; } }; this.tombstones = new import_store.AtomMap( "room tombstones", (0, import_utils.objectMapEntriesIterable)(snapshot.tombstones ?? {}) ); this.documents = new import_store.AtomMap( "room documents", function* () { for (const doc of snapshot.documents) { if (this.documentTypes.has(doc.state.typeName)) { yield [ doc.state.id, DocumentState.createWithoutValidating( doc.state, doc.lastChangedClock, (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, doc.state.typeName)) ) ]; } else { ensureClockDidIncrement("doc type was not doc type"); this.tombstones.set(doc.state.id, this.clock); } } }.call(this) ); this.tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? (0, import_findMin.findMin)(this.tombstones.values()) ?? this.clock; if (this.tombstoneHistoryStartsAtClock === 0) { this.tombstoneHistoryStartsAtClock++; } (0, import_state.transact)(() => { const schema = snapshot.schema ?? this.schema.serializeEarliestVersion(); const migrationsToApply = this.schema.getMigrationsSince(schema); (0, import_utils.assert)(migrationsToApply.ok, "Failed to get migrations"); if (migrationsToApply.value.length > 0) { const store = {}; for (const [k, v] of this.documents.entries()) { store[k] = v.state; } const migrationResult = this.schema.migrateStoreSnapshot( { store, schema }, { mutateInputStore: true } ); if (migrationResult.type === "error") { throw new Error("Failed to migrate: " + migrationResult.reason); } for (const id in migrationResult.value) { if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) { continue; } const r = migrationResult.value[id]; const existing = this.documents.get(id); if (!existing || !(0, import_utils.isEqual)(existing.state, r)) { ensureClockDidIncrement("record was added or updated during migration"); this.documents.set( r.id, DocumentState.createWithoutValidating( r, this.clock, (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, r.typeName)) ) ); } } for (const id of this.documents.keys()) { if (!migrationResult.value[id]) { ensureClockDidIncrement("record was removed during migration"); this.tombstones.set(id, this.clock); this.documents.delete(id); } } } this.pruneTombstones(); }); if (didIncrementClock) { this.documentClock = this.clock; opts.onDataChange?.(); } else { this.documentClock = getDocumentClock(snapshot); } } didSchedulePrune = true; // eslint-disable-next-line local/prefer-class-methods pruneTombstones = () => { this.didSchedulePrune = false; if (this.tombstones.size > MAX_TOMBSTONES) { const entries = Array.from(this.tombstones.entries()); entries.sort((a, b) => a[1] - b[1]); let idx = entries.length - 1 - MAX_TOMBSTONES + TOMBSTONE_PRUNE_BUFFER_SIZE; const cullClock = entries[idx++][1]; while (idx < entries.length && entries[idx][1] === cullClock) { idx++; } const keysToDelete = entries.slice(0, idx).map(([key]) => key); this.tombstoneHistoryStartsAtClock = cullClock + 1; this.tombstones.deleteMany(keysToDelete); } }; getDocument(id) { return this.documents.get(id); } addDocument(id, state, clock) { if (this.tombstones.has(id)) { this.tombstones.delete(id); } const createResult = DocumentState.createAndValidate( state, clock, (0, import_utils.assertExists)((0, import_utils.getOwnProperty)(this.schema.types, state.typeName)) ); if (!createResult.ok) return createResult; this.documents.set(id, createResult.value); return import_utils.Result.ok(void 0); } removeDocument(id, clock) { this.documents.delete(id); this.tombstones.set(id, clock); if (!this.didSchedulePrune) { this.didSchedulePrune = true; setTimeout(this.pruneTombstones, 0); } } /** * Get a complete snapshot of the current room state that can be persisted * and later used to restore the room. * * @returns Room snapshot containing all documents, tombstones, and metadata * @example * ```ts * const snapshot = room.getSnapshot() * await database.saveRoomSnapshot(roomId, snapshot) * * // Later, restore from snapshot * const restoredRoom = new TLSyncRoom({ * schema: mySchema, * snapshot: snapshot * }) * ``` */ getSnapshot() { const tombstones = Object.fromEntries(this.tombstones.entries()); const documents = []; for (const doc of this.documents.values()) { if (this.documentTypes.has(doc.state.typeName)) { documents.push({ state: doc.state, lastChangedClock: doc.lastChangedClock }); } } return { clock: this.clock, documentClock: this.documentClock, tombstones, tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock, schema: this.serializedSchema, documents }; } /** * Send a message to a particular client. Debounces data events * * @param sessionId - The id of the session to send the message to. * @param message - The message to send. */ sendMessage(sessionId, message) { const session = this.sessions.get(sessionId); if (!session) { this.log?.warn?.("Tried to send message to unknown session", message.type); return; } if (session.state !== import_RoomSession.RoomSessionState.Connected) { this.log?.warn?.("Tried to send message to disconnected client", message.type); return; } if (session.socket.isOpen) { if (message.type !== "patch" && message.type !== "push_result") { if (message.type !== "pong") { this._flushDataMessages(sessionId); } session.socket.sendMessage(message); } else { if (session.debounceTimer === null) { session.socket.sendMessage({ type: "data", data: [message] }); session.debounceTimer = setTimeout( () => this._flushDataMessages(sessionId), DATA_MESSAGE_DEBOUNCE_INTERVAL ); } else { session.outstandingDataMessages.push(message); } } } else { this.cancelSession(session.sessionId); } } // needs to accept sessionId and not a session because the session might be dead by the time // the timer fires _flushDataMessages(sessionId) { const session = this.sessions.get(sessionId); if (!session || session.state !== import_RoomSession.RoomSessionState.Connected) { return; } session.debounceTimer = null; if (session.outstandingDataMessages.length > 0) { session.socket.sendMessage({ type: "data", data: session.outstandingDataMessages }); session.outstandingDataMessages.length = 0; } } /** @internal */ removeSession(sessionId, fatalReason) { const session = this.sessions.get(sessionId); if (!session) { this.log?.warn?.("Tried to remove unknown session"); return; } this.sessions.delete(sessionId); const presence = this.getDocument(session.presenceId ?? ""); try { if (fatalReason) { session.socket.close(import_TLSyncClient.TLSyncErrorCloseEventCode, fatalReason); } else { session.socket.close(); } } catch { } if (presence) { this.documents.delete(session.presenceId); this.broadcastPatch({ diff: { [session.presenceId]: [import_diff.RecordOpType.Remove] }, sourceSessionId: sessionId }); } this.events.emit("session_removed", { sessionId, meta: session.meta }); if (this.sessions.size === 0) { this.events.emit("room_became_empty"); } } cancelSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return; } if (session.state === import_RoomSession.RoomSessionState.AwaitingRemoval) { this.log?.warn?.("Tried to cancel session that is already awaiting removal"); return; } this.sessions.set(sessionId, { state: import_RoomSession.RoomSessionState.AwaitingRemoval, sessionId, presenceId: session.presenceId, socket: session.socket, cancellationTime: Date.now(), meta: session.meta, isReadonly: session.isReadonly, requiresLegacyRejection: session.requiresLegacyRejection, supportsStringAppend: session.supportsStringAppend }); try { session.socket.close(); } catch { } } /** * Broadcast a patch to all connected clients except the one with the sessionId provided. * Automatically handles schema migration for clients on different versions. * * @param message - The broadcast message * - diff - The network diff to broadcast to all clients * - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast) * @returns This room instance for method chaining * @example * ```ts * room.broadcastPatch({ * diff: { 'shape:123': [RecordOpType.Put, newShapeData] }, * sourceSessionId: 'user-456' // This user won't receive the broadcast * }) * ``` */ broadcastPatch(message) { const { diff, sourceSessionId } = message; this.sessions.forEach((session) => { if (session.state !== import_RoomSession.RoomSessionState.Connected) return; if (sourceSessionId === session.sessionId) return; if (!session.socket.isOpen) { this.cancelSession(session.sessionId); return; } const res = this.migrateDiffForSession(session.serializedSchema, diff); if (!res.ok) { this.rejectSession( session.sessionId, res.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ); return; } this.sendMessage(session.sessionId, { type: "patch", diff: res.value, serverClock: this.clock }); }); return this; } /** * Send a custom message to a connected client. Useful for application-specific * communication that doesn't involve document synchronization. * * @param sessionId - The ID of the session to send the message to * @param data - The custom payload to send (will be JSON serialized) * @example * ```ts * // Send a custom notification * room.sendCustomMessage('user-123', { * type: 'notification', * message: 'Document saved successfully' * }) * * // Send user-specific data * room.sendCustomMessage('user-456', { * type: 'user_permissions', * canEdit: true, * canDelete: false * }) * ``` */ sendCustomMessage(sessionId, data) { this.sendMessage(sessionId, { type: "custom", data }); } /** * Register a new client session with the room. The session will be in an awaiting * state until it sends a connect message with protocol handshake. * * @param opts - Session configuration * - sessionId - Unique identifier for this session * - socket - WebSocket adapter for communication * - meta - Application-specific metadata for this session * - isReadonly - Whether this session can modify documents * @returns This room instance for method chaining * @example * ```ts * room.handleNewSession({ * sessionId: crypto.randomUUID(), * socket: new WebSocketAdapter(ws), * meta: { userId: '123', name: 'Alice', avatar: 'url' }, * isReadonly: !hasEditPermission * }) * ``` * * @internal */ handleNewSession(opts) { const { sessionId, socket, meta, isReadonly } = opts; const existing = this.sessions.get(sessionId); this.sessions.set(sessionId, { state: import_RoomSession.RoomSessionState.AwaitingConnectMessage, sessionId, socket, presenceId: existing?.presenceId ?? this.presenceType?.createId() ?? null, sessionStartTime: Date.now(), meta, isReadonly: isReadonly ?? false, // this gets set later during handleConnectMessage requiresLegacyRejection: false, supportsStringAppend: true }); return this; } /** * Checks if all connected sessions support string append operations (protocol version 8+). * If any client is on an older version, returns false to enable legacy append mode. * * @returns True if all connected sessions are on protocol version 8 or higher */ getCanEmitStringAppend() { for (const session of this.sessions.values()) { if (session.state === import_RoomSession.RoomSessionState.Connected) { if (!session.supportsStringAppend) { return false; } } } return true; } /** * When we send a diff to a client, if that client is on a lower version than us, we need to make * the diff compatible with their version. At the moment this means migrating each affected record * to the client's version and sending the whole record again. We can optimize this later by * keeping the previous versions of records around long enough to recalculate these diffs for * older client versions. */ migrateDiffForSession(serializedSchema, diff) { if (serializedSchema === this.serializedSchema) { return import_utils.Result.ok(diff); } const result = {}; for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(diff)) { if (op[0] === import_diff.RecordOpType.Remove) { result[id] = op; continue; } const doc = this.getDocument(id); if (!doc) { return import_utils.Result.err(import_store.MigrationFailureReason.TargetVersionTooNew); } const migrationResult = this.schema.migratePersistedRecord( doc.state, serializedSchema, "down" ); if (migrationResult.type === "error") { return import_utils.Result.err(migrationResult.reason); } result[id] = [import_diff.RecordOpType.Put, migrationResult.value]; } return import_utils.Result.ok(result); } /** * Process an incoming message from a client session. Handles connection requests, * data synchronization pushes, and ping/pong for connection health. * * @param sessionId - The ID of the session that sent the message * @param message - The client message to process * @example * ```ts * // Typically called by WebSocket message handlers * websocket.onMessage((data) => { * const message = JSON.parse(data) * room.handleMessage(sessionId, message) * }) * ``` */ async handleMessage(sessionId, message) { const session = this.sessions.get(sessionId); if (!session) { this.log?.warn?.("Received message from unknown session"); return; } switch (message.type) { case "connect": { return this.handleConnectRequest(session, message); } case "push": { return this.handlePushRequest(session, message); } case "ping": { if (session.state === import_RoomSession.RoomSessionState.Connected) { session.lastInteractionTime = Date.now(); } return this.sendMessage(session.sessionId, { type: "pong" }); } default: { (0, import_utils.exhaustiveSwitchError)(message); } } } /** * Reject and disconnect a session due to incompatibility or other fatal errors. * Sends appropriate error messages before closing the connection. * * @param sessionId - The session to reject * @param fatalReason - The reason for rejection (optional) * @example * ```ts * // Reject due to version mismatch * room.rejectSession('user-123', TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) * * // Reject due to permission issue * room.rejectSession('user-456', 'Insufficient permissions') * ``` */ rejectSession(sessionId, fatalReason) { const session = this.sessions.get(sessionId); if (!session) return; if (!fatalReason) { this.removeSession(sessionId); return; } if (session.requiresLegacyRejection) { try { if (session.socket.isOpen) { let legacyReason; switch (fatalReason) { case import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD: legacyReason = import_protocol.TLIncompatibilityReason.ClientTooOld; break; case import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD: legacyReason = import_protocol.TLIncompatibilityReason.ServerTooOld; break; case import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD: legacyReason = import_protocol.TLIncompatibilityReason.InvalidRecord; break; default: legacyReason = import_protocol.TLIncompatibilityReason.InvalidOperation; break; } session.socket.sendMessage({ type: "incompatibility_error", reason: legacyReason }); } } catch { } finally { this.removeSession(sessionId); } } else { this.removeSession(sessionId, fatalReason); } } handleConnectRequest(session, message) { let theirProtocolVersion = message.protocolVersion; if (theirProtocolVersion === 5) { theirProtocolVersion = 6; } session.requiresLegacyRejection = theirProtocolVersion === 6; if (theirProtocolVersion === 6) { theirProtocolVersion++; } if (theirProtocolVersion === 7) { theirProtocolVersion++; session.supportsStringAppend = false; } if (theirProtocolVersion == null || theirProtocolVersion < (0, import_protocol.getTlsyncProtocolVersion)()) { this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD); return; } else if (theirProtocolVersion > (0, import_protocol.getTlsyncProtocolVersion)()) { this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD); return; } if (message.schema == null) { this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD); return; } const migrations = this.schema.getMigrationsSince(message.schema); if (!migrations.ok || migrations.value.some((m) => m.scope === "store" || !m.down)) { this.rejectSession(session.sessionId, import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD); return; } const sessionSchema = (0, import_utils.isEqual)(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema; const connect = async (msg) => { this.sessions.set(session.sessionId, { state: import_RoomSession.RoomSessionState.Connected, sessionId: session.sessionId, presenceId: session.presenceId, socket: session.socket, serializedSchema: sessionSchema, lastInteractionTime: Date.now(), debounceTimer: null, outstandingDataMessages: [], supportsStringAppend: session.supportsStringAppend, meta: session.meta, isReadonly: session.isReadonly, requiresLegacyRejection: session.requiresLegacyRejection }); this.sendMessage(session.sessionId, msg); }; (0, import_state.transaction)((rollback) => { if ( // if the client requests changes since a time before we have tombstone history, send them the full state message.lastServerClock < this.tombstoneHistoryStartsAtClock || // similarly, if they ask for a time we haven't reached yet, send them the full state // this will only happen if the DB is reset (or there is no db) and the server restarts // or if the server exits/crashes with unpersisted changes message.lastServerClock > this.clock ) { const diff = {}; for (const [id, doc] of this.documents.entries()) { if (id !== session.presenceId) { diff[id] = [import_diff.RecordOpType.Put, doc.state]; } } const migrated = this.migrateDiffForSession(sessionSchema, diff); if (!migrated.ok) { rollback(); this.rejectSession( session.sessionId, migrated.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ); return; } connect({ type: "connect", connectRequestId: message.connectRequestId, hydrationType: "wipe_all", protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(), schema: this.schema.serialize(), serverClock: this.clock, diff: migrated.value, isReadonly: session.isReadonly }); } else { const diff = {}; for (const doc of this.documents.values()) { if (doc.lastChangedClock > message.lastServerClock) { diff[doc.state.id] = [import_diff.RecordOpType.Put, doc.state]; } else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) { diff[doc.state.id] = [import_diff.RecordOpType.Put, doc.state]; } } for (const [id, deletedAtClock] of this.tombstones.entries()) { if (deletedAtClock > message.lastServerClock) { diff[id] = [import_diff.RecordOpType.Remove]; } } const migrated = this.migrateDiffForSession(sessionSchema, diff); if (!migrated.ok) { rollback(); this.rejectSession( session.sessionId, migrated.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ); return; } connect({ type: "connect", connectRequestId: message.connectRequestId, hydrationType: "wipe_presence", schema: this.schema.serialize(), protocolVersion: (0, import_protocol.getTlsyncProtocolVersion)(), serverClock: this.clock, diff: migrated.value, isReadonly: session.isReadonly }); } }); } handlePushRequest(session, message) { if (session && session.state !== import_RoomSession.RoomSessionState.Connected) { return; } if (session) { session.lastInteractionTime = Date.now(); } this.clock++; const initialDocumentClock = this.documentClock; let didPresenceChange = false; (0, import_state.transaction)((rollback) => { const legacyAppendMode = !this.getCanEmitStringAppend(); const docChanges = { diff: null }; const presenceChanges = { diff: null }; const propagateOp = (changes, id, op) => { if (!changes.diff) changes.diff = {}; changes.diff[id] = op; }; const fail = (reason, underlyingError) => { rollback(); if (session) { this.rejectSession(session.sessionId, reason); } else { throw new Error("failed to apply changes: " + reason, underlyingError); } if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") { this.log?.error?.("failed to apply push", reason, message, underlyingError); } return import_utils.Result.err(void 0); }; const addDocument = (changes, id, _state) => { const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, "up") : { type: "success", value: _state }; if (res.type === "error") { return fail( res.reason === import_store.MigrationFailureReason.TargetVersionTooOld ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ); } const { value: state } = res; const doc = this.getDocument(id); if (doc) { const diff = doc.replaceState(state, this.clock, legacyAppendMode); if (!diff.ok) { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD); } if (diff.value) { this.documents.set(id, diff.value[1]); propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]); } } else { const result = this.addDocument(id, state, this.clock); if (!result.ok) { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD); } propagateOp(changes, id, [import_diff.RecordOpType.Put, state]); } return import_utils.Result.ok(void 0); }; const patchDocument = (changes, id, patch) => { const doc = this.getDocument(id); if (!doc) return import_utils.Result.ok(void 0); const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, "down") : { type: "success", value: doc.state }; if (downgraded.type === "error") { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD); } if (downgraded.value === doc.state) { const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode); if (!diff.ok) { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD); } if (diff.value) { this.documents.set(id, diff.value[1]); propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]); } } else { const patched = (0, import_diff.applyObjectDiff)(downgraded.value, patch); const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, "up") : { type: "success", value: patched }; if (upgraded.type === "error") { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD); } const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode); if (!diff.ok) { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD); } if (diff.value) { this.documents.set(id, diff.value[1]); propagateOp(changes, id, [import_diff.RecordOpType.Patch, diff.value[0]]); } } return import_utils.Result.ok(void 0); }; const { clientClock } = message; if (this.presenceType && session?.presenceId && "presence" in message && message.presence) { if (!session) throw new Error("session is required for presence pushes"); const id = session.presenceId; const [type, val] = message.presence; const { typeName } = this.presenceType; switch (type) { case import_diff.RecordOpType.Put: { const res = addDocument(presenceChanges, id, { ...val, id, typeName }); if (!res.ok) return; break; } case import_diff.RecordOpType.Patch: { const res = patchDocument(presenceChanges, id, { ...val, id: [import_diff.ValueOpType.Put, id], typeName: [import_diff.ValueOpType.Put, typeName] }); if (!res.ok) return; break; } } } if (message.diff && !session?.isReadonly) { for (const [id, op] of (0, import_utils.objectMapEntriesIterable)(message.diff)) { switch (op[0]) { case import_diff.RecordOpType.Put: { if (!this.documentTypes.has(op[1].typeName)) { return fail(import_TLSyncClient.TLSyncErrorCloseEventReason.INVALID_RECORD); } const res = addDocument(docChanges, id, op[1]); if (!res.ok) return; break; } case import_diff.RecordOpType.Patch: { const res = patchDocument(docChanges, id, op[1]); if (!res.ok) return; break; } case import_diff.RecordOpType.Remove: { const doc = this.getDocument(id); if (!doc) { continue; } this.removeDocument(id, this.clock); propagateOp(docChanges, id, op); break; } } } } if ( // if there was only a presence push, the client doesn't need to do anything aside from // shift the push request. !message.diff || (0, import_utils.isEqual)(docChanges.diff, message.diff) ) { if (session) { this.sendMessage(session.sessionId, { type: "push_result", serverClock: this.clock, clientClock, action: "commit" }); } } else if (!docChanges.diff) { if (session) { this.sendMessage(session.sessionId, { type: "push_result", serverClock: this.clock, clientClock, action: "discard" }); } } else { if (session) { const migrateResult = this.migrateDiffForSession( session.serializedSchema, docChanges.diff ); if (!migrateResult.ok) { return fail( migrateResult.error === import_store.MigrationFailureReason.TargetVersionTooNew ? import_TLSyncClient.TLSyncErrorCloseEventReason.SERVER_TOO_OLD : import_TLSyncClient.TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ); } this.sendMessage(session.sessionId, { type: "push_result", serverClock: this.clock, clientClock, action: { rebaseWithDiff: migrateResult.value } }); } } if (docChanges.diff || presenceChanges.diff) { this.broadcastPatch({ sourceSessionId: session?.sessionId, diff: { ...docChanges.diff, ...presenceChanges.diff } }); } if (docChanges.diff) { this.documentClock = this.clock; } if (presenceChanges.diff) { didPresenceChange = true; } return; }); if (this.documentClock !== initialDocumentClock) { this.onDataChange?.(); } if (didPresenceChange) { this.onPresenceChange?.(); } } /** * Handle the event when a client disconnects. Cleans up the session and * removes any presence information. * * @param sessionId - The session that disconnected * @example * ```ts * websocket.onClose(() => { * room.handleClose(sessionId) * }) * ``` */ handleClose(sessionId) { this.cancelSession(sessionId); } /** * Apply changes to the room's store in a transactional way. Changes are * automatically synchronized to all connected clients. * * @param updater - Function that receives store methods to make changes * @returns Promise that resolves when the transaction is complete * @example * ```ts * // Add multiple shapes atomically * await room.updateStore((store) => { * store.put(createShape({ type: 'geo', x: 100, y: 100 })) * store.put(createShape({ type: 'text', x: 200, y: 200 })) * }) * * // Async operations are supported * await room.updateStore(async (store) => { * const template = await loadTemplate() * template.shapes.forEach(shape => store.put(shape)) * }) * ``` */ async updateStore(updater) { if (this._isClosed) { throw new Error("Cannot update store on a closed room"); } const context = new StoreUpdateContext( Object.fromEntries(this.getSnapshot().documents.map((d) => [d.state.id, d.state])) ); try { await updater(context); } finally { context.close(); } const diff = context.toDiff(); if (Object.keys(diff).length === 0) { return; } this.handlePushRequest(null, { type: "push", diff, clientClock: 0 }); } } class StoreUpdateContext { constructor(snapshot) { this.snapshot = snapshot; } updates = { puts: {}, deletes: /* @__PURE__ */ new Set() }; put(record) { if (this._isClosed) throw new Error("StoreUpdateContext is closed"); if (record.id in this.snapshot && (0, import_utils.isEqual)(this.snapshot[record.id], record)) { delete this.updates.puts[record.id]; } else { this.updates.puts[record.id] = (0, import_utils.structuredClone)(record); } this.updates.deletes.delete(record.id); } delete(recordOrId) { if (this._isClosed) throw new Error("StoreUpdateContext is closed"); const id = typeof recordOrId === "string" ? recordOrId : recordOrId.id; delete this.updates.puts[id]; if (this.snapshot[id]) { this.updates.deletes.add(id); } } get(id) { if (this._isClosed) throw new Error("StoreUpdateContext is closed"); if ((0, import_utils.hasOwnProperty)(this.updates.puts, id)) { return (0, import_utils.structuredClone)(this.updates.puts[id]); } if (this.updates.deletes.has(id)) { return null; } return (0, import_utils.structuredClone)(this.snapshot[id] ?? null); } getAll() { if (this._isClosed) throw new Error("StoreUpdateContext is closed"); const result = Object.values(this.updates.puts); for (const [id, record] of Object.entries(this.snapshot)) { if (!this.updates.deletes.has(id) && !(0, import_utils.hasOwnProperty)(this.updates.puts, id)) { result.push(record); } } return (0, import_utils.structuredClone)(result); } toDiff() { const diff = {}; for (const [id, record] of Object.entries(this.updates.puts)) { diff[id] = [import_diff.RecordOpType.Put, record]; } for (const id of this.updates.deletes) { diff[id] = [import_diff.RecordOpType.Remove]; } return diff; } _isClosed = false; close() { this._isClosed = true; } } //# sourceMappingURL=TLSyncRoom.js.map