separate worker and buckets between dev & prod, fix cron job scheduler

This commit is contained in:
Jeff Emmett 2025-09-04 15:12:44 +02:00
parent 391e13c350
commit 8385e30d25
9 changed files with 4298 additions and 1976 deletions

View File

@ -26,6 +26,7 @@ jobs:
- name: Deploy to Cloudflare Workers
run: |
npm install -g wrangler@3.107.3
# Uses default wrangler.toml (production config)
wrangler deploy
working-directory: ./worker
env:

5893
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,13 @@
"scripts": {
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker\"",
"dev:client": "vite --host --port 5173",
"dev:worker": "wrangler dev --remote --port 5172 --ip 0.0.0.0",
"dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172",
"dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy",
"deploy:worker": "wrangler deploy",
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
"types": "tsc --noEmit"
},
"keywords": [],
@ -17,15 +20,17 @@
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.33.1",
"@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@oddjs/odd": "^0.37.2",
"@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0",
"@tldraw/tldraw": "^3.6.0",
"@tldraw/tlschema": "^3.6.0",
"@tldraw/assets": "^3.15.4",
"@tldraw/sync": "^3.15.4",
"@tldraw/sync-core": "^3.15.4",
"@tldraw/tldraw": "^3.15.4",
"@tldraw/tlschema": "^3.15.4",
"@types/markdown-it": "^14.1.1",
"@types/marked": "^5.0.2",
"@uiw/react-md-editor": "^4.0.5",
@ -49,7 +54,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"tldraw": "^3.15.4",
"vercel": "^39.1.1",
"webcola": "^3.4.0",
"webnative": "^0.36.3"
@ -65,7 +70,9 @@
"concurrently": "^9.1.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"wrangler": "^3.107.3"
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"wrangler": "^4.33.2"
},
"engines": {
"node": ">=18.0.0"

View File

@ -52,8 +52,10 @@ import { useAuth } from "../context/AuthContext"
import { updateLastVisited } from "../lib/starredBoards"
import { captureBoardScreenshot } from "../lib/screenshotService"
// Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
// Automatically switch between production and local dev based on environment
export const WORKER_URL = import.meta.env.DEV
? "http://localhost:5172"
: "https://jeffemmett-canvas.jeffemmett.workers.dev"
const customShapeUtils = [
ChatBoxShape,
@ -97,6 +99,7 @@ export function Board() {
[roomId, session.authed, session.username],
)
// Using TLdraw sync - fixed version compatibility issue
const store = useSync(storeConfig)
const [editor, setEditor] = useState<Editor | null>(null)
@ -176,31 +179,36 @@ export function Board() {
useEffect(() => {
if (!editor || !roomId || !store.store) return;
// Get current shapes to detect changes
const currentShapes = editor.getCurrentPageShapes();
const currentShapeCount = currentShapes.length;
let lastContentHash = '';
let timeoutId: NodeJS.Timeout;
// Create a simple hash of the content for change detection
const currentContentHash = currentShapes.length > 0
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
: '';
// Debounced screenshot capture only when content actually changes
const timeoutId = setTimeout(async () => {
const newShapes = editor.getCurrentPageShapes();
const newShapeCount = newShapes.length;
const newContentHash = newShapes.length > 0
? newShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
const captureScreenshot = async () => {
const currentShapes = editor.getCurrentPageShapes();
const currentContentHash = currentShapes.length > 0
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
: '';
// Only capture if content actually changed
if (newShapeCount !== currentShapeCount || newContentHash !== currentContentHash) {
if (currentContentHash !== lastContentHash) {
lastContentHash = currentContentHash;
await captureBoardScreenshot(editor, roomId);
}
}, 3000); // Wait 3 seconds to ensure changes are complete
};
return () => clearTimeout(timeoutId);
}, [editor, roomId, store.store?.getSnapshot()]); // Still trigger on store changes to detect them
// Listen to store changes instead of using getSnapshot() in dependencies
const unsubscribe = store.store.listen(() => {
// Clear existing timeout
if (timeoutId) clearTimeout(timeoutId);
// Set new timeout for debounced screenshot capture
timeoutId = setTimeout(captureScreenshot, 3000);
}, { source: "user", scope: "document" });
return () => {
unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
};
}, [editor, roomId, store.store]);
return (
<div style={{ position: "fixed", inset: 0 }}>

View File

@ -1,5 +1,7 @@
import { defineConfig, loadEnv } from "vite"
import react from "@vitejs/plugin-react"
import wasm from "vite-plugin-wasm"
import topLevelAwait from "vite-plugin-top-level-await"
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
@ -15,7 +17,7 @@ export default defineConfig(({ mode }) => {
return {
envPrefix: ["VITE_"],
plugins: [react()],
plugins: [react(), wasm(), topLevelAwait()],
server: {
host: "0.0.0.0",
port: 5173,

View File

@ -100,6 +100,8 @@ export class TldrawDurableObject {
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get("/connect/:roomId", async (request) => {
// Connect request received
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put("roomId", request.params.roomId)
@ -164,11 +166,19 @@ export class TldrawDurableObject {
// what happens when someone tries to connect to this room?
async handleConnect(request: IRequest): Promise<Response> {
// Check if this is a WebSocket upgrade request
const upgradeHeader = request.headers.get("Upgrade")
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("WebSocket upgrade required", { status: 426 })
}
if (!this.roomId) {
return new Response("Room not initialized", { status: 400 })
}
const sessionId = request.query.sessionId as string
// Session ID received
if (!sessionId) {
return new Response("Missing sessionId", { status: 400 })
}
@ -245,7 +255,6 @@ export class TldrawDurableObject {
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
console.log("Persisting", this.roomId, "to R2")
},
})
})()
@ -254,7 +263,7 @@ export class TldrawDurableObject {
return this.roomPromise
}
// we throttle persistance so it only happens every 10 seconds
// we throttle persistence so it only happens every 30 seconds, batching all updates
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
@ -262,7 +271,8 @@ export class TldrawDurableObject {
// convert the room to JSON and upload it to R2
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000)
console.log(`Board persisted to R2: ${this.roomId}`)
}, 30_000)
// Add CORS headers for WebSocket upgrade
handleWebSocket(request: Request) {
@ -283,10 +293,8 @@ export class TldrawDurableObject {
server.addEventListener("close", () => {
if (this.roomPromise) {
this.getRoom().then((room) => {
// Update store to ensure all changes are persisted
room.updateStore(() => {})
})
// Force a final persistence when WebSocket closes
this.schedulePersistToR2()
}
})

View File

@ -2,8 +2,9 @@ import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
// make sure our sync durable object is made available to cloudflare
// make sure our sync durable objects are made available to cloudflare
export { TldrawDurableObject } from "./TldrawDurableObject"
// export { AutomergeDurableObject } from "./AutomergeDurableObject" // Disabled - not currently used
// Lazy load heavy dependencies to avoid startup timeouts
let handleUnfurlRequest: any = null
@ -82,40 +83,80 @@ const { preflight, corsify } = cors({
credentials: true,
})
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight],
before: [
// Handle WebSocket upgrades before CORS processing
(request) => {
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader === "websocket") {
// WebSocket upgrade detected, bypassing CORS
return // Don't process CORS for WebSocket upgrades
}
return preflight(request)
}
],
finally: [
(response) => {
// Add security headers to all responses except WebSocket upgrades
if (response.status !== 101) {
// Create new headers to avoid modifying immutable headers
const newHeaders = new Headers(response.headers)
Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value)
newHeaders.set(key, value)
})
// Create a new response with the updated headers
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
})
}
return corsify(response)
},
],
catch: (e: Error) => {
// Silently handle WebSocket errors, but log other errors
// Log all errors for debugging
console.error("Worker error:", e)
console.error("Error stack:", e.stack)
// Handle WebSocket errors more gracefully
if (e.message?.includes("WebSocket")) {
console.debug("WebSocket error:", e)
return new Response(null, { status: 400 })
console.error("WebSocket error:", e)
return new Response(JSON.stringify({ error: "WebSocket connection failed", message: e.message }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
console.error(e)
return error(e)
},
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.get("/connect/:roomId", (request, env) => {
console.log("Connect request for room:", request.params.roomId)
console.log("Request headers:", Object.fromEntries(request.headers.entries()))
// Check if this is a WebSocket upgrade request
const upgradeHeader = request.headers.get("Upgrade")
console.log("Upgrade header:", upgradeHeader)
if (upgradeHeader === "websocket") {
console.log("WebSocket upgrade requested for room:", request.params.roomId)
console.log("Request URL:", request.url)
console.log("Request method:", request.method)
console.log("All headers:", Object.fromEntries(request.headers.entries()))
const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.TLDRAW_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
console.log("Calling Durable Object fetch...")
const result = room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
console.log("Durable Object fetch result:", result)
return result
}
// Handle regular GET requests
@ -159,6 +200,49 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
})
})
// Automerge routes - these will be used when we switch to Automerge sync
.get("/automerge/connect/:roomId", (request, env) => {
// Check if this is a WebSocket upgrade request
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader === "websocket") {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
}
// Handle regular GET requests
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
})
.get("/automerge/room/:roomId", (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
headers: request.headers,
body: request.body,
method: request.method,
})
})
.post("/automerge/room/:roomId", async (request, env) => {
const id = env.AUTOMERGE_DURABLE_OBJECT.idFromName(request.params.roomId)
const room = env.AUTOMERGE_DURABLE_OBJECT.get(id)
return room.fetch(request.url, {
method: "POST",
body: request.body,
})
})
.post("/daily/rooms", async (req) => {
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
@ -514,18 +598,18 @@ async function backupAllBoards(env: Environment) {
// Store in backup bucket as JSON
await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData)
console.log(`Backed up ${room.key} to ${backupKey}`)
// Backed up successfully
} catch (error) {
console.error(`Failed to backup room ${room.key}:`, error)
}
}
// Clean up old backups (keep last 30 days)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
// Clean up old backups (keep last 90 days)
const ninetyDaysAgo = new Date()
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const oldBackups = await env.BOARD_BACKUPS_BUCKET.list({
prefix: thirtyDaysAgo.toISOString().split('T')[0]
prefix: ninetyDaysAgo.toISOString().split('T')[0]
})
for (const backup of oldBackups.objects) {
@ -546,6 +630,67 @@ router
headers: { 'Content-Type': 'application/json' }
})
})
.get("/backup/test", async (_, env) => {
try {
// Simple test to check R2 access
const testResult = await env.TLDRAW_BUCKET.list({ prefix: 'rooms/', limit: 1 })
// export our router for cloudflare
export default router
return new Response(JSON.stringify({
success: true,
message: 'R2 access test successful',
roomCount: testResult.objects?.length || 0,
hasMore: testResult.truncated || false
}), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('R2 test failed:', error)
return new Response(JSON.stringify({
success: false,
message: 'R2 access test failed',
error: (error as Error).message
}), {
headers: { 'Content-Type': 'application/json' }
})
}
})
// Handle scheduled events (cron jobs)
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
// Cron job triggered
try {
// Run the backup function
const result = await backupAllBoards(env)
// Scheduled backup completed
// You can add additional logging or notifications here
if (!result.success) {
console.error('Scheduled backup failed:', result.message)
// In a real scenario, you might want to send alerts here
}
return result
} catch (error) {
console.error('Scheduled backup error:', error)
throw error
}
}
// export our router for cloudflare with CORS enabled
export default {
fetch: (request: Request, env: Environment, ctx: ExecutionContext) => {
// Handle WebSocket upgrades directly without CORS processing
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader === "websocket") {
// WebSocket upgrade detected, bypassing router CORS
return router.fetch(request, env, ctx)
}
// For regular requests, apply CORS
return router.fetch(request, env, ctx).then(response => corsify(response))
},
scheduled
}

