1// Jev's answers, kept for an hour in KV (the CACHE binding) so a request 2// the relay has already forwarded is answered again without spending the 3// site's key: a song whose state repeats, a page reloaded, the art check 4// on a song someone just checked. 5// 6// A request is its exact bytes. The key is the SHA-256 of the body as the 7// relay would forward it, under the pinned model, so two requests share an 8// answer only when every byte of what TypeSafe would read is the same; a 9// space, a key order or one digit of the state apart is another key. Only 10// the body is hashed: the relay forwards nothing else of the caller's (not 11// a header, not the address), so nothing else can change the answer, and 12// the key holds nothing of the caller either. 13// 14// Only a 200 is kept, and only when it is at most MAX_BYTES and arrived 15// whole; a refusal, an error or TypeSafe asking to back off never is. The 16// answer is stored after the page has it (ctx.waitUntil), so a miss is not 17// slower than no cache. A failing cache is logged and skipped: the relay 18// then forwards as if there were none. 19export const TTL_S = 3600; 20export const MAX_BYTES = 64 * 1024; // an answer is a few hundred bytes; this only bounds the buffer 21export const CACHE_HEADER = 'Jev-Cache'; // `hit` or `miss`, on every answer the relay gives 22const PREFIX = 'jev.relay/v1'; 23 24type Meta = { contentType: string }; 25 26export async function cacheKey(model: string, body: ArrayBuffer): Promise<string> { 27 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', body)); 28 const hex = Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join(''); 29 return `${PREFIX}/${model}/${hex}`; 30} 31 32const failed = (op: string, e: unknown) => 33 console.error({ event: 'jev.relay.cache', op, error: e instanceof Error ? e.message : String(e) }); 34 35// The kept answer for `key`, or null. 36export async function cached(cache: KVNamespace, key: string): Promise<{ body: ArrayBuffer; contentType: string } | null> { 37 try { 38 const { value, metadata } = await cache.getWithMetadata<Meta>(key, 'arrayBuffer'); 39 if (!value) return null; 40 return { body: value, contentType: metadata?.contentType ?? 'application/json' }; 41 } catch (e) { 42 failed('get', e); 43 return null; 44 } 45} 46 47// Reads `stream` (a tee of the answer the page is getting) and keeps it 48// under `key`, unless it runs past MAX_BYTES or breaks off. 49export async function keep(cache: KVNamespace, key: string, stream: ReadableStream<Uint8Array>, contentType: string) { 50 const reader = stream.getReader(); 51 const chunks: Uint8Array[] = []; 52 let size = 0; 53 try { 54 for (;;) { 55 const { done, value } = await reader.read(); 56 if (done) break; 57 size += value.byteLength; 58 if (size > MAX_BYTES) return void (await reader.cancel()); 59 chunks.push(value); 60 } 61 } catch (e) { 62 return failed('read', e); 63 } 64 const body = new Uint8Array(size); 65 let at = 0; 66 for (const chunk of chunks) { 67 body.set(chunk, at); 68 at += chunk.byteLength; 69 } 70 try { 71 await cache.put(key, body, { expirationTtl: TTL_S, metadata: { contentType } satisfies Meta }); 72 } catch (e) { 73 failed('put', e); 74 } 75} 76 77// How many answers are kept now, for the data tab (data.ts): the keys under 78// this cache's prefix, counted (never read), at most `pages` KV lists of 79// 1000; `complete` says whether that was all of them. A key is a request's 80// hash under the model, so a count is all there is to show. 81export async function cacheEntries(cache: KVNamespace, pages = 1): Promise<{ entries: number; complete: boolean }> { 82 let entries = 0; 83 let cursor: string | undefined; 84 for (let i = 0; i < pages; i++) { 85 const list = await cache.list({ prefix: `${PREFIX}/`, cursor }); 86 entries += list.keys.length; 87 if (list.list_complete) return { entries, complete: true }; 88 cursor = list.cursor; 89 } 90 return { entries, complete: false }; 91}