Skip to content

Serve docs images from R2 instead of committing them to git - #15

Open
ianmuchyri wants to merge 2 commits into
mainfrom
feat/r2-image-cdn
Open

Serve docs images from R2 instead of committing them to git#15
ianmuchyri wants to merge 2 commits into
mainfrom
feat/r2-image-cdn

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

Summary

  • Content images committed into content/docs/images/ bloated PRs and repo history. They're now stored in the shared websites-images R2 bucket (prefix hardware-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 a Next.js route handler / next-on-pages / OpenNext binding wasn't an option; Cloudflare serves matching static files directly and only invokes the Worker on an asset miss.
  • Disabled fumadocs' remarkImage (source.config.ts), which previously bundled referenced images into the webpack build at compile time, requiring the file on disk at build time.
  • Images now render via next/image's Image component (unoptimized: true) with explicit width/height from src/lib/image-dimensions.json, instead of a plain <img> tag. Along the way, found that the prior in-place override already routed through next/image's Image indirectly but never supplied dimensions — meaning it silently shipped zero real CLS protection despite looking optimized in the DOM. Fixed by feeding it real measured dimensions instead, while deliberately preserving the existing click-to-zoom (ImageZoom) UX rather than ripping it out.
  • Added scripts/publish-image.mjs (maintainer-only, see scripts/README.md) to upload an image and purge the edge cache for it.
  • Removed content/docs/images/ from git — of 53 files present, only 27 were actually referenced from MDX (confirmed via grep); the other 26 were orphaned and dropped rather than migrated. All 27 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 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 27 images uploaded to the real (--remote) R2 bucket, spot-checked byte-identical against source
  • Confirm images 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
hardware-docs 2d903f6 Commit Preview URL

Branch Preview URL
Aug 07 2026, 08:41 AM

Content images under content/docs/images/ were committed as binary
files and referenced from MDX with relative paths (../images/foo.png).
fumadocs-mdx's static-export bundler resolved those at build time into
content-hashed files, which meant the image bytes had to be physically
present in the repo just to run `next build`. That's a growing repo
and doesn't fit the shared image-hosting setup other Abstract Machines
properties are moving to (a single R2 bucket, proxied same-origin so
URLs don't change, with immediate cache purge on update).

Since this site is a pure Next.js static export with no server runtime
in production (deployed as Cloudflare static assets, no next-on-pages
or opennextjs/cloudflare), there's no Next.js request handler to reach
an R2 binding from. Instead, wrangler.jsonc now has a `main` Worker
script that falls back from the ASSETS binding (only when no static
file matches, since run_worker_first defaults to false) to reading
`/docs/hardware/img/*` straight out of the shared `websites-images`
bucket, under the `hardware-docs` key prefix.

The first pass of this migration also changed MDX authoring syntax
(relative paths -> absolute `/img/<file>`) and added a
width/height JSON manifest (src/lib/image-dimensions.json) rendered
through next/image, so every new image required a manual manifest
entry or the build would throw. That's more ceremony than this change
should require: authors should keep writing plain markdown image
syntax with whatever relative path they always used, and nothing about
next/image's optimizer is actually usable here anyway (this is a
static export with no image-optimization server, `unoptimized` was
already set).

This commit reverts the MDX content and syntax to their original form
and replaces DocImage/next/image with a plain, zoomable `<img>`:

- content/docs/**/*.mdx: reverted to the original relative-path
  markdown image syntax (`../images/foo.png`)
- src/lib/remark-doc-images.ts: new remark plugin that resolves each
  image's relative path against content/docs/images/ at compile time
  (pure path math, no image bytes needed) into the literal
  `/docs/hardware/img/...` URL the Worker serves — replaces
  fumadocs-mdx's remarkImage, which needs the file on disk
- source.config.ts: disable remarkImageOptions, wire in
  remarkDocImages
- src/mdx-components.tsx: render the `img` MDX node as a plain
  `<img>` (no next/image, no width/height) wrapped in ImageZoom for
  click-to-zoom, passing `src`/`alt` to ImageZoom itself as well as
  the inner `<img>` — ImageZoom's zoomed-in view reads its image from
  that prop directly, not from `children`
- src/components/doc-image.tsx, src/lib/image-dimensions.json:
  removed, no longer needed
- worker/index.ts, wrangler.jsonc: unchanged from the first pass
- scripts/publish-image.mjs, scripts/README.md,
  scripts/.env.publish-image.example: maintainer-only upload+purge CLI
  and its docs, unchanged from the first pass
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.
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.

1 participant