UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

413 lines (383 loc) • 13.4 kB
import type { StoreSchema, UnknownRecord } from '@tldraw/store' import { TLStoreSnapshot, createTLSchema } from '@tldraw/tlschema' import { objectMapValues, structuredClone } from '@tldraw/utils' import { RoomSessionState } from './RoomSession' import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter' import { TLSyncErrorCloseEventReason } from './TLSyncClient' import { RoomSnapshot, RoomStoreMethods, TLSyncRoom } from './TLSyncRoom' import { JsonChunkAssembler } from './chunk' import { TLSocketServerSentEvent } from './protocol' // TODO: structured logging support /** @public */ export interface TLSyncLog { warn?(...args: any[]): void error?(...args: any[]): void } /** @public */ export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta = void> { private room: TLSyncRoom<R, SessionMeta> private readonly sessions = new Map< string, // eslint-disable-next-line @typescript-eslint/method-signature-style { assembler: JsonChunkAssembler; socket: WebSocketMinimal; unlisten: () => void } >() readonly log?: TLSyncLog private readonly syncCallbacks: { onDataChange?(): void onPresenceChange?(): void } constructor( public readonly opts: { initialSnapshot?: RoomSnapshot | TLStoreSnapshot schema?: StoreSchema<R, any> // how long to wait for a client to communicate before disconnecting them clientTimeout?: number log?: TLSyncLog // a callback that is called when a client is disconnected // eslint-disable-next-line @typescript-eslint/method-signature-style onSessionRemoved?: ( room: TLSocketRoom<R, SessionMeta>, args: { sessionId: string; numSessionsRemaining: number; meta: SessionMeta } ) => void // a callback that is called whenever a message is sent // eslint-disable-next-line @typescript-eslint/method-signature-style onBeforeSendMessage?: (args: { sessionId: string /** @internal keep the protocol private for now */ message: TLSocketServerSentEvent<R> stringified: string meta: SessionMeta }) => void // eslint-disable-next-line @typescript-eslint/method-signature-style onAfterReceiveMessage?: (args: { sessionId: string /** @internal keep the protocol private for now */ message: TLSocketServerSentEvent<R> stringified: string meta: SessionMeta }) => void onDataChange?(): void /** @internal */ onPresenceChange?(): void } ) { 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<R, SessionMeta>({ ...this.syncCallbacks, schema: opts.schema ?? (createTLSchema() as any), 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 } } /** * 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: { sessionId: string socket: WebSocketMinimal isReadonly?: boolean } & (SessionMeta extends void ? object : { meta: SessionMeta }) ) { const { sessionId, socket, isReadonly = false } = opts const handleSocketMessage = (event: MessageEvent) => 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 as SessionMeta, }) : undefined, }), meta: 'meta' in opts ? (opts.meta as any) : undefined, }) 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: string, message: string | AllowSharedBufferSource) { 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) { // not enough chunks yet return } if ('data' in res) { // need to do this first in case the session gets removed as a result of handling the message if (this.opts.onAfterReceiveMessage) { const session = this.room.sessions.get(sessionId) if (session) { this.opts.onAfterReceiveMessage({ sessionId, message: res.data as any, stringified: res.stringified, meta: session.meta, }) } } this.room.handleMessage(sessionId, res.data as any) } else { this.log?.error?.('Error assembling message', res.error) // close the socket to reset the connection this.handleSocketError(sessionId) } } catch (e) { this.log?.error?.(e) // here we use rejectSession rather than removeSession to support legacy clients // that use the old incompatibility_error close event 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: string) { 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: string) { 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: string) { return structuredClone(this.room.state.get().documents[id]?.state) } /** * Returns a list of the sessions in the room. */ getSessions(): Array<{ sessionId: string isConnected: boolean isReadonly: boolean meta: SessionMeta }> { 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 = {} as Record<string, UnknownRecord> 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: RoomSnapshot | TLStoreSnapshot) { 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<R, SessionMeta>({ ...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, }) // replace room with new one and kick out all the clients 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: (store: RoomStoreMethods<R>) => void | Promise<void>) { 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: string, fatalReason?: TLSyncErrorCloseEventReason | string) { 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() } } /** @public */ export type OmitVoid<T, KS extends keyof T = keyof T> = { [K in KS extends any ? (void extends T[KS] ? never : KS) : never]: T[K] } function convertStoreSnapshotToRoomSnapshot(snapshot: TLStoreSnapshot): RoomSnapshot { return { clock: 0, documents: objectMapValues(snapshot.store).map((state) => ({ state, lastChangedClock: 0, })), schema: snapshot.schema, tombstones: {}, } }