g1t/services/og/src/capture.ts
| 1 | /** |
| 2 | * Taking and keeping production screenshots with Browser Rendering. |
| 3 | * |
| 4 | * A screenshot is a few seconds of browser time: Workers Paid includes 10 |
| 5 | * browser hours a month and charges $0.09 an hour after that, so one costs |
| 6 | * about $0.0001, and each production deploy takes at most one (plus a retry |
| 7 | * after `RETRY_AFTER_MS` if it failed). That is small enough not to meter. |
| 8 | */ |
| 9 | import puppeteer from "@cloudflare/puppeteer"; |
| 10 | |
| 11 | import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, shotKey, shouldAttempt } from "./screenshot.ts"; |
| 12 | |
| 13 | export type ShotEnv = { |
| 14 | BROWSER: Fetcher; |
| 15 | SCREENSHOTS: R2Bucket; |
| 16 | }; |
| 17 | |
| 18 | export type Shot = { body: ArrayBuffer; contentType: string; commit: string; capturedAt: string }; |
| 19 | |
| 20 | /** The kept screenshot, whatever commit it shows. */ |
| 21 | export async function stored(env: ShotEnv, host: string): Promise<Shot | null> { |
| 22 | const object = await env.SCREENSHOTS.get(shotKey(host)); |
| 23 | if (!object) return null; |
| 24 | return { |
| 25 | body: await object.arrayBuffer(), |
| 26 | contentType: object.httpMetadata?.contentType ?? "image/jpeg", |
| 27 | commit: object.customMetadata?.commit ?? "", |
| 28 | capturedAt: object.customMetadata?.capturedAt ?? object.uploaded.toISOString(), |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Takes the screenshot of `commit`, unless one was tried for it moments |
| 34 | * ago. Returns it, or null when it was not taken. |
| 35 | */ |
| 36 | export async function take(env: ShotEnv, { host, commit }: ShotRequest): Promise<Shot | null> { |
| 37 | const marker = await env.SCREENSHOTS.head(attemptKey(host)); |
| 38 | if (!shouldAttempt(marker?.customMetadata, commit, Date.now())) return null; |
| 39 | const at = new Date().toISOString(); |
| 40 | await env.SCREENSHOTS.put(attemptKey(host), "", { customMetadata: { commit, at } }); |
| 41 | |
| 42 | let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null; |
| 43 | try { |
| 44 | browser = await puppeteer.launch(env.BROWSER as unknown as Parameters<typeof puppeteer.launch>[0]); |
| 45 | const page = await browser.newPage(); |
| 46 | await page.setViewport(VIEWPORT); |
| 47 | // A page that keeps the network busy is taken as it is when time is up. |
| 48 | await page.goto(`https://${host}/`, { waitUntil: "networkidle2", timeout: SETTLE_MS }).catch((error: unknown) => { |
| 49 | console.warn("og: page did not settle", host, String(error)); |
| 50 | }); |
| 51 | const image = (await page.screenshot({ type: "jpeg", quality: 80 })) as Uint8Array; |
| 52 | const body = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength) as ArrayBuffer; |
| 53 | const capturedAt = new Date().toISOString(); |
| 54 | await env.SCREENSHOTS.put(shotKey(host), body, { |
| 55 | httpMetadata: { contentType: "image/jpeg" }, |
| 56 | customMetadata: { commit, capturedAt }, |
| 57 | }); |
| 58 | return { body, contentType: "image/jpeg", commit, capturedAt }; |
| 59 | } catch (error) { |
| 60 | console.error("og: screenshot failed", host, error); |
| 61 | return null; |
| 62 | } finally { |
| 63 | await browser?.close().catch(() => undefined); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /** The screenshot of `commit`, taken now if it has not been; else the last one kept. */ |
| 68 | export async function screenshotOf(env: ShotEnv, request: ShotRequest): Promise<Shot | null> { |
| 69 | const kept = await stored(env, request.host); |
| 70 | if (kept?.commit === request.commit) return kept; |
| 71 | return (await take(env, request)) ?? kept; |
| 72 | } |