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; }; }