Monorepo for Aesthetic.Computer aesthetic.computer
at main 289 lines 9.0 kB view raw
1/** 2 * Screenshot generation worker for aesthetic.computer 3 * Based on Cloudflare's Browser Rendering API example 4 */ 5 6// Polyfill Buffer for @cloudflare/puppeteer compatibility 7// @ts-ignore 8if (typeof Buffer === 'undefined') { 9 // @ts-ignore 10 globalThis.Buffer = class Buffer extends Uint8Array { 11 static isBuffer(obj: any): boolean { 12 return obj instanceof Uint8Array; 13 } 14 15 static from(arrayBuffer: ArrayBuffer | Uint8Array | number[], encoding?: string): Uint8Array { 16 if (arrayBuffer instanceof ArrayBuffer) { 17 return new Uint8Array(arrayBuffer); 18 } 19 if (arrayBuffer instanceof Uint8Array) { 20 return arrayBuffer; 21 } 22 if (Array.isArray(arrayBuffer)) { 23 return new Uint8Array(arrayBuffer); 24 } 25 return new Uint8Array(0); 26 } 27 }; 28} 29 30import puppeteer from "@cloudflare/puppeteer"; 31 32export interface Env { 33 MYBROWSER: Fetcher; 34 BROWSER: DurableObjectNamespace; 35 36 // Environment variables 37 ENVIRONMENT: string; 38 DOMAIN: string; 39 CACHE_TTL_SECONDS: string; 40 CDN_CACHE_TTL_SECONDS: string; 41 MAX_SCREENSHOT_AGE_MS: string; 42 BROWSER_TIMEOUT_MS: string; 43 MAX_VIEWPORT_WIDTH: string; 44 MAX_VIEWPORT_HEIGHT: string; 45 MAX_REQUESTS_PER_MINUTE: string; 46 MAX_BROWSER_SESSIONS: string; 47} 48 49export default { 50 async fetch(request: Request, env: Env): Promise<Response> { 51 const url = new URL(request.url); 52 53 // Health check endpoint 54 if (url.pathname === '/health') { 55 return Response.json({ status: 'ok', timestamp: Date.now() }); 56 } 57 58 // Parse screenshot request from URL 59 // Format: /icon/128x128/welcome.png or /preview/1200x630/prompt~wipe.png 60 const pathMatch = url.pathname.match(/^\/(icon|preview)\/(\d+)x(\d+)\/(.+)$/); 61 62 if (!pathMatch) { 63 return Response.json( 64 { error: 'Invalid request format. Use /icon/WxH/piece.png or /preview/WxH/piece.png' }, 65 { status: 400 } 66 ); 67 } 68 69 const [, type, widthStr, heightStr, piece] = pathMatch; 70 const width = parseInt(widthStr); 71 const height = parseInt(heightStr); 72 73 // Validate dimensions 74 const maxWidth = parseInt(env.MAX_VIEWPORT_WIDTH || '1920'); 75 const maxHeight = parseInt(env.MAX_VIEWPORT_HEIGHT || '1080'); 76 77 if (width > maxWidth || height > maxHeight) { 78 return Response.json( 79 { error: `Dimensions too large. Max: ${maxWidth}x${maxHeight}` }, 80 { status: 400 } 81 ); 82 } 83 84 // Get Durable Object instance 85 const id = env.BROWSER.idFromName("browser"); 86 const obj = env.BROWSER.get(id); 87 88 // Forward request to Durable Object with screenshot parameters 89 const screenshotRequest = new Request(request.url, { 90 method: 'POST', 91 headers: request.headers, 92 body: JSON.stringify({ 93 type, 94 piece, 95 width, 96 height, 97 }), 98 }); 99 100 return await obj.fetch(screenshotRequest); 101 }, 102}; 103 104const KEEP_BROWSER_ALIVE_IN_SECONDS = 60; 105 106export class Browser { 107 private state: DurableObjectState; 108 private env: Env; 109 private browser: any; 110 private keptAliveInSeconds: number; 111 private storage: DurableObjectStorage; 112 113 constructor(state: DurableObjectState, env: Env) { 114 this.state = state; 115 this.env = env; 116 this.browser = null; 117 this.keptAliveInSeconds = 0; 118 this.storage = this.state.storage; 119 } 120 121 async fetch(request: Request): Promise<Response> { 122 try { 123 // Parse request body 124 const body = await request.json() as { 125 type: string; 126 piece: string; 127 width: number; 128 height: number; 129 }; 130 131 // Handle screenshot (icon/preview) 132 return await this.takeScreenshot(body); 133 134 } catch (error) { 135 console.error(`Browser DO error:`, error); 136 return Response.json( 137 { error: 'Request failed', message: String(error) }, 138 { status: 500 } 139 ); 140 } 141 } 142 143 private async takeScreenshot(body: { 144 type: string; 145 piece: string; 146 width: number; 147 height: number; 148 }): Promise<Response> { 149 try { 150 151 // Clean piece name (remove .png extension if present) 152 const pieceName = body.piece.replace(/\.png$/, ''); 153 154 // Build target URL with appropriate query parameter 155 // The client uses ?icon=WxH or ?preview=WxH to know which resolution to render 156 const baseUrl = `https://aesthetic.computer`; 157 const queryParam = body.type === 'icon' 158 ? `icon=${body.width}x${body.height}` 159 : `preview=${body.width}x${body.height}`; 160 const targetUrl = `${baseUrl}/${pieceName}?${queryParam}`; 161 162 console.log(`Taking screenshot: ${body.width}x${body.height} of ${targetUrl}`); 163 164 // If there's a browser session open, re-use it 165 if (!this.browser || !this.browser.isConnected()) { 166 console.log(`Browser DO: Starting new instance`); 167 try { 168 this.browser = await puppeteer.launch(this.env.MYBROWSER); 169 } catch (e) { 170 console.error(`Browser DO: Could not start browser instance. Error: ${e}`); 171 return Response.json( 172 { error: 'Failed to start browser', message: String(e) }, 173 { status: 500 } 174 ); 175 } 176 } 177 178 // Reset keptAlive after each call to the DO 179 this.keptAliveInSeconds = 0; 180 181 const page = await this.browser.newPage(); 182 183 try { 184 // Set viewport 185 await page.setViewport({ 186 width: body.width, 187 height: body.height 188 }); 189 190 // Navigate to page 191 const timeout = parseInt(this.env.BROWSER_TIMEOUT_MS || '30000'); 192 console.log(`Navigating to ${targetUrl} with timeout ${timeout}ms`); 193 await page.goto(targetUrl, { 194 waitUntil: 'networkidle2', 195 timeout, 196 }); 197 198 console.log(`Page loaded successfully`); 199 200 // Get page title to verify content loaded 201 const title = await page.title(); 202 console.log(`Page title: ${title}`); 203 204 // Wait for canvas element (aesthetic.computer renders to canvas) 205 try { 206 await page.waitForSelector('canvas', { timeout: 10000 }); 207 console.log(`Canvas element found`); 208 } catch (e) { 209 console.log(`No canvas found: ${e}`); 210 } 211 212 // Wait for animations and content to render 213 await new Promise(resolve => setTimeout(resolve, 3000)); 214 215 console.log(`Taking screenshot...`); 216 // Take screenshot - try JPEG like in CF example 217 const screenshot = await page.screenshot(); 218 219 console.log(`Screenshot captured. Type: ${screenshot?.constructor?.name}, Length: ${screenshot?.byteLength || screenshot?.length || 0}`); 220 221 // Convert to proper buffer if needed 222 let imageBuffer: Uint8Array; 223 if (screenshot instanceof Uint8Array) { 224 imageBuffer = screenshot; 225 } else if (Buffer.isBuffer(screenshot)) { 226 imageBuffer = new Uint8Array(screenshot); 227 } else { 228 imageBuffer = new Uint8Array(screenshot as any); 229 } 230 231 // Close tab when done 232 await page.close(); 233 234 // Reset keptAlive after performing tasks 235 this.keptAliveInSeconds = 0; 236 237 // Set alarm to keep DO alive 238 const currentAlarm = await this.storage.getAlarm(); 239 if (currentAlarm == null) { 240 console.log(`Browser DO: setting alarm`); 241 const TEN_SECONDS = 10 * 1000; 242 await this.storage.setAlarm(Date.now() + TEN_SECONDS); 243 } 244 245 // Return screenshot - convert to ArrayBuffer for Response 246 // @ts-ignore - Workers runtime accepts Uint8Array 247 return new Response(imageBuffer, { 248 headers: { 249 'Content-Type': 'image/png', 250 'Cache-Control': `public, max-age=${this.env.CACHE_TTL_SECONDS || '3600'}`, 251 'CDN-Cache-Control': `public, max-age=${this.env.CDN_CACHE_TTL_SECONDS || '86400'}`, 252 }, 253 }); 254 255 } catch (error) { 256 await page.close(); 257 throw error; 258 } 259 260 } catch (error) { 261 console.error(`Screenshot error:`, error); 262 return Response.json( 263 { error: 'Screenshot generation failed', message: String(error) }, 264 { status: 500 } 265 ); 266 } 267 } 268 269 async alarm(): Promise<void> { 270 this.keptAliveInSeconds += 10; 271 272 // Extend browser DO life 273 if (this.keptAliveInSeconds < KEEP_BROWSER_ALIVE_IN_SECONDS) { 274 console.log( 275 `Browser DO: has been kept alive for ${this.keptAliveInSeconds} seconds. Extending lifespan.` 276 ); 277 await this.storage.setAlarm(Date.now() + 10 * 1000); 278 } else { 279 console.log( 280 `Browser DO: exceeded life of ${KEEP_BROWSER_ALIVE_IN_SECONDS}s.` 281 ); 282 if (this.browser) { 283 console.log(`Closing browser.`); 284 await this.browser.close(); 285 this.browser = null; 286 } 287 } 288 } 289}