OG share cards for Lists, plus two Cloudflare gotchas I hadn't budgeted for
Per-list share images via a Cloudflare Worker rendering Satori to PNG. The render path took five days because variable fonts and CF Image Resizing both fought back.
Lists needed share images. The plan was a Cloudflare Worker rendering a Satori SVG to PNG. Two days, easy. It took five days because I tripped over two things I had never seen before.
The feature is straightforward on paper. When someone shares a list URL on X or Discord or iMessage or Reddit, the link unfurl needs to show something more interesting than the site's default OG image. So every list gets a per-list 1200×630 PNG: a 2×2 collage of the first 4 entries' cover images on the left half, the list title plus author byline plus tile count on the right half, an accent rule between them. Cached at the edge, regenerated when the list changes.
The architecture is a small Worker at workers/og-lists/, mirroring the layout of workers/feedproxy/ and workers/plausible-proxy/. Route is path-scoped to img.pornboxd.com/og/*, leaving the rest of the img zone (Cloudflare's /cdn-cgi/image/ transforms) untouched. R2 binds to the existing pornboxd-images bucket under prefix og/lists/v1/{id}/{updated_at_epoch}.png. The v1 segment is a render-version byte that bumps when the visual layout changes, so a worker deploy busts every card without a per-list R2 sweep. The updated_at_epoch segment busts whenever the list itself is edited.
The render path is satori@^0.10 (React-tree-to-SVG) then @resvg/resvg-wasm@^2.6 (SVG-to-PNG). Both run inside the Worker. Conceptually: build a JSX tree describing the layout, hand it to satori, get an SVG string back, hand that to resvg-wasm, get a PNG buffer back, write to R2, return.
That part shipped on the first day. Then I hit the two gotchas.
Gotcha 1: variable fonts break Satori.
I dropped a Lora[wght].ttf file into the Worker bundle and pointed satori at it. Lora is a popular serif. The [wght] in the filename means it's a variable-font axis file: one TTF that holds every weight from 400 to 700 along a continuous axis. Designers love these because one file replaces five.
Satori does not love them. The render call 500'd with Cannot read properties of undefined (reading '256') from inside parseFvarTable, which is the bit of satori's font parser that reads the font-variation table. Satori expects static-instance fonts (one file per concrete weight), not the continuous-axis variable form. The error message gives no hint of this. I lost an afternoon to it before searching the satori issue tracker found a 14-month-old comment that said "use static instances".
Fix: download the static-instance Bold and Medium TTFs directly from fonts.gstatic.com. Two TTFs, 264KB total, bundled into the worker via wrangler's Data rule. The render call accepts them and runs clean. The lesson is small but specific. When bundling fonts for satori in a Worker, never use the [wght] variable file. Static instances only.
Gotcha 2: Cloudflare Image Resizing skips Worker subrequests.
The first usable render produced the title and byline correctly, but the 4 collage tiles came out as black squares. Inside the Worker, satori was issuing fetch() calls to img.pornboxd.com/cdn-cgi/image/... URLs (our normal way of serving resized images), and the responses were coming back with the right HTTP status but bodies that the renderer couldn't decode. I tried hopping to the apex domain. Same result. I tried different transform parameters. Same result.
It turns out Cloudflare Image Resizing has a constraint that's documented in retrospect but not signposted at the call site. When a Worker issues a subrequest, the /cdn-cgi/image/ transform pipeline does not always execute on that subrequest in the same way it does on a public client request. The bytes you get back can be the original asset, or a partially-transformed asset, or something else, depending on the configuration of the zone and the path. Satori's image decoder couldn't make sense of what came back and silently substituted black.
The fix that worked is one shape: don't let satori issue any outbound fetch at all. In the worker code, pre-fetch the 4 collage tiles in parallel with Promise.all, base64-encode each one, and inline them as data:image/jpeg;base64,... URIs into the JSX tree before handing the tree to satori. Satori sees the data URIs, decodes them locally, and never makes a subrequest. The tiles render. The collage is correct.
Time cost of this fix: about thirty lines of base64 encoding in the worker's render function.
Time cost of learning what those thirty lines had to do: three days of staring at black squares.
Memory rule, pinned: when a Cloudflare Worker calls fetch() against a /cdn-cgi/image/ transform URL on the same zone, do not assume the response will be the transformed bytes. Pre-fetch the original asset, encode it inline, and skip the transform pipeline entirely from worker context.
The result is honestly small. Bundle is 3.7MB raw and 1.3MB gzipped, well under the Cloudflare Workers free-tier 3MB cap. Response carries cache-control: public, max-age=3600, s-maxage=3600, so the edge cache absorbs most repeat requests. Cost ballpark is around zero dollars per month for the first thousand lists, scaling sub-linearly as the edge cache hit rate climbs. R2 storage is the dominant cost vector, and at ~50KB per PNG and ~1k lists that's about $0.0008 per month.
Verified live. curl -sI https://img.pornboxd.com/og/lists/3207794c-135b-48e6-9a74-7bf7203589b1.png returns 200, image/png, 846KB. A second hit returns x-og-cache: HIT. Title text renders cleanly. Collage tiles render cleanly. Post that URL into Discord and the unfurl shows the card.
The feature itself is small. One Worker, two routes, around 400 lines of code. The interesting thing is what shaped it: every visible decision in the layout (font choice, image source, render path) bumped against an underlying constraint that I didn't know existed when I started, and two of those constraints turned out to require workarounds I would never have found by reading the surface-level docs. The cost of the share-image feature was thirty extra lines of base64 encoding plus two static font files. The cost of learning what those lines had to do was three days.