Skip to content

Serve docs diagrams from R2 instead of committing them to git - #45

Merged
dborovcanin merged 2 commits into
mainfrom
feat/r2-image-cdn
Aug 7, 2026
Merged

Serve docs diagrams from R2 instead of committing them to git#45
dborovcanin merged 2 commits into
mainfrom
feat/r2-image-cdn

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

Summary

  • Diagrams committed straight into content/docs/images/ bloated PRs and repo history. They're now stored in the shared websites-images R2 bucket (prefix propeller-docs) and served through a hand-written Cloudflare Worker (worker/index.ts) added as main alongside the static-assets binding — this site is a fully static Next.js export (output: "export") with no server runtime, so neither @cloudflare/next-on-pages nor @opennextjs/cloudflare applies; Cloudflare's default routing serves matching static files directly and only invokes the Worker on an asset miss.
  • Disabled fumadocs' remarkImage (source.config.ts), which previously bundled every locally-referenced diagram into the webpack build at compile time (content-hashed _next/static/media/...), requiring the file on disk at build time — incompatible with removing them from git.
  • Images now render via next/image's Image component (unoptimized: true, required for a static export with no image-optimization server) with explicit width/height from a generated src/lib/image-dimensions.json manifest, instead of a plain <img> tag — real dimensions were pulled from the pre-removal git commit and measured directly (not guessed), since an earlier build without R2 credentials had briefly generated placeholder-sized SVGs that would have baked in wrong aspect ratios.
  • Added scripts/publish-image.mjs (maintainer-only, see scripts/README.md) to upload a diagram and purge the edge cache for it.
  • Removed content/docs/images/ (40 files, after dropping 5 that weren't actually referenced from any MDX) from git. All 40 have been uploaded to the real R2 bucket and spot-checked byte-for-byte against the originals.

Test plan

  • pnpm run lint, pnpm run types:check, pnpm run check:assets, pnpm run build all pass locally with content/docs/images/ genuinely absent from disk
  • npx wrangler deploy --dry-run validates both the IMAGES_BUCKET and ASSETS bindings
  • All 40 diagrams uploaded to the real (--remote) R2 bucket, spot-checked byte-identical against source
  • Confirm diagrams render correctly on this PR's preview deployment before merging

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
propeller-docs 4934250 Commit Preview URL

Branch Preview URL
Aug 07 2026, 08:59 AM

Diagrams committed into content/docs/images/ bloated PRs and repo
history. They're now stored in the shared "websites-images" R2
bucket (prefix "propeller-docs") and served through a hand-written
Cloudflare Worker (worker/index.ts) added as `main` alongside the
static-assets binding -- this site is a fully static Next.js export
with no server runtime, so neither @cloudflare/next-on-pages nor
@opennextjs/cloudflare applies.

Authoring is unchanged: markdown image syntax stays exactly as it
was, relative paths and all -- ![alt](./images/dag/architecture.svg),
![alt](../images/fml/x.svg). This works via:

- source.config.ts disables fumadocs' remarkImage plugin, which
  otherwise needs the file on local disk at build time.
- src/lib/remark-doc-images.ts, a small remark plugin, resolves each
  image's path relative to its source .mdx file's own location into
  the literal /docs/propeller/img/... URL the Worker serves -- pure
  path math, no image bytes needed, no manifest.
- src/mdx-components.tsx's `img:` override renders the resolved
  result as a plain, zoomable <img> (fumadocs-ui's ImageZoom wrapping
  a plain element, not next/image) -- no width or height required, so
  there's nothing to keep in sync when images change. External image
  references (e.g. an asciinema embed) pass through untouched.

ImageZoom is given `src`/`alt` directly, not just via `children`: its
zoomed-in view reads the image from that prop, so omitting it
rendered a blank zoomed-in image even though the inline thumbnail
looked correct. Passing `src` there required explicitly typing
`props` as `ComponentPropsWithoutRef<"img">` with a `typeof props.src
!== "string"` narrow beforehand -- left implicit, `props.src` picks
up `Blob` from React's experimental `img` src types (which Next
16.2's type surface references), which ImageZoom's own `src?: string
| StaticImport` prop type rejects.

scripts/publish-image.mjs (maintainer-only, see scripts/README.md)
uploads an image and purges the edge cache for it -- simplified back
to just those two steps, no manifest bookkeeping. All 40 actually-
referenced diagrams (of 45 total; 5 were orphaned and dropped) have
already been uploaded to the real R2 bucket and spot-checked
byte-for-byte against the originals.
env.IMAGES_BUCKET.get() is an R2 binding call, not an HTTP subrequest.
Workers run before Cloudflare's cache in the request pipeline, so a
Response the Worker constructs and returns is never automatically
written into the edge cache, no matter what Cache-Control header is
set on it -- that only happens via explicit Cache API use, or a zone
Cache Rule intercepting it. Neither was happening here, so despite
s-maxage=31536000 being set, every request (every visitor, every edge
location) was a live R2 read.

Fixed by writing responses into the Workers Cache API (caches.default)
after the first R2 read, keyed by the request's own URL unmodified (so
it stays purgeable by the existing purge-by-URL call in
scripts/publish-image.mjs on every upload). This also adds Range/206
support as a side effect: cache.match() automatically serves 206
Partial Content for a Range request against a cached 200 response.

Bumped browser max-age from 300s to 3600s while leaving s-maxage at a
year -- purge-on-publish already invalidates the edge instantly on
every upload, so there's no freshness benefit to a short edge TTL.

Same fix as absmach/website#178, applied here since this repo's
worker/index.ts uses the identical binding-without-caching pattern.
@dborovcanin
dborovcanin merged commit ed988c4 into main Aug 7, 2026
2 checks passed
@dborovcanin
dborovcanin deleted the feat/r2-image-cdn branch August 7, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants