Accepted
ADR 0011 — Render adapter and publish-time render
How Cusp renders pages to R2 at publish time, behind a stable render-adapter contract
Cusp uses a hybrid render model: pages are pre-rendered to R2 at publish time, served from Cloudflare's cache, and only fall through to live rendering as a last resort. This decision settles how the render itself runs inside the publish workflow, and behind what contract.
The decision
The render layer is an interface, not a framework. The publish workflow calls render adapter functions that take a document and a render context and return HTML. That interface lives at src/worker/render/index.ts, and any concrete renderer substitutes behind it without changing the workflow.
The implementation under that interface is a set of plain TypeScript template functions in src/themes/default/templates.ts that return HTML strings. The first-party theme is built this way, and themes loaded from R2 follow the same shape — a precompiled template the adapter loads and runs inside the Dynamic Worker Loader sandbox, falling back to the bundled default templates if the load fails.
// the contract the publish workflow depends on
render(document, context) => HTMLResponseWhy template functions, not Astro in the Worker
The render is satisfied by TypeScript template functions rather than Astro components run via Astro's Container API. The Worker bundling pipeline (Wrangler's esbuild) does not process .astro files, so running Astro components inside the Worker would require a bespoke build step disproportionate to the need. Template functions meet the contract directly, with no extra tooling.
Astro stays in the project for the admin SPA, which Astro builds into a static bundle served from Workers Static Assets. It never enters the Worker bundle. This keeps the Worker well under its size budget and keeps the render path direct — no service-binding hop, no container cold start, one resource boundary inside the workflow step (R2).
What the workflow renders
- Document page — rendered to rendered/{permalink}/index.html in R2.
- Listing and home pages — re-rendered so they reflect the newly published document.
- Sitemap, RSS feed, and robots.txt — regenerated from the set of published documents.
A document render is sub-second and a sitemap for a large site completes in seconds, so the work sits comfortably inside the workflow step's time budget.
Consequences
- One Worker, one deploy, one binding namespace — the render path does not introduce a sidecar.
- Themes are TypeScript template functions returning HTML, both for the first-party theme and for themes loaded from R2.
- The render-adapter interface is preserved, so a different renderer could be substituted later by changing only src/worker/render/index.ts.
Alternatives considered
- Astro components via the Container API — rejected because the Worker bundling pipeline does not process .astro files, and configuring it to do so is disproportionate to the need.
- A separate render Worker behind a service binding — rejected for the extra deploy and the network hop inside the workflow step.
- Cloudflare Containers running a full Node toolchain — rejected for the cold-start latency and operational weight, unnecessary once template functions met the need inside the Worker.