first commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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;
|
||||
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.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);
|
||||
}
|
||||
Reference in New Issue
Block a user