Cache R2-proxied images/video at Cloudflare's edge - #178
Merged
Conversation
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.
Deploying with
|
| 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 |
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 -- ,
. 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 -- , . 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 (``)
- 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  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  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  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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/img/*and/video/*were never actually cached at Cloudflare's edge, despites-maxage=31536000being set —env.IMAGES_BUCKET.get()is an R2 binding call, not an HTTP subrequest, and Workers run before Cloudflare's cache, so aResponsea Worker returns is never automatically written into the edge cache. Confirmed live: nocf-cache-statusheader at all on/img/*//video/*(vsHITon the HTML page), flat ~400ms TTFB on repeat requests to the same image.Rangerequests were also ignored (always returned the full body as200, never206), which hurts video buffering/seeking specifically.caches.default) after the first R2 read, keyed by the request's own URL (so it stays purgeable by the existingpurge_cachecall inpublish-image.mjs). This also fixes Range support for free —cache.match()serves206automatically against a cached200.max-age300s → 3600s;s-maxagestays 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 errorspnpm run build— clean buildcf-cache-status: HITon a second request to the same image URLRange: bytes=0-1023request to a video returns206 Partial Contentpublish-image.mjs, confirm the purge still invalidates the cached edge copy (fetch immediately after and confirm new content, not a staleHIT)