UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

547 lines (546 loc) • 18.4 kB
import { createTLSchema } from "@tldraw/tlschema"; import { objectMapValues, structuredClone } from "@tldraw/utils"; import { RoomSessionState } from "./RoomSession.mjs"; import { ServerSocketAdapter } from "./ServerSocketAdapter.mjs"; import { TLSyncErrorCloseEventReason } from "./TLSyncClient.mjs"; import { TLSyncRoom } from "./TLSyncRoom.mjs"; import { JsonChunkAssembler } from "./chunk.mjs"; class TLSocketRoom { /** * Creates a new TLSocketRoom instance for managing collaborative document synchronization. * * opts - Configuration options for the room * - initialSnapshot - Optional initial document state to load * - schema - Store schema defining record types and validation * - clientTimeout - Milliseconds to wait before disconnecting inactive clients * - log - Optional logger for warnings and errors * - onSessionRemoved - Called when a client session is removed * - onBeforeSendMessage - Called before sending messages to clients * - onAfterReceiveMessage - Called after receiving messages from clients * - onDataChange - Called when document data changes * - onPresenceChange - Called when presence data changes */ constructor(opts) { this.opts = opts; const initialSnapshot = opts.initialSnapshot && "store" in opts.initialSnapshot ? convertStoreSnapshotToRoomSnapshot(opts.initialSnapshot) : opts.initialSnapshot; this.syncCallbacks = { onDataChange: opts.onDataChange, onPresenceChange: opts.onPresenceChange }; this.room = new TLSyncRoom({ ...this.syncCallbacks, schema: opts.schema ?? createTLSchema(), snapshot: initialSnapshot, log: opts.log }); this.room.events.on("session_removed", (args) => { this.sessions.delete(args.sessionId); if (this.opts.onSessionRemoved) { this.opts.onSessionRemoved(this, { sessionId: args.sessionId, numSessionsRemaining: this.room.sessions.size, meta: args.meta }); } }); this.log = "log" in opts ? opts.log : { error: console.error }; } room; sessions = /* @__PURE__ */ new Map(); log; syncCallbacks; /** * Returns the number of active sessions. * Note that this is not the same as the number of connected sockets! * Sessions time out a few moments after sockets close, to smooth over network hiccups. * * @returns the number of active sessions */ getNumActiveSessions() { return this.room.sessions.size; } /** * Handles a new client WebSocket connection, creating a session within the room. * This should be called whenever a client establishes a WebSocket connection to join * the collaborative document. * * @param opts - Connection options * - sessionId - Unique identifier for the client session (typically from browser tab) * - socket - WebSocket-like object for client communication * - isReadonly - Whether the client can modify the document (defaults to false) * - meta - Additional session metadata (required if SessionMeta is not void) * * @example * ```ts * // Handle new WebSocket connection * room.handleSocketConnect({ * sessionId: 'user-session-abc123', * socket: webSocketConnection, * isReadonly: !userHasEditPermission * }) * ``` * * @example * ```ts * // With session metadata * room.handleSocketConnect({ * sessionId: 'session-xyz', * socket: ws, * meta: { userId: 'user-123', name: 'Alice' } * }) * ``` */ handleSocketConnect(opts) { const { sessionId, socket, isReadonly = false } = opts; const handleSocketMessage = (event) => this.handleSocketMessage(sessionId, event.data); const handleSocketError = this.handleSocketError.bind(this, sessionId); const handleSocketClose = this.handleSocketClose.bind(this, sessionId); this.sessions.set(sessionId, { assembler: new JsonChunkAssembler(), socket, unlisten: () => { socket.removeEventListener?.("message", handleSocketMessage); socket.removeEventListener?.("close", handleSocketClose); socket.removeEventListener?.("error", handleSocketError); } }); this.room.handleNewSession({ sessionId, isReadonly, socket: new ServerSocketAdapter({ ws: socket, onBeforeSendMessage: this.opts.onBeforeSendMessage ? (message, stringified) => this.opts.onBeforeSendMessage({ sessionId, message, stringified, meta: this.room.sessions.get(sessionId)?.meta }) : void 0 }), meta: "meta" in opts ? opts.meta : void 0 }); socket.addEventListener?.("message", handleSocketMessage); socket.addEventListener?.("close", handleSocketClose); socket.addEventListener?.("error", handleSocketError); } /** * Processes a message received from a client WebSocket. Use this method in server * environments where WebSocket event listeners cannot be attached directly to socket * instances (e.g., Bun.serve, Cloudflare Workers with WebSocket hibernation). * * The method handles message chunking/reassembly and forwards complete messages * to the underlying sync room for processing. * * @param sessionId - Session identifier matching the one used in handleSocketConnect * @param message - Raw message data from the client (string or binary) * * @example * ```ts * // In a Bun.serve handler * server.upgrade(req, { * data: { sessionId, room }, * upgrade(res, req) { * // Connection established * }, * message(ws, message) { * const { sessionId, room } = ws.data * room.handleSocketMessage(sessionId, message) * } * }) * ``` */ handleSocketMessage(sessionId, message) { const assembler = this.sessions.get(sessionId)?.assembler; if (!assembler) { this.log?.warn?.("Received message from unknown session", sessionId); return; } try { const messageString = typeof message === "string" ? message : new TextDecoder().decode(message); const res = assembler.handleMessage(messageString); if (!res) { return; } if ("data" in res) { if (this.opts.onAfterReceiveMessage) { const session = this.room.sessions.get(sessionId); if (session) { this.opts.onAfterReceiveMessage({ sessionId, message: res.data, stringified: res.stringified, meta: session.meta }); } } this.room.handleMessage(sessionId, res.data); } else { this.log?.error?.("Error assembling message", res.error); this.handleSocketError(sessionId); } } catch (e) { this.log?.error?.(e); this.room.rejectSession(sessionId, TLSyncErrorCloseEventReason.UNKNOWN_ERROR); } } /** * Handles a WebSocket error for the specified session. Use this in server environments * where socket event listeners cannot be attached directly. This will initiate cleanup * and session removal for the affected client. * * @param sessionId - Session identifier matching the one used in handleSocketConnect * * @example * ```ts * // In a custom WebSocket handler * socket.addEventListener('error', () => { * room.handleSocketError(sessionId) * }) * ``` */ handleSocketError(sessionId) { this.room.handleClose(sessionId); } /** * Handles a WebSocket close event for the specified session. Use this in server * environments where socket event listeners cannot be attached directly. This will * initiate cleanup and session removal for the disconnected client. * * @param sessionId - Session identifier matching the one used in handleSocketConnect * * @example * ```ts * // In a custom WebSocket handler * socket.addEventListener('close', () => { * room.handleSocketClose(sessionId) * }) * ``` */ handleSocketClose(sessionId) { this.room.handleClose(sessionId); } /** * Returns the current document clock value. The clock is a monotonically increasing * integer that increments with each document change, providing a consistent ordering * of changes across the distributed system. * * @returns The current document clock value * * @example * ```ts * const clock = room.getCurrentDocumentClock() * console.log(`Document is at version ${clock}`) * ``` */ getCurrentDocumentClock() { return this.room.documentClock; } /** * Retrieves a deeply cloned copy of a record from the document store. * Returns undefined if the record doesn't exist. The returned record is * safe to mutate without affecting the original store data. * * @param id - Unique identifier of the record to retrieve * @returns Deep clone of the record, or undefined if not found * * @example * ```ts * const shape = room.getRecord('shape:abc123') * if (shape) { * console.log('Shape position:', shape.x, shape.y) * // Safe to modify without affecting store * shape.x = 100 * } * ``` */ getRecord(id) { return structuredClone(this.room.documents.get(id)?.state); } /** * Returns information about all active sessions in the room. Each session * represents a connected client with their current connection status and metadata. * * @returns Array of session information objects containing: * - sessionId - Unique session identifier * - isConnected - Whether the session has an active WebSocket connection * - isReadonly - Whether the session can modify the document * - meta - Custom session metadata * * @example * ```ts * const sessions = room.getSessions() * console.log(`Room has ${sessions.length} active sessions`) * * for (const session of sessions) { * console.log(`${session.sessionId}: ${session.isConnected ? 'online' : 'offline'}`) * if (session.isReadonly) { * console.log(' (read-only access)') * } * } * ``` */ getSessions() { return [...this.room.sessions.values()].map((session) => { return { sessionId: session.sessionId, isConnected: session.state === RoomSessionState.Connected, isReadonly: session.isReadonly, meta: session.meta }; }); } /** * Creates a complete snapshot of the current document state, including all records * and synchronization metadata. This snapshot can be persisted to storage and used * to restore the room state later or revert to a previous version. * * @returns Complete room snapshot including documents, clock values, and tombstones * * @example * ```ts * // Capture current state for persistence * const snapshot = room.getCurrentSnapshot() * await saveToDatabase(roomId, JSON.stringify(snapshot)) * * // Later, restore from snapshot * const savedSnapshot = JSON.parse(await loadFromDatabase(roomId)) * const newRoom = new TLSocketRoom({ initialSnapshot: savedSnapshot }) * ``` */ getCurrentSnapshot() { return this.room.getSnapshot(); } /** * Retrieves all presence records from the document store. Presence records * contain ephemeral user state like cursor positions and selections. * * @returns Object mapping record IDs to presence record data * @internal */ getPresenceRecords() { const result = {}; for (const document of this.room.documents.values()) { if (document.state.typeName === this.room.presenceType?.typeName) { result[document.state.id] = document.state; } } return result; } /** * Returns a JSON-serialized snapshot of the current document state. This is * equivalent to JSON.stringify(getCurrentSnapshot()) but provided as a convenience. * * @returns JSON string representation of the room snapshot * @internal */ getCurrentSerializedSnapshot() { return JSON.stringify(this.room.getSnapshot()); } /** * Loads a document snapshot, completely replacing the current room state. * This will disconnect all current clients and update the document to match * the provided snapshot. Use this for restoring from backups or implementing * document versioning. * * @param snapshot - Room or store snapshot to load * * @example * ```ts * // Restore from a saved snapshot * const backup = JSON.parse(await loadBackup(roomId)) * room.loadSnapshot(backup) * * // All clients will be disconnected and need to reconnect * // to see the restored document state * ``` */ loadSnapshot(snapshot) { if ("store" in snapshot) { snapshot = convertStoreSnapshotToRoomSnapshot(snapshot); } const oldRoom = this.room; const oldRoomSnapshot = oldRoom.getSnapshot(); const oldIds = oldRoomSnapshot.documents.map((d) => d.state.id); const newIds = new Set(snapshot.documents.map((d) => d.state.id)); const removedIds = oldIds.filter((id) => !newIds.has(id)); const tombstones = { ...oldRoomSnapshot.tombstones }; removedIds.forEach((id) => { tombstones[id] = oldRoom.clock + 1; }); newIds.forEach((id) => { delete tombstones[id]; }); const newRoom = new TLSyncRoom({ ...this.syncCallbacks, schema: oldRoom.schema, snapshot: { clock: oldRoom.clock + 1, documentClock: oldRoom.clock + 1, documents: snapshot.documents.map((d) => ({ lastChangedClock: oldRoom.clock + 1, state: d.state })), schema: snapshot.schema, tombstones, tombstoneHistoryStartsAtClock: oldRoomSnapshot.tombstoneHistoryStartsAtClock }, log: this.log }); this.room = newRoom; oldRoom.close(); } /** * Executes a transaction to modify the document store. Changes made within the * transaction are atomic and will be synchronized to all connected clients. * The transaction provides isolation from concurrent changes until it commits. * * @param updater - Function that receives store methods to make changes * - store.get(id) - Retrieve a record (safe to mutate, but must call put() to commit) * - store.put(record) - Save a modified record * - store.getAll() - Get all records in the store * - store.delete(id) - Remove a record from the store * @returns Promise that resolves when the transaction completes * * @example * ```ts * // Update multiple shapes in a single transaction * await room.updateStore(store => { * const shape1 = store.get('shape:abc123') * const shape2 = store.get('shape:def456') * * if (shape1) { * shape1.x = 100 * store.put(shape1) * } * * if (shape2) { * shape2.meta.approved = true * store.put(shape2) * } * }) * ``` * * @example * ```ts * // Async transaction with external API call * await room.updateStore(async store => { * const doc = store.get('document:main') * if (doc) { * doc.lastModified = await getCurrentTimestamp() * store.put(doc) * } * }) * ``` */ async updateStore(updater) { return this.room.updateStore(updater); } /** * Sends a custom message to a specific client session. This allows sending * application-specific data that doesn't modify the document state, such as * notifications, chat messages, or custom commands. * * @param sessionId - Target session identifier * @param data - Custom payload to send (will be JSON serialized) * * @example * ```ts * // Send a notification to a specific user * room.sendCustomMessage('session-123', { * type: 'notification', * message: 'Your changes have been saved' * }) * * // Send a chat message * room.sendCustomMessage('session-456', { * type: 'chat', * from: 'Alice', * text: 'Great work on this design!' * }) * ``` */ sendCustomMessage(sessionId, data) { this.room.sendCustomMessage(sessionId, data); } /** * Immediately removes a session from the room and closes its WebSocket connection. * The client will attempt to reconnect automatically unless a fatal reason is provided. * * @param sessionId - Session identifier to remove * @param fatalReason - Optional fatal error reason that prevents reconnection * * @example * ```ts * // Kick a user (they can reconnect) * room.closeSession('session-troublemaker') * * // Permanently ban a user * room.closeSession('session-banned', 'PERMISSION_DENIED') * * // Close session due to inactivity * room.closeSession('session-idle', 'TIMEOUT') * ``` */ closeSession(sessionId, fatalReason) { this.room.rejectSession(sessionId, fatalReason); } /** * Closes the room and disconnects all connected clients. This should be called * when shutting down the room permanently, such as during server shutdown or * when the room is no longer needed. Once closed, the room cannot be reopened. * * @example * ```ts * // Clean shutdown when no users remain * if (room.getNumActiveSessions() === 0) { * await persistSnapshot(room.getCurrentSnapshot()) * room.close() * } * * // Server shutdown * process.on('SIGTERM', () => { * for (const room of activeRooms.values()) { * room.close() * } * }) * ``` */ close() { this.room.close(); } /** * Checks whether the room has been permanently closed. Closed rooms cannot * accept new connections or process further changes. * * @returns True if the room is closed, false if still active * * @example * ```ts * if (room.isClosed()) { * console.log('Room has been shut down') * // Create a new room or redirect users * } else { * // Room is still accepting connections * room.handleSocketConnect({ sessionId, socket }) * } * ``` */ isClosed() { return this.room.isClosed(); } } function convertStoreSnapshotToRoomSnapshot(snapshot) { return { clock: 0, documentClock: 0, documents: objectMapValues(snapshot.store).map((state) => ({ state, lastChangedClock: 0 })), schema: snapshot.schema, tombstones: {} }; } export { TLSocketRoom }; //# sourceMappingURL=TLSocketRoom.mjs.map