g1t/services/og/src/screenshot.ts

73 lines3,213 bytesCodeBlame
1/**
2 * Screenshots of a project's production: one per deploy, kept in R2 under
3 * the app's g1t.page hostname, with the commit it shows. Only apps on
4 * g1t.page are ever visited, and only by hostname: a custom domain serves
5 * the same app.
6 */
7
8export const DEPLOYMENTS_DOMAIN = "g1t.page";
9/** The page is drawn at this size; the overview shows it scaled down. */
10export const VIEWPORT = { width: 1280, height: 800 };
11/** How long a page has to settle before it is taken anyway. */
12export const SETTLE_MS = 12_000;
13/** A screenshot that could not be taken is tried again after this long. */
14export const RETRY_AFTER_MS = 5 * 60 * 1000;
15
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 */
22export type ShotRequest = { host: string; commit: string; since?: string };
23
24/** Kept screenshots and attempt notes older than this are deleted; a page asking for one again takes it anew. */
25export const KEEP_DAYS = 30;
26
27/** Hostnames on g1t.page that are not apps. */
28const RESERVED = new Set(["domains", "www"]);
29
30/** The request, if it names an app on g1t.page and a commit. */
31export function parseShot(input: unknown): ShotRequest | null {
32 if (!input || typeof input !== "object") return null;
33 const { host, commit, since } = input as Record<string, unknown>;
34 if (typeof host !== "string" || typeof commit !== "string") return null;
35 const name = host.toLowerCase();
36 const suffix = `.${DEPLOYMENTS_DOMAIN}`;
37 if (!name.endsWith(suffix)) return null;
38 const label = name.slice(0, -suffix.length);
39 if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || RESERVED.has(label)) return null;
40 if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null;
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;
44}
45
46/** Where an app's screenshot is kept. */
47export function shotKey(host: string): string {
48 return `production/${host}.jpg`;
49}
50
51/** Where the last attempt at an app's screenshot is noted. */
52export function attemptKey(host: string): string {
53 return `attempts/${host}`;
54}
55
56/** Whether a kept screenshot shows the request: its commit, taken after the deploy. */
57export 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. */
64export function expired(uploaded: Date, now: number, days = KEEP_DAYS): boolean {
65 return now - uploaded.getTime() > days * 24 * 60 * 60 * 1000;
66}
67
68/** Whether to try again: not for the same commit within `RETRY_AFTER_MS`. */
69export function shouldAttempt(last: { commit?: string; at?: string } | null | undefined, commit: string, now: number): boolean {
70 if (!last?.at || last.commit !== commit) return true;
71 const at = Date.parse(last.at);
72 return !Number.isFinite(at) || now - at >= RETRY_AFTER_MS;
73}