Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt

Use this file to discover all available pages before exploring further.

Server API

Complete API reference for server-side multiplayer synchronization.

TLSocketRoom

Server-side room managing WebSocket connections and document synchronization.
import { TLSocketRoom } from '@tldraw/sync-core'

const room = new TLSocketRoom(options)

Constructor parameters

schema
StoreSchema<R, any>
required
Store schema defining record types and migrations.
import { createTLSchema } from '@tldraw/tlschema'

const room = new TLSocketRoom({
  schema: createTLSchema()
})
storage
TLSyncStorage<R>
required
Storage backend for persistence. See storage options.
import { InMemorySyncStorage } from '@tldraw/sync-core'

storage: new InMemorySyncStorage()
log
TLSyncLog
Optional logger for warnings and errors.
log: {
  warn: (...args) => console.warn('[SYNC]', ...args),
  error: (...args) => console.error('[SYNC]', ...args)
}
onSessionRemoved
(room: TLSocketRoom, args: SessionRemovedArgs) => void
Callback when a client disconnects.
onSessionRemoved: (room, { sessionId, numSessionsRemaining, meta }) => {
  console.log(`User ${meta.userId} disconnected`)
  
  if (numSessionsRemaining === 0) {
    room.close()
  }
}
onBeforeSendMessage
(args: MessageArgs) => void
Called before sending each message to a client.
onBeforeSendMessage: ({ sessionId, message, stringified, meta }) => {
  console.log(`Sending ${message.type} to ${sessionId}, size: ${stringified.length}`)
}
onPresenceChange
() => void
Called when any client’s presence data changes.
onPresenceChange: () => {
  console.log('Presence updated')
}

Methods

handleNewSession
(options: NewSessionOptions) => TLSocketRoom
Register a new client session.
room.handleNewSession({
  sessionId: 'unique-session-id',
  socket: webSocketAdapter,
  meta: { userId: 'user-123' },
  isReadonly: false
})
Parameters:
  • sessionId (string, required) - Unique session identifier
  • socket (TLRoomSocket, required) - WebSocket adapter
  • meta (SessionMeta, required) - Application-specific metadata
  • isReadonly (boolean, required) - Whether client can modify documents
handleMessage
(sessionId: string, message: TLSocketClientSentEvent) => Promise<void>
Process incoming message from a client.
await room.handleMessage(sessionId, message)
Handles:
  • connect - Initial handshake
  • push - Document changes
  • ping - Keep-alive
handleClose
(sessionId: string) => void
Handle client disconnection.
room.handleClose(sessionId)
sendCustomMessage
(sessionId: string, data: any) => void
Send custom message to specific client.
room.sendCustomMessage('session-123', {
  type: 'notification',
  message: 'Document saved'
})
rejectSession
(sessionId: string, reason?: TLSyncErrorCloseEventReason) => void
Reject and disconnect a session.
import { TLSyncErrorCloseEventReason } from '@tldraw/sync-core'

room.rejectSession(
  'session-123',
  TLSyncErrorCloseEventReason.FORBIDDEN
)
close
() => void
Close room and disconnect all clients.
room.close()
isClosed
() => boolean
Check if room is closed.
if (room.isClosed()) {
  console.log('Room is closed')
}

Events

events.on
(event: string, callback: Function) => () => void
Subscribe to room events. Returns unsubscribe function.
const unsubscribe = room.events.on('room_became_empty', () => {
  console.log('Last user left')
  room.close()
})

const unsubscribe2 = room.events.on('session_removed', ({ sessionId, meta }) => {
  console.log(`Session ${sessionId} removed`)
})
Available events:
  • room_became_empty - Last client disconnected
  • session_removed - Client session ended

Example

import { WebSocketServer } from 'ws'
import { TLSocketRoom } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'

const wss = new WebSocketServer({ port: 8080 })
const rooms = new Map<string, TLSocketRoom>()

wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `wss://${req.headers.host}`)
  const roomId = url.pathname.split('/').pop()!
  const sessionId = url.searchParams.get('sessionId')!
  
  // Get or create room
  let room = rooms.get(roomId)
  if (!room) {
    room = new TLSocketRoom({
      schema: createTLSchema(),
      onSessionRemoved: (room, { numSessionsRemaining }) => {
        if (numSessionsRemaining === 0) {
          room.close()
          rooms.delete(roomId)
        }
      },
      log: {
        warn: (...args) => console.warn(`[${roomId}]`, ...args),
        error: (...args) => console.error(`[${roomId}]`, ...args)
      }
    })
    rooms.set(roomId, room)
  }
  
  // Connect client
  room.handleNewSession({
    sessionId,
    socket: ws,
    meta: { userId: 'anonymous' },
    isReadonly: false
  })
  
  // Handle messages
  ws.on('message', async (data) => {
    const message = JSON.parse(data.toString())
    await room.handleMessage(sessionId, message)
  })
  
  // Handle disconnect
  ws.on('close', () => {
    room.handleClose(sessionId)
  })
})

TLSyncRoom

Lower-level room implementation. Most servers should use TLSocketRoom instead.
import { TLSyncRoom } from '@tldraw/sync-core'

const room = new TLSyncRoom(options)

Constructor parameters

Similar to TLSocketRoom but without WebSocket handling:
schema
StoreSchema<R, any>
required
Store schema.
storage
TLSyncStorage<R>
required
Storage backend.
log
TLSyncLog
Optional logger.
onPresenceChange
() => void
Presence change callback.

Methods

Provides lower-level control over sessions and messaging. See source code at /home/daytona/workspace/source/packages/sync-core/src/lib/TLSyncRoom.ts:147 for full API.

