flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/og/src/capture.ts

72 lines3,197 bytesCodeBlame
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 */
9import puppeteer from "@cloudflare/puppeteer";
10
11import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, shotKey, shouldAttempt } from "./screenshot.ts";
12
13export type ShotEnv = {
14 BROWSER: Fetcher;
15 SCREENSHOTS: R2Bucket;
16};
17
18export type Shot = { body: ArrayBuffer; contentType: string; commit: string; capturedAt: string };
19
20/** The kept screenshot, whatever commit it shows. */
21export 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 */
36export 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. */
68export 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}