UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

330 lines (329 loc) • 12.1 kB
"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 { 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; } /** * Call this when a client establishes a new socket connection. * * - `sessionId` is a unique ID for a browser tab. This is passed as a query param by the useSync hook. * - `socket` is a WebSocket-like object that the server uses to communicate with the client. * - `isReadonly` is an optional boolean that can be set to true if the client should not be able to make changes to the document. They will still be able to send presence updates. * - `meta` is an optional object that can be used to store additional information about the session. * * @param opts - The options object */ 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); } /** * If executing in a server environment where sockets do not have instance-level listeners * (e.g. Bun.serve, Cloudflare Worker with WebSocket hibernation), you should call this * method when messages are received. See our self-hosting example for Bun.serve for an example. * * @param sessionId - The id of the session. (should match the one used when calling handleSocketConnect) * @param message - The message received from the client. */ 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); } } /** * If executing in a server environment where sockets do not have instance-level listeners, * call this when a socket error occurs. * @param sessionId - The id of the session. (should match the one used when calling handleSocketConnect) */ handleSocketError(sessionId) { this.room.handleClose(sessionId); } /** * If executing in a server environment where sockets do not have instance-level listeners, * call this when a socket is closed. * @param sessionId - The id of the session. (should match the one used when calling handleSocketConnect) */ handleSocketClose(sessionId) { this.room.handleClose(sessionId); } /** * Returns the current 'clock' of the document. * The clock is an integer that increments every time the document changes. * The clock is stored as part of the snapshot of the document for consistency purposes. * * @returns The clock */ getCurrentDocumentClock() { return this.room.documentClock; } /** * Returns a deeply cloned record from the store, if available. * @param id - The id of the record * @returns the cloned record */ getRecord(id) { return (0, import_utils.structuredClone)(this.room.state.get().documents[id]?.state); } /** * Returns a list of the sessions in the room. */ 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 }; }); } /** * Return a snapshot of the document state, including clock-related bookkeeping. * You can store this and load it later on when initializing a TLSocketRoom. * You can also pass a snapshot to {@link TLSocketRoom#loadSnapshot} if you need to revert to a previous state. * @returns The snapshot */ getCurrentSnapshot() { return this.room.getSnapshot(); } /** * @internal */ getPresenceRecords() { const result = {}; for (const document of Object.values(this.room.state.get().documents)) { if (document.state.typeName === this.room.presenceType?.typeName) { result[document.state.id] = document.state; } } return result; } /** * Return a serialized snapshot of the document state, including clock-related bookkeeping. * @returns The serialized snapshot * @internal */ getCurrentSerializedSnapshot() { return JSON.stringify(this.room.getSnapshot()); } /** * Load a snapshot of the document state, overwriting the current state. * @param snapshot - The snapshot to load */ loadSnapshot(snapshot) { if ("store" in snapshot) { snapshot = convertStoreSnapshotToRoomSnapshot(snapshot); } const oldRoom = this.room; const oldIds = oldRoom.getSnapshot().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 = { ...snapshot.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, documents: snapshot.documents.map((d) => ({ lastChangedClock: oldRoom.clock + 1, state: d.state })), schema: snapshot.schema, tombstones }, log: this.log }); this.room = newRoom; oldRoom.close(); } /** * Allow applying changes to the store inside of a transaction. * * You can get values from the store by id with `store.get(id)`. * These values are safe to mutate, but to commit the changes you must call `store.put(...)` with the updated value. * You can get all values in the store with `store.getAll()`. * You can also delete values with `store.delete(id)`. * * @example * ```ts * room.updateStore(store => { * const shape = store.get('shape:abc123') * shape.meta.approved = true * store.put(shape) * }) * ``` * * Changes to the store inside the callback are isolated from changes made by other clients until the transaction commits. * * @param updater - A function that will be called with a store object that can be used to make changes. * @returns A promise that resolves when the transaction is complete. */ async updateStore(updater) { return this.room.updateStore(updater); } /** * Immediately remove a session from the room, and close its socket if not already closed. * * The client will attempt to reconnect unless you provide a `fatalReason` parameter. * * The `fatalReason` parameter will be available in the return value of the `useSync` hook as `useSync().error.reason`. * * @param sessionId - The id of the session to remove * @param fatalReason - The reason message to use when calling .close on the underlying websocket */ closeSession(sessionId, fatalReason) { this.room.rejectSession(sessionId, fatalReason); } /** * Close the room and disconnect all clients. Call this before discarding the room instance or shutting down the server. */ close() { this.room.close(); } /** * @returns true if the room is closed */ isClosed() { return this.room.isClosed(); } } function convertStoreSnapshotToRoomSnapshot(snapshot) { return { clock: 0, documents: (0, import_utils.objectMapValues)(snapshot.store).map((state) => ({ state, lastChangedClock: 0 })), schema: snapshot.schema, tombstones: {} }; } //# sourceMappingURL=TLSocketRoom.js.map