| 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 | |
| 8 | export const DEPLOYMENTS_DOMAIN = "g1t.page"; |
| 9 | /** The page is drawn at this size; the overview shows it scaled down. */ |
| 10 | export const VIEWPORT = { width: 1280, height: 800 }; |
| 11 | /** How long a page has to settle before it is taken anyway. */ |
| 12 | export const SETTLE_MS = 12_000; |
| 13 | /** A screenshot that could not be taken is tried again after this long. */ |
| 14 | export 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 | */ |
| 22 | export 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. */ |
| 25 | export const KEEP_DAYS = 30; |
| 26 | |
| 27 | /** Hostnames on g1t.page that are not apps. */ |
| 28 | const RESERVED = new Set(["domains", "www"]); |
| 29 | |
| 30 | /** The request, if it names an app on g1t.page and a commit. */ |
| 31 | export 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. */ |
| 47 | export function shotKey(host: string): string { |
| 48 | return `production/${host}.jpg`; |
| 49 | } |
| 50 | |
| 51 | /** Where the last attempt at an app's screenshot is noted. */ |
| 52 | export 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. */ |
| 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 | |
| 68 | /** Whether to try again: not for the same commit within `RETRY_AFTER_MS`. */ |
| 69 | export 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 | } |