Skip to content

g1t/apps/web/app/lib/cache.server.ts

87 lines3,261 bytesCodeBlame
1import { waitUntil } from "cloudflare:workers";
2
3import { mustReadFresh } from "./perf.server";
4
5/**
6 * A few seconds of memory for answers that rarely change and are asked on
7 * every page, such as the sidebar's projects and the workspace's spend:
8 * kept per isolate, and in the data centre's cache so the other isolates
9 * there find it too, by a key that names the viewer, so one person's answer
10 * is never another's. A person's pages land on many isolates, so the
11 * isolate's memory alone was rarely there when the next page asked.
12 *
13 * Never used while the answer must be current: during a request that
14 * writes, and for a while after the person's last write (lib/perf.ts,
15 * `PRIMARY_WINDOW_SECONDS`). Failures and `{ ok: false }` results are not
16 * kept.
17 */
18const kept = new Map<string, { until: number; value: unknown }>();
19
20/** Entries kept at most; the oldest go first. */
21const MAX_ENTRIES = 2_000;
22
23/** Where the data centre's cache keeps them: a name nothing outside can ask for. */
24const ORIGIN = "https://short.g1t.internal/";
25
26function colo(): Cache | null {
27 try {
28 return (caches as unknown as { default: Cache }).default ?? null;
29 } catch {
30 // No cache (local development).
31 return null;
32 }
33}
34
35function keep(key: string, ttlMs: number, value: unknown) {
36 kept.delete(key);
37 kept.set(key, { until: Date.now() + ttlMs, value });
38 if (kept.size > MAX_ENTRIES) kept.delete(kept.keys().next().value as string);
39}
40
41/**
42 * Only settled answers are kept, never a promise still in flight: a
43 * Worker's I/O belongs to the request that started it, so another request
44 * must not wait on it.
45 */
46export async function shortCache<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
47 const cache = colo();
48 const url = ORIGIN + encodeURIComponent(key);
49 if (mustReadFresh()) {
50 kept.delete(key);
51 // What the data centre kept is older than what they just did.
52 if (cache) waitUntil(cache.delete(url).then(() => undefined, () => undefined));
53 return load();
54 }
55 const hit = kept.get(key);
56 if (hit && hit.until > Date.now()) return hit.value as T;
57 if (cache) {
58 const shared = await cache.match(url).catch(() => undefined);
59 if (shared) {
60 const value = (await shared.json().catch(() => undefined)) as T | undefined;
61 const left = Number(shared.headers.get("x-until")) - Date.now();
62 if (value !== undefined && left > 0) {
63 keep(key, Math.min(left, ttlMs), value);
64 return value;
65 }
66 }
67 }
68 const value = await load();
69 const failed = typeof value === "object" && value != null && "ok" in value && value.ok === false;
70 if (!failed) {
71 keep(key, ttlMs, value);
72 if (cache && value !== undefined) {
73 const seconds = Math.max(1, Math.ceil(ttlMs / 1000));
74 const response = new Response(JSON.stringify(value), {
75 headers: {
76 "content-type": "application/json",
77 // Not `private`: the cache would refuse it. The key names the viewer.
78 "cache-control": `max-age=${seconds}`,
79 "x-until": String(Date.now() + ttlMs),
80 },
81 });
82 // After the page is sent: the person waits for nothing here.
83 waitUntil(cache.put(url, response).catch(() => undefined));
84 }
85 }
86 return value;
87}