Blob: docs/sites-cover-og-image-plan.md
Sites cover Open Graph image plan
Status: decision complete
Objective
Add public Open Graph images for bland Sites page covers.
Public HTML should emit og:image and og:image:type when the current cover can
produce a safe public image. Generated gradient images are fixed at 1200x630
PNG and also emit og:image:width=1200 and og:image:height=630.
Do not render WASM images in the public request path.
Core Decision
Add a Sites route before the generic asset route:
GET /_assets/:pageId/cover
GET /_assets/:pageId/:uploadIdThe cover route is D1-first:
- Resolve the host, site, and page through the existing Sites publication path.
- Return 404 for unpublished, archived, missing, wrong-workspace, non-doc, or otherwise invalid pages.
- Only after D1 resolution, read uploads R2, read
SITESR2, or enqueue repair.
Generated gradient covers are written asynchronously by TASKS_QUEUE to:
<workspaceId>/<pageId>/cover.pngThe fixed key is valid only when object metadata matches the current cover:
cover_hash = <hash>
cover_url = <page.cover_url>
width = 1200
height = 630HTML uses a versioned URL:
https://<site-host>/_assets/<pageId>/cover?v=<cover_hash>If v is present and does not match the current cover hash, return 404.
Behavior Matrix
Current cover_url |
HTML OG meta | Cover route | Queue repair |
|---|---|---|---|
null |
none | 404 | no |
/uploads/:id, OG-safe type, current page upload |
og:image + type |
stream uploads R2 object | no |
/uploads/:id, HEIC/PDF/other unsafe type |
none | 404 | no |
| shared gradient preset | og:image, image/png, dimensions |
serve matching SITES artifact, or 503 while repairing |
yes, only if artifact missing/stale |
linear-gradient(...) not in shared presets |
none | 404 | no |
| malformed/other string | none | 404 | no |
| old version URL after cover changed | stale HTML may still reference it | 404 | no |
For a repairable generated cover miss, return:
503
Cache-Control: no-store
Retry-After: 5For successful cover responses:
- matching
v:Cache-Control: public, max-age=31536000, immutable - no
v:Cache-Control: public, max-age=300, must-revalidate
Important Constraints
- Public Sites HTML already accepts bounded staleness. Stale HTML may reference an old cover hash; do not store historical cover PNGs to support it.
- D1 remains the public reachability authority. Never serve a
SITESor uploads object before resolving the published page. - Request-path repair must be impossible for doomed covers. Only shared gradient
presets that pass the parser may enqueue
site-cover. - The queue handler also reads current D1 state and no-ops unsupported current covers without retry.
- Queue delivery is at least once. Duplicate
site-covermessages rewrite the same fixed R2 key and are harmless. - Workspace Sites cleanup already deletes by
<workspaceId>/prefix, so it will delete generated cover PNGs.
Implementation Plan
1. Shared Cover Presets
Move GRADIENT_PRESETS from src/client/components/cover-picker.tsx to:
src/shared/page-cover.tsThis matches the existing top-level shared domain primitive pattern
(page-id.ts, site-slug.ts). Do not put this in src/shared/sites; covers
are page metadata used by both the app UI and public Sites.
Keep the module Worker-safe:
export const GRADIENT_PRESETS = [...] as const;
export type GradientPreset = (typeof GRADIENT_PRESETS)[number];
export function isGradientPreset(value: string): value is GradientPreset;Use it in CoverPicker, PATCH validation, gradient parser tests, and queue
tests.
2. Dependency
Use @cf-wasm/png, not @cf-wasm/og.
Reason: this task only needs PNG encoding for a rasterized gradient rectangle.
@cf-wasm/og is for HTML/CSS OG cards, pulls satori/resvg/html-to-react, and
warns about breaking changes outside strict SemVer.
Install with lifecycle scripts disabled:
npm install --ignore-scripts @cf-wasm/pngIn the queue renderer, dynamically import:
const { encode } = await import("@cf-wasm/png/workerd");Only add @cf-wasm/plugins if Vite/Vitest/Wrangler cannot resolve the .wasm
module import from @cf-wasm/png/workerd.
3. Cover Helper
Add src/worker/sites/cover.ts for the cover-specific logic:
buildSiteCoverR2ObjectKey(workspaceId, pageId)createSiteCoverHash(coverUrl)parseUploadCoverUrl(coverUrl)parseSupportedLinearGradient(coverUrl)isOgSafeUploadContentType(contentType)resolveSiteOgCoverMeta(db, page, canonicalUrl)serveSiteCover(args)enqueueSiteCover(env, pageId)
Hash input should include at least:
{
version: "site-cover:v1",
width: 1200,
height: 630,
coverUrl: page.cover_url
}Use the existing SHA-256/base64url style from createRenderDependencyHash if it
can be shared cleanly.
OG-safe uploaded cover types:
image/png
image/jpeg
image/webp
image/gif4. Gradient Renderer
Support only the preset subset:
linear-gradient(<angle>deg, <hex-color> <percent>%, ...)Requirements:
- angle in degrees
- two or more stops
- colors are
#rgbor#rrggbb - positions are explicit percentages
- positions clamp to 0 through 100 and sort before rendering
Rasterize 1200 * 630 * 4 RGBA bytes, interpolate stops in byte-space sRGB,
and encode with @cf-wasm/png/workerd.
5. Queue Work
Extend TasksQueueMessage:
export type TasksQueueMessage =
| { type: "index-page"; pageId: string }
| { type: "page-projection"; pageId: string }
| { type: "workspace-sites-cleanup"; workspaceId: string }
| { type: "site-cover"; pageId: string };Add src/worker/queues/site-cover.ts:
- read D1 with
createSessionDb(env.DB, "first-primary") - retry only when the page is not yet visible
- skip non-doc, null, upload, non-preset gradient, parser-rejected gradient, and unsupported covers without retry
- render supported preset gradients and write
SITEScover metadata
Update src/worker/index.ts queue dispatch and log context.
6. Metadata Write Path
Update PATCH /workspaces/:wid/pages/:id in src/worker/routes/pages.ts:
- accept
null - accept
/uploads/:id - accept only
linear-gradient(...)values present in sharedGRADIENT_PRESETS - reject all other cover values
- after a successful
cover_urlupdate, best-effort enqueuesite-cover
This prevents new arbitrary gradients from creating pathological request-path repair.
7. Public Route
Update src/worker/sites/router.ts:
- add
COVER_ASSET_ROUTEbeforeASSET_ROUTE - reject apex hosts
- call existing
resolveCurrentSitePage(c, pageId)before any object read - delegate to
serveSiteCover
Keep serveSiteAsset for generic /_assets/:pageId/:uploadId behavior.
8. HTML Metadata
Resolve OG cover metadata while rendering Sites HTML. Do not check that a generated gradient PNG already exists before emitting the HTML meta URL.
For uploads, perform the small D1 uploads check only when the cover is
/uploads/:id. For gradients, use the shared preset predicate and cover hash.
Pass resolved fields through the Sites React render context into SiteHead.
Files To Touch
src/shared/page-cover.tssrc/client/components/cover-picker.tsxsrc/worker/sites/cover.tssrc/worker/queues/messages.tssrc/worker/queues/site-cover.tssrc/worker/index.tssrc/worker/routes/pages.tssrc/worker/sites/router.tssrc/worker/sites/render-page-stream.tsxsrc/sites/react-render-context.tssrc/sites/types.tssrc/sites/document.tsxpackage.jsonand lockfile
Tests
Add focused coverage:
- cover helper unit tests
- hash changes on cover/version inputs
- upload parser accepts only
/uploads/:id - OG-safe type helper rejects HEIC/PDF
- parser accepts every shared preset
- parser rejects non-preset or unsupported CSS
- queue runtime tests
- missing page retries
- supported preset writes
<workspaceId>/<pageId>/cover.png - object has
image/pngand expected custom metadata - PNG bytes start with the PNG signature
- null, upload, non-preset gradient, unsupported gradient, and canvas no-op
- route runtime tests
- upload cover serves only current page/workspace upload
- unsafe upload type returns 404
- unpublished page returns 404 before R2
- matching generated artifact serves
- missing/stale generated artifact returns 503 and enqueues repair
- old
vreturns 404 - generic asset route still works
- page PATCH tests
- accepts shared gradient preset
- rejects arbitrary non-preset
linear-gradient(...) - still accepts null and
/uploads/:id
- Sites HTML tests
- emits absolute versioned
og:image - emits
og:image:type - emits dimensions for gradients
- omits
og:imagefor unsupported covers
- emits absolute versioned
Run:
npm run typecheck
npm run lint
npm run build
npm run test:worker-unit -- tests/worker/sites/cover.test.ts
npm run test:worker-runtime -- tests/worker/queues/site-cover.workers.test.ts tests/worker/sites/asset-gate.workers.test.ts tests/worker/sites/dispatch.workers.test.tsnpm run typecheck and npm run build run emoji:generate through existing
pre* scripts. npm run lint does not. Focused test:* scripts do not run the
broad pretest hook; run npm run emoji:generate first if generated emoji data
is missing.
Code And Dependency Context Checked
src/worker/sites/router.tssrc/worker/sites/assets.tssrc/worker/sites/cache.tssrc/worker/sites/render-page-stream.tsxsrc/sites/document.tsxsrc/worker/routes/pages.tssrc/worker/routes/uploads.tssrc/worker/queues/messages.tssrc/worker/queues/page-projection.tssrc/worker/index.tssrc/client/components/cover-picker.tsxwrangler.jsoncgithub.com/fineshopdesign/cf-wasm@cf-wasm/png@cf-wasm/og@cf-wasm/plugins
Addendum: Uploaded Cover OG Derivatives
Status: research complete; implementation requested as a plan update.
This addendum supersedes the upload-cover behavior in the original matrix where uploaded covers are streamed directly from uploads R2. Uploaded page covers should instead produce the same fixed-size public OG artifact shape as generated gradient covers when Cloudflare Images binding is available.
Decision
Use the Cloudflare Images binding for uploaded cover derivatives, not a custom WASM decoder/resizer stack.
Reasoning:
- Cloudflare Images binding can transform image bytes from a
ReadableStream, including a private R2 object body, without exposing the original upload URL. - It supports resize/crop/encode operations that match this task directly.
- It avoids adding JPEG/WebP/GIF decoding dependencies to the Worker bundle and avoids Worker memory risk from manual full-image decode paths.
- It keeps D1 as the public reachability authority and preserves the existing
derived-artifact model in the
SITESbucket.
Relevant Cloudflare docs:
https://developers.cloudflare.com/images/optimization/transformations/bindings/https://developers.cloudflare.com/images/optimization/features/
Semantics
The generated upload cover artifact should be:
1200x630
image/png
fit = cover
gravity = center
anim = falseThis matches the current cover positioning rule, not a specific viewport crop:
- the app cover uses
object-covercentered image rendering - public Sites uses
bg-cover bg-center
Because the page cover width is responsive and the Sites page cover is h-48,
there is no single exact browser viewport crop to reproduce. The invariant is
the shared crop rule: fill the target rectangle and center-crop the source.
If exact author-controlled positioning is needed later, add persisted focal
point metadata and apply it in both CSS cover rendering and Cloudflare Images
gravity: { x, y }. Do not add that in this change unless explicitly requested.
Wrangler Binding
Add the Images binding to wrangler.jsonc:
"images": {
"binding": "IMAGES"
}Regenerate worker-configuration.d.ts through the repo's existing Wrangler type
generation flow after editing wrangler.jsonc.
Hash And Key
Keep using the fixed cover artifact key:
<workspaceId>/<pageId>/cover.pngExpand the hash input so uploaded cover artifacts change when the upload identity or output transform contract changes:
{
version: "site-cover:v2",
width: 1200,
height: 630,
fit: "cover",
gravity: "center",
format: "image/png",
anim: false,
coverUrl: page.cover_url
}The upload id is already embedded in coverUrl as /uploads/:id. Do not hash
the original object bytes in the request path. If an uploaded object is replaced
in the future, that should be represented by immutable upload ids or a separate
version field, not by reading blob bytes during public HTML rendering.
Store the same metadata as gradients, with optional source metadata for diagnostics:
cover_hash = <hash>
cover_url = <page.cover_url>
width = 1200
height = 630
source = upload | gradientQueue Behavior
Update site-cover queue handling:
- Read current page state from D1 with a primary read, as planned.
- Skip null, archived, non-doc, unsupported, or missing covers fail-closed.
- For gradient presets, keep the existing gradient renderer path.
- For
/uploads/:id, resolve the upload row through D1:uploads.id = uploadIduploads.workspace_id = page.workspace_iduploads.page_id = page.iduploads.content_typeis OG-safe input
- Read the original object from uploads R2 only after the D1 checks pass.
- Transform the private R2 stream with Images binding.
- Write the transformed PNG to
SITESat the fixed cover key with current cover metadata.
Sketch:
const result = await env.IMAGES.input(object.body)
.transform({
width: SITE_COVER_WIDTH,
height: SITE_COVER_HEIGHT,
fit: "cover",
gravity: "center",
})
.output({
format: "image/png",
anim: false,
});
await env.SITES.put(key, result.image(), {
httpMetadata: { contentType: "image/png" },
customMetadata,
});If the Images binding throws for an otherwise valid uploaded image, return a retry result for transient transformation failures. For deterministic unsupported input, log and no-op. Prefer classifying by known Images error codes only if the current generated types or docs expose stable codes; otherwise keep the failure classification conservative and bounded by queue retry policy.
Public Route Behavior
serveSiteCover should no longer stream upload originals for OG covers. It
should serve only the matching SITES cover artifact for both gradients and
uploaded covers.
For repairable misses:
- gradient preset current cover: enqueue repair, return 503
- upload current cover with safe upload row: enqueue repair, return 503
For doomed covers:
- missing upload row, wrong workspace, wrong page, unsafe type, missing R2 object: return 404 and do not enqueue repeated doomed repair from the public route
Keep the D1-first invariant: no R2 or SITES object read before resolving the
published page and current cover authority through D1.
HTML Metadata
For uploaded covers that pass the D1 upload check, emit:
og:image = https://<site-host>/_assets/<pageId>/cover?v=<cover_hash>
og:image:type = image/png
og:image:width = 1200
og:image:height = 630This makes uploaded covers behave like gradient covers from the perspective of social crawlers: fixed dimensions, stable content type, and hash-versioned URL.
Behavior Matrix Amendment
Replace the upload rows in the original matrix with:
Current cover_url |
HTML OG meta | Cover route | Queue repair |
|---|---|---|---|
/uploads/:id, OG-safe type, current page upload |
og:image, image/png, dimensions |
serve matching SITES artifact, or 503 while repairing |
yes, if artifact missing/stale |
/uploads/:id, HEIC/PDF/other unsafe type |
none | 404 | no |
Tests To Add Or Update
Add or update focused coverage:
- queue runtime tests
- uploaded PNG/JPEG/WebP cover reads uploads R2 and writes
SITESPNG - upload artifact has
image/png,width=1200,height=630,source=upload - upload cover skips wrong page/workspace rows
- unsafe upload type no-ops without
SITESwrite - missing upload object no-ops or returns bounded retry according to the final failure classifier
- uploaded PNG/JPEG/WebP cover reads uploads R2 and writes
- route runtime tests
- uploaded cover no longer streams the original upload
- matching uploaded derived artifact serves from
SITES - missing/stale uploaded derived artifact enqueues repair and returns 503
- doomed upload cover returns 404 without repair
- Sites HTML tests
- uploaded cover emits versioned
og:image - uploaded cover emits
image/pngtype and fixed dimensions
- uploaded cover emits versioned
Because the Images binding local test implementation may be lower fidelity than production, mock or stub the binding narrowly in Worker runtime tests where needed. Do not make tests depend on production Cloudflare image transformation availability.