///
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
import {
TLRecord,
TLShape,
createTLSchema,
defaultBindingSchemas,
defaultShapeSchemas,
} from "@tldraw/tlschema"
import { AutoRouter, IRequest, error } from "itty-router"
import throttle from "lodash.throttle"
import { Environment } from "./types"
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { TLContent } from '@tldraw/tldraw'
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
// each whiteboard room is hosted in a DurableObject:
// https://developers.cloudflare.com/durable-objects/
// there's only ever one durable object instance per room. it keeps all the room state in memory and
// handles websocket connections. periodically, it persists the room state to the R2 bucket.
export class TldrawDurableObject {
private r2: R2Bucket
private backupsR2: R2Bucket
private roomId: string | null = null
private roomPromise: Promise> | null = null
constructor(private readonly ctx: DurableObjectState, env: Environment) {
console.log('[Debug] Constructor - env:', {
isDev: env.DEV,
bucketName: env.TLDRAW_BUCKET_NAME,
})
this.r2 = env.TLDRAW_BUCKET
this.backupsR2 = env.TLDRAW_BACKUP_BUCKET
ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as string | null
})
}
private readonly router = AutoRouter({
catch: (e) => {
console.log(e)
return error(e)
},
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get("/connect/:roomId", async (request) => {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
this.roomId = request.params.roomId
})
}
return this.handleConnect(request)
})
.get("/room/:roomId", async (request) => {
const room = await this.getRoom()
const snapshot = room.getCurrentSnapshot()
return new Response(JSON.stringify(snapshot.documents), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
.post("/room/:roomId", async (request) => {
const records = (await request.json()) as TLRecord[]
return new Response(JSON.stringify(Array.from(records)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
})
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise {
try {
return this.router.fetch(request)
} catch (err) {
console.error("Error in DO fetch:", err)
return new Response(
JSON.stringify({
error: "Internal Server Error",
message: (err as Error).message,
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, Upgrade, Connection",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true",
},
},
)
}
}
// what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise {
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
const sessionId = request.query.sessionId as string
if (!sessionId) {
return new Response("Missing sessionId", { status: 400 })
}
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
try {
serverWebSocket.accept()
const room = await this.getRoom()
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId,
socket: {
send: serverWebSocket.send.bind(serverWebSocket),
close: serverWebSocket.close.bind(serverWebSocket),
addEventListener:
serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener:
serverWebSocket.removeEventListener.bind(serverWebSocket),
readyState: serverWebSocket.readyState,
},
})
return new Response(null, {
status: 101,
webSocket: clientWebSocket,
headers: {
"Access-Control-Allow-Origin": request.headers.get("Origin") || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, UPGRADE",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Credentials": "true",
Upgrade: "websocket",
Connection: "Upgrade",
},
})
} catch (error) {
console.error("WebSocket connection error:", error)
serverWebSocket.close(1011, "Failed to initialize connection")
return new Response("Failed to establish WebSocket connection", {
status: 500,
})
}
}
getRoom() {
const roomId = this.roomId
if (!roomId) {
console.error('[Error] Missing roomId')
throw new Error("Missing roomId")
}
if (!this.roomPromise) {
this.roomPromise = (async () => {
try {
// Add debug logging
console.log('[Debug] Room ID:', roomId)
console.log('[Debug] R2 Bucket:', this.r2)
const path = `rooms/${roomId}`
console.log('[Debug] Fetching path:', path)
if (!this.r2) {
throw new Error('R2 bucket not initialized')
}
// fetch the room from R2
const roomFromBucket = await this.r2.get(path)
if (!roomFromBucket) {
console.warn(`[Warn] No data found for room: ${roomId}`)
return new TLSocketRoom({
schema: customSchema,
onDataChange: () => this.schedulePersistToR2(),
})
}
const text = await roomFromBucket.text()
if (!text) {
throw new Error('Empty room data')
}
const initialSnapshot = JSON.parse(text) as RoomSnapshot
return new TLSocketRoom({
schema: customSchema,
initialSnapshot,
onDataChange: () => this.schedulePersistToR2(),
})
} catch (e) {
console.error('[Error] Failed to initialize room:', e)
throw e
}
})()
}
return this.roomPromise
}
// we throttle persistance so it only happens every 10 seconds
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
// Save to main storage
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
// Check if we need to create a daily backup
const today = new Date().toISOString().split('T')[0]
const lastBackupKey = `backups/${this.roomId}/${today}`
const existingBackup = await this.backupsR2.head(lastBackupKey)
if (!existingBackup) {
await this.createDailyBackup()
}
}, 10_000)
// Add CORS headers for WebSocket upgrade
handleWebSocket(request: Request) {
const upgradeHeader = request.headers.get("Upgrade")
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("Expected Upgrade: websocket", { status: 426 })
}
const webSocketPair = new WebSocketPair()
const [client, server] = Object.values(webSocketPair)
server.accept()
// Add error handling and reconnection logic
server.addEventListener("error", (err) => {
console.error("WebSocket error:", err)
})
server.addEventListener("close", () => {
if (this.roomPromise) {
this.getRoom().then((room) => {
// Update store to ensure all changes are persisted
room.updateStore(() => {})
})
}
})
return new Response(null, {
status: 101,
webSocket: client,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
},
})
}
private async listVersions(): Promise> {
const prefix = `backups/${this.roomId}/`
const objects = await this.backupsR2.list({ prefix })
return objects.objects
.map(obj => {
const dateKey = obj.key.split('/').pop()!
return {
timestamp: obj.uploaded.getTime(),
version: 1,
dateKey,
}
})
.sort((a, b) => b.timestamp - a.timestamp)
}
private async restoreVersion(dateKey: string): Promise {
const backupKey = `backups/${this.roomId}/${dateKey}`
const backup = await this.backupsR2.get(backupKey)
if (!backup) return false
const backupData = await backup.json() as RoomSnapshot
// Update the current room state
const room = await this.getRoom()
room.updateStore((store) => {
// Delete all existing records
store.getAll().forEach(record => store.delete(record.id))
// Apply the backup snapshot
backupData.documents.forEach(record => store.put(record as unknown as TLRecord))
})
// Also update the main storage
await this.r2.put(`rooms/${this.roomId}`, JSON.stringify(backupData))
return true
}
private async createDailyBackup() {
if (!this.roomId) return
const room = await this.getRoom()
const snapshot = room.getCurrentSnapshot()
const dateKey = new Date().toISOString().split('T')[0]
await this.backupsR2.put(
`backups/${this.roomId}/${dateKey}`,
JSON.stringify(snapshot)
)
}
}