Skip to content

Commit 334fe94

Browse files
rafavallsclaude
andauthored
Per-diagnostic OG card rendering on Cloudflare Workers (#53)
Previously /og-default.png was bundled as a single static PNG because Satori 0.26.x cannot run on Cloudflare Workers (yoga-layout instantiates WASM from bytes, which Workers forbids). Crawlers thus saw the same generic image for every share URL. This change keeps the static default as a fallback but adds a real per-diagnostic /og/{token}.png endpoint that runs on Workers. The trick is dropping Satori entirely and hand-writing the SVG ourselves — then using @resvg/resvg-wasm alone for SVG → PNG, which is fine on Workers when handed a pre-compiled WebAssembly.Module via wrangler's CompiledWasm rule. Design (in api/lib/og-card.ts): - Solid dark background, no gradients. - Left column: site favicon (fetched from Google's favicon service), diagnostic title (auto-wraps to 2 lines if needed, ellipsised after), domain underneath. - Right column: huge health-score number color-coded (green ≥80, yellow ≥50, red <50, gray for N/A) + "HEALTH SCORE" label. - Title cleaned of duplicate "(domain)" parentheticals since domain is shown separately below. Behavior (api/app.ts): - /og/{token}.png renders on first hit, caches in R2 fire-and-forget. - R2 cache hit returns the PNG directly — same path the previous endpoint used. - Any miss (bad token, share gone, render error) 302s to /og-default.png so crawlers always get a valid image. - HEAD requests supported (Response with null body, same headers). - Share-page <head> meta now points og:image at /og/{token}.png so Discord/WhatsApp/Facebook get the per-diagnostic card. Verified locally against wrangler dev: - /og/{real-token}.png returns a fresh per-token PNG (26720 bytes for the Centauro share, vs 81863 for the static default — different content confirms dynamic rendering). - /og/{bad-token}.png returns 302 → /og-default.png. - Share page meta tag now serializes as /og/{token}.png. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79f297b commit 334fe94

4 files changed

Lines changed: 359 additions & 6 deletions

File tree

api/app.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import { resolveOrg } from "../src/auth/resolve-org.ts";
99
import type { KVStore } from "../src/cache/interface.ts";
1010
import ogDefaultPngAsset from "./assets/og-default.png";
1111
import { renderLoginPage } from "./lib/login-page.ts";
12+
import { generateOgCard } from "./lib/og-card.ts";
1213
import {
1314
getScreenshot,
1415
loadDiagnostic,
16+
loadOgImage,
1517
loadPublicShare,
18+
saveOgImage,
1619
} from "./lib/storage.ts";
1720
import { prompts } from "./prompts/index.ts";
1821
import { createDiagnoseAppResource } from "./resources/diagnose.ts";
@@ -384,7 +387,7 @@ export function createApp(config: AppConfig = {}) {
384387
);
385388
const canonicalUrl = `${url.origin}/d/${token}`;
386389

387-
const ogImageUrl = `${url.origin}/og-default.png`;
390+
const ogImageUrl = `${url.origin}/og/${token}.png`;
388391
const pageImageAlt = escapeAttr(
389392
`Site Diagnostics report preview for ${diagDomain}`,
390393
);
@@ -468,15 +471,74 @@ export function createApp(config: AppConfig = {}) {
468471
);
469472
}
470473

