Commit

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.

syntaqxcommitted Parent384551fBrowse files
8 files+100−150/8 viewed
+2−1
5858 import { madeByG1t } from "../../lib/opened-by";
5959 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
6060 import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
61+import { shotVersion } from "./production-screenshot";
6162
6263 const MAX_LANDED = 6;
6364 /** Branches read for the Active branches list, and shown. */
554555 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
555556 {member && (production || settings?.enabled) && (
556557 <ProductionShot
557− src={production ? `${base}/production.jpg?v=${production.commit}` : null}
558+ src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
558559 href={production ? (productionUrl ?? production.url) : null}
559560 label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
560561 className="w-full shrink-0 sm:w-60 lg:w-72"
+19−5
11 /**
22 * 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.
68 */
79 import { env } from "cloudflare:workers";
810
1113 import { getViewer } from "../../lib/session.server";
1214
1315 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+}
1421 const BRIEF = "private, max-age=60";
1522
1623 export async function loader({ params, context, request }: Route.LoaderArgs) {
2027 const list = await deployments.list({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null);
2128 const production = list?.ok ? list.value.live.find((app) => app.kind === "production") : undefined;
2229 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(
2435 (error: unknown) => (console.warn("screenshot:", error), null),
2536 );
2637 if (!shot) return none(404);
2738 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);
2943 return new Response(shot.body, {
3044 headers: {
3145 "content-type": shot.contentType,
+1−1
2222 SEARCH: ServiceBinding;
2323 /** Production screenshots, from the og service's `Screenshots` entrypoint. */
2424 SCREENSHOTS?: {
25− image(input: { host: string; commit: string }): Promise<{
25+ image(input: { host: string; commit: string; since?: string }): Promise<{
2626 body: ArrayBuffer;
2727 contentType: string;
2828 commit: string;
+25−3
88 */
99 import puppeteer from "@cloudflare/puppeteer";
1010
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";
1212
1313 export type ShotEnv = {
1414 BROWSER: Fetcher;
6464 }
6565 }
6666
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. */
6868 export async function screenshotOf(env: ShotEnv, request: ShotRequest): Promise<Shot | null> {
6969 const kept = await stored(env, request.host);
70− if (kept?.commit === request.commit) return kept;
70+ if (kept && isCurrent(kept, request)) return kept;
7171 return (await take(env, request)) ?? kept;
7272 }
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+}
+7−1
3232 import mono500 from "./fonts/ibm-plex-mono-500.ttf";
3333 import { cardPng } from "./render.ts";
3434 import { cacheKey } from "./cache.ts";
35−import { type Shot, screenshotOf, take } from "./capture.ts";
35+import { type Shot, screenshotOf, sweep, take } from "./capture.ts";
3636 import { parseShot } from "./screenshot.ts";
3737 import { BRAND, type Card, docsCard, resolve } from "./resolve.ts";
3838
101101 const NO_STORE = "no-store";
102102
103103 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+
104110 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
105111 const url = new URL(request.url);
106112 if (request.method !== "GET" && request.method !== "HEAD") {
+18−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
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";
55
66 const commit = "abc1234def5678abc1234def5678abc1234def56";
77
3737 assert.equal(shouldAttempt(just, "fff0000", now), true);
3838 assert.equal(shouldAttempt({ commit, at: new Date(now - RETRY_AFTER_MS).toISOString() }, commit, now), true);
3939 });
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+});
+26−3
1313 /** A screenshot that could not be taken is tried again after this long. */
1414 export const RETRY_AFTER_MS = 5 * 60 * 1000;
1515
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 };
1723
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+
1827 /** Hostnames on g1t.page that are not apps. */
1928 const RESERVED = new Set(["domains", "www"]);
2029
2130 /** The request, if it names an app on g1t.page and a commit. */
2231 export function parseShot(input: unknown): ShotRequest | null {
2332 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>;
2534 if (typeof host !== "string" || typeof commit !== "string") return null;
2635 const name = host.toLowerCase();
2736 const suffix = `.${DEPLOYMENTS_DOMAIN}`;
2938 const label = name.slice(0, -suffix.length);
3039 if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || RESERVED.has(label)) return null;
3140 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;
3344 }
3445
3546 /** Where an app's screenshot is kept. */
4253 return `attempts/${host}`;
4354 }
4455
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+
4568 /** Whether to try again: not for the same commit within `RETRY_AFTER_MS`. */
4669 export function shouldAttempt(last: { commit?: string; at?: string } | null | undefined, commit: string, now: number): boolean {
4770 if (!last?.at || last.commit !== commit) return true;
+2−0
2525 "browser": { "binding": "BROWSER" },
2626 // Made by `npx wrangler r2 bucket create g1t-screenshots`.
2727 "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 * * *"] },
2830 "services": [
2931 { "binding": "IDENTITY", "service": "g1t-identity" },
3032 { "binding": "REPOS", "service": "g1t-repos" },