60
wrangler.dev.toml Normal file
View File

@ -0,0 +1,60 @@
main = "worker/worker.ts"
compatibility_date = "2024-07-01"
name = "jeffemmett-canvas-dev"
account_id = "0e7b3338d5278ed1b148e6456b940913"
[vars]
# Development environment variables
DAILY_DOMAIN = "mycopunks.daily.co"
[dev]
port = 5172
ip = "0.0.0.0"
local_protocol = "http"
upstream_protocol = "https"
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
# { name = "AUTOMERGE_DURABLE_OBJECT", class_name = "AutomergeDurableObject" }, # Disabled - not currently used
]
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
[[migrations]]
tag = "v2"
new_classes = ["AutomergeDurableObject"]
[[migrations]]
tag = "v3"
deleted_classes = ["AutomergeDurableObject"]
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas-preview'
preview_bucket_name = 'jeffemmett-canvas-preview'
[[r2_buckets]]
binding = 'BOARD_BACKUPS_BUCKET'
bucket_name = 'board-backups-preview'
preview_bucket_name = 'board-backups-preview'
[miniflare]
kv_persist = true
r2_persist = true
durable_objects_persist = true
[observability]
enabled = true
head_sampling_rate = 1
[triggers]
crons = ["0 0 * * *"] # Run at midnight UTC every day
# Secrets should be set using `wrangler secret put` command for dev environment
# DO NOT put these directly in wrangler.toml:
# - DAILY_API_KEY
# - CLOUDFLARE_API_TOKEN
# etc.

