snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal file
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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<TLRecord, void> | null = null
|
||||
/** Map sessionId → ws so onSessionSnapshot can serialize to the right socket. */
|
||||
private readonly sessionIdToWs = new Map<string, WebSocket>()
|
||||
|
||||
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<TLRecord, void> {
|
||||
if (!this.room) {
|
||||
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
|
||||
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
||||
|
||||
this.room = new TLSocketRoom<TLRecord, void>({
|
||||
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<Response> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user