UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

8 lines (7 loc) • 31.4 kB
{ "version": 3, "sources": ["../../src/lib/TLSocketRoom.ts"], "sourcesContent": ["import type { StoreSchema, UnknownRecord } from '@tldraw/store'\nimport { TLStoreSnapshot, createTLSchema } from '@tldraw/tlschema'\nimport { objectMapValues, structuredClone } from '@tldraw/utils'\nimport { RoomSessionState } from './RoomSession'\nimport { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'\nimport { TLSyncErrorCloseEventReason } from './TLSyncClient'\nimport { RoomSnapshot, RoomStoreMethods, TLSyncRoom } from './TLSyncRoom'\nimport { JsonChunkAssembler } from './chunk'\nimport { TLSocketServerSentEvent } from './protocol'\n\n/**\n * Logging interface for TLSocketRoom operations. Provides optional methods\n * for warning and error logging during synchronization operations.\n *\n * @example\n * ```ts\n * const logger: TLSyncLog = {\n * warn: (...args) => console.warn('[SYNC]', ...args),\n * error: (...args) => console.error('[SYNC]', ...args)\n * }\n *\n * const room = new TLSocketRoom({ log: logger })\n * ```\n *\n * @public\n */\nexport interface TLSyncLog {\n\t/**\n\t * Optional warning logger for non-fatal sync issues\n\t * @param args - Arguments to log\n\t */\n\twarn?(...args: any[]): void\n\t/**\n\t * Optional error logger for sync errors and failures\n\t * @param args - Arguments to log\n\t */\n\terror?(...args: any[]): void\n}\n\n/**\n * A server-side room that manages WebSocket connections and synchronizes tldraw document state\n * between multiple clients in real-time. Each room represents a collaborative document space\n * where users can work together on drawings with automatic conflict resolution.\n *\n * TLSocketRoom handles:\n * - WebSocket connection lifecycle management\n * - Real-time synchronization of document changes\n * - Session management and presence tracking\n * - Message chunking for large payloads\n * - Automatic client timeout and cleanup\n *\n * @example\n * ```ts\n * // Basic room setup\n * const room = new TLSocketRoom({\n * onSessionRemoved: (room, { sessionId, numSessionsRemaining }) => {\n * console.log(`Client ${sessionId} disconnected, ${numSessionsRemaining} remaining`)\n * if (numSessionsRemaining === 0) {\n * room.close()\n * }\n * },\n * onDataChange: () => {\n * console.log('Document data changed, consider persisting')\n * }\n * })\n *\n * // Handle new client connections\n * room.handleSocketConnect({\n * sessionId: 'user-session-123',\n * socket: webSocket,\n * isReadonly: false\n * })\n * ```\n *\n * @example\n * ```ts\n * // Room with initial snapshot and schema\n * const room = new TLSocketRoom({\n * initialSnapshot: existingSnapshot,\n * schema: myCustomSchema,\n * clientTimeout: 30000,\n * log: {\n * warn: (...args) => logger.warn('SYNC:', ...args),\n * error: (...args) => logger.error('SYNC:', ...args)\n * }\n * })\n *\n * // Update document programmatically\n * await room.updateStore(store => {\n * const shape = store.get('shape:abc123')\n * if (shape) {\n * shape.x = 100\n * store.put(shape)\n * }\n * })\n * ```\n *\n * @public\n */\nexport class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta = void> {\n\tprivate room: TLSyncRoom<R, SessionMeta>\n\tprivate readonly sessions = new Map<\n\t\tstring,\n\t\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\t\t{ assembler: JsonChunkAssembler; socket: WebSocketMinimal; unlisten: () => void }\n\t>()\n\treadonly log?: TLSyncLog\n\tprivate readonly syncCallbacks: {\n\t\tonDataChange?(): void\n\t\tonPresenceChange?(): void\n\t}\n\n\t/**\n\t * Creates a new TLSocketRoom instance for managing collaborative document synchronization.\n\t *\n\t * opts - Configuration options for the room\n\t * - initialSnapshot - Optional initial document state to load\n\t * - schema - Store schema defining record types and validation\n\t * - clientTimeout - Milliseconds to wait before disconnecting inactive clients\n\t * - log - Optional logger for warnings and errors\n\t * - onSessionRemoved - Called when a client session is removed\n\t * - onBeforeSendMessage - Called before sending messages to clients\n\t * - onAfterReceiveMessage - Called after receiving messages from clients\n\t * - onDataChange - Called when document data changes\n\t * - onPresenceChange - Called when presence data changes\n\t */\n\tconstructor(\n\t\tpublic readonly opts: {\n\t\t\tinitialSnapshot?: RoomSnapshot | TLStoreSnapshot\n\t\t\tschema?: StoreSchema<R, any>\n\t\t\t// how long to wait for a client to communicate before disconnecting them\n\t\t\tclientTimeout?: number\n\t\t\tlog?: TLSyncLog\n\t\t\t// a callback that is called when a client is disconnected\n\t\t\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\t\t\tonSessionRemoved?: (\n\t\t\t\troom: TLSocketRoom<R, SessionMeta>,\n\t\t\t\targs: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta }\n\t\t\t) => void\n\t\t\t// a callback that is called whenever a message is sent\n\t\t\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\t\t\tonBeforeSendMessage?: (args: {\n\t\t\t\tsessionId: string\n\t\t\t\t/** @internal keep the protocol private for now */\n\t\t\t\tmessage: TLSocketServerSentEvent<R>\n\t\t\t\tstringified: string\n\t\t\t\tmeta: SessionMeta\n\t\t\t}) => void\n\t\t\t// eslint-disable-next-line @typescript-eslint/method-signature-style\n\t\t\tonAfterReceiveMessage?: (args: {\n\t\t\t\tsessionId: string\n\t\t\t\t/** @internal keep the protocol private for now */\n\t\t\t\tmessage: TLSocketServerSentEvent<R>\n\t\t\t\tstringified: string\n\t\t\t\tmeta: SessionMeta\n\t\t\t}) => void\n\t\t\tonDataChange?(): void\n\t\t\t/** @internal */\n\t\t\tonPresenceChange?(): void\n\t\t}\n\t) {\n\t\tconst initialSnapshot =\n\t\t\topts.initialSnapshot && 'store' in opts.initialSnapshot\n\t\t\t\t? convertStoreSnapshotToRoomSnapshot(opts.initialSnapshot!)\n\t\t\t\t: opts.initialSnapshot\n\n\t\tthis.syncCallbacks = {\n\t\t\tonDataChange: opts.onDataChange,\n\t\t\tonPresenceChange: opts.onPresenceChange,\n\t\t}\n\t\tthis.room = new TLSyncRoom<R, SessionMeta>({\n\t\t\t...this.syncCallbacks,\n\t\t\tschema: opts.schema ?? (createTLSchema() as any),\n\t\t\tsnapshot: initialSnapshot,\n\t\t\tlog: opts.log,\n\t\t})\n\t\tthis.room.events.on('session_removed', (args) => {\n\t\t\tthis.sessions.delete(args.sessionId)\n\t\t\tif (this.opts.onSessionRemoved) {\n\t\t\t\tthis.opts.onSessionRemoved(this, {\n\t\t\t\t\tsessionId: args.sessionId,\n\t\t\t\t\tnumSessionsRemaining: this.room.sessions.size,\n\t\t\t\t\tmeta: args.meta,\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t\tthis.log = 'log' in opts ? opts.log : { error: console.error }\n\t}\n\n\t/**\n\t * Returns the number of active sessions.\n\t * Note that this is not the same as the number of connected sockets!\n\t * Sessions time out a few moments after sockets close, to smooth over network hiccups.\n\t *\n\t * @returns the number of active sessions\n\t */\n\tgetNumActiveSessions() {\n\t\treturn this.room.sessions.size\n\t}\n\n\t/**\n\t * Handles a new client WebSocket connection, creating a session within the room.\n\t * This should be called whenever a client establishes a WebSocket connection to join\n\t * the collaborative document.\n\t *\n\t * @param opts - Connection options\n\t * - sessionId - Unique identifier for the client session (typically from browser tab)\n\t * - socket - WebSocket-like object for client communication\n\t * - isReadonly - Whether the client can modify the document (defaults to false)\n\t * - meta - Additional session metadata (required if SessionMeta is not void)\n\t *\n\t * @example\n\t * ```ts\n\t * // Handle new WebSocket connection\n\t * room.handleSocketConnect({\n\t * sessionId: 'user-session-abc123',\n\t * socket: webSocketConnection,\n\t * isReadonly: !userHasEditPermission\n\t * })\n\t * ```\n\t *\n\t * @example\n\t * ```ts\n\t * // With session metadata\n\t * room.handleSocketConnect({\n\t * sessionId: 'session-xyz',\n\t * socket: ws,\n\t * meta: { userId: 'user-123', name: 'Alice' }\n\t * })\n\t * ```\n\t */\n\thandleSocketConnect(\n\t\topts: {\n\t\t\tsessionId: string\n\t\t\tsocket: WebSocketMinimal\n\t\t\tisReadonly?: boolean\n\t\t} & (SessionMeta extends void ? object : { meta: SessionMeta })\n\t) {\n\t\tconst { sessionId, socket, isReadonly = false } = opts\n\t\tconst handleSocketMessage = (event: MessageEvent) =>\n\t\t\tthis.handleSocketMessage(sessionId, event.data)\n\t\tconst handleSocketError = this.handleSocketError.bind(this, sessionId)\n\t\tconst handleSocketClose = this.handleSocketClose.bind(this, sessionId)\n\n\t\tthis.sessions.set(sessionId, {\n\t\t\tassembler: new JsonChunkAssembler(),\n\t\t\tsocket,\n\t\t\tunlisten: () => {\n\t\t\t\tsocket.removeEventListener?.('message', handleSocketMessage)\n\t\t\t\tsocket.removeEventListener?.('close', handleSocketClose)\n\t\t\t\tsocket.removeEventListener?.('error', handleSocketError)\n\t\t\t},\n\t\t})\n\n\t\tthis.room.handleNewSession({\n\t\t\tsessionId,\n\t\t\tisReadonly,\n\t\t\tsocket: new ServerSocketAdapter({\n\t\t\t\tws: socket,\n\t\t\t\tonBeforeSendMessage: this.opts.onBeforeSendMessage\n\t\t\t\t\t? (message, stringified) =>\n\t\t\t\t\t\t\tthis.opts.onBeforeSendMessage!({\n\t\t\t\t\t\t\t\tsessionId,\n\t\t\t\t\t\t\t\tmessage,\n\t\t\t\t\t\t\t\tstringified,\n\t\t\t\t\t\t\t\tmeta: this.room.sessions.get(sessionId)?.meta as SessionMeta,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t: undefined,\n\t\t\t}),\n\t\t\tmeta: 'meta' in opts ? (opts.meta as any) : undefined,\n\t\t})\n\n\t\tsocket.addEventListener?.('message', handleSocketMessage)\n\t\tsocket.addEventListener?.('close', handleSocketClose)\n\t\tsocket.addEventListener?.('error', handleSocketError)\n\t}\n\n\t/**\n\t * Processes a message received from a client WebSocket. Use this method in server\n\t * environments where WebSocket event listeners cannot be attached directly to socket\n\t * instances (e.g., Bun.serve, Cloudflare Workers with WebSocket hibernation).\n\t *\n\t * The method handles message chunking/reassembly and forwards complete messages\n\t * to the underlying sync room for processing.\n\t *\n\t * @param sessionId - Session identifier matching the one used in handleSocketConnect\n\t * @param message - Raw message data from the client (string or binary)\n\t *\n\t * @example\n\t * ```ts\n\t * // In a Bun.serve handler\n\t * server.upgrade(req, {\n\t * data: { sessionId, room },\n\t * upgrade(res, req) {\n\t * // Connection established\n\t * },\n\t * message(ws, message) {\n\t * const { sessionId, room } = ws.data\n\t * room.handleSocketMessage(sessionId, message)\n\t * }\n\t * })\n\t * ```\n\t */\n\thandleSocketMessage(sessionId: string, message: string | AllowSharedBufferSource) {\n\t\tconst assembler = this.sessions.get(sessionId)?.assembler\n\t\tif (!assembler) {\n\t\t\tthis.log?.warn?.('Received message from unknown session', sessionId)\n\t\t\treturn\n\t\t}\n\n\t\ttry {\n\t\t\tconst messageString =\n\t\t\t\ttypeof message === 'string' ? message : new TextDecoder().decode(message)\n\t\t\tconst res = assembler.handleMessage(messageString)\n\t\t\tif (!res) {\n\t\t\t\t// not enough chunks yet\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif ('data' in res) {\n\t\t\t\t// need to do this first in case the session gets removed as a result of handling the message\n\t\t\t\tif (this.opts.onAfterReceiveMessage) {\n\t\t\t\t\tconst session = this.room.sessions.get(sessionId)\n\t\t\t\t\tif (session) {\n\t\t\t\t\t\tthis.opts.onAfterReceiveMessage({\n\t\t\t\t\t\t\tsessionId,\n\t\t\t\t\t\t\tmessage: res.data as any,\n\t\t\t\t\t\t\tstringified: res.stringified,\n\t\t\t\t\t\t\tmeta: session.meta,\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tthis.room.handleMessage(sessionId, res.data as any)\n\t\t\t} else {\n\t\t\t\tthis.log?.error?.('Error assembling message', res.error)\n\t\t\t\t// close the socket to reset the connection\n\t\t\t\tthis.handleSocketError(sessionId)\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tthis.log?.error?.(e)\n\t\t\t// here we use rejectSession rather than removeSession to support legacy clients\n\t\t\t// that use the old incompatibility_error close event\n\t\t\tthis.room.rejectSession(sessionId, TLSyncErrorCloseEventReason.UNKNOWN_ERROR)\n\t\t}\n\t}\n\n\t/**\n\t * Handles a WebSocket error for the specified session. Use this in server environments\n\t * where socket event listeners cannot be attached directly. This will initiate cleanup\n\t * and session removal for the affected client.\n\t *\n\t * @param sessionId - Session identifier matching the one used in handleSocketConnect\n\t *\n\t * @example\n\t * ```ts\n\t * // In a custom WebSocket handler\n\t * socket.addEventListener('error', () => {\n\t * room.handleSocketError(sessionId)\n\t * })\n\t * ```\n\t */\n\thandleSocketError(sessionId: string) {\n\t\tthis.room.handleClose(sessionId)\n\t}\n\n\t/**\n\t * Handles a WebSocket close event for the specified session. Use this in server\n\t * environments where socket event listeners cannot be attached directly. This will\n\t * initiate cleanup and session removal for the disconnected client.\n\t *\n\t * @param sessionId - Session identifier matching the one used in handleSocketConnect\n\t *\n\t * @example\n\t * ```ts\n\t * // In a custom WebSocket handler\n\t * socket.addEventListener('close', () => {\n\t * room.handleSocketClose(sessionId)\n\t * })\n\t * ```\n\t */\n\thandleSocketClose(sessionId: string) {\n\t\tthis.room.handleClose(sessionId)\n\t}\n\n\t/**\n\t * Returns the current document clock value. The clock is a monotonically increasing\n\t * integer that increments with each document change, providing a consistent ordering\n\t * of changes across the distributed system.\n\t *\n\t * @returns The current document clock value\n\t *\n\t * @example\n\t * ```ts\n\t * const clock = room.getCurrentDocumentClock()\n\t * console.log(`Document is at version ${clock}`)\n\t * ```\n\t */\n\tgetCurrentDocumentClock() {\n\t\treturn this.room.documentClock\n\t}\n\n\t/**\n\t * Retrieves a deeply cloned copy of a record from the document store.\n\t * Returns undefined if the record doesn't exist. The returned record is\n\t * safe to mutate without affecting the original store data.\n\t *\n\t * @param id - Unique identifier of the record to retrieve\n\t * @returns Deep clone of the record, or undefined if not found\n\t *\n\t * @example\n\t * ```ts\n\t * const shape = room.getRecord('shape:abc123')\n\t * if (shape) {\n\t * console.log('Shape position:', shape.x, shape.y)\n\t * // Safe to modify without affecting store\n\t * shape.x = 100\n\t * }\n\t * ```\n\t */\n\tgetRecord(id: string) {\n\t\treturn structuredClone(this.room.documents.get(id)?.state)\n\t}\n\n\t/**\n\t * Returns information about all active sessions in the room. Each session\n\t * represents a connected client with their current connection status and metadata.\n\t *\n\t * @returns Array of session information objects containing:\n\t * - sessionId - Unique session identifier\n\t * - isConnected - Whether the session has an active WebSocket connection\n\t * - isReadonly - Whether the session can modify the document\n\t * - meta - Custom session metadata\n\t *\n\t * @example\n\t * ```ts\n\t * const sessions = room.getSessions()\n\t * console.log(`Room has ${sessions.length} active sessions`)\n\t *\n\t * for (const session of sessions) {\n\t * console.log(`${session.sessionId}: ${session.isConnected ? 'online' : 'offline'}`)\n\t * if (session.isReadonly) {\n\t * console.log(' (read-only access)')\n\t * }\n\t * }\n\t * ```\n\t */\n\tgetSessions(): Array<{\n\t\tsessionId: string\n\t\tisConnected: boolean\n\t\tisReadonly: boolean\n\t\tmeta: SessionMeta\n\t}> {\n\t\treturn [...this.room.sessions.values()].map((session) => {\n\t\t\treturn {\n\t\t\t\tsessionId: session.sessionId,\n\t\t\t\tisConnected: session.state === RoomSessionState.Connected,\n\t\t\t\tisReadonly: session.isReadonly,\n\t\t\t\tmeta: session.meta,\n\t\t\t}\n\t\t})\n\t}\n\n\t/**\n\t * Creates a complete snapshot of the current document state, including all records\n\t * and synchronization metadata. This snapshot can be persisted to storage and used\n\t * to restore the room state later or revert to a previous version.\n\t *\n\t * @returns Complete room snapshot including documents, clock values, and tombstones\n\t *\n\t * @example\n\t * ```ts\n\t * // Capture current state for persistence\n\t * const snapshot = room.getCurrentSnapshot()\n\t * await saveToDatabase(roomId, JSON.stringify(snapshot))\n\t *\n\t * // Later, restore from snapshot\n\t * const savedSnapshot = JSON.parse(await loadFromDatabase(roomId))\n\t * const newRoom = new TLSocketRoom({ initialSnapshot: savedSnapshot })\n\t * ```\n\t */\n\tgetCurrentSnapshot() {\n\t\treturn this.room.getSnapshot()\n\t}\n\n\t/**\n\t * Retrieves all presence records from the document store. Presence records\n\t * contain ephemeral user state like cursor positions and selections.\n\t *\n\t * @returns Object mapping record IDs to presence record data\n\t * @internal\n\t */\n\tgetPresenceRecords() {\n\t\tconst result = {} as Record<string, UnknownRecord>\n\t\tfor (const document of this.room.documents.values()) {\n\t\t\tif (document.state.typeName === this.room.presenceType?.typeName) {\n\t\t\t\tresult[document.state.id] = document.state\n\t\t\t}\n\t\t}\n\t\treturn result\n\t}\n\n\t/**\n\t * Returns a JSON-serialized snapshot of the current document state. This is\n\t * equivalent to JSON.stringify(getCurrentSnapshot()) but provided as a convenience.\n\t *\n\t * @returns JSON string representation of the room snapshot\n\t * @internal\n\t */\n\tgetCurrentSerializedSnapshot() {\n\t\treturn JSON.stringify(this.room.getSnapshot())\n\t}\n\n\t/**\n\t * Loads a document snapshot, completely replacing the current room state.\n\t * This will disconnect all current clients and update the document to match\n\t * the provided snapshot. Use this for restoring from backups or implementing\n\t * document versioning.\n\t *\n\t * @param snapshot - Room or store snapshot to load\n\t *\n\t * @example\n\t * ```ts\n\t * // Restore from a saved snapshot\n\t * const backup = JSON.parse(await loadBackup(roomId))\n\t * room.loadSnapshot(backup)\n\t *\n\t * // All clients will be disconnected and need to reconnect\n\t * // to see the restored document state\n\t * ```\n\t */\n\tloadSnapshot(snapshot: RoomSnapshot | TLStoreSnapshot) {\n\t\tif ('store' in snapshot) {\n\t\t\tsnapshot = convertStoreSnapshotToRoomSnapshot(snapshot)\n\t\t}\n\t\tconst oldRoom = this.room\n\t\tconst oldRoomSnapshot = oldRoom.getSnapshot()\n\t\tconst oldIds = oldRoomSnapshot.documents.map((d) => d.state.id)\n\t\tconst newIds = new Set(snapshot.documents.map((d) => d.state.id))\n\t\tconst removedIds = oldIds.filter((id) => !newIds.has(id))\n\n\t\tconst tombstones: RoomSnapshot['tombstones'] = { ...oldRoomSnapshot.tombstones }\n\t\tremovedIds.forEach((id) => {\n\t\t\ttombstones[id] = oldRoom.clock + 1\n\t\t})\n\t\tnewIds.forEach((id) => {\n\t\t\tdelete tombstones[id]\n\t\t})\n\n\t\tconst newRoom = new TLSyncRoom<R, SessionMeta>({\n\t\t\t...this.syncCallbacks,\n\t\t\tschema: oldRoom.schema,\n\t\t\tsnapshot: {\n\t\t\t\tclock: oldRoom.clock + 1,\n\t\t\t\tdocumentClock: oldRoom.clock + 1,\n\t\t\t\tdocuments: snapshot.documents.map((d) => ({\n\t\t\t\t\tlastChangedClock: oldRoom.clock + 1,\n\t\t\t\t\tstate: d.state,\n\t\t\t\t})),\n\t\t\t\tschema: snapshot.schema,\n\t\t\t\ttombstones,\n\t\t\t\ttombstoneHistoryStartsAtClock: oldRoomSnapshot.tombstoneHistoryStartsAtClock,\n\t\t\t},\n\t\t\tlog: this.log,\n\t\t})\n\t\t// replace room with new one and kick out all the clients\n\t\tthis.room = newRoom\n\t\toldRoom.close()\n\t}\n\n\t/**\n\t * Executes a transaction to modify the document store. Changes made within the\n\t * transaction are atomic and will be synchronized to all connected clients.\n\t * The transaction provides isolation from concurrent changes until it commits.\n\t *\n\t * @param updater - Function that receives store methods to make changes\n\t * - store.get(id) - Retrieve a record (safe to mutate, but must call put() to commit)\n\t * - store.put(record) - Save a modified record\n\t * - store.getAll() - Get all records in the store\n\t * - store.delete(id) - Remove a record from the store\n\t * @returns Promise that resolves when the transaction completes\n\t *\n\t * @example\n\t * ```ts\n\t * // Update multiple shapes in a single transaction\n\t * await room.updateStore(store => {\n\t * const shape1 = store.get('shape:abc123')\n\t * const shape2 = store.get('shape:def456')\n\t *\n\t * if (shape1) {\n\t * shape1.x = 100\n\t * store.put(shape1)\n\t * }\n\t *\n\t * if (shape2) {\n\t * shape2.meta.approved = true\n\t * store.put(shape2)\n\t * }\n\t * })\n\t * ```\n\t *\n\t * @example\n\t * ```ts\n\t * // Async transaction with external API call\n\t * await room.updateStore(async store => {\n\t * const doc = store.get('document:main')\n\t * if (doc) {\n\t * doc.lastModified = await getCurrentTimestamp()\n\t * store.put(doc)\n\t * }\n\t * })\n\t * ```\n\t */\n\tasync updateStore(updater: (store: RoomStoreMethods<R>) => void | Promise<void>) {\n\t\treturn this.room.updateStore(updater)\n\t}\n\n\t/**\n\t * Sends a custom message to a specific client session. This allows sending\n\t * application-specific data that doesn't modify the document state, such as\n\t * notifications, chat messages, or custom commands.\n\t *\n\t * @param sessionId - Target session identifier\n\t * @param data - Custom payload to send (will be JSON serialized)\n\t *\n\t * @example\n\t * ```ts\n\t * // Send a notification to a specific user\n\t * room.sendCustomMessage('session-123', {\n\t * type: 'notification',\n\t * message: 'Your changes have been saved'\n\t * })\n\t *\n\t * // Send a chat message\n\t * room.sendCustomMessage('session-456', {\n\t * type: 'chat',\n\t * from: 'Alice',\n\t * text: 'Great work on this design!'\n\t * })\n\t * ```\n\t */\n\tsendCustomMessage(sessionId: string, data: any) {\n\t\tthis.room.sendCustomMessage(sessionId, data)\n\t}\n\n\t/**\n\t * Immediately removes a session from the room and closes its WebSocket connection.\n\t * The client will attempt to reconnect automatically unless a fatal reason is provided.\n\t *\n\t * @param sessionId - Session identifier to remove\n\t * @param fatalReason - Optional fatal error reason that prevents reconnection\n\t *\n\t * @example\n\t * ```ts\n\t * // Kick a user (they can reconnect)\n\t * room.closeSession('session-troublemaker')\n\t *\n\t * // Permanently ban a user\n\t * room.closeSession('session-banned', 'PERMISSION_DENIED')\n\t *\n\t * // Close session due to inactivity\n\t * room.closeSession('session-idle', 'TIMEOUT')\n\t * ```\n\t */\n\tcloseSession(sessionId: string, fatalReason?: TLSyncErrorCloseEventReason | string) {\n\t\tthis.room.rejectSession(sessionId, fatalReason)\n\t}\n\n\t/**\n\t * Closes the room and disconnects all connected clients. This should be called\n\t * when shutting down the room permanently, such as during server shutdown or\n\t * when the room is no longer needed. Once closed, the room cannot be reopened.\n\t *\n\t * @example\n\t * ```ts\n\t * // Clean shutdown when no users remain\n\t * if (room.getNumActiveSessions() === 0) {\n\t * await persistSnapshot(room.getCurrentSnapshot())\n\t * room.close()\n\t * }\n\t *\n\t * // Server shutdown\n\t * process.on('SIGTERM', () => {\n\t * for (const room of activeRooms.values()) {\n\t * room.close()\n\t * }\n\t * })\n\t * ```\n\t */\n\tclose() {\n\t\tthis.room.close()\n\t}\n\n\t/**\n\t * Checks whether the room has been permanently closed. Closed rooms cannot\n\t * accept new connections or process further changes.\n\t *\n\t * @returns True if the room is closed, false if still active\n\t *\n\t * @example\n\t * ```ts\n\t * if (room.isClosed()) {\n\t * console.log('Room has been shut down')\n\t * // Create a new room or redirect users\n\t * } else {\n\t * // Room is still accepting connections\n\t * room.handleSocketConnect({ sessionId, socket })\n\t * }\n\t * ```\n\t */\n\tisClosed() {\n\t\treturn this.room.isClosed()\n\t}\n}\n\n/**\n * Utility type that removes properties with void values from an object type.\n * This is used internally to conditionally require session metadata based on\n * whether SessionMeta extends void.\n *\n * @example\n * ```ts\n * type Example = { a: string, b: void, c: number }\n * type Result = OmitVoid<Example> // { a: string, c: number }\n * ```\n *\n * @public\n */\nexport type OmitVoid<T, KS extends keyof T = keyof T> = {\n\t[K in KS extends any ? (void extends T[KS] ? never : KS) : never]: T[K]\n}\n\nfunction convertStoreSnapshotToRoomSnapshot(snapshot: TLStoreSnapshot): RoomSnapshot {\n\treturn {\n\t\tclock: 0,\n\t\tdocumentClock: 0,\n\t\tdocuments: objectMapValues(snapshot.store).map((state) => ({\n\t\t\tstate,\n\t\t\tlastChangedClock: 0,\n\t\t})),\n\t\tschema: snapshot.schema,\n\t\ttombstones: {},\n\t}\n}\n"], "mappings": "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", "names": [] }