diff --git a/src/lib/screenshotService.ts b/src/lib/screenshotService.ts index 535ee4d..e456e3b 100644 --- a/src/lib/screenshotService.ts +++ b/src/lib/screenshotService.ts @@ -38,7 +38,7 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise { } // Create room name based on board ID and timestamp - const roomName = `board_${boardId}_${Date.now()}`; + // Sanitize boardId to only use valid Daily.co characters (A-Z, a-z, 0-9, '-', '_') + const sanitizedBoardId = boardId.replace(/[^A-Za-z0-9\-_]/g, '_'); + const roomName = `board_${sanitizedBoardId}_${Date.now()}`; + + console.log('🔧 Room name generation:'); + console.log('Original boardId:', boardId); + console.log('Sanitized boardId:', sanitizedBoardId); + console.log('Final roomName:', roomName); const response = await fetch(`${workerUrl}/daily/rooms`, { method: 'POST', @@ -135,22 +142,7 @@ export class VideoChatShape extends BaseBoxShapeUtil { enable_chat: true, enable_screenshare: true, start_video_off: true, - start_audio_off: true, - enable_recording: "cloud", - start_cloud_recording: true, - start_cloud_recording_opts: { - layout: { - preset: "active-speaker" - }, - format: "mp4", - mode: "audio-only" - }, - // Transcription settings - transcription: { - enabled: true, - auto_start: false - }, - recordings_template: "{room_name}/audio-{epoch_time}.mp4" + start_audio_off: true } }) }); @@ -205,6 +197,12 @@ export class VideoChatShape extends BaseBoxShapeUtil { const apiKey = import.meta.env.VITE_DAILY_API_KEY; try { + // Extract room name from URL (same as transcription methods) + const roomName = shape.props.roomUrl.split('/').pop(); + if (!roomName) { + throw new Error('Could not extract room name from URL'); + } + const response = await fetch(`${workerUrl}/daily/recordings/start`, { method: 'POST', headers: { @@ -212,7 +210,7 @@ export class VideoChatShape extends BaseBoxShapeUtil { 'Content-Type': 'application/json' }, body: JSON.stringify({ - room_name: shape.id, + room_name: roomName, layout: { preset: "active-speaker" } diff --git a/src/utils/pdfUtils.ts b/src/utils/pdfUtils.ts index 6e3f393..4a73c1c 100644 --- a/src/utils/pdfUtils.ts +++ b/src/utils/pdfUtils.ts @@ -20,7 +20,7 @@ export const saveToPdf = async (editor: Editor) => { scale: 2, background: true, padding: 0, - preserveAspectRatio: "true", + preserveAspectRatio: "xMidYMid meet", }, }) diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index 5869c06..7d499a0 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -1,14 +1,5 @@ /// -import { RoomSnapshot, TLSocketRoom } from "@tldraw/sync-core" -import { - TLRecord, - TLShape, - createTLSchema, - defaultBindingSchemas, - defaultShapeSchemas, - shapeIdValidator, -} from "@tldraw/tlschema" import { AutoRouter, IRequest, error } from "itty-router" import throttle from "lodash.throttle" import { Environment } from "./types" @@ -21,45 +12,61 @@ import { SlideShape } from "@/shapes/SlideShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil" import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" -// 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, - }, - Slide: { - props: SlideShape.props, - migrations: SlideShape.migrations, - }, - Prompt: { - props: PromptShape.props, - migrations: PromptShape.migrations, - }, - SharedPiano: { - props: SharedPianoShape.props, - migrations: SharedPianoShape.migrations, - }, - }, - bindings: defaultBindingSchemas, -}) +// Lazy load TLDraw dependencies to avoid startup timeouts +let customSchema: any = null +let TLSocketRoom: any = null + +async function getTldrawDependencies() { + if (!customSchema) { + const { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } = await import("@tldraw/tlschema") + + 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, + }, + Slide: { + props: SlideShape.props, + migrations: SlideShape.migrations, + }, + Prompt: { + props: PromptShape.props, + migrations: PromptShape.migrations, + }, + SharedPiano: { + props: SharedPianoShape.props, + migrations: SharedPianoShape.migrations, + }, + }, + bindings: defaultBindingSchemas, + }) + } + + if (!TLSocketRoom) { + const syncCore = await import("@tldraw/sync-core") + TLSocketRoom = syncCore.TLSocketRoom + } + + return { customSchema, TLSocketRoom } +} // each whiteboard room is hosted in a DurableObject: // https://developers.cloudflare.com/durable-objects/ @@ -72,7 +79,7 @@ export class TldrawDurableObject { 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 + private roomPromise: Promise | null = null constructor(private readonly ctx: DurableObjectState, env: Environment) { this.r2 = env.TLDRAW_BUCKET @@ -114,7 +121,7 @@ export class TldrawDurableObject { }) }) .post("/room/:roomId", async (request) => { - const records = (await request.json()) as TLRecord[] + const records = (await request.json()) as any[] return new Response(JSON.stringify(Array.from(records)), { headers: { @@ -206,29 +213,32 @@ export class TldrawDurableObject { } } - getRoom() { + async getRoom() { const roomId = this.roomId if (!roomId) throw new Error("Missing roomId") if (!this.roomPromise) { this.roomPromise = (async () => { + // Lazy load dependencies + const { customSchema, TLSocketRoom } = await getTldrawDependencies() + // 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) + ? ((await roomFromBucket.json()) as any) : undefined if (initialSnapshot) { initialSnapshot.documents = initialSnapshot.documents.filter( - (record) => { - const shape = record.state as TLShape + (record: any) => { + const shape = record.state as any return shape.type !== "ChatBox" }, ) } // 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({ + return new TLSocketRoom({ schema: customSchema, initialSnapshot, onDataChange: () => { diff --git a/worker/worker.ts b/worker/worker.ts index 29f7afe..077e60a 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -1,4 +1,3 @@ -import { handleUnfurlRequest } from "cloudflare-workers-unfurl" import { AutoRouter, cors, error, IRequest } from "itty-router" import { handleAssetDownload, handleAssetUpload } from "./assetUploads" import { Environment } from "./types" @@ -6,6 +5,17 @@ import { Environment } from "./types" // make sure our sync durable object is made available to cloudflare export { TldrawDurableObject } from "./TldrawDurableObject" +// Lazy load heavy dependencies to avoid startup timeouts +let handleUnfurlRequest: any = null + +async function getUnfurlHandler() { + if (!handleUnfurlRequest) { + const unfurl = await import("cloudflare-workers-unfurl") + handleUnfurlRequest = unfurl.handleUnfurlRequest + } + return handleUnfurlRequest +} + // Define security headers const securityHeaders = { "Content-Security-Policy": @@ -27,6 +37,8 @@ const { preflight, corsify } = cors({ "https://www.jeffemmett.com", "https://jeffemmett-canvas.jeffemmett.workers.dev", "https://jeffemmett.com/board/*", + "http://localhost:5173", + "http://127.0.0.1:5173", ] // Always allow if no origin (like from a local file) @@ -94,6 +106,19 @@ const router = AutoRouter({ }) // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing .get("/connect/:roomId", (request, env) => { + // Check if this is a WebSocket upgrade request + const upgradeHeader = request.headers.get("Upgrade") + if (upgradeHeader === "websocket") { + 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, + body: request.body, + method: request.method, + }) + } + + // Handle regular GET requests const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const room = env.TLDRAW_DURABLE_OBJECT.get(id) return room.fetch(request.url, { @@ -110,7 +135,10 @@ const router = AutoRouter({ .get("/uploads/:uploadId", handleAssetDownload) // bookmarks need to extract metadata from pasted URLs: - .get("/unfurl", handleUnfurlRequest) + .get("/unfurl", async (request, env) => { + const handler = await getUnfurlHandler() + return handler(request, env) + }) .get("/room/:roomId", (request, env) => { const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) @@ -142,14 +170,26 @@ const router = AutoRouter({ } try { + // Get the request body from the client + const body = await req.json() + const response = await fetch('https://api.daily.co/v1/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` - } + }, + body: JSON.stringify(body) }) + if (!response.ok) { + const error = await response.json() + return new Response(JSON.stringify(error), { + status: response.status, + headers: { 'Content-Type': 'application/json' } + }) + } + const data = await response.json() return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' }