Install board reversion history

This commit is contained in:
Jeff-Emmett 2025-01-06 21:23:53 +07:00
parent e3e2c474ac
commit c33e36cb73
10 changed files with 1187 additions and 42 deletions

View File

@ -376,4 +376,47 @@ p:has(+ ol) {
box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15);
overflow: hidden; overflow: hidden;
background-color: white; background-color: white;
}
.version-history-menu {
padding: 8px;
min-width: 300px;
}
.version-list {
max-height: 400px;
overflow-y: auto;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--color-muted);
}
.version-date {
font-size: 14px;
color: var(--color-text);
}
.restore-button {
padding: 4px 8px;
border-radius: 4px;
background: var(--color-primary);
color: white;
border: none;
cursor: pointer;
}
.restore-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.no-versions {
padding: 16px;
text-align: center;
color: var(--color-muted);
} }

View File

@ -20,8 +20,12 @@ import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad"
import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool" import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
// Default to production URL if env var isn't available // Use development URL when running locally
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" export const WORKER_URL = import.meta.env.DEV
? "http://localhost:5172"
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
//console.log('[Debug] WORKER_URL:', WORKER_URL)
const shapeUtils = [ const shapeUtils = [
ChatBoxShape, ChatBoxShape,

View File

@ -6,11 +6,42 @@ import {
DefaultMainMenuContent, DefaultMainMenuContent,
useEditor, useEditor,
useExportAs, useExportAs,
TldrawUiMenuSubmenu,
} from "tldraw"; } from "tldraw";
import { useState, useEffect } from 'react';
interface BackupVersion {
key: string;
timestamp: string;
}
export function CustomMainMenu() { export function CustomMainMenu() {
const editor = useEditor() const editor = useEditor()
const exportAs = useExportAs() const exportAs = useExportAs()
const [backupVersions, setBackupVersions] = useState<BackupVersion[]>([])
useEffect(() => {
const fetchBackups = async (roomId: string) => {
try {
const response = await fetch(`/backups/${roomId}`);
const versions = await response.json() as BackupVersion[];
setBackupVersions(versions);
} catch (error) {
console.error('Failed to fetch backup versions:', error);
}
};
fetchBackups([roomId]);
}, []);
const restoreVersion = async (key: string) => {
try {
const response = await fetch(`/backups/${key}`);
const jsonData = await response.json() as TLContent;
editor.putContentOntoCurrentPage(jsonData, { select: true });
} catch (error) {
console.error('Failed to restore version:', error);
}
};
const importJSON = (editor: Editor) => { const importJSON = (editor: Editor) => {
const input = document.createElement("input"); const input = document.createElement("input");
@ -40,6 +71,16 @@ export function CustomMainMenu() {
return ( return (
<DefaultMainMenu> <DefaultMainMenu>
<DefaultMainMenuContent /> <DefaultMainMenuContent />
<TldrawUiMenuSubmenu id="restore-version" label="Restore Version">
{backupVersions.map((version) => (
<TldrawUiMenuItem
key={version.key}
id={`restore-${version.key}`}
label={version.timestamp}
onSelect={() => restoreVersion(version.key)}
/>
))}
</TldrawUiMenuSubmenu>
<TldrawUiMenuItem <TldrawUiMenuItem
id="export" id="export"
label="Export JSON" label="Export JSON"

View File

@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react'
import { useEditor } from 'tldraw'
import { WORKER_URL } from '../routes/Board'
import { useParams } from 'react-router-dom'
interface Version {
timestamp: number
version: number
dateKey: string
}
export function VersionHistoryMenu() {
const editor = useEditor()
const { slug } = useParams<{ slug: string }>()
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(false)
const fetchVersions = useCallback(async () => {
try {
const response = await fetch(`/backups/${slug}`)
const data = await response.json()
setVersions(data as Version[])
} catch (error) {
console.error('Failed to fetch versions:', error)
}
}, [slug])
const restoreVersion = async (dateKey: string) => {
if (!confirm('Are you sure you want to restore this version? Current changes will be lost.')) {
return
}
setLoading(true)
try {
await fetch(`${WORKER_URL}/rooms/${slug}/restore/${dateKey}`, {
method: 'POST'
})
// Reload the page to get the restored version
window.location.reload()
} catch (error) {
console.error('Failed to restore version:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchVersions()
// Refresh versions list every 5 minutes
const interval = setInterval(fetchVersions, 5 * 60 * 1000)
return () => clearInterval(interval)
}, [fetchVersions])
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
return (
<div className="version-history-menu">
<h3>Daily Backups</h3>
<div className="version-list">
{versions.length === 0 ? (
<div className="no-versions">No backups available yet</div>
) : (
versions.map((version) => (
<div key={version.dateKey} className="version-item">
<span className="version-date">
{formatDate(version.timestamp)}
</span>
<button
onClick={() => restoreVersion(version.dateKey)}
disabled={loading}
className="restore-button"
>
{loading ? 'Restoring...' : 'Restore'}
</button>
</div>
))
)}
</div>
</div>
)
}

5
src/vite-env.d.ts vendored
View File

@ -8,7 +8,10 @@ interface ImportMetaEnv {
readonly VITE_CLOUDFLARE_ACCOUNT_ID: string readonly VITE_CLOUDFLARE_ACCOUNT_ID: string
readonly VITE_CLOUDFLARE_ZONE_ID: string readonly VITE_CLOUDFLARE_ZONE_ID: string
readonly VITE_R2_BUCKET_NAME: string readonly VITE_R2_BUCKET_NAME: string
readonly VITE_R2_PREVIEW_BUCKET_NAME: string readonly VITE_R2_BACKUP_BUCKET_NAME: string
readonly VITE_R2_BUCKET: R2Bucket
readonly VITE_R2_BACKUP_BUCKET: R2Bucket
} }
interface ImportMeta { interface ImportMeta {

View File

@ -0,0 +1,816 @@
/// <reference types="@cloudflare/workers-types" />
import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core"
import {
TLRecord,
TLShape,
TLStoreSnapshot,
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 { WORKER_URL } from "@/routes/Board"
// Add after the imports
interface BoardVersion {
timestamp: number
snapshot: RoomSnapshot
version: number
dateKey: string
}
// 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 backupR2: R2Bucket
private roomId: string | null = null
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
private room: TLSocketRoom<TLRecord, void> | null = null
private lastBackupDate: string | null = null
private readonly MAX_VERSIONS = 31
private readonly env: Environment
private readonly BACKUP_INTERVAL: number
private readonly schedulePersistToR2: ReturnType<typeof throttle>
constructor(private readonly ctx: DurableObjectState, env: Environment) {
if (!ctx) {
console.error('[Debug] DurableObjectState is undefined!')
throw new Error('DurableObjectState is required')
}
if (!env) {
console.error('[Debug] Environment is undefined!')
throw new Error('Environment is required')
}
// Initialize all class properties explicitly
this.env = env
this.roomId = null
this.roomPromise = null
this.room = null
this.lastBackupDate = null
this.BACKUP_INTERVAL = this.env?.DEV === true
? 10 * 1000 // 10 seconds in development
: 24 * 60 * 60 * 1000 // 24 hours in production
console.log('[Debug] Initializing TldrawDurableObject:', {
hasContext: !!this,
hasState: !!ctx,
ctxId: ctx.id,
hasEnv: !!env,
envKeys: Object.keys(env),
thisKeys: Object.keys(this)
})
// Verify R2 buckets
if (!env.TLDRAW_BUCKET) {
console.error('[Debug] TLDRAW_BUCKET is undefined!')
throw new Error('TLDRAW_BUCKET is required')
}
if (!env.TLDRAW_BACKUP_BUCKET) {
console.error('[Debug] TLDRAW_BACKUP_BUCKET is undefined!')
throw new Error('TLDRAW_BACKUP_BUCKET is required')
}
this.r2 = env.TLDRAW_BUCKET
this.backupR2 = env.TLDRAW_BACKUP_BUCKET
// Verify buckets were assigned
console.log('[Debug] Bucket initialization:', {
hasMainBucket: !!this.r2,
hasBackupBucket: !!this.backupR2,
mainBucketMethods: Object.keys(this.r2 || {}),
backupBucketMethods: Object.keys(this.backupR2 || {})
})
// Add more detailed logging
console.log('[Debug] Environment:', {
TLDRAW_BUCKET: !!env.TLDRAW_BUCKET,
TLDRAW_BACKUP_BUCKET: !!env.TLDRAW_BACKUP_BUCKET,
envKeys: Object.keys(env)
})
console.log('[Debug] Using buckets:', {
main: this.r2.get(`rooms/${this.roomId}`) || 'undefined',
backup: this.backupR2.get(`rooms/${this.roomId}`) || 'undefined'
})
// Add more detailed logging for storage initialization
ctx.blockConcurrencyWhile(async () => {
try {
console.log('[Debug] Attempting to load roomId from storage...')
console.log('[Debug] this.ctx.storage:', this.ctx.storage.get)
console.log('[Debug] ctx.storage.get:', ctx.storage.get)
console.log('[Debug] this.ctx.storage.get<string>("roomId"):', this.ctx.storage.get<string>("roomId"))
const storedRoomId = await ctx.storage.get<string>("roomId")
console.log('[Debug] Loaded roomId from storage:', storedRoomId)
if (storedRoomId) {
this.roomId = storedRoomId
console.log('[Debug] Successfully set roomId:', this.roomId)
} else {
console.log('[Debug] No roomId found in storage')
}
} catch (error) {
console.error('[Debug] Error loading roomId from storage:', error)
throw error // Re-throw to ensure we know if there's a storage issue
}
}).catch(error => {
console.error('[Debug] Failed to initialize storage:', error)
})
// this.BACKUP_INTERVAL = this.env?.DEV === true
// ? 10 * 1000 // 10 seconds in development
// : 24 * 60 * 60 * 1000 // 24 hours in production
this.schedulePersistToR2 = throttle(async () => {
if (!this.room || !this.roomId) {
console.log('[Backup] No room available for backup')
return
}
try {
console.log(`[Backup] Starting backup process for room ${this.roomId}...`)
const snapshot = this.room.getCurrentSnapshot()
// Update current version in main bucket
await this.r2.put(
`rooms/${this.roomId}`,
JSON.stringify(snapshot)
).catch(err => {
console.error(`[Backup] Failed to update main bucket:`, err)
})
// Check if today's backup already exists
const today = new Date().toISOString().split('T')[0]
const backupKey = `backups/${this.roomId}/${today}`
console.log(`[Backup] Checking for existing backup at key: ${backupKey}`)
const existingBackup = await this.backupR2.get(backupKey)
// Create daily backup if needed
if (!existingBackup || this.lastBackupDate !== today) {
console.log(`[Backup] Creating new daily backup for ${today}`)
// Get all assets for this room
const assetsPrefix = `uploads/${this.roomId}/`
const assets = await this.r2.list({ prefix: assetsPrefix })
const assetData: { [key: string]: string } = {}
// Fetch and store each asset
for (const asset of assets.objects) {
const assetContent = await this.r2.get(asset.key)
if (assetContent) {
const assetBuffer = await assetContent.arrayBuffer()
const base64Data = Buffer.from(assetBuffer).toString('base64')
assetData[asset.key] = base64Data
}
}
const version = {
timestamp: Date.now(),
snapshot,
dateKey: today,
version: 0,
assets: assetData
}
//TO DO: FIX DAILY BACKUP INTO CLOUDFLARE R2 BACKUPS BUCKET
await this.backupR2.put(backupKey, JSON.stringify(version))
console.log(`[Backup] ✅ Successfully saved daily backup with ${Object.keys(assetData).length} assets to: ${backupKey}`)
this.lastBackupDate = today
}
} catch (error) {
console.error('[Backup] Error during backup:', error)
}
}, this.BACKUP_INTERVAL, { leading: false, trailing: true })
}
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) => {
try {
await this.ensureRoomId(request.params.roomId)
return this.handleConnect(request)
} catch (error) {
console.error('[Debug] Connection error:', error)
return new Response((error as Error).message, { status: 400 })
}
})
.get("/room/:roomId", async (request) => {
// Directly fetch from jeffemmett-canvas bucket first
const currentState = await this.r2.get(`rooms/${request.params.roomId}`)
console.log('[Debug] Loading board state from jeffemmett-canvas:', currentState ? 'found' : 'not found')
if (currentState) {
const snapshot = await currentState.json() as RoomSnapshot
console.log('[Debug] Loaded snapshot with', snapshot.documents.length, 'documents')
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",
},
})
}
// Fallback to empty state
console.log('[Debug] No existing board state found, returning empty array')
return new Response(JSON.stringify([]), {
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",
},
})
})
.get("/room/:roomId/versions", async () => {
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
const prefix = `backups/${this.roomId}/`
const objects = await this.backupR2.list({ prefix })
const versions = objects.objects
.map(obj => {
const dateKey = obj.key.split('/').pop() || ''
return {
timestamp: obj.uploaded.getTime(),
dateKey,
version: 0
}
})
.sort((a, b) => b.timestamp - a.timestamp)
return new Response(JSON.stringify(versions), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
})
.post("/room/:roomId/restore/:dateKey", async (request) => {
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
try {
const version = await this.restoreVersion(request.params.dateKey)
return new Response(JSON.stringify(version), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
} catch (error) {
return new Response(
JSON.stringify({ error: (error as Error).message }),
{
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
}
)
}
})
.get("/debug/backup", async (_request) => {
console.log('[Debug] Listing all rooms in backup bucket...')
const objects = await this.backupR2.list()
// Group objects by room ID
const rooms = objects.objects.reduce((acc, obj) => {
const roomId = obj.key.split('/')[0]
if (!acc[roomId]) {
acc[roomId] = []
}
acc[roomId].push({
key: obj.key,
uploaded: obj.uploaded,
size: obj.size
})
return acc
}, {} as Record<string, any[]>)
console.log('[Debug] Found rooms:', rooms)
return new Response(JSON.stringify(rooms, null, 2), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
})
.get("/debug/bucket", async () => {
console.log('[Debug] Listing all objects in bucket:', this.env.TLDRAW_BUCKET_NAME)
const objects = await this.r2.list()
console.log('[Debug] Found', objects.objects.length, 'objects')
objects.objects.forEach(obj => {
console.log('[Debug] Object:', {
key: obj.key,
size: `${(obj.size / 1024).toFixed(2)} KB`,
uploaded: obj.uploaded.toISOString()
})
})
return new Response(JSON.stringify({
bucket: this.env.TLDRAW_BUCKET_NAME,
objects: objects.objects.map(obj => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded
}))
}), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
})
.get("/debug/sync-from-prod", async () => {
console.log('[Debug] Starting production sync...')
try {
// List objects directly from production bucket
const objects = await this.r2.list()
console.log('[Debug] Path:', WORKER_URL + '/rooms/' + this.roomId)
console.log('[Debug] Found', objects.objects.length, 'rooms in production')
// Copy each room to local bucket
let syncedCount = 0
for (const obj of objects.objects) {
// Get the room data directly from production bucket
const roomData = await this.r2.get(obj.key)
if (!roomData) {
console.error(`Failed to fetch room data for ${obj.key}`)
continue
}
// Store in local bucket
await this.r2.put(obj.key, roomData.body)
syncedCount++
console.log(`[Debug] Synced room: ${obj.key}`)
}
return new Response(JSON.stringify({
message: 'Sync complete',
totalRooms: objects.objects.length,
syncedRooms: syncedCount
}), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
} catch (error) {
console.error('[Debug] Sync error:', error)
return new Response(JSON.stringify({
error: 'Sync failed',
message: (error as Error).message
}), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
})
}
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
console.log('[Debug] Incoming request:', request.url, request.method)
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<Response> {
console.log('[Worker] handleConnect called')
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
try {
console.log('[Worker] Accepting WebSocket connection')
serverWebSocket.accept()
const room = await this.getRoom()
console.log('[Debug] Room obtained, connecting socket')
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId: request.query.sessionId as string,
socket: {
send: (data: string) => {
// console.log('[WebSocket] Sending:', data.slice(0, 100) + '...')
try {
serverWebSocket.send(data)
} catch (err) {
console.error('[WebSocket] Send error:', err)
}
},
close: () => {
try {
serverWebSocket.close()
} catch (err) {
console.error('[WebSocket] Close error:', err)
}
},
addEventListener: serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener: serverWebSocket.removeEventListener.bind(serverWebSocket),
readyState: serverWebSocket.readyState,
},
})
console.log('[Debug] WebSocket connection established successfully')
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("[Debug] WebSocket connection error:", error)
serverWebSocket.close(1011, "Failed to initialize connection")
return new Response("Failed to establish WebSocket connection", {
status: 500,
})
}
}
async getRoom() {
const roomId = this.roomId
console.log('[Debug] Getting room:', roomId)
console.log('[Debug] R2 bucket instance:', {
exists: !!this.r2
})
if (!roomId) throw new Error("Missing roomId")
if (!this.roomPromise) {
console.log('[Debug] Creating new room promise')
this.roomPromise = (async () => {
// First, list all objects to see what's actually in the bucket
const allObjects = await this.r2.list({ prefix: 'rooms/' })
console.log('[Debug] Current bucket contents:',
allObjects.objects.map(obj => ({
key: obj.key,
size: obj.size
}))
)
const path = `rooms/${this.roomId}`
console.log('[Debug] Attempting to fetch from path:', path)
const roomFromBucket = await this.r2.get(path)
console.log('[Debug] Room fetch result:', {
exists: !!roomFromBucket,
size: roomFromBucket?.size,
etag: roomFromBucket?.etag,
path: path,
bucket: this.r2 ? 'initialized' : 'undefined'
})
// Add this to see the actual content if it exists
if (roomFromBucket) {
const content = await roomFromBucket.text()
console.log('[Debug] Room content preview:', content.slice(0, 100))
}
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined
// Create the room and store it in this.room for direct access
const room = new TLSocketRoom<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: async () => {
console.log('[Backup] Data change detected in room:', this.roomId)
if (!this.lastBackupDate) {
console.log('[Backup] First change detected, forcing immediate backup')
await this.schedulePersistToR2.flush()
}
this.schedulePersistToR2()
},
})
console.log('[Debug] Room created with snapshot:', initialSnapshot ? 'yes' : 'no')
this.room = room
return room
})()
}
return this.roomPromise
}
// Comment out the duplicate function
/*
schedulePersistToR2 = throttle(async () => {
if (!this.room || !this.roomId) {
console.log('[Backup] No room available for backup')
return
}
try {
console.log(`[Backup] Starting backup process for room ${this.roomId}...`)
const snapshot = this.room.getCurrentSnapshot()
// Update current version in main bucket
await this.r2.put(
`rooms/${this.roomId}`,
JSON.stringify(snapshot)
).catch(err => {
console.error(`[Backup] Failed to update main bucket:`, err)
})
// Check if today's backup already exists
const today = new Date().toISOString().split('T')[0]
const backupKey = `backups/${this.roomId}/${today}`
console.log(`[Backup] Checking for existing backup at key: ${backupKey}`)
const existingBackup = await this.backupR2.get(backupKey)
// Create daily backup if needed
if (!existingBackup || this.lastBackupDate !== today) {
console.log(`[Backup] Creating new daily backup for ${today}`)
// Get all assets for this room
const assetsPrefix = `uploads/${this.roomId}/`
const assets = await this.r2.list({ prefix: assetsPrefix })
const assetData: { [key: string]: string } = {}
// Fetch and store each asset
for (const asset of assets.objects) {
const assetContent = await this.r2.get(asset.key)
if (assetContent) {
const assetBuffer = await assetContent.arrayBuffer()
const base64Data = Buffer.from(assetBuffer).toString('base64')
assetData[asset.key] = base64Data
}
}
const version = {
timestamp: Date.now(),
snapshot,
dateKey: today,
version: 0,
assets: assetData
}
await this.backupR2.put(backupKey, JSON.stringify(version))
console.log(`[Backup] ✅ Successfully saved daily backup with ${Object.keys(assetData).length} assets to: ${backupKey}`)
this.lastBackupDate = today
}
} catch (error) {
console.error('[Backup] Error during backup:', error)
}
}, this.BACKUP_INTERVAL)
*/
// Modified scheduleBackupToR2 method
scheduleBackupToR2 = throttle(async () => {
if (!this.room || !this.roomId) return
// Get current snapshot using TLSocketRoom's method
const snapshot = this.room.getCurrentSnapshot()
// Always update current version
await this.r2.put(
`rooms/${this.roomId}`,
JSON.stringify(snapshot)
)
// Check if we should create a daily backup
const today = new Date().toISOString().split('T')[0] // YYYY-MM-DD format
if (this.lastBackupDate !== today) {
// Create version object with date info
const version: BoardVersion = {
timestamp: Date.now(),
snapshot,
version: 0,
dateKey: today
}
// Store versioned backup with date in key
await this.backupR2.put(
`backups/${this.roomId}/${today}`,
JSON.stringify(version)
)
this.lastBackupDate = today
// Clean up old versions
//await this.cleanupOldVersions()
}
}, this.BACKUP_INTERVAL )
// Modified method to restore specific version
async restoreVersion(dateKey: string) {
const versionKey = `backups/${this.roomId}/${dateKey}`
console.log(`[Restore] Attempting to restore version from ${this.backupR2} at key: ${versionKey}`)
const versionObj = await this.backupR2.get(versionKey)
if (!versionObj) {
console.error(`[Restore] Version not found in ${this.backupR2}`)
throw new Error('Version not found')
}
console.log(`[Restore] Found version in ${this.backupR2}, restoring...`)
const version = JSON.parse(await versionObj.text()) as BoardVersion & { assets?: { [key: string]: string } }
// Restore assets if they exist
if (version.assets) {
console.log(`[Restore] Restoring ${Object.keys(version.assets).length} assets to ${this.r2}...`)
for (const [key, base64Data] of Object.entries(version.assets)) {
const binaryData = Buffer.from(base64Data, 'base64')
await this.r2.put(key, binaryData)
console.log(`[Restore] Asset restored: ${key}`)
}
}
if (!this.room) {
this.room = new TLSocketRoom({
schema: customSchema,
initialSnapshot: version.snapshot,
onDataChange: () => {
console.log('[Backup] Data change detected, triggering backup...')
this.schedulePersistToR2()
},
})
} else {
this.room.loadSnapshot(version.snapshot)
}
await this.r2.put(
`rooms/${this.roomId}`,
JSON.stringify(version.snapshot)
)
return version
}
// Add method to initialize room from snapshot
private async initializeRoom(snapshot?: TLStoreSnapshot) {
this.room = new TLSocketRoom({
schema: customSchema,
initialSnapshot: snapshot,
onDataChange: () => {
this.schedulePersistToR2()
},
})
}
// Modified method to handle WebSocket connections
async handleWebSocket(webSocket: WebSocket) {
if (!this.room) {
const current = await this.r2.get(`rooms/${this.roomId}`)
if (current) {
const snapshot = JSON.parse(await current.text()) as TLStoreSnapshot
await this.initializeRoom(snapshot)
} else {
await this.initializeRoom()
}
}
this.room?.handleSocketConnect({
sessionId: crypto.randomUUID(),
socket: {
send: webSocket.send.bind(webSocket),
close: webSocket.close.bind(webSocket),
addEventListener: webSocket.addEventListener.bind(webSocket),
removeEventListener: webSocket.removeEventListener.bind(webSocket),
readyState: webSocket.readyState,
},
})
}
//TODO: TURN ON OLD VERSION CLEANUP AT SOME POINT
// private async cleanupOldVersions() {
// if (!this.roomId) return
// const prefix = `${this.roomId}/`
// const objects = await this.backupR2.list({ prefix })
// const versions = objects.objects
// .sort((a, b) => b.uploaded.getTime() - a.uploaded.getTime())
// // Delete versions beyond MAX_VERSIONS
// for (let i = this.MAX_VERSIONS; i < versions.length; i++) {
// await this.backupR2.delete(versions[i].key)
// }
// }
// Modify the connect handler to ensure roomId is set
private async ensureRoomId(requestRoomId: string): Promise<void> {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
// Double-check inside the critical section
if (!this.roomId) {
await this.ctx.storage.put("roomId", requestRoomId)
this.roomId = requestRoomId
console.log('[Debug] Set new roomId:', this.roomId)
}
})
} else if (this.roomId !== requestRoomId) {
throw new Error(`Room ID mismatch: expected ${this.roomId}, got ${requestRoomId}`)
}
}
}

View File

@ -16,6 +16,7 @@ import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil" import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil" import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { TLContent } from '@tldraw/tldraw'
// add custom shapes and bindings here if needed: // add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({ export const customSchema = createTLSchema({
@ -52,19 +53,20 @@ export const customSchema = createTLSchema({
// handles websocket connections. periodically, it persists the room state to the R2 bucket. // handles websocket connections. periodically, it persists the room state to the R2 bucket.
export class TldrawDurableObject { export class TldrawDurableObject {
private r2: R2Bucket private r2: R2Bucket
// the room ID will be missing whilst the room is being initialized private backupsR2: R2Bucket
private roomId: string | null = null private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
constructor(private readonly ctx: DurableObjectState, env: Environment) { 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.r2 = env.TLDRAW_BUCKET
this.backupsR2 = env.TLDRAW_BACKUP_BUCKET
ctx.blockConcurrencyWhile(async () => { ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as string | null
| string
| null
}) })
} }
@ -192,34 +194,51 @@ export class TldrawDurableObject {
getRoom() { getRoom() {
const roomId = this.roomId const roomId = this.roomId
if (!roomId) throw new Error("Missing roomId") if (!roomId) {
console.error('[Error] Missing roomId')
throw new Error("Missing roomId")
}
if (!this.roomPromise) { if (!this.roomPromise) {
this.roomPromise = (async () => { this.roomPromise = (async () => {
// fetch the room from R2 try {
const roomFromBucket = await this.r2.get(`rooms/${roomId}`) // Add debug logging
// if it doesn't exist, we'll just create a new empty room console.log('[Debug] Room ID:', roomId)
const initialSnapshot = roomFromBucket console.log('[Debug] R2 Bucket:', this.r2)
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined const path = `rooms/${roomId}`
if (initialSnapshot) { console.log('[Debug] Fetching path:', path)
initialSnapshot.documents = initialSnapshot.documents.filter(
(record) => { if (!this.r2) {
const shape = record.state as TLShape throw new Error('R2 bucket not initialized')
return shape.type !== "chatBox" }
},
) // 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<TLRecord, void>({
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<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: () => this.schedulePersistToR2(),
})
} catch (e) {
console.error('[Error] Failed to initialize room:', e)
throw e
} }
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({
schema: customSchema,
initialSnapshot,
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
},
})
})() })()
} }
@ -231,9 +250,18 @@ export class TldrawDurableObject {
if (!this.roomPromise || !this.roomId) return if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom() const room = await this.getRoom()
// convert the room to JSON and upload it to R2 // Save to main storage
const snapshot = JSON.stringify(room.getCurrentSnapshot()) const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot) 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) }, 10_000)
// Add CORS headers for WebSocket upgrade // Add CORS headers for WebSocket upgrade
@ -271,4 +299,56 @@ export class TldrawDurableObject {
}, },
}) })
} }
private async listVersions(): Promise<Array<{ timestamp: number; version: number; dateKey: string }>> {
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<boolean> {
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)
)
}
} }

