Monorepo for Aesthetic.Computer
aesthetic.computer
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}