Skip to content

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

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

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

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

Summary

  • Images committed into public/img/ bloated PRs and repo history. They're now stored in the shared websites-images R2 bucket (prefix cocos-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.
  • Fixed a real bug at its root cause: the custom img: component override in mdx-components.tsx (meant to base-path-prefix image URLs) never fired for the doc images in this repo — traced this into MDX/JSX compilation semantics: lowercase JSX tags (<img>) always compile to hardcoded intrinsic elements that bypass the components override entirely; only capitalized tags (<Image>) route through it. Confirmed empirically by compiling sample MDX and inspecting the generated output. Fixed by registering the override under a capitalized Image key instead and converting the 16 doc-image references to use it — not a workaround, the actual mechanism now works.
  • Images render via next/image's Image component (unoptimized: true) with explicit width/height, reusing dimensions already hand-extracted from git history (with one gap filled in: srtm.png was missing a height in the original markup, recovered from the source file itself).
  • Added scripts/publish-image.mjs (maintainer-only, see scripts/README.md) to upload an image and purge the edge cache for it.
  • Removed public/img/ (16 files, all referenced from MDX) from git. All 16 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 lint:md, pnpm run build all pass locally with public/img/ genuinely absent from disk
  • Verified in the built HTML: every doc image correctly base-path-prefixed, data-nimg present (genuine next/image output), no unprefixed /img/... remaining
  • All 16 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
cocos-docs 34ef864 Commit Preview URL

Branch Preview URL
Aug 07 2026, 09:11 AM

@ianmuchyri
ianmuchyri force-pushed the feat/r2-image-cdn branch 2 times, most recently from b10dd03 to fae3cbc Compare August 6, 2026 15:38
Images under public/img/ were committed straight into the repo and bundled
into the Next.js static export at build time (markdown image syntax gets
compiled by fumadocs' remark-image plugin into a static import of the local
file). That meant every image add/update was a binary diff in git history,
and the build required the files to exist locally.

This moves image storage to the shared Cloudflare R2 bucket
("websites-images", object keys under the "cocos-docs" prefix so they don't
collide with other properties in the same bucket), fronted by a Cloudflare
Worker script that proxies /docs/cocos-ai/img/... requests to R2. The
site's build is a full static export (output: "export") deployed as
Cloudflare Workers static assets with no server runtime, so there's no
Next.js route handler that could do this proxying the way a normal SSR
deployment would - the Worker sits in front of the static asset handler
instead, only running for requests that don't match a file under out/
(which, now that images aren't built into out/, is every /img/ request).

The first pass of this migration converted markdown image syntax
(![]()) to raw <img>/<Image> JSX to stop the build from statically
importing local files, on the theory that a lowercase `img:` override in
mdx-components.tsx wasn't actually reachable from markdown-syntax images.
That's more ceremony than this change should require: authors should
keep writing plain markdown image syntax with the same `/img/...` paths
as before. Markdown-syntax images (`![...](...)`) always compile through
MDX's `_components.img`, confirmed by compiling sample MDX through
@mdx-js/mdx directly - the `img:` key just needs to actually be wired up
to render them, which it now is. Disabling fumadocs-mdx's remarkImage
plugin (source.config.ts) is what actually stops the build-time static
import; converting authoring syntax was never necessary for that.

Changes:
- wrangler.jsonc: add "main" pointing at the new Worker script, an
  explicit "binding" name for the assets handler, and the IMAGES_BUCKET R2
  binding (bucket_name "websites-images")
- worker/index.ts, worker/r2-proxy.ts: Worker script that proxies
  /docs/cocos-ai/img/... to R2 and otherwise falls through to the static
  asset handler; Env/R2Bucket/Fetcher types defined locally rather than
  relying on the gitignored, wrangler-generated worker-configuration.d.ts
  (pnpm run build never regenerates it, only the separate types:check
  script does)
- source.config.ts: disable remarkImageOptions so markdown images aren't
  statically imported at build time (the file no longer exists on disk)
- content/docs/**/*.mdx: reverted to the original plain markdown image
  syntax (`![alt](/img/foo.png)`)
- mdx-components.tsx: register a plain, zoomable `<img>` (ImageZoom,
  no next/image, no width/height) under the lowercase `img` key only;
  removed the capitalized `Image` component and next/image dependency
  from the first pass. ImageZoom is given `src`/`alt` directly (not just
  via `children`) since its zoomed-in view reads the image from that prop
- public/img/: remove all 16 image files (now served from R2)
- scripts/publish-image.mjs, scripts/README.md,
  scripts/.env.publish-image.example: maintainer-only upload+purge tool,
  modeled on the same tool in the absmach-website repo, adapted for this
  repo's domain and base path
- package.json: add "publish-image" script; "types:check" runs
  "wrangler types" first so the Worker's Env type is available to tsc
- .gitignore: ignore worker-configuration.d.ts and the maintainer's
  scripts/.env.publish-image credentials file
- README.md: document the new worker/ directory and cross-link
  scripts/README.md

Verified in the static export output (out/docs/cocos-ai/): every doc image
renders as a plain <img src="/docs/cocos-ai/img/...">, and ImageZoom
receives the same resolved src/alt as the thumbnail.

The production domain (www.ultraviolet.rs) is known from next.config.mjs
and README.md, but the Cloudflare zone ID isn't available from this repo,
so CLOUDFLARE_ZONE_ID in scripts/.env.publish-image.example is left as an
explicit TODO placeholder rather than guessed.
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 the
publish-image script 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/r2-proxy.ts uses the identical binding-without-caching pattern.
@dborovcanin
dborovcanin merged commit db5f5ea into main Aug 7, 2026
2 checks passed
@dborovcanin
dborovcanin deleted the feat/r2-image-cdn branch August 7, 2026 10:35
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