release: WRNexusJS 0.7.0
This commit is contained in:
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export { TagCache } from "./memory.ts";
|
||||
export type { CacheEntry, CacheLookup, CacheSetOptions, TagCacheOptions } from "./memory.ts";
|
||||
export { responseCache } from "./response.ts";
|
||||
export type { CachedResponse, ResponseCacheOptions } from "./response.ts";
|
||||
Vendored
+151
@@ -0,0 +1,151 @@
|
||||
export interface CacheEntry<V> {
|
||||
value: V;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
staleUntil: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type CacheLookup<V> = { state: "miss" } | { state: "fresh" | "stale"; entry: CacheEntry<V> };
|
||||
|
||||
export interface CacheSetOptions {
|
||||
ttlMs?: number;
|
||||
staleWhileRevalidateMs?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TagCacheOptions {
|
||||
ttlMs?: number;
|
||||
staleWhileRevalidateMs?: number;
|
||||
maxEntries?: number;
|
||||
clock?: () => number;
|
||||
}
|
||||
|
||||
export class TagCache<V = unknown> {
|
||||
private entries = new Map<string, CacheEntry<V>>();
|
||||
private tagIndex = new Map<string, Set<string>>();
|
||||
private pending = new Map<string, Promise<V>>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly staleMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(options: TagCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? 60_000;
|
||||
this.staleMs = options.staleWhileRevalidateMs ?? 0;
|
||||
this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
lookup(key: string): CacheLookup<V> {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return { state: "miss" };
|
||||
const now = this.clock();
|
||||
if (entry.staleUntil <= now) {
|
||||
this.delete(key);
|
||||
return { state: "miss" };
|
||||
}
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return { state: entry.expiresAt > now ? "fresh" : "stale", entry };
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const hit = this.lookup(key);
|
||||
return hit.state === "miss" ? undefined : hit.entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V, options: CacheSetOptions = {}): void {
|
||||
this.delete(key);
|
||||
const now = this.clock();
|
||||
const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
|
||||
const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
|
||||
const tags = [...new Set(options.tags ?? [])];
|
||||
const entry: CacheEntry<V> = {
|
||||
value,
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
staleUntil: now + ttlMs + staleMs,
|
||||
tags,
|
||||
};
|
||||
this.entries.set(key, entry);
|
||||
for (const tag of tags) {
|
||||
const keys = this.tagIndex.get(tag) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
this.tagIndex.set(tag, keys);
|
||||
}
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldest = this.entries.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
this.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async getOrLoad(
|
||||
key: string,
|
||||
loader: () => V | Promise<V>,
|
||||
options: CacheSetOptions = {},
|
||||
): Promise<V> {
|
||||
const hit = this.lookup(key);
|
||||
if (hit.state === "fresh") return hit.entry.value;
|
||||
if (hit.state === "stale") {
|
||||
if (!this.pending.has(key)) {
|
||||
const refresh = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
this.pending.set(key, refresh);
|
||||
}
|
||||
return hit.entry.value;
|
||||
}
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
const pending = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
this.pending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return false;
|
||||
this.entries.delete(key);
|
||||
for (const tag of entry.tags) {
|
||||
const keys = this.tagIndex.get(tag);
|
||||
keys?.delete(key);
|
||||
if (keys?.size === 0) this.tagIndex.delete(tag);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
invalidateTag(tag: string): number {
|
||||
const keys = [...(this.tagIndex.get(tag) ?? [])];
|
||||
for (const key of keys) this.delete(key);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
invalidateTags(tags: Iterable<string>): number {
|
||||
const keys = new Set<string>();
|
||||
for (const tag of tags) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
||||
for (const key of keys) this.delete(key);
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.tagIndex.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
Vendored
+109
@@ -0,0 +1,109 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import { etag, notModified } from "@wrnexus/core";
|
||||
import { TagCache, type CacheSetOptions } from "./memory.ts";
|
||||
|
||||
export interface CachedResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: [string, string][];
|
||||
body: Uint8Array;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface ResponseCacheOptions extends CacheSetOptions {
|
||||
cache?: TagCache<CachedResponse>;
|
||||
key?: (ctx: Context) => string;
|
||||
vary?: string[];
|
||||
shouldCache?: (ctx: Context, response: Response) => boolean;
|
||||
/**
|
||||
* Optional detached revalidator used for stale-while-revalidate. Middleware
|
||||
* `next()` is deliberately never called after a response has been returned,
|
||||
* because many middleware pipelines are single-use.
|
||||
*/
|
||||
revalidate?: (ctx: Context) => Promise<Response>;
|
||||
onRevalidateError?: (error: unknown, ctx: Context) => void;
|
||||
}
|
||||
|
||||
function defaultKey(ctx: Context, vary: string[]): string {
|
||||
const values = vary.map((name) => `${name.toLowerCase()}=${ctx.req.headers.get(name) ?? ""}`);
|
||||
return `${ctx.req.method}:${ctx.url.origin}${ctx.url.pathname}${ctx.url.search}|${values.join("|")}`;
|
||||
}
|
||||
|
||||
function cacheable(ctx: Context, response: Response): boolean {
|
||||
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return false;
|
||||
if (response.status < 200 || response.status >= 400) return false;
|
||||
if (response.headers.has("set-cookie")) return false;
|
||||
const control = response.headers.get("cache-control") ?? "";
|
||||
return !/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(control);
|
||||
}
|
||||
|
||||
function fromCached(ctx: Context, cached: CachedResponse, state: "fresh" | "stale"): Response {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set("x-wrnexus-cache", state === "fresh" ? "HIT" : "STALE");
|
||||
headers.set("age", "0");
|
||||
if (notModified(ctx.req, cached.etag)) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
return new Response(ctx.req.method === "HEAD" ? null : cached.body.slice(), {
|
||||
status: cached.status,
|
||||
statusText: cached.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(response: Response): Promise<CachedResponse> {
|
||||
const body = new Uint8Array(await response.clone().arrayBuffer());
|
||||
const tag = response.headers.get("etag") ?? etag(body);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("etag", tag);
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: [...headers.entries()],
|
||||
body,
|
||||
etag: tag,
|
||||
};
|
||||
}
|
||||
|
||||
export function responseCache(options: ResponseCacheOptions = {}): Middleware {
|
||||
const cache = options.cache ?? new TagCache<CachedResponse>();
|
||||
const vary = options.vary ?? ["accept-encoding", "accept-language"];
|
||||
return async (ctx, next) => {
|
||||
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return next();
|
||||
const key = options.key?.(ctx) ?? defaultKey(ctx, vary);
|
||||
const hit = cache.lookup(key);
|
||||
if (hit.state === "fresh") return fromCached(ctx, hit.entry.value, "fresh");
|
||||
if (hit.state === "stale") {
|
||||
if (options.revalidate) {
|
||||
void options
|
||||
.revalidate(ctx)
|
||||
.then(async (response) => {
|
||||
if ((options.shouldCache ?? cacheable)(ctx, response)) {
|
||||
cache.set(key, await capture(response), options);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => options.onRevalidateError?.(error, ctx));
|
||||
}
|
||||
return fromCached(ctx, hit.entry.value, "stale");
|
||||
}
|
||||
|
||||
const response = await next();
|
||||
if (!(options.shouldCache ?? cacheable)(ctx, response)) {
|
||||
try {
|
||||
response.headers.set("x-wrnexus-cache", "BYPASS");
|
||||
} catch {
|
||||
// Immutable response.
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const stored = await capture(response);
|
||||
cache.set(key, stored, options);
|
||||
const headers = new Headers(stored.headers);
|
||||
headers.set("x-wrnexus-cache", "MISS");
|
||||
return new Response(ctx.req.method === "HEAD" ? null : stored.body.slice(), {
|
||||
status: stored.status,
|
||||
statusText: stored.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user