Skip to content
147 linesCodeBlameRaw
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 */
15export 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 */
27export 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. */
77export 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`). */
84export 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. */
90export 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. */
93export const SHARED_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
94
95export 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 */
117export 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}