diff --git a/src/css/style.css b/src/css/style.css index 8fb74d6..74bef6d 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -376,4 +376,47 @@ p:has(+ ol) { box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); overflow: hidden; 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); } \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 6339121..e7ba656 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -20,8 +20,12 @@ import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad" import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" -// Default to production URL if env var isn't available -export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" +// Use development URL when running locally +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 = [ ChatBoxShape, diff --git a/src/ui/CustomMainMenu.tsx b/src/ui/CustomMainMenu.tsx index 0cfb914..3252597 100644 --- a/src/ui/CustomMainMenu.tsx +++ b/src/ui/CustomMainMenu.tsx @@ -6,11 +6,42 @@ import { DefaultMainMenuContent, useEditor, useExportAs, + TldrawUiMenuSubmenu, } from "tldraw"; +import { useState, useEffect } from 'react'; + +interface BackupVersion { + key: string; + timestamp: string; +} export function CustomMainMenu() { const editor = useEditor() const exportAs = useExportAs() + const [backupVersions, setBackupVersions] = useState([]) + + 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 input = document.createElement("input"); @@ -40,6 +71,16 @@ export function CustomMainMenu() { return ( + + {backupVersions.map((version) => ( + restoreVersion(version.key)} + /> + ))} + () + const [versions, setVersions] = useState([]) + 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 ( +
+

Daily Backups

+
+ {versions.length === 0 ? ( +
No backups available yet
+ ) : ( + versions.map((version) => ( +
+ + {formatDate(version.timestamp)} + + +
+ )) + )} +
+
+ ) +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 83cff2d..a860928 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,7 +8,10 @@ interface ImportMetaEnv { readonly VITE_CLOUDFLARE_ACCOUNT_ID: string readonly VITE_CLOUDFLARE_ZONE_ID: 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 { diff --git a/worker/COPY OF TldrawDurableObject.ts b/worker/COPY OF TldrawDurableObject.ts new file mode 100644 index 0000000..ce57f1f --- /dev/null +++ b/worker/COPY OF TldrawDurableObject.ts @@ -0,0 +1,816 @@ +/// + +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> | null = null + private room: TLSocketRoom | 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 + + + 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("roomId"):', this.ctx.storage.get("roomId")) + const storedRoomId = await ctx.storage.get("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) + + 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 { + 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 { + 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({ + 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 { + 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}`) + } + } +} diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index d2bb3dd..22a6f5a 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -16,6 +16,7 @@ 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({ @@ -52,19 +53,20 @@ export const customSchema = createTLSchema({ // handles websocket connections. periodically, it persists the room state to the R2 bucket. export class TldrawDurableObject { private r2: R2Bucket - // the room ID will be missing whilst the room is being initialized + private backupsR2: R2Bucket 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> | 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 + this.roomId = ((await this.ctx.storage.get("roomId")) ?? null) as string | null }) } @@ -192,34 +194,51 @@ export class TldrawDurableObject { getRoom() { 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) { this.roomPromise = (async () => { - // fetch the room from R2 - const roomFromBucket = await this.r2.get(`rooms/${roomId}`) - // if it doesn't exist, we'll just create a new empty room - const initialSnapshot = roomFromBucket - ? ((await roomFromBucket.json()) as RoomSnapshot) - : undefined - if (initialSnapshot) { - initialSnapshot.documents = initialSnapshot.documents.filter( - (record) => { - const shape = record.state as TLShape - return shape.type !== "chatBox" - }, - ) + 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 } - // 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({ - 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 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()) 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 @@ -271,4 +299,56 @@ export class TldrawDurableObject { }, }) } + + 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) + ) + } } diff --git a/worker/types.ts b/worker/types.ts index 3a756ae..b646c4d 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -4,7 +4,18 @@ export interface Environment { TLDRAW_BUCKET: R2Bucket + TLDRAW_BUCKET_NAME: 'jeffemmett-canvas' + TLDRAW_BACKUP_BUCKET: R2Bucket + TLDRAW_BACKUP_BUCKET_NAME: 'board-backups' TLDRAW_DURABLE_OBJECT: DurableObjectNamespace DAILY_API_KEY: string; DAILY_DOMAIN: string; -} \ No newline at end of file + DEV: boolean; +} + +// export interface BoardVersion { +// timestamp: number +// snapshot: RoomSnapshot +// version: number +// dateKey: string // YYYY-MM-DD format +// } \ No newline at end of file diff --git a/worker/worker.ts b/worker/worker.ts index 32606a5..3897a1f 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -66,13 +66,17 @@ const router = AutoRouter({ before: [preflight], finally: [ (response) => { - // Add security headers to all responses except WebSocket upgrades - if (response.status !== 101) { - Object.entries(securityHeaders).forEach(([key, value]) => { - response.headers.set(key, value) - }) + // Skip header modification for responses that already have CORS headers + if (!response.headers.has('Access-Control-Allow-Origin')) { + // Add security headers to all responses except WebSocket upgrades + 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) => { @@ -85,6 +89,27 @@ const router = AutoRouter({ 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 .get("/connect/:roomId", (request, env) => { const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) @@ -180,5 +205,26 @@ const router = AutoRouter({ } }) + //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 default router diff --git a/wrangler.toml b/wrangler.toml index 840cbbd..abd7ced 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,6 +6,15 @@ account_id = "0e7b3338d5278ed1b148e6456b940913" [vars] # Environment variables are managed in Cloudflare Dashboard # 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] port = 5172 @@ -25,7 +34,10 @@ new_classes = ["TldrawDurableObject"] [[r2_buckets]] binding = 'TLDRAW_BUCKET' bucket_name = 'jeffemmett-canvas' -preview_bucket_name = 'jeffemmett-canvas-preview' + +[[r2_buckets]] +binding = 'TLDRAW_BACKUP_BUCKET' +bucket_name = 'board-backups' [observability] enabled = true