@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
567 lines (566 loc) • 19.5 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var TLSocketRoom_exports = {};
__export(TLSocketRoom_exports, {
TLSocketRoom: () => TLSocketRoom
});
module.exports = __toCommonJS(TLSocketRoom_exports);
var import_tlschema = require("@tldraw/tlschema");
var import_utils = require("@tldraw/utils");
var import_RoomSession = require("./RoomSession");
var import_ServerSocketAdapter = require("./ServerSocketAdapter");
var import_TLSyncClient = require("./TLSyncClient");
var import_TLSyncRoom = require("./TLSyncRoom");
var import_chunk = require("./chunk");
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 import_TLSyncRoom.TLSyncRoom({
...this.syncCallbacks,
schema: opts.schema ?? (0, import_tlschema.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 import_chunk.JsonChunkAssembler(),
socket,
unlisten: () => {
socket.removeEventListener?.("message", handleSocketMessage);
socket.removeEventListener?.("close", handleSocketClose);
socket.removeEventListener?.("error", handleSocketError);
}
});
this.room.handleNewSession({
sessionId,
isReadonly,
socket: new import_ServerSocketAdapter.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, import_TLSyncClient.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 (0, import_utils.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 === import_RoomSession.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 import_TLSyncRoom.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: (0, import_utils.objectMapValues)(snapshot.store).map((state) => ({
state,
lastChangedClock: 0
})),
schema: snapshot.schema,
tombstones: {}
};
}
//# sourceMappingURL=TLSocketRoom.js.map