Production thumbnails follow each deploy, not each commit, and old ones are cleared daily
The overview asked for its screenshot with ?v=<commit> and the browser kept it for a year. An app paused and resumed, or moved to a new name, is deployed again at the same commit, so the old picture (automation-lab's said it was paused) stayed. - The image is named by the deploy (commit and when it went live), and og counts a kept screenshot as current only if it shows that commit and was taken after the deploy; otherwise it takes it again. - og deletes screenshots and attempt notes older than 30 days once a day; apps that moved or went leave none behind, and a live app's is taken again when its page next asks.
8 files+100−150/8 viewed
| 58 | 58 | import { madeByG1t } from "../../lib/opened-by"; | |
| 59 | 59 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 60 | 60 | import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server"; | |
| 61 | + | import { shotVersion } from "./production-screenshot"; | |
| 61 | 62 | ||
| 62 | 63 | const MAX_LANDED = 6; | |
| 63 | 64 | /** Branches read for the Active branches list, and shown. */ | |
| 554 | 555 | <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6"> | |
| 555 | 556 | {member && (production || settings?.enabled) && ( | |
| 556 | 557 | <ProductionShot | |
| 557 | − | src={production ? `${base}/production.jpg?v=${production.commit}` : null} | |
| 558 | + | src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null} | |
| 558 | 559 | href={production ? (productionUrl ?? production.url) : null} | |
| 559 | 560 | label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))} | |
| 560 | 561 | className="w-full shrink-0 sm:w-60 lg:w-72" |
| 1 | 1 | /** | |
| 2 | 2 | * A screenshot of a project's production, for its overview. Members only, | |
| 3 | − | * like the deployments it shows. The overview asks with `?v=<commit>`, so a | |
| 4 | − | * screenshot of that commit is kept by the browser for good; one of an | |
| 5 | − | * older deploy, while the new one is being taken, only for a minute. | |
| 3 | + | * like the deployments it shows. The overview asks with `?v=` naming the | |
| 4 | + | * deploy (its commit and when it went live), so the browser keeps a | |
| 5 | + | * screenshot of that deploy for good, and a resumed or moved app, deployed | |
| 6 | + | * again at the same commit, is asked for anew. One taken before the | |
| 7 | + | * deploy, while the new one is being taken, is kept only for a minute. | |
| 6 | 8 | */ | |
| 7 | 9 | import { env } from "cloudflare:workers"; | |
| 8 | 10 | ||
| 11 | 13 | import { getViewer } from "../../lib/session.server"; | |
| 12 | 14 | ||
| 13 | 15 | const LONG = "private, max-age=31536000, immutable"; | |
| 16 | + | ||
| 17 | + | /** What names one deploy of production: its commit and when it went live. */ | |
| 18 | + | export function shotVersion(app: { commit: string; deployedAt: string }): string { | |
| 19 | + | return `${app.commit}-${Date.parse(app.deployedAt) || 0}`; | |
| 20 | + | } | |
| 14 | 21 | const BRIEF = "private, max-age=60"; | |
| 15 | 22 | ||
| 16 | 23 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | 27 | const list = await deployments.list({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null); | |
| 21 | 28 | const production = list?.ok ? list.value.live.find((app) => app.kind === "production") : undefined; | |
| 22 | 29 | if (!production) return none(404); | |
| 23 | − | const shot = await env.SCREENSHOTS.image({ host: new URL(production.url).host, commit: production.commit }).catch( | |
| 30 | + | const shot = await env.SCREENSHOTS.image({ | |
| 31 | + | host: new URL(production.url).host, | |
| 32 | + | commit: production.commit, | |
| 33 | + | since: production.deployedAt, | |
| 34 | + | }).catch( | |
| 24 | 35 | (error: unknown) => (console.warn("screenshot:", error), null), | |
| 25 | 36 | ); | |
| 26 | 37 | if (!shot) return none(404); | |
| 27 | 38 | const asked = new URL(request.url).searchParams.get("v"); | |
| 28 | − | const current = shot.commit === production.commit && asked === production.commit; | |
| 39 | + | const current = | |
| 40 | + | shot.commit === production.commit && | |
| 41 | + | Date.parse(shot.capturedAt) >= Date.parse(production.deployedAt) && | |
| 42 | + | asked === shotVersion(production); | |
| 29 | 43 | return new Response(shot.body, { | |
| 30 | 44 | headers: { | |
| 31 | 45 | "content-type": shot.contentType, |
| 22 | 22 | SEARCH: ServiceBinding; | |
| 23 | 23 | /** Production screenshots, from the og service's `Screenshots` entrypoint. */ | |
| 24 | 24 | SCREENSHOTS?: { | |
| 25 | − | image(input: { host: string; commit: string }): Promise<{ | |
| 25 | + | image(input: { host: string; commit: string; since?: string }): Promise<{ | |
| 26 | 26 | body: ArrayBuffer; | |
| 27 | 27 | contentType: string; | |
| 28 | 28 | commit: string; |
| 8 | 8 | */ | |
| 9 | 9 | import puppeteer from "@cloudflare/puppeteer"; | |
| 10 | 10 | ||
| 11 | − | import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 11 | + | import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, expired, isCurrent, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 12 | 12 | ||
| 13 | 13 | export type ShotEnv = { | |
| 14 | 14 | BROWSER: Fetcher; | |
| 64 | 64 | } | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | − | /** The screenshot of `commit`, taken now if it has not been; else the last one kept. */ | |
| 67 | + | /** The screenshot of the request's deploy, taken now if it has not been; else the last one kept. */ | |
| 68 | 68 | export async function screenshotOf(env: ShotEnv, request: ShotRequest): Promise<Shot | null> { | |
| 69 | 69 | const kept = await stored(env, request.host); | |
| 70 | − | if (kept?.commit === request.commit) return kept; | |
| 70 | + | if (kept && isCurrent(kept, request)) return kept; | |
| 71 | 71 | return (await take(env, request)) ?? kept; | |
| 72 | 72 | } | |
| 73 | + | ||
| 74 | + | /** | |
| 75 | + | * Deletes screenshots and attempt notes older than `KEEP_DAYS`: apps that | |
| 76 | + | * moved or were removed leave theirs behind, and a live app's is taken | |
| 77 | + | * again the next time its page asks. Returns how many went. | |
| 78 | + | */ | |
| 79 | + | export async function sweep(env: ShotEnv, now = Date.now()): Promise<number> { | |
| 80 | + | let removed = 0; | |
| 81 | + | for (const prefix of ["production/", "attempts/"]) { | |
| 82 | + | let cursor: string | undefined; | |
| 83 | + | do { | |
| 84 | + | const page = await env.SCREENSHOTS.list({ prefix, cursor, limit: 500 }); | |
| 85 | + | const old = page.objects.filter((object) => expired(object.uploaded, now)).map((object) => object.key); | |
| 86 | + | if (old.length) { | |
| 87 | + | await env.SCREENSHOTS.delete(old); | |
| 88 | + | removed += old.length; | |
| 89 | + | } | |
| 90 | + | cursor = page.truncated ? page.cursor : undefined; | |
| 91 | + | } while (cursor); | |
| 92 | + | } | |
| 93 | + | return removed; | |
| 94 | + | } |
| 32 | 32 | import mono500 from "./fonts/ibm-plex-mono-500.ttf"; | |
| 33 | 33 | import { cardPng } from "./render.ts"; | |
| 34 | 34 | import { cacheKey } from "./cache.ts"; | |
| 35 | − | import { type Shot, screenshotOf, take } from "./capture.ts"; | |
| 35 | + | import { type Shot, screenshotOf, sweep, take } from "./capture.ts"; | |
| 36 | 36 | import { parseShot } from "./screenshot.ts"; | |
| 37 | 37 | import { BRAND, type Card, docsCard, resolve } from "./resolve.ts"; | |
| 38 | 38 | ||
| 101 | 101 | const NO_STORE = "no-store"; | |
| 102 | 102 | ||
| 103 | 103 | export default { | |
| 104 | + | /** Once a day: old screenshots out (capture.ts `sweep`). */ | |
| 105 | + | async scheduled(_event: ScheduledController, env: Env): Promise<void> { | |
| 106 | + | const removed = await sweep(env).catch((error) => (console.error("og: sweep failed", error), 0)); | |
| 107 | + | if (removed) console.log(`og: removed ${removed} old screenshots`); | |
| 108 | + | }, | |
| 109 | + | ||
| 104 | 110 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 105 | 111 | const url = new URL(request.url); | |
| 106 | 112 | if (request.method !== "GET" && request.method !== "HEAD") { |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { RETRY_AFTER_MS, attemptKey, parseShot, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 4 | + | import { KEEP_DAYS, RETRY_AFTER_MS, attemptKey, expired, isCurrent, parseShot, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const commit = "abc1234def5678abc1234def5678abc1234def56"; | |
| 7 | 7 | ||
| 37 | 37 | assert.equal(shouldAttempt(just, "fff0000", now), true); | |
| 38 | 38 | assert.equal(shouldAttempt({ commit, at: new Date(now - RETRY_AFTER_MS).toISOString() }, commit, now), true); | |
| 39 | 39 | }); | |
| 40 | + | ||
| 41 | + | test("a screenshot is current when it shows the commit and was taken after the deploy", () => { | |
| 42 | + | const kept = { commit, capturedAt: "2026-10-06T10:00:00Z" }; | |
| 43 | + | assert.ok(isCurrent(kept, { host: "a.g1t.page", commit })); | |
| 44 | + | assert.ok(isCurrent(kept, { host: "a.g1t.page", commit, since: "2026-10-06T09:00:00Z" })); | |
| 45 | + | // Resumed (deployed again at the same commit) after it was taken. | |
| 46 | + | assert.ok(!isCurrent(kept, { host: "a.g1t.page", commit, since: "2026-10-06T11:00:00Z" })); | |
| 47 | + | assert.ok(!isCurrent(kept, { host: "a.g1t.page", commit: "f".repeat(40) })); | |
| 48 | + | assert.deepEqual(parseShot({ host: "a-b.g1t.page", commit, since: "2026-10-06T11:00:00Z" })?.since, "2026-10-06T11:00:00Z"); | |
| 49 | + | assert.equal(parseShot({ host: "a-b.g1t.page", commit, since: "nope" })?.since, undefined); | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test("kept objects expire after KEEP_DAYS", () => { | |
| 53 | + | const now = Date.parse("2026-10-06T00:00:00Z"); | |
| 54 | + | assert.ok(expired(new Date(now - (KEEP_DAYS + 1) * 86_400_000), now)); | |
| 55 | + | assert.ok(!expired(new Date(now - 86_400_000), now)); | |
| 56 | + | }); |
| 13 | 13 | /** A screenshot that could not be taken is tried again after this long. */ | |
| 14 | 14 | export const RETRY_AFTER_MS = 5 * 60 * 1000; | |
| 15 | 15 | ||
| 16 | − | export type ShotRequest = { host: string; commit: string }; | |
| 16 | + | /** | |
| 17 | + | * An app's production at `commit`. `since` is when it was last deployed: | |
| 18 | + | * a resumed or moved app is deployed again at the same commit, and a | |
| 19 | + | * screenshot from before that (of a page saying it was paused, say) is not | |
| 20 | + | * current. | |
| 21 | + | */ | |
| 22 | + | export type ShotRequest = { host: string; commit: string; since?: string }; | |
| 17 | 23 | ||
| 24 | + | /** Kept screenshots and attempt notes older than this are deleted; a page asking for one again takes it anew. */ | |
| 25 | + | export const KEEP_DAYS = 30; | |
| 26 | + | ||
| 18 | 27 | /** Hostnames on g1t.page that are not apps. */ | |
| 19 | 28 | const RESERVED = new Set(["domains", "www"]); | |
| 20 | 29 | ||
| 21 | 30 | /** The request, if it names an app on g1t.page and a commit. */ | |
| 22 | 31 | export function parseShot(input: unknown): ShotRequest | null { | |
| 23 | 32 | if (!input || typeof input !== "object") return null; | |
| 24 | − | const { host, commit } = input as Record<string, unknown>; | |
| 33 | + | const { host, commit, since } = input as Record<string, unknown>; | |
| 25 | 34 | if (typeof host !== "string" || typeof commit !== "string") return null; | |
| 26 | 35 | const name = host.toLowerCase(); | |
| 27 | 36 | const suffix = `.${DEPLOYMENTS_DOMAIN}`; | |
| 29 | 38 | const label = name.slice(0, -suffix.length); | |
| 30 | 39 | if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || RESERVED.has(label)) return null; | |
| 31 | 40 | if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null; | |
| 32 | − | return { host: name, commit: commit.toLowerCase() }; | |
| 41 | + | const shot: ShotRequest = { host: name, commit: commit.toLowerCase() }; | |
| 42 | + | if (typeof since === "string" && Number.isFinite(Date.parse(since))) shot.since = since; | |
| 43 | + | return shot; | |
| 33 | 44 | } | |
| 34 | 45 | ||
| 35 | 46 | /** Where an app's screenshot is kept. */ | |
| 42 | 53 | return `attempts/${host}`; | |
| 43 | 54 | } | |
| 44 | 55 | ||
| 56 | + | /** Whether a kept screenshot shows the request: its commit, taken after the deploy. */ | |
| 57 | + | export function isCurrent(kept: { commit: string; capturedAt: string }, request: ShotRequest): boolean { | |
| 58 | + | if (kept.commit !== request.commit) return false; | |
| 59 | + | if (!request.since) return true; | |
| 60 | + | return Date.parse(kept.capturedAt) >= Date.parse(request.since); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** Whether a kept object is old enough to delete. */ | |
| 64 | + | export function expired(uploaded: Date, now: number, days = KEEP_DAYS): boolean { | |
| 65 | + | return now - uploaded.getTime() > days * 24 * 60 * 60 * 1000; | |
| 66 | + | } | |
| 67 | + | ||
| 45 | 68 | /** Whether to try again: not for the same commit within `RETRY_AFTER_MS`. */ | |
| 46 | 69 | export function shouldAttempt(last: { commit?: string; at?: string } | null | undefined, commit: string, now: number): boolean { | |
| 47 | 70 | if (!last?.at || last.commit !== commit) return true; |
| 25 | 25 | "browser": { "binding": "BROWSER" }, | |
| 26 | 26 | // Made by `npx wrangler r2 bucket create g1t-screenshots`. | |
| 27 | 27 | "r2_buckets": [{ "binding": "SCREENSHOTS", "bucket_name": "g1t-screenshots" }], | |
| 28 | + | // Once a day, screenshots older than 30 days are deleted (src/capture.ts). | |
| 29 | + | "triggers": { "crons": ["37 5 * * *"] }, | |
| 28 | 30 | "services": [ | |
| 29 | 31 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 30 | 32 | { "binding": "REPOS", "service": "g1t-repos" }, |