View File

@ -4,7 +4,18 @@
export interface Environment { export interface Environment {
TLDRAW_BUCKET: R2Bucket TLDRAW_BUCKET: R2Bucket
TLDRAW_BUCKET_NAME: 'jeffemmett-canvas'
TLDRAW_BACKUP_BUCKET: R2Bucket
TLDRAW_BACKUP_BUCKET_NAME: 'board-backups'
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
DAILY_API_KEY: string; DAILY_API_KEY: string;
DAILY_DOMAIN: string; DAILY_DOMAIN: string;
} DEV: boolean;
}
// export interface BoardVersion {
// timestamp: number
// snapshot: RoomSnapshot
// version: number
// dateKey: string // YYYY-MM-DD format
// }

View File

@ -66,13 +66,17 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight], before: [preflight],
finally: [ finally: [
(response) => { (response) => {
// Add security headers to all responses except WebSocket upgrades // Skip header modification for responses that already have CORS headers
if (response.status !== 101) { if (!response.headers.has('Access-Control-Allow-Origin')) {
Object.entries(securityHeaders).forEach(([key, value]) => { // Add security headers to all responses except WebSocket upgrades
response.headers.set(key, value) if (response.status !== 101) {
}) Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value)
})
}
return corsify(response)
} }
return corsify(response) return response
}, },
], ],
catch: (e: Error) => { catch: (e: Error) => {
@ -85,6 +89,27 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
return error(e) return error(e)
}, },
}) })
// Add debug routes that forward to the Durable Object
.get("/debug/:command", async (request, env) => {
try {
console.log('[Debug] Handling debug command:', request.params.command)
const id = env.TLDRAW_DURABLE_OBJECT.idFromName('debug')
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url)
} catch (error) {
console.error('[Debug] Error in debug endpoint:', error)
return new Response(JSON.stringify({
error: 'Internal Server Error',
message: (error as Error).message
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get("/connect/:roomId", (request, env) => { .get("/connect/:roomId", (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
@ -180,5 +205,26 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
} }
}) })
//DOES THIS NEED TO LOOK AT BOARD_BACKUPS OR JEFFEMMETT_CANVAS?
// Get all versions for a room
.get("/room/:roomId/:dateKey", async (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
method: request.method,
})
})
// Restore a specific version
.post("/room/:roomId/restore/:dateKey", async (request, env) => {
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
method: request.method,
})
})
// export our router for cloudflare // export our router for cloudflare
export default router export default router

View File

@ -6,6 +6,15 @@ account_id = "0e7b3338d5278ed1b148e6456b940913"
[vars] [vars]
# Environment variables are managed in Cloudflare Dashboard # Environment variables are managed in Cloudflare Dashboard
# Workers & Pages → jeffemmett-canvas → Settings → Variables # Workers & Pages → jeffemmett-canvas → Settings → Variables
DEV = false
TLDRAW_BUCKET_NAME = "jeffemmett-canvas"
TLDRAW_BACKUP_BUCKET_NAME = "board-backups"
[env.development]
vars = { DEV = true }
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas-preview'
[dev] [dev]
port = 5172 port = 5172
@ -25,7 +34,10 @@ new_classes = ["TldrawDurableObject"]
[[r2_buckets]] [[r2_buckets]]
binding = 'TLDRAW_BUCKET' binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas' bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview'
[[r2_buckets]]
binding = 'TLDRAW_BACKUP_BUCKET'
bucket_name = 'board-backups'
[observability] [observability]
enabled = true enabled = true