Files
WRNexusJS/packages/core/src/cache.ts
2026-08-01 10:04:42 +05:30

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);
}