@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
547 lines (546 loc) • 18.4 kB
JavaScript
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