| 1 | /** |
| 2 | * Work done on content that never changes once written: highlighting a |
| 3 | * file's text, turning a README into a tree. The answer depends on the |
| 4 | * content alone, so it is kept by a hash of that content and is good for |
| 5 | * as long as the code that made it is the same (`version`, part of every |
| 6 | * key). No viewer, path or branch is in the key, so the same text reached |
| 7 | * by another branch, commit or page is the same entry, and nothing in an |
| 8 | * entry says whose it is or where it came from. |
| 9 | */ |
| 10 | |
| 11 | /** |
| 12 | * A key for `parts`: SHA-256 over each part, length-prefixed, so |
| 13 | * `["ab", "c"]` and `["a", "bc"]` never collide. Hex, 64 characters. |
| 14 | */ |
| 15 | export async function contentKey(parts: readonly string[]): Promise<string> { |
| 16 | const text = parts.map((part) => `${part.length}:${part}`).join(""); |
| 17 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); |
| 18 | return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * An isolate's memory of recent answers, bounded by their total weight |
| 23 | * (characters, roughly) rather than by count, so a few large files cannot |
| 24 | * hold more than `maxWeight` between them. The least recently used go |
| 25 | * first. An entry heavier than a quarter of the whole is never kept. |
| 26 | */ |
| 27 | export class WeightedLru<V> { |
| 28 | private readonly entries = new Map<string, { value: V; weight: number }>(); |
| 29 | private total = 0; |
| 30 | private readonly maxWeight: number; |
| 31 | private readonly weigh: (value: V) => number; |
| 32 | |
| 33 | constructor(maxWeight: number, weigh: (value: V) => number) { |
| 34 | this.maxWeight = maxWeight; |
| 35 | this.weigh = weigh; |
| 36 | } |
| 37 | |
| 38 | get(key: string): V | undefined { |
| 39 | const entry = this.entries.get(key); |
| 40 | if (!entry) return undefined; |
| 41 | // Most recently used last. |
| 42 | this.entries.delete(key); |
| 43 | this.entries.set(key, entry); |
| 44 | return entry.value; |
| 45 | } |
| 46 | |
| 47 | set(key: string, value: V): void { |
| 48 | const weight = Math.max(1, this.weigh(value)); |
| 49 | this.delete(key); |
| 50 | if (weight > this.maxWeight / 4) return; |
| 51 | this.entries.set(key, { value, weight }); |
| 52 | this.total += weight; |
| 53 | for (const [oldest, entry] of this.entries) { |
| 54 | if (this.total <= this.maxWeight) break; |
| 55 | this.entries.delete(oldest); |
| 56 | this.total -= entry.weight; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | delete(key: string): void { |
| 61 | const entry = this.entries.get(key); |
| 62 | if (!entry) return; |
| 63 | this.entries.delete(key); |
| 64 | this.total -= entry.weight; |
| 65 | } |
| 66 | |
| 67 | get size(): number { |
| 68 | return this.entries.size; |
| 69 | } |
| 70 | |
| 71 | get weight(): number { |
| 72 | return this.total; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /** The weight of a list of strings: their characters. */ |
| 77 | export function weightOfLines(lines: readonly (string | null)[]): number { |
| 78 | let total = 0; |
| 79 | for (const line of lines) total += (line?.length ?? 0) + 8; |
| 80 | return total; |
| 81 | } |
| 82 | |
| 83 | /** The data centre's cache, as far as a content cache needs it (`caches.default`). */ |
| 84 | export type SharedCache = { |
| 85 | match(url: string): Promise<Response | undefined>; |
| 86 | put(url: string, response: Response): Promise<void>; |
| 87 | }; |
| 88 | |
| 89 | /** Where shared entries live: a name nothing outside can ask for. */ |
| 90 | export const CONTENT_ORIGIN = "https://content.g1t.internal/"; |
| 91 | |
| 92 | /** A shared entry is good for this long; its key changes when its content or code does. */ |
| 93 | export const SHARED_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; |
| 94 | |
| 95 | export type ContentCacheOptions<V> = { |
| 96 | /** What is kept, e.g. `highlight-lines`: part of every key. */ |
| 97 | name: string; |
| 98 | /** Bumped whenever the code that makes an entry changes what it makes. */ |
| 99 | version: string; |
| 100 | /** The isolate's share, in `weigh` units. */ |
| 101 | maxWeight: number; |
| 102 | weigh: (value: V) => number; |
| 103 | /** The data centre's cache, or null where there is none (tests, local development). */ |
| 104 | shared: () => SharedCache | null; |
| 105 | /** Lets a write to the shared cache finish after the answer (`waitUntil`). */ |
| 106 | defer: (work: Promise<unknown>) => void; |
| 107 | }; |
| 108 | |
| 109 | /** |
| 110 | * `compute`'s answer for content `parts`, kept in the isolate and in the |
| 111 | * data centre's cache. The isolate is asked first, then the data centre; |
| 112 | * only when neither has it is `compute` run, and its answer kept in both. |
| 113 | * A null answer (nothing to keep: no language, too large, failed) is |
| 114 | * returned and never kept. A shared entry that cannot be read is treated |
| 115 | * as missing. |
| 116 | */ |
| 117 | export function contentCache<V>(options: ContentCacheOptions<V>) { |
| 118 | const memory = new WeightedLru<V>(options.maxWeight, options.weigh); |
| 119 | const cached = async (parts: readonly string[], compute: () => Promise<V | null>): Promise<V | null> => { |
| 120 | const key = await contentKey([options.name, options.version, ...parts]); |
| 121 | const remembered = memory.get(key); |
| 122 | if (remembered !== undefined) return remembered; |
| 123 | const url = `${CONTENT_ORIGIN}${options.name}/${options.version}/${key}`; |
| 124 | const shared = options.shared(); |
| 125 | if (shared) { |
| 126 | const found = await shared.match(url).catch(() => undefined); |
| 127 | if (found) { |
| 128 | const value = (await found.json().catch(() => undefined)) as V | undefined; |
| 129 | if (value !== undefined && value !== null) { |
| 130 | memory.set(key, value); |
| 131 | return value; |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | const value = await compute(); |
| 136 | if (value === null) return null; |
| 137 | memory.set(key, value); |
| 138 | if (shared) { |
| 139 | const response = new Response(JSON.stringify(value), { |
| 140 | headers: { "content-type": "application/json", "cache-control": `public, max-age=${SHARED_MAX_AGE_SECONDS}` }, |
| 141 | }); |
| 142 | options.defer(shared.put(url, response).catch(() => undefined)); |
| 143 | } |
| 144 | return value; |
| 145 | }; |
| 146 | return Object.assign(cached, { memory }); |
| 147 | } |