@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
310 lines (309 loc) • 11.1 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 {
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;
}
/**
* 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 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);
}
/**
* 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, 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 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 === 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 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: objectMapValues(snapshot.store).map((state) => ({
state,
lastChangedClock: 0
})),
schema: snapshot.schema,
tombstones: {}
};
}
export {
TLSocketRoom
};
//# sourceMappingURL=TLSocketRoom.mjs.map