import { DurableObjectSqliteSyncWrapper, type SessionStateSnapshot, SQLiteSyncStorage, TLSocketRoom, } from '@tldraw/sync-core' import { createTLSchema, // defaultBindingSchemas, defaultShapeSchemas, TLRecord, } from '@tldraw/tlschema' import { DurableObject } from 'cloudflare:workers' import { AutoRouter, error, IRequest } from 'itty-router' // add custom shapes and bindings here if needed: const schema = createTLSchema({ shapes: { ...defaultShapeSchemas }, // bindings: { ...defaultBindingSchemas }, }) interface SocketAttachment { sessionId: string snapshot: SessionStateSnapshot | null } function getAttachment(ws: WebSocket): SocketAttachment | null { const attachment = ws.deserializeAttachment() as SocketAttachment | null return attachment?.sessionId ? attachment : null } // Each whiteboard room is hosted in a Durable Object with WebSocket Hibernation. // https://developers.cloudflare.com/durable-objects/ // // There's only ever one durable object instance per room. Room state is // persisted automatically to SQLite via ctx.storage. When all clients are // idle, the DO hibernates (freeing memory) while WebSocket connections // stay alive at the Cloudflare layer. export class TldrawDurableObject extends DurableObject { private room: TLSocketRoom | null = null /** Map sessionId → ws so onSessionSnapshot can serialize to the right socket. */ private readonly sessionIdToWs = new Map() constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) // Respond to ping messages at the platform level without waking the DO. // The TLSyncClient sends {"type":"ping"} every 5s; without this, each // ping would wake the DO from hibernation. this.ctx.setWebSocketAutoResponse( new WebSocketRequestResponsePair('{"type":"ping"}', '{"type":"pong"}') ) } private getOrCreateRoom(): TLSocketRoom { if (!this.room) { const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage) const storage = new SQLiteSyncStorage({ sql }) this.room = new TLSocketRoom({ schema, storage, // Disable idle timeout since Cloudflare handles keep-alive via auto-response. // Without this, sessions would be pruned after 20s of no "real" messages // even though the client is still connected and being auto-ponged. clientTimeout: Infinity, onSessionSnapshot: (sessionId, snapshot) => { const ws = this.sessionIdToWs.get(sessionId) if (ws) ws.serializeAttachment({ sessionId, snapshot }) }, }) // Resume any sessions that survived hibernation for (const ws of this.ctx.getWebSockets()) { const attachment = getAttachment(ws) if (!attachment?.snapshot) continue this.room.handleSocketResume({ sessionId: attachment.sessionId, socket: ws, snapshot: attachment.snapshot, }) } } return this.room } private readonly router = AutoRouter({ catch: (e) => error(e) }).get( '/api/connect/:roomId', (request) => this.handleConnect(request) ) // Entry point for all requests to the Durable Object fetch(request: Request): Response | Promise { return this.router.fetch(request) } // Handle new WebSocket connection requests async handleConnect(request: IRequest) { const sessionId = request.query.sessionId as string if (!sessionId) return error(400, 'Missing sessionId') // Create the websocket pair for the client const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair() // Use hibernation API instead of serverWebSocket.accept() this.ctx.acceptWebSocket(serverWebSocket) // Store sessionId in attachment immediately so we can identify this socket // after hibernation, before the connect handshake completes. const attachment: SocketAttachment = { sessionId, snapshot: null } serverWebSocket.serializeAttachment(attachment) // Connect to the room. The first webSocketMessage from the client will // complete the handshake and trigger debounced snapshot storage. this.getOrCreateRoom().handleSocketConnect({ sessionId, socket: serverWebSocket }) return new Response(null, { status: 101, webSocket: clientWebSocket }) } // --- WebSocket Hibernation API handlers --- override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { const attachment = getAttachment(ws) if (!attachment) return this.sessionIdToWs.set(attachment.sessionId, ws) this.getOrCreateRoom().handleSocketMessage(attachment.sessionId, message) } override async webSocketClose(ws: WebSocket) { this.handleWebSocketEnd(ws, 'handleSocketClose') } override async webSocketError(ws: WebSocket) { this.handleWebSocketEnd(ws, 'handleSocketError') } private handleWebSocketEnd(ws: WebSocket, method: 'handleSocketClose' | 'handleSocketError') { const attachment = getAttachment(ws) if (!attachment) return this.sessionIdToWs.delete(attachment.sessionId) const room = this.getOrCreateRoom() // If the DO was hibernating, this session was never re-added to the room // (ctx.getWebSockets() doesn't include the disconnecting socket). Resume it // briefly so the room can broadcast presence removal to other clients. if (attachment.snapshot && !room.getSessionSnapshot(attachment.sessionId)) { room.handleSocketResume({ sessionId: attachment.sessionId, socket: ws, snapshot: attachment.snapshot, }) } room[method](attachment.sessionId) } }