flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/og/src/index.ts

201 lines8,180 bytesCodeBlame
1/**
2 * Social cards: the PNG a link to g1t shows in a chat, a post or a search
3 * result, at `https://og.g1t.sh`.
4 *
5 * GET /image?path=/<any page of g1t.sh>[&v=<version>]
6 * GET /docs?title=&section=&description=
7 *
8 * A page's card is looked up as an anonymous visitor would see the page,
9 * so nothing private ever reaches one (see `resolve.ts`). Cards are kept in
10 * the edge cache by their full address; the site adds `v`, which changes
11 * when what a card shows does, so an edited title gets a new card.
12 *
13 * Also the `Screenshots` entrypoint, reached only through service
14 * bindings: a screenshot of each project's production, per deploy (see
15 * `capture.ts`).
16 */
17import { WorkerEntrypoint } from "cloudflare:workers";
18import { type ServiceBinding, identityClient, projectsClient, reposClient, workClient } from "@g1t/contracts";
19import type { Font } from "satori/standalone";
20import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
21import yogaWasm from "satori/yoga.wasm";
22
23import display500 from "./fonts/bricolage-grotesque-500.ttf";
24import display600 from "./fonts/bricolage-grotesque-600.ttf";
25import sans400 from "./fonts/hanken-grotesk-400.ttf";
26import sans500 from "./fonts/hanken-grotesk-500.ttf";
27import sans600 from "./fonts/hanken-grotesk-600.ttf";
28import sans700 from "./fonts/hanken-grotesk-700.ttf";
29import mono400 from "./fonts/ibm-plex-mono-400.ttf";
30import mono500 from "./fonts/ibm-plex-mono-500.ttf";
31import { cardPng } from "./render.ts";
32import { cacheKey } from "./cache.ts";
33import { type Shot, screenshotOf, take } from "./capture.ts";
34import { parseShot } from "./screenshot.ts";
35import { BRAND, type Card, docsCard, resolve } from "./resolve.ts";
36
37interface Env {
38 /** Uploaded avatars by hash, with `{ contentType }`; written by identity. */
39 AVATARS: KVNamespace;
40 IDENTITY: ServiceBinding;
41 REPOS: ServiceBinding;
42 WORK: ServiceBinding;
43 PROJECTS: ServiceBinding;
44 /** Browser Rendering, for production screenshots. */
45 BROWSER: Fetcher;
46 /** Production screenshots, by app hostname. */
47 SCREENSHOTS: R2Bucket;
48}
49
50/**
51 * Production screenshots. `capture` is called by deployments when
52 * production goes live; `image` by the site, for a project's overview, which
53 * decides who may see it.
54 */
55export class Screenshots extends WorkerEntrypoint<Env> {
56 /** Takes the screenshot of `{ host, commit }` in the background. */
57 async capture(input: unknown): Promise<boolean> {
58 const request = parseShot(input);
59 if (!request) return false;
60 this.ctx.waitUntil(take(this.env, request));
61 return true;
62 }
63
64 /**
65 * The screenshot of `{ host, commit }`, taken now if it has not been, or
66 * the last one kept for that app. Its `commit` says which it is.
67 */
68 async image(input: unknown): Promise<Shot | null> {
69 const request = parseShot(input);
70 if (!request) return null;
71 return screenshotOf(this.env, request);
72 }
73}
74
75/*
76 * g1t's typefaces, as on the site (packages/theme), but static: the
77 * renderer takes no variable fonts. Bricolage is cut at the optical size
78 * of the headlines it sets on a card.
79 */
80const FONTS: Font[] = [
81 { name: "Hanken Grotesk", data: sans400, weight: 400, style: "normal" },
82 { name: "Hanken Grotesk", data: sans500, weight: 500, style: "normal" },
83 { name: "Hanken Grotesk", data: sans600, weight: 600, style: "normal" },
84 { name: "Hanken Grotesk", data: sans700, weight: 700, style: "normal" },
85 { name: "Bricolage Grotesque", data: display500, weight: 500, style: "normal" },
86 { name: "Bricolage Grotesque", data: display600, weight: 600, style: "normal" },
87 { name: "IBM Plex Mono", data: mono400, weight: 400, style: "normal" },
88 { name: "IBM Plex Mono", data: mono500, weight: 500, style: "normal" },
89];
90
91const ASSETS = { yoga: yogaWasm, resvg: resvgWasm, fonts: FONTS };
92
93/** Kept an hour by browsers and a day at the edge. */
94const CACHE_CONTROL = "public, max-age=3600, s-maxage=86400";
95/** When a service could not be reached: soon tried again. */
96const BRIEF_CACHE_CONTROL = "public, max-age=60";
97
98/** The static card, for when rendering itself fails. */
99const FALLBACK = "https://g1t.sh/brand/g1t-og.png";
100
101export default {
102 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
103 const url = new URL(request.url);
104 if (request.method !== "GET" && request.method !== "HEAD") {
105 return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
106 }
107 if (url.pathname === "/health") return new Response("ok");
108 if (url.pathname !== "/" && url.pathname !== "/image" && url.pathname !== "/docs") {
109 return new Response("Not found", { status: 404 });
110 }
111
112 const key = cacheKey(url);
113 const cache = (caches as unknown as { default: Cache }).default;
114 const hit = await cache.match(key);
115 if (hit) return hit;
116
117 const card = await cardFor(url, env);
118 const failed = card.kind === "brand" && card.failed === true;
119 // Every page without a card of its own shares the one brand card, so
120 // made-up paths cannot make the service draw it again and again.
121 const brandKey = cacheKey(new URL("/", url));
122 if (card.kind === "brand" && key !== brandKey) {
123 const brand = await cache.match(brandKey);
124 if (brand) return withCacheControl(brand, failed);
125 }
126
127 let png: Uint8Array;
128 try {
129 png = await cardPng(card, ASSETS);
130 } catch (error) {
131 console.error("og: rendering failed", url.pathname, error);
132 // An icon it could not draw is left out rather than losing the card.
133 if ((card.kind !== "workspace" && card.kind !== "person") || !card.icon) return Response.redirect(FALLBACK, 302);
134 try {
135 png = await cardPng({ ...card, icon: undefined }, ASSETS);
136 } catch {
137 return Response.redirect(FALLBACK, 302);
138 }
139 }
140 const response = new Response(png, {
141 headers: {
142 "content-type": "image/png",
143 "cache-control": CACHE_CONTROL,
144 "access-control-allow-origin": "*",
145 "x-content-type-options": "nosniff",
146 },
147 });
148 if (card.kind === "brand") {
149 ctx.waitUntil(cache.put(brandKey, response.clone()));
150 } else {
151 ctx.waitUntil(cache.put(key, response.clone()));
152 }
153 return withCacheControl(response, failed);
154 },
155} satisfies ExportedHandler<Env>;
156
157/** A card shown because a service failed is kept only briefly, by anyone. */
158function withCacheControl(response: Response, failed: boolean): Response {
159 if (!failed) return response;
160 const brief = new Response(response.body, response);
161 brief.headers.set("cache-control", BRIEF_CACHE_CONTROL);
162 return brief;
163}
164
165/** What satori can draw: PNG and JPEG. A WebP or GIF icon is left off the card. */
166const DRAWABLE = new Set(["image/png", "image/jpeg"]);
167
168/** A workspace's or person's uploaded icon as a data URI, if it has one satori can draw. */
169async function iconFor(env: Env, avatar: string | null | undefined): Promise<string | undefined> {
170 if (!avatar || !/^[0-9a-f]{64}$/.test(avatar)) return undefined;
171 try {
172 const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(avatar, {
173 type: "arrayBuffer",
174 });
175 const type = metadata?.contentType;
176 if (!value || !type || !DRAWABLE.has(type)) return undefined;
177 const bytes = new Uint8Array(value);
178 let binary = "";
179 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
180 return `data:${type};base64,${btoa(binary)}`;
181 } catch {
182 return undefined;
183 }
184}
185
186async function cardFor(url: URL, env: Env): Promise<Card> {
187 const card = await lookUpCard(url, env);
188 if (card.kind === "workspace" || card.kind === "person") return { ...card, icon: await iconFor(env, card.avatar) };
189 return card;
190}
191
192async function lookUpCard(url: URL, env: Env): Promise<Card> {
193 if (url.pathname === "/docs") return docsCard(url.searchParams);
194 if (url.pathname === "/") return BRAND;
195 return resolve(url.searchParams.get("path") ?? "/", {
196 identity: identityClient(env.IDENTITY),
197 repos: reposClient(env.REPOS),
198 work: workClient(env.WORK),
199 projects: projectsClient(env.PROJECTS),
200 });
201}