Skip to content

Cache R2-proxied images/video at Cloudflare's edge - #178

Merged
dborovcanin merged 1 commit into
mainfrom
fix/r2-proxy-edge-caching
Aug 7, 2026
Merged

Cache R2-proxied images/video at Cloudflare's edge#178
dborovcanin merged 1 commit into
mainfrom
fix/r2-proxy-edge-caching

Conversation

@ianmuchyri

@ianmuchyri ianmuchyri commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Images/video served via /img/* and /video/* were never actually cached at Cloudflare's edge, despite s-maxage=31536000 being set — env.IMAGES_BUCKET.get() is an R2 binding call, not an HTTP subrequest, and Workers run before Cloudflare's cache, so a Response a Worker returns is never automatically written into the edge cache. Confirmed live: no cf-cache-status header at all on /img/*//video/* (vs HIT on the HTML page), flat ~400ms TTFB on repeat requests to the same image.
  • As a side effect, Range requests were also ignored (always returned the full body as 200, never 206), which hurts video buffering/seeking specifically.
  • Fixed by writing responses into the Workers Cache API (caches.default) after the first R2 read, keyed by the request's own URL (so it stays purgeable by the existing purge_cache call in publish-image.mjs). This also fixes Range support for free — cache.match() serves 206 automatically against a cached 200.
  • Bumped browser max-age 300s → 3600s; s-maxage stays at a year since purge-on-publish already invalidates the edge instantly.

Test plan

  • pnpm run lint — clean (pre-existing unrelated errors only in .wolf/hooks/*.js)
  • pnpm run check — 0 errors
  • pnpm run build — clean build
  • After deploy: confirm cf-cache-status: HIT on a second request to the same image URL
  • After deploy: confirm a Range: bytes=0-1023 request to a video returns 206 Partial Content
  • After deploy: publish an image update via publish-image.mjs, confirm the purge still invalidates the cached edge copy (fetch immediately after and confirm new content, not a stale HIT)

Images and video are served same-origin via /img/* and /video/* routes
that read from R2 through env.IMAGES_BUCKET.get() -- a binding call,
not an HTTP subrequest. Workers run before Cloudflare's cache in the
request pipeline, so a Response a Worker constructs and returns is
never automatically written into Cloudflare's 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
single request (every visitor, every edge location) was a live R2
read. Confirmed live in production: no cf-cache-status header at all
on /img/* or /video/* responses (vs. cf-cache-status: HIT on the HTML
page), and flat ~400ms TTFB across repeated requests to the same
image.

As a second-order effect, Range requests were also being ignored --
bucket.get() was always called without forwarding the Range header,
so the whole object came back as a 200 regardless of what the browser
asked for, which hurts video specifically (buffering/seeking relies
on Range requests).

Fixed by writing responses into the Workers Cache API (caches.default)
after the first R2 read, keyed by the request's own URL unmodified
(not a custom key, so it stays purgeable by the existing purge-by-URL
call in publish-image.mjs on every upload). This also fixes Range
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; the
browser-side value is a bounded blast radius in the unlikely case a
purge is ever missed, not the primary freshness mechanism.
@cloudflare-workers-and-pages

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
absmach-website 611ea7e Commit Preview URL

Branch Preview URL
Aug 07 2026, 07:18 AM

@dborovcanin
dborovcanin merged commit e6b8d68 into main Aug 7, 2026
2 checks passed
@dborovcanin
dborovcanin deleted the fix/r2-proxy-edge-caching branch August 7, 2026 07:31
ianmuchyri added a commit to absmach/hardware-docs that referenced this pull request Aug 7, 2026
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.
ianmuchyri added a commit to absmach/atom that referenced this pull request Aug 7, 2026
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.

This repo already depends on @cloudflare/workers-types (unlike the
sister sites), so Cache/CacheStorage/ExecutionContext are used via
their ambient global types instead of local structural interfaces.

Same fix as absmach/website#178, applied here since this repo's
worker/index.ts uses the identical binding-without-caching pattern.
ianmuchyri added a commit to absmach/propeller-docs that referenced this pull request Aug 7, 2026
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.
ianmuchyri added a commit to ultravioletrs/prism-docs that referenced this pull request Aug 7, 2026
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.

The fetch handler already had an ExecutionContext parameter (unused,
prefixed _ctx) -- it's used now instead of discarded.

Same fix as absmach/website#178, applied here since this repo's
worker.ts uses the identical binding-without-caching pattern.
ianmuchyri added a commit to ultravioletrs/cocos-docs that referenced this pull request Aug 7, 2026
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.
ianmuchyri added a commit to ultravioletrs/cube-docs that referenced this pull request Aug 7, 2026
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.
ianmuchyri added a commit to ianmuchyri/magistrala-docs that referenced this pull request Aug 7, 2026
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.

createR2ProxyHandler's returned handler now takes the request and
ExecutionContext too (previously just path + env), threaded through
from image-proxy.ts's fetch() for all three routes (img, diagrams,
screenshots) alike.

Same fix as absmach/website#178, applied here since this repo's
workers/r2-proxy.ts uses the identical binding-without-caching pattern.
dborovcanin pushed a commit to absmach/magistrala-docs that referenced this pull request Aug 7, 2026
* Serve docs images from R2 instead of committing them to git

Images committed into content/docs/img/, content/docs/diagrams/, and
public/screenshots/ bloated PRs and repo history (611 tracked files).
They're now stored in the shared "websites-images" R2 bucket (prefix
"magistrala-docs") and served through a hand-written Cloudflare
Worker (workers/image-proxy.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](../img/foo.png),
![alt](/screenshots/bar.png). This works via:

- source.config.ts disables fumadocs' remarkImage plugin, which
  otherwise needs the file on local disk at build time.
- lib/remark-doc-images.ts, a small remark plugin, resolves each
  image's path (relative to its source .mdx file's own location, or
  the fixed /screenshots/... prefix) into the literal
  /docs/magistrala/{img,diagrams,screenshots}/... URL the Worker
  serves -- pure path math, no image bytes needed, no manifest.
- mdx-components.tsx's `img:` override renders anything carrying that
  resolved prefix 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.

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.

Added scripts/publish-image.mjs (maintainer-only, see
scripts/README.md) to upload an image and purge the edge cache for
it. All 611 images have already been uploaded to the real R2 bucket
and spot-checked byte-for-byte against the originals.

Note: dev-guide/benchmark.mdx's one external (GitHub-hosted) image
reference gets its URL mangled by assetPath() being called
unconditionally on any string img src -- confirmed pre-existing on
main, unrelated to this change, left alone.

* Cache R2-proxied doc images at Cloudflare's edge

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.

createR2ProxyHandler's returned handler now takes the request and
ExecutionContext too (previously just path + env), threaded through
from image-proxy.ts's fetch() for all three routes (img, diagrams,
screenshots) alike.

Same fix as absmach/website#178, applied here since this repo's
workers/r2-proxy.ts uses the identical binding-without-caching pattern.
dborovcanin pushed a commit to absmach/propeller-docs that referenced this pull request Aug 7, 2026
* Move docs diagrams to shared R2 bucket instead of git

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.

* Cache R2-proxied doc images at Cloudflare's edge

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 pushed a commit to ultravioletrs/cocos-docs that referenced this pull request Aug 7, 2026
* Serve docs images from R2 instead of committing them to git

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.

* Cache R2-proxied doc images at Cloudflare's edge

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 pushed a commit to ultravioletrs/cube-docs that referenced this pull request Aug 7, 2026
* Serve images from Cloudflare R2 instead of the repo

Images committed into public/img/ bloated PRs and repo history.
They're now stored in the shared "websites-images" R2 bucket (prefix
"cube-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: MDX content already referenced doc images by
their final /img/... path (no relative-path convention existed here
to begin with), so nothing about writing ![alt](/img/foo.png) needed
to change.

- source.config.ts disables fumadocs' remarkImage plugin, which
  otherwise needs the file on local disk at build time.
- mdx-components.tsx's img: override renders the 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. ImageZoom's src/alt are passed
  explicitly (not just to the inner <img>), since its zoomed-in view
  reads the image from those props directly, not from `children`.
- Dropped lib/image-dimensions.json entirely.

All 58 content images have already been uploaded to the real R2
bucket and spot-checked byte-for-byte against the originals.

* Cache R2-proxied doc images at Cloudflare's edge

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 pushed a commit to ultravioletrs/prism-docs that referenced this pull request Aug 7, 2026
* Serve images from Cloudflare R2 instead of the repo

Images committed into public/img/ bloated PRs and repo history.
They're now stored in the shared "websites-images" R2 bucket (prefix
"prism-docs") and served through a hand-written Cloudflare Worker
(worker.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: MDX content already referenced doc images by
their final /img/... path (no relative-path convention existed here
to begin with), so nothing about writing ![alt](/img/foo.png) needed
to change.

- source.config.ts disables fumadocs' remarkImage plugin, which
  otherwise needs the file on local disk at build time.
- mdx-components.tsx's img: override renders the 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. ImageZoom's src/alt are passed
  explicitly (not just to the inner <img>), since its zoomed-in view
  reads the image from those props directly, not from `children`.
- Dropped lib/image-dimensions.json and the manifest-sync logic
  (including the space-in-filename percent-encoding handling, no
  longer needed) that used to live in scripts/publish-image.mjs --
  simplified back to just upload + purge.

All 158 images have already been uploaded to the real R2 bucket and
spot-checked byte-for-byte against the originals.

* Cache R2-proxied doc images at Cloudflare's edge

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.

The fetch handler already had an ExecutionContext parameter (unused,
prefixed _ctx) -- it's used now instead of discarded.

Same fix as absmach/website#178, applied here since this repo's
worker.ts uses the identical binding-without-caching pattern.
dborovcanin pushed a commit to absmach/atom that referenced this pull request Aug 7, 2026
* Serve doc images from Cloudflare R2 instead of the repo

Images committed into docs/public/img/ bloated PRs and repo history.
They're now stored in the shared "websites-images" R2 bucket (prefix
"atom-docs") and served through a hand-written Cloudflare Worker
(docs/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: MDX content already referenced doc images by
their final /img/... path (no relative-path convention existed here
to begin with), so nothing about writing ![alt](/img/foo.png) needed
to change.

- source.config.ts disables fumadocs' remarkImage plugin, which
  otherwise needs the file on local disk at build time.
- docs/components/doc-image.tsx renders the 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. ImageZoom's src/alt are passed
  explicitly (not just to the inner <img>), since its zoomed-in view
  reads the image from those props directly, not from `children`.
- Dropped lib/image-dimensions.json and the manifest-sync logic that
  used to live in scripts/publish-image.mjs -- simplified back to
  just upload + purge.

All 88 images have already been uploaded to the real R2 bucket and
spot-checked byte-for-byte against the originals.

* Cache R2-proxied doc images at Cloudflare's edge

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.

This repo already depends on @cloudflare/workers-types (unlike the
sister sites), so Cache/CacheStorage/ExecutionContext are used via
their ambient global types instead of local structural interfaces.

Same fix as absmach/website#178, applied here since this repo's
worker/index.ts uses the identical binding-without-caching pattern.

* Fix CI: pin js-yaml past CVE-2026-59870 (quadratic CPU on !!omap)

pnpm audit --prod --audit-level high was failing in CI on js-yaml
4.3.0, pulled in transitively via fumadocs-mdx (.>fumadocs-mdx>js-yaml).
fumadocs-mdx's own declared range ("^4.1.0") already permits the
patched 4.3.1, so this is pinned via a pnpm.overrides entry rather
than waiting on fumadocs-mdx to bump its lockfile-resolved version
itself.

Also pins sharp and postcss to the versions they were already
resolving to before this change (^0.35.3 and ^8.5.18, both already
past their own advisories' patched thresholds) -- introducing the
js-yaml override alone caused pnpm to re-resolve the whole dependency
graph and land on older, vulnerable versions of both (sharp
0.35.3->0.34.5, plus a stray duplicate postcss@8.4.31 no longer
deduped with the top-level 8.5.18), surfacing 2 more high-severity
findings that don't exist in a plain install of main. Pinning all
three keeps the lockfile at exactly the versions already known safe,
with no other dependency churn (confirmed via lockfile diff).

Pre-existing, unrelated to the R2 image-caching work on this branch --
confirmed the same js-yaml advisory already fails audit on a clean
install of main.
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