471-
// Legacy /og/{token}.png — point at the static card so any links
472-
// that social-card scrapers cached before this change keep working.
474+
// Per-diagnostic OG card: /og/{token}.png — rendered on first hit,
475+
// cached in R2 for subsequent hits. On any miss (bad token, share
476+
// gone, render failure) we 302 to /og-default.png so crawlers
477+
// always get a valid image.
473478
if (
474479
url.pathname.startsWith("/og/") &&
475480
(req.method === "GET" || req.method === "HEAD")
476481
) {
477-
return new Response(null, {
478-
status: 302,
479-
headers: { location: "/og-default.png" },
482+
const m = url.pathname.match(/^\/og\/([A-Za-z0-9_-]{16,64})\.png$/);
483+
if (!m) {
484+
return new Response(null, {
485+
status: 302,
486+
headers: { location: "/og-default.png" },
487+
});
488+
}
489+
const ogToken = m[1];
490+
491+
const cached = await loadOgImage(ogToken).catch(() => null);
492+
if (cached) {
493+
return new Response(cached, {
494+
headers: {
495+
"content-type": "image/png",
496+
"cache-control": "public, max-age=31536000, immutable",
497+
},
498+
});
499+
}
500+
501+
const ogShare = await loadPublicShare(ogToken).catch(() => null);
502+
if (!ogShare) {
503+
return new Response(null, {
504+
status: 302,
505+
headers: { location: "/og-default.png" },
506+
});
507+
}
508+
const ogDiagnostic = await loadDiagnostic(
509+
ogShare.diagnosticId,
510+
ogShare.orgId,
511+
).catch(() => null);
512+
if (!ogDiagnostic) {
513+
return new Response(null, {
514+
status: 302,
515+
headers: { location: "/og-default.png" },
516+
});
517+
}
518+
519+
let png: Uint8Array;
520+
try {
521+
png = await generateOgCard(ogDiagnostic);
522+
} catch (err) {
523+
console.error("[og-card] render failed:", err);
524+
return new Response(null, {
525+
status: 302,
526+
headers: { location: "/og-default.png" },
527+
});
528+
}
529+
530+
// Fire-and-forget R2 write. If it fails the next request just
531+
// regenerates — no correctness impact.
532+
saveOgImage(ogToken, png).catch((err) => {
533+
console.error("[og-card] cache write failed:", err);
534+
});
535+
536+
return new Response(req.method === "HEAD" ? null : (png as BodyInit), {
537+
headers: {
538+
"content-type": "image/png",
539+
"content-length": String(png.byteLength),
540+
"cache-control": "public, max-age=31536000, immutable",
541+
},
480542
});
481543
}
482544

api/lib/og-card.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* Per-diagnostic OG card. Runs on both Bun and Cloudflare Workers.
3+
*
4+
* Implementation detail: we hand-write the SVG instead of using Satori
5+
* because Satori bundles yoga-layout (Emscripten WASM) which calls
6+
* `WebAssembly.instantiate(bytes)` — Cloudflare Workers forbids that
7+
* ("Wasm code generation disallowed by embedder"). resvg-wasm alone is
8+
* fine when given a pre-compiled WebAssembly.Module (via wrangler's
9+
* `[[rules]] CompiledWasm` rule for *.wasm imports), so SVG → PNG works.
10+
*/
11+
12+
import { readFile } from "node:fs/promises";
13+
import { initWasm, Resvg } from "@resvg/resvg-wasm";
14+
import resvgWasmAsset from "@resvg/resvg-wasm/index_bg.wasm";
15+
import fontBoldAsset from "../assets/Lato-Bold.ttf";
16+
import fontRegularAsset from "../assets/Lato-Regular.ttf";
17+
import type { Diagnostic } from "../types/diagnostic.ts";
18+
19+
type DataAsset = string | ArrayBuffer | Uint8Array | WebAssembly.Module;
20+
21+
const WIDTH = 1200;
22+
const HEIGHT = 630;
23+
24+
let initPromise: Promise<void> | null = null;
25+
let fontRegular: Uint8Array | null = null;
26+
let fontBold: Uint8Array | null = null;
27+
28+
async function assetToUint8Array(
29+
asset: DataAsset,
30+
label: string,
31+
): Promise<Uint8Array> {
32+
if (asset instanceof Uint8Array) return asset;
33+
if (asset instanceof ArrayBuffer) return new Uint8Array(asset);
34+
if (typeof asset === "string") {
35+
const buf = await readFile(asset);
36+
return new Uint8Array(buf);
37+
}
38+
throw new Error(`[${label}] unsupported asset shape`);
39+
}
40+
41+
function init(): Promise<void> {
42+
if (initPromise) return initPromise;
43+
initPromise = (async () => {
44+
try {
45+
const [regular, bold] = await Promise.all([
46+
assetToUint8Array(fontRegularAsset as DataAsset, "font-regular"),
47+
assetToUint8Array(fontBoldAsset as DataAsset, "font-bold"),
48+
]);
49+
const wasmInput =
50+
resvgWasmAsset instanceof WebAssembly.Module
51+
? resvgWasmAsset
52+
: await assetToUint8Array(resvgWasmAsset as DataAsset, "resvg-wasm");
53+
await initWasm(wasmInput);
54+
fontRegular = regular;
55+
fontBold = bold;
56+
} catch (err) {
57+
initPromise = null;
58+
throw err;
59+
}
60+
})();
61+
return initPromise;
62+
}
63+
64+
function getDomain(url: string): string {
65+
try {
66+
return new URL(url).hostname.replace(/^www\./, "");
67+
} catch {
68+
return url;
69+
}
70+
}
71+
72+
function scoreColor(score: number | null): string {
73+
if (score == null) return "#71717a";
74+
if (score >= 80) return "#22c55e";
75+
if (score >= 50) return "#eab308";
76+
return "#ef4444";
77+
}
78+
79+
function xmlEscape(s: string): string {
80+
return s
81+
.replace(/&/g, "&amp;")
82+
.replace(/</g, "&lt;")
83+
.replace(/>/g, "&gt;")
84+
.replace(/"/g, "&quot;")
85+
.replace(/'/g, "&apos;");
86+
}
87+
88+
function uint8ToBase64(bytes: Uint8Array): string {
89+
let s = "";
90+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
91+
return btoa(s);
92+
}
93+
94+
async function fetchFaviconDataUri(domain: string): Promise<string | null> {
95+
try {
96+
const res = await fetch(
97+
`https://www.google.com/s2/favicons?domain=${domain}&sz=128`,
98+
{ signal: AbortSignal.timeout(3000) },
99+
);
100+
if (!res.ok) return null;
101+
const buf = await res.arrayBuffer();
102+
const ct = res.headers.get("content-type") ?? "image/png";
103+
return `data:${ct};base64,${uint8ToBase64(new Uint8Array(buf))}`;
104+
} catch {
105+
return null;
106+
}
107+
}
108+
109+
const TITLE_FONT_SIZE = 56;
110+
const TITLE_LINE_HEIGHT = 68;
111+
const TITLE_MAX_CHARS_PER_LINE = 22;
112+
const TITLE_MAX_LINES = 2;
113+
114+
function truncate(text: string, maxLength: number): string {
115+
const trimmed = text.replace(/\s+/g, " ").trim();
116+
if (trimmed.length <= maxLength) return trimmed;
117+
return `${trimmed.slice(0, maxLength - 1).trim()}…`;
118+
}
119+
120+
// Greedy word-wrap. Returns up to TITLE_MAX_LINES lines, last one truncated
121+
// with ellipsis if the text exceeds the budget. SVG has no native wrapping —
122+
// we emit each line as its own <tspan>.
123+
function wrapTitle(text: string): string[] {
124+
const words = text.replace(/\s+/g, " ").trim().split(" ");
125+
const lines: string[] = [];
126+
let current = "";
127+
for (const word of words) {
128+
const candidate = current ? `${current} ${word}` : word;
129+
if (candidate.length > TITLE_MAX_CHARS_PER_LINE && current) {
130+
lines.push(current);
131+
current = word;
132+
} else {
133+
current = candidate;
134+
}
135+
}
136+
if (current) lines.push(current);
137+
138+
if (lines.length > TITLE_MAX_LINES) {
139+
const out = lines.slice(0, TITLE_MAX_LINES);
140+
out[TITLE_MAX_LINES - 1] = truncate(
141+
out[TITLE_MAX_LINES - 1],
142+
TITLE_MAX_CHARS_PER_LINE,
143+
);
144+
return out;
145+
}
146+
return lines;
147+
}
148+
149+
// Strip "(domain)" or "(www.domain)" from the title — we show the domain
150+
// separately on the URL line below, so it would otherwise be duplicated.
151+
function cleanTitle(rawTitle: string, domain: string): string {
152+
const escaped = domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
153+
return rawTitle
154+
.replace(new RegExp(`\\s*\\((?:www\\.)?${escaped}\\)\\s*`, "gi"), " ")
155+
.trim();
156+
}
157+
158+
function buildSvg(
159+
title: string,
160+
domain: string,
161+
score: number | null,
162+
faviconDataUri: string | null,
163+
): string {
164+
const color = scoreColor(score);
165+
const scoreDisplay = score != null ? String(score) : "N/A";
166+
const titleLines = wrapTitle(title);
167+
const safeDomain = xmlEscape(truncate(domain, 40));
168+
const favicon = faviconDataUri
169+
? `<image x="80" y="180" width="96" height="96" preserveAspectRatio="xMidYMid meet" href="${faviconDataUri}"/>`
170+
: "";
171+
172+
// Layout: favicon top, title starts 32px below it, URL 40px below
173+
// the final title line. Title is multi-line via <tspan>.
174+
const titleFirstBaseline = 360;
175+
const titleTspans = titleLines
176+
.map(
177+
(line, i) =>
178+
`<tspan x="80" y="${
179+
titleFirstBaseline + i * TITLE_LINE_HEIGHT
180+
}">${xmlEscape(line)}</tspan>`,
181+
)
182+
.join("");
183+
const urlY =
184+
titleFirstBaseline + (titleLines.length - 1) * TITLE_LINE_HEIGHT + 44;
185+
186+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}">
187+
<rect width="${WIDTH}" height="${HEIGHT}" fill="#0a0a0a"/>
188+
${favicon}
189+
<text font-family="Lato" font-size="${TITLE_FONT_SIZE}" font-weight="700" fill="#fafafa">${titleTspans}</text>
190+
<text x="80" y="${urlY}" font-family="Lato" font-size="28" font-weight="400" fill="#a1a1aa">${safeDomain}</text>
191+
<text x="1120" y="380" font-family="Lato" font-size="200" font-weight="700" fill="${color}" text-anchor="end">${scoreDisplay}</text>
192+
<text x="1120" y="425" font-family="Lato" font-size="22" font-weight="400" fill="#a1a1aa" letter-spacing="4" text-anchor="end">HEALTH SCORE</text>
193+
</svg>`;
194+
}
195+
196+
export async function generateOgCard(
197+
diagnostic: Diagnostic,
198+
): Promise<Uint8Array> {
199+
await init();
200+
const domain = getDomain(diagnostic.url);
201+
const title = cleanTitle(diagnostic.title || domain, domain);
202+
const favicon = await fetchFaviconDataUri(domain);
203+
const svg = buildSvg(title, domain, diagnostic.healthScore ?? null, favicon);
204+
const resvg = new Resvg(svg, {
205+
font: {
206+
// biome-ignore lint/style/noNonNullAssertion: guaranteed by init()
207+
fontBuffers: [fontRegular!, fontBold!],
208+
loadSystemFonts: false,
209+
},
210+
fitTo: { mode: "width", value: WIDTH },
211+
background: "#0a0a0a",
212+
});
213+
return resvg.render().asPng();
214+
}

api/lib/storage.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,50 @@ export async function loadPublicShare(
196196
return share;
197197
}
198198

199+
// ---------------------------------------------------------------------------
200+
// OG image cache (per-token, R2-backed)
201+
// ---------------------------------------------------------------------------
202+
// Per-diagnostic OG cards are rendered on first request and cached here so
203+
// subsequent crawler hits are O(1) and don't re-fetch the favicon or run
204+
// resvg again.
205+
206+
export async function saveOgImage(
207+
token: string,
208+
png: Uint8Array,
209+
): Promise<void> {
210+
await getClient().send(
211+
new PutObjectCommand({
212+
Bucket: getBucket(),
213+
Key: `og-images/${token}.png`,
214+
Body: png,
215+
ContentType: "image/png",
216+
CacheControl: "public, max-age=31536000, immutable",
217+
}),
218+
);
219+
}
220+
221+
export async function loadOgImage(
222+
token: string,
223+
): Promise<ReadableStream | null> {
224+
try {
225+
const res = await getClient().send(
226+
new GetObjectCommand({
227+
Bucket: getBucket(),
228+
Key: `og-images/${token}.png`,
229+
}),
230+
);
231+
return (res.Body?.transformToWebStream() as ReadableStream) ?? null;
232+
} catch (err: unknown) {
233+
if (
234+
err instanceof Error &&
235+
(err.name === "NoSuchKey" || err.name === "NotFound")
236+
) {
237+
return null;
238+
}
239+
throw err;
240+
}
241+
}
242+
199243
export async function deletePublicShare(token: string): Promise<void> {
200244
await getClient().send(
201245
new DeleteObjectCommand({

0 commit comments

Comments
 (0)