UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

1,608 lines (1,488 loc) • 50.5 kB
import { transact, transaction } from '@tldraw/state' import { AtomMap, IdOf, MigrationFailureReason, RecordType, SerializedSchema, StoreSchema, UnknownRecord, } from '@tldraw/store' import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema' import { IndexKey, Result, assert, assertExists, exhaustiveSwitchError, getOwnProperty, hasOwnProperty, isEqual, isNativeStructuredClone, objectMapEntriesIterable, structuredClone, } from '@tldraw/utils' import { createNanoEvents } from 'nanoevents' import { RoomSession, RoomSessionState, SESSION_IDLE_TIMEOUT, SESSION_REMOVAL_WAIT_TIME, SESSION_START_WAIT_TIME, } from './RoomSession' import { TLSyncLog } from './TLSocketRoom' import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient' import { NetworkDiff, ObjectDiff, RecordOp, RecordOpType, ValueOpType, applyObjectDiff, diffRecord, } from './diff' import { findMin } from './findMin' import { interval } from './interval' import { TLIncompatibilityReason, TLSocketClientSentEvent, TLSocketServerSentDataEvent, TLSocketServerSentEvent, getTlsyncProtocolVersion, } from './protocol' /** * WebSocket interface for server-side room connections. This defines the contract * that socket implementations must follow to work with TLSyncRoom. * * @internal */ export interface TLRoomSocket<R extends UnknownRecord> { /** * Whether the socket connection is currently open and ready to send messages. */ isOpen: boolean /** * Send a message to the connected client through this socket. * * @param msg - The server-sent event message to transmit */ sendMessage(msg: TLSocketServerSentEvent<R>): void /** * Close the socket connection with optional status code and reason. * * @param code - WebSocket close code (optional) * @param reason - Human-readable close reason (optional) */ close(code?: number, reason?: string): void } /** * The maximum number of tombstone records to keep in memory. Tombstones track * deleted records to prevent resurrection during sync operations. * @public */ export const MAX_TOMBSTONES = 3000 /** * The number of tombstones to delete when pruning occurs after reaching MAX_TOMBSTONES. * This buffer prevents frequent pruning operations. * @public */ export const TOMBSTONE_PRUNE_BUFFER_SIZE = 300 /** * The minimum time interval (in milliseconds) between sending batched data messages * to clients. This debouncing prevents overwhelming clients with rapid updates. * @public */ export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60 const timeSince = (time: number) => Date.now() - time /** * Represents the state of a document record within a sync room, including * its current data and the clock value when it was last modified. * * @internal */ export class DocumentState<R extends UnknownRecord> { /** * 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<R extends UnknownRecord>( state: R, lastChangedClock: number, recordType: RecordType<R, any> ): DocumentState<R> { 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<R extends UnknownRecord>( state: R, lastChangedClock: number, recordType: RecordType<R, any> ): Result<DocumentState<R>, Error> { try { recordType.validate(state) } catch (error: any) { return Result.err(error) } return Result.ok(new DocumentState(state, lastChangedClock, recordType)) } private constructor( public readonly state: R, public readonly lastChangedClock: number, private readonly recordType: RecordType<R, any> ) {} /** * 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: R, clock: number, legacyAppendMode = false ): Result<[ObjectDiff, DocumentState<R>] | null, Error> { const diff = diffRecord(this.state, state, legacyAppendMode) if (!diff) return Result.ok(null) try { this.recordType.validate(state) } catch (error: any) { return Result.err(error) } return 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: ObjectDiff, clock: number, legacyAppendMode = false ): Result<[ObjectDiff, DocumentState<R>] | null, Error> { const newState = applyObjectDiff(this.state, diff) return this.replaceState(newState, clock, legacyAppendMode) } } /** * Snapshot of a room's complete state that can be persisted and restored. * Contains all documents, tombstones, and metadata needed to reconstruct the room. * * @public */ export interface RoomSnapshot { /** * The current logical clock value for the room */ clock: number /** * Clock value when document data was last changed (optional for backwards compatibility) */ documentClock?: number /** * Array of all document records with their last modification clocks */ documents: Array<{ state: UnknownRecord; lastChangedClock: number }> /** * Map of deleted record IDs to their deletion clock values (optional) */ tombstones?: Record<string, number> /** * Clock value where tombstone history begins - older deletions are not tracked (optional) */ tombstoneHistoryStartsAtClock?: number /** * Serialized schema used when creating this snapshot (optional) */ schema?: SerializedSchema } function getDocumentClock(snapshot: RoomSnapshot) { 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 } /** * A collaborative workspace that manages multiple client sessions and synchronizes * document changes between them. The room serves as the authoritative source for * all document state and handles conflict resolution, schema migrations, and * real-time data distribution. * * @example * ```ts * const room = new TLSyncRoom({ * schema: mySchema, * onDataChange: () => saveToDatabase(room.getSnapshot()), * onPresenceChange: () => updateLiveCursors() * }) * * // Handle new client connections * room.handleNewSession({ * sessionId: 'user-123', * socket: webSocketAdapter, * meta: { userId: '123', name: 'Alice' }, * isReadonly: false * }) * ``` * * @internal */ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> { // A table of connected clients readonly sessions = new Map<string, RoomSession<R, SessionMeta>>() // eslint-disable-next-line local/prefer-class-methods pruneSessions = () => { for (const client of this.sessions.values()) { switch (client.state) { case RoomSessionState.Connected: { const hasTimedOut = timeSince(client.lastInteractionTime) > SESSION_IDLE_TIMEOUT if (hasTimedOut || !client.socket.isOpen) { this.cancelSession(client.sessionId) } break } case RoomSessionState.AwaitingConnectMessage: { const hasTimedOut = timeSince(client.sessionStartTime) > SESSION_START_WAIT_TIME if (hasTimedOut || !client.socket.isOpen) { // remove immediately this.removeSession(client.sessionId) } break } case RoomSessionState.AwaitingRemoval: { const hasTimedOut = timeSince(client.cancellationTime) > SESSION_REMOVAL_WAIT_TIME if (hasTimedOut) { this.removeSession(client.sessionId) } break } default: { exhaustiveSwitchError(client) } } } } private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)] private _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 } readonly events = createNanoEvents<{ room_became_empty(): void session_removed(args: { sessionId: string; meta: SessionMeta }): void }>() // Values associated with each uid (must be serializable). /** @internal */ documents: AtomMap<string, DocumentState<R>> tombstones: AtomMap<string, number> // 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: number documentClock: number tombstoneHistoryStartsAtClock: number // map from record id to clock upon deletion readonly serializedSchema: SerializedSchema readonly documentTypes: Set<string> readonly presenceType: RecordType<R, any> | null private log?: TLSyncLog public readonly schema: StoreSchema<R, any> private onDataChange?(): void private onPresenceChange?(): void constructor(opts: { log?: TLSyncLog schema: StoreSchema<R, any> snapshot?: RoomSnapshot onDataChange?(): void onPresenceChange?(): void }) { this.schema = opts.schema let snapshot = opts.snapshot this.log = opts.log this.onDataChange = opts.onDataChange this.onPresenceChange = opts.onPresenceChange assert( isNativeStructuredClone, 'TLSyncRoom is supposed to run either on Cloudflare Workers' + 'or on a 18+ version of Node.js, which both support the native structuredClone API' ) // do a json serialization cycle to make sure the schema has no 'undefined' values this.serializedSchema = JSON.parse(JSON.stringify(this.schema.serialize())) this.documentTypes = new Set( Object.values<RecordType<R, any>>(this.schema.types) .filter((t) => t.scope === 'document') .map((t) => t.typeName) ) const presenceTypes = new Set( Object.values<RecordType<R, any>>(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: DocumentRecordType.create({ id: TLDOCUMENT_ID }), lastChangedClock: 0, }, { state: PageRecordType.create({ name: 'Page 1', index: 'a1' as IndexKey }), lastChangedClock: 0, }, ], } } this.clock = snapshot.clock let didIncrementClock = false const ensureClockDidIncrement = (_reason: string) => { if (!didIncrementClock) { didIncrementClock = true this.clock++ } } this.tombstones = new AtomMap( 'room tombstones', objectMapEntriesIterable(snapshot.tombstones ?? {}) ) this.documents = new AtomMap( 'room documents', function* (this: TLSyncRoom<R, SessionMeta>) { for (const doc of snapshot.documents) { if (this.documentTypes.has(doc.state.typeName)) { yield [ doc.state.id, DocumentState.createWithoutValidating<R>( doc.state as R, doc.lastChangedClock, assertExists(getOwnProperty(this.schema.types, doc.state.typeName)) ), ] as const } else { ensureClockDidIncrement('doc type was not doc type') this.tombstones.set(doc.state.id, this.clock) } } }.call(this) ) this.tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? findMin(this.tombstones.values()) ?? this.clock if (this.tombstoneHistoryStartsAtClock === 0) { // Before this comment was added, new clients would send '0' as their 'lastServerClock' // which was technically an error because clocks start at 0, but the error didn't manifest // because we initialized tombstoneHistoryStartsAtClock to 1 and then never updated it. // Now that we handle tombstoneHistoryStartsAtClock properly we need to increment it here to make sure old // clients still get data when they connect. This if clause can be deleted after a few months. this.tombstoneHistoryStartsAtClock++ } transact(() => { // eslint-disable-next-line @typescript-eslint/no-deprecated const schema = snapshot.schema ?? this.schema.serializeEarliestVersion() const migrationsToApply = this.schema.getMigrationsSince(schema) assert(migrationsToApply.ok, 'Failed to get migrations') if (migrationsToApply.value.length > 0) { // only bother allocating a snapshot if there are migrations to apply const store = {} as Record<IdOf<R>, R> for (const [k, v] of this.documents.entries()) { store[k as IdOf<R>] = v.state } const migrationResult = this.schema.migrateStoreSnapshot( { store, schema }, { mutateInputStore: true } ) if (migrationResult.type === 'error') { // TODO: Fault tolerance throw new Error('Failed to migrate: ' + migrationResult.reason) } // use for..in to iterate over the keys of the object because it consumes less memory than // Object.entries for (const id in migrationResult.value) { if (!Object.prototype.hasOwnProperty.call(migrationResult.value, id)) { continue } const r = migrationResult.value[id as keyof typeof migrationResult.value] const existing = this.documents.get(id) if (!existing || !isEqual(existing.state, r)) { // record was added or updated during migration ensureClockDidIncrement('record was added or updated during migration') this.documents.set( r.id, DocumentState.createWithoutValidating( r, this.clock, assertExists(getOwnProperty(this.schema.types, r.typeName)) as any ) ) } } for (const id of this.documents.keys()) { if (!migrationResult.value[id as keyof typeof migrationResult.value]) { // record was removed during migration 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) } } private didSchedulePrune = true // eslint-disable-next-line local/prefer-class-methods private pruneTombstones = () => { this.didSchedulePrune = false // avoid blocking any pending responses if (this.tombstones.size > MAX_TOMBSTONES) { const entries = Array.from(this.tombstones.entries()) // sort entries in ascending order by clock 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++ } // trim off the first bunch const keysToDelete = entries.slice(0, idx).map(([key]) => key) this.tombstoneHistoryStartsAtClock = cullClock + 1 this.tombstones.deleteMany(keysToDelete) } } private getDocument(id: string) { return this.documents.get(id) } private addDocument(id: string, state: R, clock: number): Result<void, Error> { if (this.tombstones.has(id)) { this.tombstones.delete(id) } const createResult = DocumentState.createAndValidate( state, clock, assertExists(getOwnProperty(this.schema.types, state.typeName)) ) if (!createResult.ok) return createResult this.documents.set(id, createResult.value) return Result.ok(undefined) } private removeDocument(id: string, clock: number) { 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(): RoomSnapshot { 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. */ private sendMessage( sessionId: string, message: TLSocketServerSentEvent<R> | TLSocketServerSentDataEvent<R> ) { const session = this.sessions.get(sessionId) if (!session) { this.log?.warn?.('Tried to send message to unknown session', message.type) return } if (session.state !== 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') { // this is not a data message if (message.type !== 'pong') { // non-data messages like "connect" might still need to be ordered correctly with // respect to data messages, so it's better to flush just in case this._flushDataMessages(sessionId) } session.socket.sendMessage(message) } else { if (session.debounceTimer === null) { // this is the first message since the last flush, don't delay it 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: string) { const session = this.sessions.get(sessionId) if (!session || session.state !== RoomSessionState.Connected) { return } session.debounceTimer = null if (session.outstandingDataMessages.length > 0) { session.socket.sendMessage({ type: 'data', data: session.outstandingDataMessages }) session.outstandingDataMessages.length = 0 } } /** @internal */ private removeSession(sessionId: string, fatalReason?: string) { 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(TLSyncErrorCloseEventCode, fatalReason) } else { session.socket.close() } } catch { // noop, calling .close() multiple times is fine } if (presence) { this.documents.delete(session.presenceId!) this.broadcastPatch({ diff: { [session.presenceId!]: [RecordOpType.Remove] }, sourceSessionId: sessionId, }) } this.events.emit('session_removed', { sessionId, meta: session.meta }) if (this.sessions.size === 0) { this.events.emit('room_became_empty') } } private cancelSession(sessionId: string) { const session = this.sessions.get(sessionId) if (!session) { return } if (session.state === RoomSessionState.AwaitingRemoval) { this.log?.warn?.('Tried to cancel session that is already awaiting removal') return } this.sessions.set(sessionId, { state: 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 { // noop, calling .close() multiple times is fine } } /** * 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: { diff: NetworkDiff<R>; sourceSessionId?: string }) { const { diff, sourceSessionId } = message this.sessions.forEach((session) => { if (session.state !== 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) { // disconnect client and send incompatibility error this.rejectSession( session.sessionId, res.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : 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: string, data: any): void { 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: { sessionId: string socket: TLRoomSocket<R> meta: SessionMeta isReadonly: boolean }) { const { sessionId, socket, meta, isReadonly } = opts const existing = this.sessions.get(sessionId) this.sessions.set(sessionId, { state: 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(): boolean { for (const session of this.sessions.values()) { if (session.state === 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. */ private migrateDiffForSession( serializedSchema: SerializedSchema, diff: NetworkDiff<R> ): Result<NetworkDiff<R>, MigrationFailureReason> { // TODO: optimize this by recalculating patches using the previous versions of records // when the client connects we check whether the schema is identical and make sure // to use the same object reference so that === works on this line if (serializedSchema === this.serializedSchema) { return Result.ok(diff) } const result: NetworkDiff<R> = {} for (const [id, op] of objectMapEntriesIterable(diff)) { if (op[0] === RecordOpType.Remove) { result[id] = op continue } const doc = this.getDocument(id) if (!doc) { return Result.err(MigrationFailureReason.TargetVersionTooNew) } const migrationResult = this.schema.migratePersistedRecord( doc.state, serializedSchema, 'down' ) if (migrationResult.type === 'error') { return Result.err(migrationResult.reason) } result[id] = [RecordOpType.Put, migrationResult.value] } return 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: string, message: TLSocketClientSentEvent<R>) { 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 === RoomSessionState.Connected) { session.lastInteractionTime = Date.now() } return this.sendMessage(session.sessionId, { type: 'pong' }) } default: { 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: string, fatalReason?: TLSyncErrorCloseEventReason | string) { const session = this.sessions.get(sessionId) if (!session) return if (!fatalReason) { this.removeSession(sessionId) return } if (session.requiresLegacyRejection) { try { if (session.socket.isOpen) { // eslint-disable-next-line @typescript-eslint/no-deprecated let legacyReason: TLIncompatibilityReason switch (fatalReason) { case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD: // eslint-disable-next-line @typescript-eslint/no-deprecated legacyReason = TLIncompatibilityReason.ClientTooOld break case TLSyncErrorCloseEventReason.SERVER_TOO_OLD: // eslint-disable-next-line @typescript-eslint/no-deprecated legacyReason = TLIncompatibilityReason.ServerTooOld break case TLSyncErrorCloseEventReason.INVALID_RECORD: // eslint-disable-next-line @typescript-eslint/no-deprecated legacyReason = TLIncompatibilityReason.InvalidRecord break default: // eslint-disable-next-line @typescript-eslint/no-deprecated legacyReason = TLIncompatibilityReason.InvalidOperation break } session.socket.sendMessage({ type: 'incompatibility_error', reason: legacyReason, }) } } catch { // noop } finally { this.removeSession(sessionId) } } else { this.removeSession(sessionId, fatalReason) } } private handleConnectRequest( session: RoomSession<R, SessionMeta>, message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }> ) { // if the protocol versions don't match, disconnect the client // we will eventually want to try to make our protocol backwards compatible to some degree // and have a MIN_PROTOCOL_VERSION constant that the TLSyncRoom implements support for let theirProtocolVersion = message.protocolVersion // 5 is the same as 6 if (theirProtocolVersion === 5) { theirProtocolVersion = 6 } // 6 is almost the same as 7 session.requiresLegacyRejection = theirProtocolVersion === 6 if (theirProtocolVersion === 6) { theirProtocolVersion++ } if (theirProtocolVersion === 7) { theirProtocolVersion++ session.supportsStringAppend = false } if (theirProtocolVersion == null || theirProtocolVersion < getTlsyncProtocolVersion()) { this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) return } else if (theirProtocolVersion > getTlsyncProtocolVersion()) { this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.SERVER_TOO_OLD) return } // If the client's store is at a different version to ours, it could cause corruption. // We should disconnect the client and ask them to refresh. if (message.schema == null) { this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) return } const migrations = this.schema.getMigrationsSince(message.schema) // if the client's store is at a different version to ours, we can't support them if (!migrations.ok || migrations.value.some((m) => m.scope === 'store' || !m.down)) { this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) return } const sessionSchema = isEqual(message.schema, this.serializedSchema) ? this.serializedSchema : message.schema const connect = async (msg: Extract<TLSocketServerSentEvent<R>, { type: 'connect' }>) => { this.sessions.set(session.sessionId, { state: 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) } 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: NetworkDiff<R> = {} for (const [id, doc] of this.documents.entries()) { if (id !== session.presenceId) { diff[id] = [RecordOpType.Put, doc.state] } } const migrated = this.migrateDiffForSession(sessionSchema, diff) if (!migrated.ok) { rollback() this.rejectSession( session.sessionId, migrated.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ) return } connect({ type: 'connect', connectRequestId: message.connectRequestId, hydrationType: 'wipe_all', protocolVersion: getTlsyncProtocolVersion(), schema: this.schema.serialize(), serverClock: this.clock, diff: migrated.value, isReadonly: session.isReadonly, }) } else { // calculate the changes since the time the client last saw const diff: NetworkDiff<R> = {} for (const doc of this.documents.values()) { if (doc.lastChangedClock > message.lastServerClock) { diff[doc.state.id] = [RecordOpType.Put, doc.state] } else if (this.presenceType?.isId(doc.state.id) && doc.state.id !== session.presenceId) { diff[doc.state.id] = [RecordOpType.Put, doc.state] } } for (const [id, deletedAtClock] of this.tombstones.entries()) { if (deletedAtClock > message.lastServerClock) { diff[id] = [RecordOpType.Remove] } } const migrated = this.migrateDiffForSession(sessionSchema, diff) if (!migrated.ok) { rollback() this.rejectSession( session.sessionId, migrated.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ) return } connect({ type: 'connect', connectRequestId: message.connectRequestId, hydrationType: 'wipe_presence', schema: this.schema.serialize(), protocolVersion: getTlsyncProtocolVersion(), serverClock: this.clock, diff: migrated.value, isReadonly: session.isReadonly, }) } }) } private handlePushRequest( session: RoomSession<R, SessionMeta> | null, message: Extract<TLSocketClientSentEvent<R>, { type: 'push' }> ) { // We must be connected to handle push requests if (session && session.state !== RoomSessionState.Connected) { return } // update the last interaction time if (session) { session.lastInteractionTime = Date.now() } // increment the clock for this push this.clock++ const initialDocumentClock = this.documentClock let didPresenceChange = false transaction((rollback) => { const legacyAppendMode = !this.getCanEmitStringAppend() // collect actual ops that resulted from the push // these will be broadcast to other users interface ActualChanges { diff: NetworkDiff<R> | null } const docChanges: ActualChanges = { diff: null } const presenceChanges: ActualChanges = { diff: null } const propagateOp = (changes: ActualChanges, id: string, op: RecordOp<R>) => { if (!changes.diff) changes.diff = {} changes.diff[id] = op } const fail = ( reason: TLSyncErrorCloseEventReason, underlyingError?: Error ): Result<void, void> => { 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 Result.err(undefined) } const addDocument = (changes: ActualChanges, id: string, _state: R): Result<void, void> => { const res = session ? this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up') : { type: 'success' as const, value: _state } if (res.type === 'error') { return fail( res.reason === MigrationFailureReason.TargetVersionTooOld // target version is our version ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ) } const { value: state } = res // Get the existing document, if any const doc = this.getDocument(id) if (doc) { // If there's an existing document, replace it with the new state // but propagate a diff rather than the entire value const diff = doc.replaceState(state, this.clock, legacyAppendMode) if (!diff.ok) { return fail(TLSyncErrorCloseEventReason.INVALID_RECORD) } if (diff.value) { this.documents.set(id, diff.value[1]) propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]) } } else { // Otherwise, if we don't already have a document with this id // create the document and propagate the put op const result = this.addDocument(id, state, this.clock) if (!result.ok) { return fail(TLSyncErrorCloseEventReason.INVALID_RECORD) } propagateOp(changes, id, [RecordOpType.Put, state]) } return Result.ok(undefined) } const patchDocument = ( changes: ActualChanges, id: string, patch: ObjectDiff ): Result<void, void> => { // if it was already deleted, there's no need to apply the patch const doc = this.getDocument(id) if (!doc) return Result.ok(undefined) // If the client's version of the record is older than ours, // we apply the patch to the downgraded version of the record const downgraded = session ? this.schema.migratePersistedRecord(doc.state, session.serializedSchema, 'down') : { type: 'success' as const, value: doc.state } if (downgraded.type === 'error') { return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) } if (downgraded.value === doc.state) { // If the versions are compatible, apply the patch and propagate the patch op const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode) if (!diff.ok) { return fail(TLSyncErrorCloseEventReason.INVALID_RECORD) } if (diff.value) { this.documents.set(id, diff.value[1]) propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]) } } else { // need to apply the patch to the downgraded version and then upgrade it // apply the patch to the downgraded version const patched = applyObjectDiff(downgraded.value, patch) // then upgrade the patched version and use that as the new state const upgraded = session ? this.schema.migratePersistedRecord(patched, session.serializedSchema, 'up') : { type: 'success' as const, value: patched } // If the client's version is too old, we'll hit an error if (upgraded.type === 'error') { return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD) } // replace the state with the upgraded version and propagate the patch op const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode) if (!diff.ok) { return fail(TLSyncErrorCloseEventReason.INVALID_RECORD) } if (diff.value) { this.documents.set(id, diff.value[1]) propagateOp(changes, id, [RecordOpType.Patch, diff.value[0]]) } } return Result.ok(undefined) } const { clientClock } = message if (this.presenceType && session?.presenceId && 'presence' in message && message.presence) { if (!session) throw new Error('session is required for presence pushes') // The push request was for the presence scope. const id = session.presenceId const [type, val] = message.presence const { typeName } = this.presenceType switch (type) { case RecordOpType.Put: { // Try to put the document. If it fails, stop here. const res = addDocument(presenceChanges, id, { ...val, id, typeName }) // if res.ok is false here then we already called `fail` and we should stop immediately if (!res.ok) return break } case RecordOpType.Patch: { // Try to patch the document. If it fails, stop here. const res = patchDocument(presenceChanges, id, { ...val, id: [ValueOpType.Put, id], typeName: [ValueOpType.Put, typeName], }) // if res.ok is false here then we already called `fail` and we should stop immediately if (!res.ok) return break } } } if (message.diff && !session?.isReadonly) { // The push request was for the document scope. for (const [id, op] of objectMapEntriesIterable(message.diff!)) { switch (op[0]) { case RecordOpType.Put: { // Try to add the document. // If we're putting a record with a type that we don't recognize, fail if (!this.documentTypes.has(op[1].typeName)) { return fail(TLSyncErrorCloseEventReason.INVALID_RECORD) } const res = addDocument(docChanges, id, op[1]) // if res.ok is false here then we already called `fail` and we should stop immediately if (!res.ok) return break } case RecordOpType.Patch: { // Try to patch the document. If it fails, stop here. const res = patchDocument(docChanges, id, op[1]) // if res.ok is false here then we already called `fail` and we should stop immediately if (!res.ok) return break } case RecordOpType.Remove: { const doc = this.getDocument(id) if (!doc) { // If the doc was already deleted, don't do anything, no need to propagate a delete op continue } // Delete the document and propagate the delete op this.removeDocument(id, this.clock) // Schedule a pruneTombstones call to happen on the next call stack propagateOp(docChanges, id, op) break } } } } // Let the client know what action to take based on the results of the push if ( // if there was only a presence push, the client doesn't need to do anything aside from // shift the push request. !message.diff || isEqual(docChanges.diff, message.diff) ) { // COMMIT // Applying the client's changes had the exact same effect on the server as // they had on the client, so the client should keep the diff if (session) { this.sendMessage(session.sessionId, { type: 'push_result', serverClock: this.clock, clientClock, action: 'commit', }) } } else if (!docChanges.diff) { // DISCARD // Applying the client's changes had no effect, so the client should drop the diff if (session) { this.sendMessage(session.sessionId, { type: 'push_result', serverClock: this.clock, clientClock, action: 'discard', }) } } else { // REBASE // Applying the client's changes had a different non-empty effect on the server, // so the client should rebase with our gold-standard / authoritative diff. // First we need to migrate the diff to the client's version if (session) { const migrateResult = this.migrateDiffForSession( session.serializedSchema, docChanges.diff ) if (!migrateResult.ok) { return fail( migrateResult.error === MigrationFailureReason.TargetVersionTooNew ? TLSyncErrorCloseEventReason.SERVER_TOO_OLD : TLSyncErrorCloseEventReason.CLIENT_TOO_OLD ) } // If the migration worked, send the rebased diff to the client this.sendMessage(session.sessionId, { type: 'push_result', serverClock: this.clock, clientClock, action: { rebaseWithDiff: migrateResult.value }, }) } } // If there are merged changes, broadcast them to all other clients 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 it threw the changes will have been rolled back and the document clock will not have been incremented 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: string) { 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: (store: RoomStoreMethods<R>) => void | Promise<void>) { if (this._isClosed) { throw new Error('Cannot update store on a closed room') } const context = new StoreUpdateContext<R>( 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 }) } } /** * Interface for making transactional changes to room store data. Used within * updateStore transactions to modify documents atomically. * * @example * ```ts * await room.updateStore((store) => { * const shape = store.get('shape:123') * if (shape) { * store.put({ ...shape, x: shape.x + 10 }) * } * store.delete('shape:456') * }) * ``` * * @public */ export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> { /** * Add or update a record in the store. * * @param record - The record to store */ put(record: R): void /** * Delete a record from the store. * * @param recordOrId - The record or record ID to delete */ delete(recordOrId: R | string): void /** * Get a record by its ID. * * @param id - The record ID * @returns The record or null if not found */ get(id: string): R | null /** * Get all records in the store. * * @returns Array of all records */ getAll(): R[] } class StoreUpdateContext<R extends UnknownRecord> implements RoomStoreMethods<R> { constructor(private readonly snapshot: Record<string, UnknownRecord>) {} private readonly updates = { puts: {} as Record<string, UnknownRecord>, deletes: new Set<string>(), } put(record: R): void { if (this._isClosed) throw new Error('StoreUpdateContext is closed') if (record.id in this.snapshot && isEqual(this.snapshot[record.id], record)) { delete this.updates.puts[record.id] } else { this.updates.puts[record.id] = structuredClone(record) } this.updates.deletes.delete(record.id) } delete(recordOrId: R | string): void { 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: string): R | null { if (this._isClosed) throw new Error('StoreUpdateContext is closed') if (hasOwnProperty(this.updates.puts, id)) { return structuredClone(this.updates.puts[id]) as R } if (this.updates.deletes.has(id)) { return null } return structuredClone(this.snapshot[id] ?? null) as R } getAll(): R[] { 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) && !