| 1 | /** |
| 2 | * A screenshot of a project's production, for its overview. Members only, |
| 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. |
| 8 | */ |
| 9 | import { env } from "cloudflare:workers"; |
| 10 | |
| 11 | import type { Route } from "./+types/production-screenshot"; |
| 12 | import { deployments } from "../../lib/services.server"; |
| 13 | import { getViewer } from "../../lib/session.server"; |
| 14 | |
| 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 | } |
| 21 | const BRIEF = "private, max-age=60"; |
| 22 | |
| 23 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 24 | const viewer = getViewer(context); |
| 25 | const none = (status: number) => new Response(null, { status, headers: { "cache-control": BRIEF } }); |
| 26 | if (!viewer || !env.SCREENSHOTS) return none(404); |
| 27 | const list = await deployments.list({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null); |
| 28 | const production = list?.ok ? list.value.live.find((app) => app.kind === "production") : undefined; |
| 29 | if (!production) return none(404); |
| 30 | const shot = await env.SCREENSHOTS.image({ |
| 31 | host: new URL(production.url).host, |
| 32 | commit: production.commit, |
| 33 | since: production.deployedAt, |
| 34 | }).catch( |
| 35 | (error: unknown) => (console.warn("screenshot:", error), null), |
| 36 | ); |
| 37 | if (!shot) return none(404); |
| 38 | const asked = new URL(request.url).searchParams.get("v"); |
| 39 | const current = |
| 40 | shot.commit === production.commit && |
| 41 | Date.parse(shot.capturedAt) >= Date.parse(production.deployedAt) && |
| 42 | asked === shotVersion(production); |
| 43 | return new Response(shot.body, { |
| 44 | headers: { |
| 45 | "content-type": shot.contentType, |
| 46 | "cache-control": current ? LONG : BRIEF, |
| 47 | "x-content-type-options": "nosniff", |
| 48 | "last-modified": new Date(shot.capturedAt).toUTCString(), |
| 49 | }, |
| 50 | }); |
| 51 | } |