View File

@ -17,21 +17,28 @@ upstream_protocol = "https"
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
# { name = "AUTOMERGE_DURABLE_OBJECT", class_name = "AutomergeDurableObject" }, # Disabled - not currently used
]
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
[[migrations]]
tag = "v2"
new_classes = ["AutomergeDurableObject"]
[[migrations]]
tag = "v3"
deleted_classes = ["AutomergeDurableObject"]
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview'
[[r2_buckets]]
binding = 'BOARD_BACKUPS_BUCKET'
bucket_name = 'board-backups'
preview_bucket_name = 'board-backups-preview'
[miniflare]
kv_persist = true
@ -46,6 +53,43 @@ head_sampling_rate = 1
crons = ["0 0 * * *"] # Run at midnight UTC every day
# crons = ["*/10 * * * *"] # Run every 10 minutes
# Development environment configuration
[env.dev]
name = "jeffemmett-canvas-dev"
compatibility_date = "2024-07-01"
[env.dev.vars]
DAILY_DOMAIN = "mycopunks.daily.co"
[env.dev.durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
# { name = "AUTOMERGE_DURABLE_OBJECT", class_name = "AutomergeDurableObject" }, # Disabled - not currently used
]
[[env.dev.migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
[[env.dev.migrations]]
tag = "v2"
new_classes = ["AutomergeDurableObject"]
[[env.dev.migrations]]
tag = "v3"
deleted_classes = ["AutomergeDurableObject"]
[[env.dev.r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas-preview'
[[env.dev.r2_buckets]]
binding = 'BOARD_BACKUPS_BUCKET'
bucket_name = 'board-backups-preview'
[env.dev.triggers]
crons = ["0 0 * * *"] # Run at midnight UTC every day
# Secrets should be set using `wrangler secret put` command
# DO NOT put these directly in wrangler.toml:
# - DAILY_API_KEY