From 611ea7e25ebf591899e0af2c723fe8e9aab1c358 Mon Sep 17 00:00:00 2001 From: ianmuchyri Date: Fri, 7 Aug 2026 10:17:03 +0300 Subject: [PATCH] Cache R2-proxied images/video at Cloudflare's edge 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. --- src/lib/r2-proxy.ts | 59 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/lib/r2-proxy.ts b/src/lib/r2-proxy.ts index 0d1cf7e4..3c9f6059 100644 --- a/src/lib/r2-proxy.ts +++ b/src/lib/r2-proxy.ts @@ -11,6 +11,20 @@ interface R2Bucket { get(key: string): Promise; } +// Minimal structural types for the Workers Cache API -- avoids depending on +// the gitignored, wrangler-generated worker-configuration.d.ts (pnpm run +// build never regenerates it, only the separate types:check script does). +interface Cache { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} +interface CacheStorage { + readonly default: Cache; +} +interface ExecutionContext { + waitUntil(promise: Promise): void; +} + const notFound = () => new Response("Not found", { status: 404, @@ -21,14 +35,35 @@ const notFound = () => // keyPrefix keeps this site's objects from colliding with the docs sites', // and separates images from video within this site. export function createR2ProxyRoute(keyPrefix: string): APIRoute { - return async ({ params, locals }) => { + return async ({ params, locals, request }) => { const path = params.path; if (!path) return notFound(); - const bucket = ( - locals as { runtime?: { env?: { IMAGES_BUCKET?: R2Bucket } } } - ).runtime?.env?.IMAGES_BUCKET; - if (!bucket) return notFound(); + const runtime = ( + locals as { + runtime?: { + env?: { IMAGES_BUCKET?: R2Bucket }; + caches?: CacheStorage; + ctx?: ExecutionContext; + }; + } + ).runtime; + const bucket = runtime?.env?.IMAGES_BUCKET; + if (!bucket || !runtime?.caches || !runtime?.ctx) return notFound(); + + // R2 binding reads (bucket.get()) never touch Cloudflare's HTTP cache -- + // they're a direct storage call, not a subrequest. Without explicitly + // writing the response into the Cache API, every single request (from + // every visitor, at every edge location) would re-read from R2, no + // matter what Cache-Control header gets set on the returned Response. + // Using the request's own URL (unmodified) as the cache key keeps this + // purgeable by the existing purge-by-URL call in publish-image.mjs -- + // a *custom* cache key would not be. + const cache = runtime.caches.default; + const cacheKey = new Request(request.url, request); + + const cached = await cache.match(cacheKey); + if (cached) return cached; const object = await bucket.get(`${keyPrefix}/${path}`); if (!object) return notFound(); @@ -37,10 +72,16 @@ export function createR2ProxyRoute(keyPrefix: string): APIRoute { object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); headers.set("content-length", String(object.size)); - // Short browser TTL (revalidates quickly) + long edge TTL (until purged - // explicitly by the publish-image script on upload). - headers.set("cache-control", "public, max-age=300, s-maxage=31536000"); + // Browser TTL long enough to skip most repeat-visit requests, short + // enough to self-heal within the hour if a purge is ever missed. Edge + // TTL is effectively unbounded -- publish-image.mjs purges it + // explicitly and immediately on every upload, so there's no benefit to + // a shorter one, and every edge location that has ever served an image + // now actually caches it (see the Cache API use above). + headers.set("cache-control", "public, max-age=3600, s-maxage=31536000"); - return new Response(object.body, { headers }); + const response = new Response(object.body, { headers }); + runtime.ctx.waitUntil(cache.put(cacheKey, response.clone())); + return response; }; }