151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
/**
|
|
* Caching primitives:
|
|
* - `TTLCache` — a small in-memory time-to-live cache with `getOrLoad`, for
|
|
* memoising expensive data (query results, computed pages).
|
|
* - HTTP helpers — `cacheControl` to build a directive, `withCacheControl` to
|
|
* apply it, and `etag` / `notModified` for conditional requests (304s).
|
|
*/
|
|
|
|
// --- In-memory TTL cache ---------------------------------------------------
|
|
|
|
interface Entry<V> {
|
|
value: V;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export class TTLCache<V = unknown> {
|
|
private store = new Map<string, Entry<V>>();
|
|
private loading = new Map<string, Promise<V>>();
|
|
private revisions = new Map<string, number>();
|
|
private generation = 0;
|
|
constructor(private readonly ttlMs = 60_000) {}
|
|
|
|
get(key: string): V | undefined {
|
|
const entry = this.store.get(key);
|
|
if (!entry) return undefined;
|
|
if (entry.expiresAt <= Date.now()) {
|
|
this.store.delete(key);
|
|
return undefined;
|
|
}
|
|
return entry.value;
|
|
}
|
|
|
|
set(key: string, value: V, ttlMs = this.ttlMs): void {
|
|
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
|
|
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
}
|
|
|
|
/** Return the cached value or compute, cache, and return it. */
|
|
async getOrLoad(key: string, loader: () => Promise<V> | V, ttlMs = this.ttlMs): Promise<V> {
|
|
const hit = this.get(key);
|
|
if (hit !== undefined) return hit;
|
|
const pending = this.loading.get(key);
|
|
if (pending) return pending;
|
|
const revision = this.revisions.get(key) ?? 0;
|
|
const generation = this.generation;
|
|
const promise = Promise.resolve().then(loader);
|
|
this.loading.set(key, promise);
|
|
try {
|
|
const value = await promise;
|
|
if (this.generation === generation && (this.revisions.get(key) ?? 0) === revision) {
|
|
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
}
|
|
return value;
|
|
} finally {
|
|
if (this.loading.get(key) === promise) this.loading.delete(key);
|
|
}
|
|
}
|
|
|
|
delete(key: string): void {
|
|
this.store.delete(key);
|
|
this.loading.delete(key);
|
|
this.revisions.set(key, (this.revisions.get(key) ?? 0) + 1);
|
|
}
|
|
|
|
clear(): void {
|
|
this.store.clear();
|
|
this.loading.clear();
|
|
this.revisions.clear();
|
|
this.generation++;
|
|
}
|
|
|
|
get size(): number {
|
|
return this.store.size;
|
|
}
|
|
}
|
|
|
|
// --- HTTP caching ----------------------------------------------------------
|
|
|
|
export interface CacheControlOptions {
|
|
/** max-age in seconds. */
|
|
maxAge?: number;
|
|
/** s-maxage (shared/CDN cache) in seconds. */
|
|
sMaxAge?: number;
|
|
/** Mark private (per-user) rather than public. */
|
|
private?: boolean;
|
|
/** no-store: never cache. Overrides other directives. */
|
|
noStore?: boolean;
|
|
/** no-cache: revalidate before use. */
|
|
noCache?: boolean;
|
|
/** stale-while-revalidate window in seconds. */
|
|
staleWhileRevalidate?: number;
|
|
/** stale-if-error window in seconds. */
|
|
staleIfError?: number;
|
|
immutable?: boolean;
|
|
}
|
|
|
|
/** Build a Cache-Control header value from options. */
|
|
export function cacheControl(options: CacheControlOptions): string {
|
|
if (options.noStore) return "no-store";
|
|
const parts: string[] = [options.private ? "private" : "public"];
|
|
if (options.noCache) parts.push("no-cache");
|
|
if (options.maxAge !== undefined)
|
|
parts.push(`max-age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
if (options.sMaxAge !== undefined)
|
|
parts.push(`s-maxage=${Math.max(0, Math.floor(options.sMaxAge))}`);
|
|
if (options.staleWhileRevalidate !== undefined) {
|
|
parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
|
|
}
|
|
if (options.staleIfError !== undefined) {
|
|
parts.push(`stale-if-error=${Math.max(0, Math.floor(options.staleIfError))}`);
|
|
}
|
|
if (options.immutable) parts.push("immutable");
|
|
return parts.join(", ");
|
|
}
|
|
|
|
/** Apply a Cache-Control header to a response (returns the same response). */
|
|
export function withCacheControl(res: Response, options: CacheControlOptions): Response {
|
|
try {
|
|
res.headers.set("Cache-Control", cacheControl(options));
|
|
} catch {
|
|
/* immutable response — skip */
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/** A stable, quoted ETag for a string/bytes body (FNV-1a, weak by default). */
|
|
export function etag(body: string | ArrayBuffer | Uint8Array, weak = true): string {
|
|
const bytes =
|
|
typeof body === "string"
|
|
? new TextEncoder().encode(body)
|
|
: body instanceof Uint8Array
|
|
? body
|
|
: new Uint8Array(body);
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
hash ^= bytes[i]!;
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
const tag = `"${(hash >>> 0).toString(16)}-${bytes.length.toString(16)}"`;
|
|
return weak ? `W/${tag}` : tag;
|
|
}
|
|
|
|
/** True when the request's If-None-Match matches the given ETag (send a 304). */
|
|
export function notModified(req: Request, tag: string): boolean {
|
|
const inm = req.headers.get("if-none-match");
|
|
if (!inm) return false;
|
|
const normalize = (t: string) => t.trim().replace(/^W\//, "");
|
|
const target = normalize(tag);
|
|
return inm.split(",").some((candidate) => normalize(candidate) === target);
|
|
}
|