Skip to content
File

Blob: src/worker/cache/cache.ts

typescript359 lines
1/**
2 * Small helpers around the Cloudflare Workers Cache API for JSON payloads.
3 *
4 * Notes
5 * - Cache lives per colo; keys should be stable and include all inputs.
6 * - Always use the same origin as the incoming request when constructing keys.
7 * - Only cache GET responses.
8 */
9 
10import { asBodyInit } from "@/worker/common/webtypes";
11import type { IdxView, PackCatalogRow, PackedObjectResult } from "@/worker/git/object-store/types";
12import type { PackRefView, PackRefViewLoadResult } from "@/worker/git/pack/refIndex";
13import { isRequestPrivate } from "./policy";
14 
15const CACHE_NAME_JSON = "git-on-cf:json";
16const CACHE_NAME_OBJECTS = "git-on-cf:objects";
17 
18/**
19 * Optional per-request memoization store to avoid repeated upstream calls
20 * (DO RPCs, R2) within a single Worker request.
21 */
22export interface RequestMemo {
23 /** Pin the repository for this request memo to prevent cross-repo contamination */
24 repoId?: string;
25 /** Object results by OID (git header removed) */
26 objects?: Map<string, { type: string; payload: Uint8Array } | undefined>;
27 /** Parsed references for objects: commit -> [tree, parents], tree -> [entries] */
28 refs?: Map<string, string[]>;
29 /** Active pack catalog snapshot for the current repo */
30 packCatalog?: PackCatalogRow[];
31 /** In-flight promise for the active pack catalog */
32 packCatalogPromise?: Promise<PackCatalogRow[]>;
33 /** Parsed idx views keyed by full pack key */
34 idxViews?: Map<string, IdxView>;
35 /** In-flight idx view loads keyed by pack key plus size hint when present */
36 idxViewPromises?: Map<string, Promise<IdxView | undefined>>;
37 /** Parsed pack logical-reference sidecars keyed by pack key plus idx checksum */
38 packRefViews?: Map<string, PackRefView>;
39 /** In-flight pack logical-reference sidecar loads keyed by pack key plus idx checksum */
40 packRefViewPromises?: Map<string, Promise<PackRefViewLoadResult>>;
41 /** Worker-local packed object results */
42 packedObjects?: Map<string, PackedObjectResult | null>;
43 /** In-flight worker-local packed object reads */
44 packedObjectPromises?: Map<string, Promise<PackedObjectResult | undefined>>;
45 /** Small flags set for once-per-request log throttling and guards */
46 flags?: Set<string>;
47 /** Optional per-request soft subrequest budget to degrade before hitting platform hard limits */
48 subreqBudget?: number;
49 /** Optional concurrency limiter for upstream calls; must provide a run(label, fn) API */
50 limiter?: { run<T>(label: string, fn: () => Promise<T>): Promise<T> };
51}
52 
53/**
54 * Context for cacheable operations.
55 * Combines request and execution context for caching and background tasks.
56 * When provided, both fields are required since they typically come together.
57 */
58export interface CacheContext {
59 req: Request;
60 ctx: ExecutionContext;
61 /** Optional per-request memoization */
62 memo?: RequestMemo;
63}
64 
65/**
66 * Resolve the zone cache instance used for JSON payloads.
67 *
68 * We intentionally use a named cache via `caches.open(...)` instead of
69 * `caches.default` so that TypeScript does not need Cloudflare-specific
70 * ambient declarations for `caches.default`.
71 */
72async function getZoneCache(): Promise<Cache> {
73 // Use a named cache to avoid relying on caches.default typings
74 return await caches.open(CACHE_NAME_JSON);
75}
76 
77/**
78 * Resolve the zone cache instance used for git objects.
79 * Git objects are immutable, so we use a separate cache with longer TTLs.
80 */
81async function getObjectCache(): Promise<Cache> {
82 return await caches.open(CACHE_NAME_OBJECTS);
83}
84 
85/**
86 * Build a same-origin GET Request to use as the cache key.
87 *
88 * Why same-origin?
89 * - Cloudflare recommends keeping the hostname aligned with the Worker hostname
90 * to avoid unnecessary DNS lookups and improve cache efficiency.
91 *
92 * Key design
93 * - Use a dedicated pathname (for example, "/_cache/commits") and include only
94 * the parameters that affect the response in `params`.
95 * - Omit empty/undefined params to keep keys clean and deterministic.
96 */
97export function buildCacheKeyFrom(
98 req: Request,
99 pathname: string,
100 params: Record<string, string | undefined>
101): Request {
102 const u = new URL(req.url);
103 u.pathname = pathname;
104 // Reset and set only the parameters we care about
105 u.search = "";
106 const sp = u.searchParams;
107 for (const [k, v] of Object.entries(params)) {
108 if (v && v !== "") sp.set(k, v);
109 }
110 return new Request(u.toString(), { method: "GET" });
111}
112 
113/**
114 * Retrieve JSON from the Workers cache by key request.
115 *
116 * @param keyReq - The synthetic GET Request produced by `buildCacheKeyFrom()`.
117 * @returns Parsed JSON on hit, or null on miss/error.
118 */
119export async function cacheGetJSON<T = unknown>(keyReq: Request): Promise<T | null> {
120 try {
121 const cache = await getZoneCache();
122 const res = await cache.match(keyReq);
123 if (!res || !res.ok) return null;
124 const data = (await res.json()) as T;
125 return data;
126 } catch {
127 return null;
128 }
129}
130 
131/**
132 * Store JSON into the Workers cache under `keyReq` with a TTL.
133 *
134 * Implementation
135 * - Serializes `payload` to JSON and sets `Cache-Control: public, max-age=...`.
136 * - Caller is responsible for picking an appropriate TTL.
137 *
138 * @param keyReq - Cache key request built via `buildCacheKeyFrom()`
139 * @param payload - Any JSON-serializable value
140 * @param ttlSeconds - Time to live in seconds
141 */
142export async function cachePutJSON(
143 keyReq: Request,
144 payload: unknown,
145 ttlSeconds: number
146): Promise<void> {
147 try {
148 const body = JSON.stringify(payload);
149 const headers = new Headers();
150 headers.set("Content-Type", "application/json; charset=utf-8");
151 headers.set("Cache-Control", `public, max-age=${Math.max(0, Math.floor(ttlSeconds))}`);
152 const res = new Response(body, { status: 200, headers });
153 const cache = await getZoneCache();
154 await cache.put(keyReq, res);
155 } catch {
156 // best-effort only
157 }
158}
159 
160/**
161 * Build a cache key for a git object.
162 * Git objects are content-addressable and immutable, so we can use long TTLs.
163 *
164 * @param req - The incoming request (for origin)
165 * @param repoId - RepoDO storage identity (`doName` in D1)
166 * @param oid - Object ID (SHA-1 hash)
167 * @returns Cache key request
168 */
169export function buildObjectCacheKey(req: Request, repoId: string, oid: string): Request {
170 const u = new URL(req.url);
171 u.pathname = `/_cache/obj/${repoId}/${oid.toLowerCase()}`;
172 u.search = "";
173 return new Request(u.toString(), { method: "GET" });
174}
175 
176/**
177 * Retrieve a git object from cache.
178 *
179 * @param keyReq - The cache key request
180 * @returns Object data with type and payload, or null on miss
181 */
182export async function cacheGetObject(
183 keyReq: Request
184): Promise<{ type: string; payload: Uint8Array } | null> {
185 try {
186 const cache = await getObjectCache();
187 const res = await cache.match(keyReq);
188 if (!res || !res.ok) return null;
189 
190 // Objects are stored as binary with type in header
191 const type = res.headers.get("X-Git-Type") || "blob";
192 const payload = new Uint8Array(await res.arrayBuffer());
193 return { type, payload };
194 } catch {
195 return null;
196 }
197}
198 
199/**
200 * Store a git object in cache with immutable headers.
201 * Since git objects are content-addressed, they never change.
202 *
203 * @param keyReq - Cache key request
204 * @param type - Git object type (blob, tree, commit, tag)
205 * @param payload - Raw object payload (without git header)
206 */
207export async function cachePutObject(
208 keyReq: Request,
209 type: string,
210 payload: Uint8Array
211): Promise<void> {
212 try {
213 const headers = new Headers();
214 headers.set("Content-Type", "application/octet-stream");
215 headers.set("X-Git-Type", type);
216 // Git objects are immutable - cache for 1 year
217 headers.set("Cache-Control", "public, max-age=31536000, immutable");
218 
219 const res = new Response(asBodyInit(payload), { status: 200, headers });
220 const cache = await getObjectCache();
221 await cache.put(keyReq, res);
222 } catch {
223 // best-effort only
224 }
225}
226 
227/**
228 * Helper to handle the check-load-save cache pattern with ctx.waitUntil.
229 * Checks cache first, loads from source if needed, and saves to cache in background.
230 *
231 * @param cacheKey - The cache key request
232 * @param loader - Function to load the data if not cached
233 * @param ctx - ExecutionContext for waitUntil (optional)
234 * @returns The cached or loaded git object
235 */
236export async function cacheOrLoadObject<T extends { type: string; payload: Uint8Array }>(
237 cacheKey: Request,
238 loader: () => Promise<T | undefined>,
239 ctx?: ExecutionContext
240): Promise<T | undefined> {
241 // Try cache first
242 const cached = await cacheGetObject(cacheKey);
243 if (cached) {
244 return cached as T;
245 }
246 
247 // Load from source
248 const result = await loader();
249 if (!result) return undefined;
250 
251 // Save to cache in background if ctx is provided
252 const savePromise = cachePutObject(cacheKey, result.type, result.payload);
253 if (ctx) {
254 ctx.waitUntil(savePromise);
255 } else {
256 // If no ctx, we still save but don't wait
257 savePromise.catch(() => {}); // Ignore errors
258 }
259 
260 return result;
261}
262 
263/**
264 * Helper for JSON cache with the check-load-save pattern.
265 *
266 * @param cacheKey - The cache key request
267 * @param loader - Function to load the data if not cached
268 * @param ttl - Time to live in seconds
269 * @param ctx - ExecutionContext for waitUntil (optional)
270 * @returns The cached or loaded data
271 */
272export async function cacheOrLoadJSON<T>(
273 cacheKey: Request,
274 loader: () => Promise<T | null>,
275 ttl: number,
276 ctx?: ExecutionContext
277): Promise<T | null> {
278 // Try cache first
279 const cached = await cacheGetJSON<T>(cacheKey);
280 if (cached) {
281 return cached;
282 }
283 
284 // Load from source
285 const result = await loader();
286 if (!result) return null;
287 
288 // Save to cache in background if ctx is provided
289 const savePromise = cachePutJSON(cacheKey, result, ttl);
290 if (ctx) {
291 ctx.waitUntil(savePromise);
292 } else {
293 // If no ctx, we still save but don't wait
294 savePromise.catch(() => {}); // Ignore errors
295 }
296 
297 return result;
298}
299 
300/**
301 * Request-aware JSON cache helper. Private request contexts bypass the
302 * shared Workers Cache entirely; public contexts delegate to the existing
303 * cache helper so cache keys and TTL semantics stay unchanged.
304 */
305export async function cacheOrLoadJSONForRequest<T>(
306 cacheCtx: CacheContext,
307 cacheKey: Request,
308 loader: () => Promise<T | null>,
309 ttl: number
310): Promise<T | null> {
311 if (isRequestPrivate(cacheCtx)) {
312 return await loader();
313 }
314 return await cacheOrLoadJSON(cacheKey, loader, ttl, cacheCtx.ctx);
315}
316 
317/**
318 * Variant of cacheOrLoadJSON where the TTL depends on the loaded value.
319 * Useful when the response type determines TTL (e.g., tree listings vs blob metadata).
320 */
321export async function cacheOrLoadJSONWithTTL<T>(
322 cacheKey: Request,
323 loader: () => Promise<T | null>,
324 ttlResolver: (value: T) => number,
325 ctx?: ExecutionContext
326): Promise<T | null> {
327 // Try cache first
328 const cached = await cacheGetJSON<T>(cacheKey);
329 if (cached) return cached;
330 
331 // Load from source
332 const result = await loader();
333 if (!result) return null;
334 
335 // Resolve TTL and save in background
336 const ttl = Math.max(0, Math.floor(ttlResolver(result)));
337 const savePromise = cachePutJSON(cacheKey, result, ttl);
338 if (ctx) ctx.waitUntil(savePromise);
339 else savePromise.catch(() => {});
340 return result;
341}
342 
343/**
344 * Request-aware TTL variant. The loader still runs for private requests,
345 * but no shared-cache read or write is attempted for membership-derived or
346 * otherwise sensitive data.
347 */
348export async function cacheOrLoadJSONForRequestWithTTL<T>(
349 cacheCtx: CacheContext,
350 cacheKey: Request,
351 loader: () => Promise<T | null>,
352 ttlResolver: (value: T) => number
353): Promise<T | null> {
354 if (isRequestPrivate(cacheCtx)) {
355 return await loader();
356 }
357 return await cacheOrLoadJSONWithTTL(cacheKey, loader, ttlResolver, cacheCtx.ctx);
358}