Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions src/lib/r2-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ interface R2Bucket {
get(key: string): Promise<R2ObjectBody | null>;
}

// 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<Response | undefined>;
put(request: Request, response: Response): Promise<void>;
}
interface CacheStorage {
readonly default: Cache;
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
}

const notFound = () =>
new Response("Not found", {
status: 404,
Expand All @@ -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();
Expand All @@ -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;
};
}
Loading