Storage

InMemorySyncStorage

In-memory storage for development.
import { InMemorySyncStorage } from '@tldraw/sync-core'

const storage = new InMemorySyncStorage()
Loses all data on server restart. Only use for development.

SQLiteSyncStorage

SQLite-based persistent storage.
import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
import Database from 'better-sqlite3'

const db = new Database('./rooms.db')
const sql = new NodeSqliteWrapper(db)
const storage = new SQLiteSyncStorage({ sql })
Parameters:
  • sql (TLSyncSqliteWrapper, required) - SQLite wrapper
  • snapshot (RoomSnapshot, optional) - Initial snapshot to load

DurableObjectSqliteSyncWrapper

SQLite wrapper for Cloudflare Durable Objects.
import { DurableObjectSqliteSyncWrapper } from '@tldraw/sync-core'

const sql = new DurableObjectSqliteSyncWrapper(durableObject.ctx.storage)
const storage = new SQLiteSyncStorage({ sql })

NodeSqliteWrapper

SQLite wrapper for Node.js with better-sqlite3.
import { NodeSqliteWrapper } from '@tldraw/sync-core'
import Database from 'better-sqlite3'

const db = new Database('./room.db')
const sql = new NodeSqliteWrapper(db)

ServerSocketAdapter

WebSocket adapter for standard WebSocket libraries.
import { ServerSocketAdapter } from '@tldraw/sync-core'
import type { WebSocket } from 'ws'

const adapter = new ServerSocketAdapter({
  ws: websocket,
  onBeforeSendMessage: ({ message, stringified }) => {
    console.log(`Sending ${message.type}`)
  }
})

Constructor parameters

ws
WebSocketMinimal
required
WebSocket instance implementing minimal interface:
interface WebSocketMinimal {
  send(data: string): void
  close(code?: number, reason?: string): void
  addEventListener(event: string, listener: Function): void
  removeEventListener(event: string, listener: Function): void
}
onBeforeSendMessage
(args: { message: TLSocketServerSentEvent, stringified: string }) => void
Called before sending each message.

Properties

isOpen
boolean
Whether socket is currently open.

Methods

sendMessage
(msg: TLSocketServerSentEvent) => void
Send message to client.
close
(code?: number, reason?: string) => void
Close socket connection.

Types

SessionMeta

Application-specific metadata attached to each session.
interface MySessionMeta {
  userId: string
  permissions: string[]
  connectedAt: number
}

const room = new TLSocketRoom<TLRecord, MySessionMeta>({
  schema: createTLSchema(),
  onSessionRemoved: (room, { meta }) => {
    console.log(`User ${meta.userId} disconnected`)
  }
})

TLRoomSocket

Socket interface required by TLSocketRoom.
interface TLRoomSocket<R extends UnknownRecord> {
  isOpen: boolean
  sendMessage(msg: TLSocketServerSentEvent<R>): void
  close(code?: number, reason?: string): void
}

RoomSnapshot

Complete room state for persistence.
interface RoomSnapshot {
  clock?: number
  documentClock?: number
  documents: Array<{
    state: UnknownRecord
    lastChangedClock: number
  }>
  tombstones?: Record<string, number>
  tombstoneHistoryStartsAtClock?: number
  schema?: SerializedSchema
}

Protocol messages

Client → Server

type TLSocketClientSentEvent =
  | { type: 'connect'; connectRequestId: string; schema: SerializedSchema; protocolVersion: number; lastServerClock: number }
  | { type: 'push'; clientClock: number; diff?: NetworkDiff; presence?: [RecordOpType, ObjectDiff | R] }
  | { type: 'ping' }

Server → Client

type TLSocketServerSentEvent =
  | { type: 'connect'; connectRequestId: string; hydrationType: 'wipe_all' | 'wipe_presence'; protocolVersion: number; schema: SerializedSchema; serverClock: number; diff: NetworkDiff; isReadonly: boolean }
  | { type: 'patch'; diff: NetworkDiff; serverClock: number }
  | { type: 'push_result'; clientClock: number; serverClock: number; action: 'commit' | 'discard' | { rebaseWithDiff: NetworkDiff } }
  | { type: 'pong' }
  | { type: 'data'; data: TLSocketServerSentDataEvent[] }
  | { type: 'custom'; data: any }

Error codes

import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'

// Close code (4099)
TLSyncErrorCloseEventCode

// Error reasons
TLSyncErrorCloseEventReason.NOT_FOUND         // 'NOT_FOUND'
TLSyncErrorCloseEventReason.FORBIDDEN         // 'FORBIDDEN'
TLSyncErrorCloseEventReason.NOT_AUTHENTICATED // 'NOT_AUTHENTICATED'
TLSyncErrorCloseEventReason.UNKNOWN_ERROR     // 'UNKNOWN_ERROR'
TLSyncErrorCloseEventReason.CLIENT_TOO_OLD    // 'CLIENT_TOO_OLD'
TLSyncErrorCloseEventReason.SERVER_TOO_OLD    // 'SERVER_TOO_OLD'
TLSyncErrorCloseEventReason.INVALID_RECORD    // 'INVALID_RECORD'
TLSyncErrorCloseEventReason.RATE_LIMITED      // 'RATE_LIMITED'
TLSyncErrorCloseEventReason.ROOM_FULL         // 'ROOM_FULL'
Usage:
if (!hasPermission(userId, roomId)) {
  socket.close(
    TLSyncErrorCloseEventCode,
    TLSyncErrorCloseEventReason.FORBIDDEN
  )
}

See also

Client API

Client-side API reference

Store sync

Storage API reference

Setup guide

Server setup guide

Authentication

Add authentication