@alxia/cache 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/cache.ts", "../src/control.ts", "../src/flight.ts", "../src/guard.ts", "../src/keep.ts", "../src/keys.ts", "../src/lookup.ts", "../src/respond.ts", "../src/store.ts"],
4
+ "sourcesContent": [
5
+ "import { type BaseContext, definePlugin, type Empty } from '@alxia/core';\nimport { requestControls } from './control';\nimport { type Loaded, refreshBehind, singleFlight } from './flight';\nimport { storeGuard } from './guard';\nimport { keepable, toCached } from './keep';\nimport { defaultKey, pathTag } from './keys';\nimport { bypasses, freshness } from './lookup';\nimport { respond } from './respond';\nimport { type CacheStore, MemoryCacheStore } from './store';\n\n/**\n * `Requires` is what `key` and `tags` read from the context beyond\n * `BaseContext` — a `user` an earlier plugin adds — and what the app that\n * uses the cache must then give.\n */\nexport interface CacheOptions<Requires extends object = Empty> {\n\t/** Seconds a response is fresh. */\n\treadonly ttl: number;\n\t/**\n\t * Seconds a response is served stale after, while one request refreshes\n\t * it in the background: no client waits for a slow route. None by default.\n\t */\n\treadonly staleWhileRevalidate?: number;\n\t/** Where responses are kept: this process's memory by default. */\n\treadonly store?: CacheStore;\n\t/**\n\t * The key of a request: its path and query by default, and the headers\n\t * in `vary`. `undefined` is not cached: a request with a session, say.\n\t */\n\treadonly key?: (ctx: BaseContext & Requires) => string | undefined;\n\t/** Request headers the response varies by: `accept-language`. Each is part of the key, and of `Vary`. */\n\treadonly vary?: readonly string[];\n\t/** The statuses kept. `200` by default; a 404 may be worth keeping too. */\n\treadonly statuses?: readonly number[];\n\t/** Tags every response of these routes carries, for `invalidateTag`. */\n\treadonly tags?: (ctx: BaseContext & Requires) => readonly string[];\n\t/** Whether `Cache-Control: no-cache` from the client skips the cache. Off by default: a client cannot empty yours. */\n\treadonly honorClientNoCache?: boolean;\n\t/** Says `X-Cache: HIT`, `STALE` or `MISS`, and `Age`. On by default. */\n\treadonly debugHeaders?: boolean;\n}\n\n/** What the routes behind the cache read. */\nexport interface CacheControls {\n\t/** Tags the response being built, beyond the plugin's `tags`. Tags starting `alxia:` are the plugin's own. */\n\ttag(...tags: string[]): void;\n\t/** Keeps this response out of the cache. */\n\tskip(): void;\n}\n\n/** A cache of responses, and the hands to empty it. */\nexport interface Cache {\n\t/**\n\t * Forgets every response kept for `path` — `/users/1?x=y`, as the request\n\t * asked it — whatever its key: each `vary` value, a `key` of your own.\n\t */\n\tinvalidate(path: string): Promise<void>;\n\t/** Forgets every response tagged `tag`. */\n\tinvalidateTag(tag: string): Promise<void>;\n\treadonly store: CacheStore;\n}\n\n/**\n * Responses kept and served again, as a plugin: a `GET` to a route declared\n * after it is answered from the store while fresh, and from the route\n * otherwise. Concurrent misses run the route once. Stale, it is served at\n * once and refreshed behind. A response that says `no-store` or `private`,\n * sets a cookie, or has another status is never kept.\n *\n * Every kept response gets a weak `ETag` from its body when it has none, so\n * a client whose copy is current gets a 304.\n *\n * ```ts\n * const products = cache({ ttl: 60, staleWhileRevalidate: 300, tags: () => ['products'] });\n * app.use(products).get('/products', ...);\n * await products.invalidateTag('products');\n * ```\n *\n * A `key` or `tags` that reads what an earlier plugin added names it, and\n * the app must then give it: `cache<{ user: User }>({ tags: ({ user }) => [user.id], … })`.\n */\nexport function cache<Requires extends object = Empty>(\n\toptions: CacheOptions<Requires>,\n) {\n\tconst store = options.store ?? new MemoryCacheStore();\n\tconst ttl = options.ttl * 1000;\n\tconst stale = (options.staleWhileRevalidate ?? 0) * 1000;\n\tconst vary = (options.vary ?? []).map((name) => name.toLowerCase());\n\tconst statuses = new Set(options.statuses ?? [200]);\n\tconst debug = options.debugHeaders ?? true;\n\tconst honorNoCache = options.honorClientNoCache ?? false;\n\tconst keyOf =\n\t\toptions.key ??\n\t\t((ctx: BaseContext & Requires) =>\n\t\t\tdefaultKey(\n\t\t\t\t`${ctx.url.pathname}${ctx.url.search}`,\n\t\t\t\tvary,\n\t\t\t\tctx.request.headers,\n\t\t\t));\n\tconst controls = requestControls();\n\tconst attempt = storeGuard();\n\tconst flight = singleFlight();\n\n\t/** The miss: the route runs, and what it answers is kept when it may be. */\n\tconst keepOnMiss = async (\n\t\tkey: string,\n\t\tctx: BaseContext & Requires,\n\t\tnext: () => Promise<Response>,\n\t): Promise<Loaded> => {\n\t\tconst response = await next();\n\t\tconst control = controls.get(ctx.request);\n\t\tif (!keepable(response, control, statuses)) return { own: response };\n\t\tconst cached = await toCached(response, {\n\t\t\tttl,\n\t\t\tstale,\n\t\t\tvary,\n\t\t\ttags: () => [\n\t\t\t\t...(options.tags?.(ctx) ?? []),\n\t\t\t\t...(control?.tags ?? []),\n\t\t\t\tpathTag(`${ctx.url.pathname}${ctx.url.search}`),\n\t\t\t],\n\t\t});\n\t\tawait attempt(() => store.set(key, cached, ttl + stale), undefined);\n\t\treturn { kept: cached };\n\t};\n\tconst load = (\n\t\tkey: string,\n\t\tctx: BaseContext & Requires,\n\t\tnext: () => Promise<Response>,\n\t) => flight.load(key, () => keepOnMiss(key, ctx, next), next);\n\n\tconst label = (says: 'HIT' | 'STALE' | 'MISS') => (debug ? says : undefined);\n\n\tconst plugin = definePlugin<Requires>()((app) =>\n\t\tapp\n\t\t\t.derive(({ request }) => {\n\t\t\t\tconst cacheControls: CacheControls = controls.open(request);\n\t\t\t\treturn { cache: cacheControls };\n\t\t\t})\n\t\t\t.wrap(async (ctx, next) => {\n\t\t\t\tconst { request } = ctx;\n\t\t\t\tconst key = bypasses(request, honorNoCache) ? undefined : keyOf(ctx);\n\t\t\t\tif (key === undefined) return next();\n\n\t\t\t\tconst found = await attempt(() => store.get(key), undefined);\n\t\t\t\tconst worth = found === undefined ? undefined : freshness(found);\n\t\t\t\tif (found !== undefined && worth === 'fresh') {\n\t\t\t\t\treturn respond(request, found, label('HIT'));\n\t\t\t\t}\n\t\t\t\tif (found !== undefined && worth === 'stale') {\n\t\t\t\t\tif (!flight.has(key)) refreshBehind(load(key, ctx, next));\n\t\t\t\t\treturn respond(request, found, label('STALE'));\n\t\t\t\t}\n\t\t\t\tconst loaded = await load(key, ctx, next);\n\t\t\t\tif (loaded instanceof Response) return loaded;\n\t\t\t\treturn respond(request, loaded, label('MISS'));\n\t\t\t}),\n\t);\n\n\treturn Object.assign(plugin, handlesOf(store));\n}\n\n/** The hands to empty a cache: by the path a request asked, or by tag. */\nfunction handlesOf(store: CacheStore): Cache {\n\treturn {\n\t\tstore,\n\t\tinvalidate: async (path) => {\n\t\t\tawait store.deleteTag(pathTag(path));\n\t\t},\n\t\tinvalidateTag: async (tag) => {\n\t\t\tawait store.deleteTag(tag);\n\t\t},\n\t};\n}\n",
6
+ "/** What each route behind the cache says through `ctx.cache`, kept by request. */\n\n/** What a route said through `ctx.cache`, for the response it is building. */\nexport interface Control {\n\treadonly tags: string[];\n\tskipped: boolean;\n}\n\nexport function requestControls() {\n\tconst controls = new WeakMap<Request, Control>();\n\treturn {\n\t\t/** What the route said for `request`, once it has run. */\n\t\tget: (request: Request): Control | undefined => controls.get(request),\n\t\t/** A fresh control for `request`, and the hands a route is given to it. */\n\t\topen(request: Request) {\n\t\t\tconst control: Control = { tags: [], skipped: false };\n\t\t\tcontrols.set(request, control);\n\t\t\treturn {\n\t\t\t\ttag: (...tags: string[]) => {\n\t\t\t\t\tcontrol.tags.push(...tags);\n\t\t\t\t},\n\t\t\t\tskip: () => {\n\t\t\t\t\tcontrol.skipped = true;\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n",
7
+ "import type { CachedResponse } from './store';\n\n/** What a run of the route gives: a response it kept, or its own when it kept nothing. */\nexport type Loaded = { kept: CachedResponse } | { own: Response };\n\n/**\n * Runs the route once for every concurrent miss of a key. Only a response\n * that is kept is shared: one that is not — private, skipped, a cookie,\n * another status — answers the request that ran the route, and every other\n * waiting request runs the route itself, through its own `next`.\n */\nexport function singleFlight() {\n\t/** The run of each key being loaded: what it kept, or `undefined` when it kept nothing. */\n\tconst loading = new Map<string, Promise<CachedResponse | undefined>>();\n\treturn {\n\t\t/** Whether a run of `key` is under way. */\n\t\thas: (key: string): boolean => loading.has(key),\n\t\tload(\n\t\t\tkey: string,\n\t\t\trun: () => Promise<Loaded>,\n\t\t\tnext: () => Promise<Response>,\n\t\t): Promise<CachedResponse | Response> {\n\t\t\tconst running = loading.get(key);\n\t\t\tif (running !== undefined) {\n\t\t\t\treturn running.then(\n\t\t\t\t\t(cached): Promise<CachedResponse | Response> | CachedResponse =>\n\t\t\t\t\t\tcached ?? next(),\n\t\t\t\t\t(): Promise<CachedResponse | Response> => next(),\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst ran = run();\n\t\t\tconst shared = ran.then((loaded) =>\n\t\t\t\t'kept' in loaded ? loaded.kept : undefined,\n\t\t\t);\n\t\t\tloading.set(key, shared);\n\t\t\tshared.finally(() => loading.delete(key)).catch(() => {});\n\t\t\treturn ran.then((loaded) =>\n\t\t\t\t'kept' in loaded ? loaded.kept : loaded.own,\n\t\t\t);\n\t\t},\n\t};\n}\n\n/**\n * A stale response's refresh, run behind it: the route's own error is its to\n * log, and a response it does not keep is read by no one.\n */\nexport function refreshBehind(\n\tloading: Promise<CachedResponse | Response>,\n): void {\n\tloading\n\t\t.then((loaded) =>\n\t\t\tloaded instanceof Response ? loaded.body?.cancel() : undefined,\n\t\t)\n\t\t.catch((error) => console.error(error));\n}\n",
8
+ "/**\n * A store that cannot answer is a miss, or keeps nothing: a cache is never\n * worth a 500. Said to the log once per outage — again only after the store\n * has answered since.\n */\nexport function storeGuard() {\n\tlet failing = false;\n\treturn async <T>(work: () => Promise<T> | T, fallback: T): Promise<T> => {\n\t\ttry {\n\t\t\tconst value = await work();\n\t\t\tfailing = false;\n\t\t\treturn value;\n\t\t} catch (error) {\n\t\t\tif (!failing) console.error(error);\n\t\t\tfailing = true;\n\t\t\treturn fallback;\n\t\t}\n\t};\n}\n",
9
+ "/** What a response must be to be kept, and what is kept of it. */\nimport { vary as addVary } from '@alxia/core';\nimport type { Control } from './control';\nimport type { CachedResponse } from './store';\n\n/** Response headers that make a response someone's own. */\nconst PRIVATE = /\\b(no-store|private)\\b/i;\n\n/**\n * Whether a response may be kept: not skipped, of a kept status, nobody's\n * own — no `no-store`, `private` or cookie — and not an event stream.\n */\nexport function keepable(\n\tresponse: Response,\n\tcontrol: Control | undefined,\n\tstatuses: ReadonlySet<number>,\n): boolean {\n\treturn !(\n\t\tcontrol?.skipped ||\n\t\t!statuses.has(response.status) ||\n\t\tPRIVATE.test(response.headers.get('cache-control') ?? '') ||\n\t\tresponse.headers.has('set-cookie') ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t);\n}\n\n/**\n * The response as it is kept: its body read, `Content-Length` and `Date`\n * dropped, a weak `ETag` of its body when it has none, and `Vary` naming\n * each header in `vary`. `tags` is asked once the body is read.\n */\nexport async function toCached(\n\tresponse: Response,\n\tkeep: {\n\t\treadonly ttl: number;\n\t\treadonly stale: number;\n\t\treadonly vary: readonly string[];\n\t\treadonly tags: () => string[];\n\t},\n): Promise<CachedResponse> {\n\tconst body = new Uint8Array(await response.arrayBuffer());\n\tconst headers = new Headers(response.headers);\n\theaders.delete('content-length');\n\theaders.delete('date');\n\tif (!headers.has('etag')) {\n\t\theaders.set('etag', `W/\"${Bun.hash(body).toString(36)}\"`);\n\t}\n\tfor (const name of keep.vary) addVary(headers, name);\n\treturn {\n\t\tstatus: response.status,\n\t\theaders: [...headers],\n\t\tbody,\n\t\tstoredAt: Date.now(),\n\t\tttl: keep.ttl,\n\t\tstale: keep.stale,\n\t\ttags: keep.tags(),\n\t};\n}\n",
10
+ "/**\n * The tag every kept response carries for its path — `/users/1?x=y`, as the\n * request asked it — and what `invalidate(path)` forgets. Tags starting\n * `alxia:` are the plugin's own. Exported for a store, or a job, that\n * forgets a path without the `Cache` at hand: `store.deleteTag(pathTag(p))`.\n */\nexport const pathTag = (path: string): string => `alxia:path:${path}`;\n\n/** The default key: the path and query, then each varying header's value. */\nexport function defaultKey(\n\tpath: string,\n\tvary: readonly string[],\n\theaders: Headers,\n): string {\n\tif (vary.length === 0) return path;\n\treturn `${path}|${vary.map((name) => `${name}=${headers.get(name) ?? ''}`).join('|')}`;\n}\n",
11
+ "/** Which requests the cache answers, and what a kept response is worth now. */\nimport type { CachedResponse } from './store';\n\n/** Whether a request goes straight to the route: not a `GET` or `HEAD`, or a `no-cache` the cache honors. */\nexport function bypasses(\n\trequest: Request,\n\thonorClientNoCache: boolean,\n): boolean {\n\tif (request.method !== 'GET' && request.method !== 'HEAD') return true;\n\treturn (\n\t\thonorClientNoCache &&\n\t\t/\\bno-cache\\b/i.test(request.headers.get('cache-control') ?? '')\n\t);\n}\n\n/** A kept response is `fresh` within its ttl, `stale` within its stale window after, and worth nothing beyond. */\nexport function freshness(\n\tfound: CachedResponse,\n\tnow: number = Date.now(),\n): 'fresh' | 'stale' | undefined {\n\tconst age = now - found.storedAt;\n\tif (age < found.ttl) return 'fresh';\n\tif (age < found.ttl + found.stale) return 'stale';\n\treturn undefined;\n}\n",
12
+ "import type { CachedResponse } from './store';\n\n/** A kept response, or a 304 to a client that has it. */\nexport function respond(\n\trequest: Request,\n\tcached: CachedResponse,\n\tstate: string | undefined,\n): Response {\n\tconst headers = new Headers(cached.headers as [string, string][]);\n\tif (state !== undefined) {\n\t\theaders.set('x-cache', state);\n\t\theaders.set(\n\t\t\t'age',\n\t\t\tString(Math.max(0, Math.floor((Date.now() - cached.storedAt) / 1000))),\n\t\t);\n\t}\n\tconst etag = headers.get('etag');\n\tconst match = request.headers.get('if-none-match');\n\tif (etag !== null && match !== null) {\n\t\tconst weak = (tag: string) => tag.trim().replace(/^W\\//, '');\n\t\tif (\n\t\t\tmatch\n\t\t\t\t.split(',')\n\t\t\t\t.some((tag) => tag.trim() === '*' || weak(tag) === weak(etag))\n\t\t) {\n\t\t\treturn new Response(null, { status: 304, headers });\n\t\t}\n\t}\n\treturn new Response(\n\t\tcached.status === 204 ? null : (cached.body as Uint8Array<ArrayBuffer>),\n\t\t{\n\t\t\tstatus: cached.status,\n\t\t\theaders,\n\t\t},\n\t);\n}\n",
13
+ "/** A response as a store keeps it. */\nexport interface CachedResponse {\n\treadonly status: number;\n\treadonly headers: readonly (readonly [string, string])[];\n\treadonly body: Uint8Array;\n\t/** Milliseconds since the epoch. */\n\treadonly storedAt: number;\n\t/** Milliseconds it is fresh for, from `storedAt`. */\n\treadonly ttl: number;\n\t/** Milliseconds it may be served stale after, while it is refreshed. */\n\treadonly stale: number;\n\treadonly tags: readonly string[];\n}\n\n/**\n * Where responses are kept. The memory store keeps them in one process;\n * `@alxia/redis`'s `redisCacheStore` across every process sharing a Redis.\n */\nexport interface CacheStore {\n\tget(\n\t\tkey: string,\n\t): Promise<CachedResponse | undefined> | CachedResponse | undefined;\n\t/** Keeps `value` for `keepFor` milliseconds: its freshness and its staleness together. */\n\tset(\n\t\tkey: string,\n\t\tvalue: CachedResponse,\n\t\tkeepFor: number,\n\t): Promise<void> | void;\n\tdelete(key: string): Promise<void> | void;\n\t/** Forgets every response tagged `tag`. */\n\tdeleteTag(tag: string): Promise<void> | void;\n}\n\nexport interface MemoryCacheOptions {\n\t/** The most responses kept: the least recently read goes first. 1 000 by default. */\n\treadonly maxEntries?: number;\n\t/** The most bytes of bodies kept. 64 MiB by default. */\n\treadonly maxBytes?: number;\n}\n\n/** A least-recently-used store in one process's memory. */\nexport class MemoryCacheStore implements CacheStore {\n\treadonly #entries = new Map<\n\t\tstring,\n\t\t{ value: CachedResponse; expiresAt: number }\n\t>();\n\treadonly #tags = new Map<string, Set<string>>();\n\treadonly #maxEntries: number;\n\treadonly #maxBytes: number;\n\t#bytes = 0;\n\n\tconstructor(options: MemoryCacheOptions = {}) {\n\t\tthis.#maxEntries = options.maxEntries ?? 1_000;\n\t\tthis.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024;\n\t}\n\n\tget(key: string): CachedResponse | undefined {\n\t\tconst entry = this.#entries.get(key);\n\t\tif (entry === undefined) return undefined;\n\t\tif (entry.expiresAt <= Date.now()) {\n\t\t\tthis.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\t// Read again: the most recently used goes to the end.\n\t\tthis.#entries.delete(key);\n\t\tthis.#entries.set(key, entry);\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: CachedResponse, keepFor: number): void {\n\t\tthis.delete(key);\n\t\tif (value.body.byteLength > this.#maxBytes) return;\n\t\tthis.#entries.set(key, { value, expiresAt: Date.now() + keepFor });\n\t\tthis.#bytes += value.body.byteLength;\n\t\tfor (const tag of value.tags) {\n\t\t\tlet keys = this.#tags.get(tag);\n\t\t\tif (keys === undefined) {\n\t\t\t\tkeys = new Set();\n\t\t\t\tthis.#tags.set(tag, keys);\n\t\t\t}\n\t\t\tkeys.add(key);\n\t\t}\n\t\tfor (const oldest of this.#entries.keys()) {\n\t\t\tif (\n\t\t\t\tthis.#entries.size <= this.#maxEntries &&\n\t\t\t\tthis.#bytes <= this.#maxBytes\n\t\t\t)\n\t\t\t\tbreak;\n\t\t\tthis.delete(oldest);\n\t\t}\n\t}\n\n\tdelete(key: string): void {\n\t\tconst entry = this.#entries.get(key);\n\t\tif (entry === undefined) return;\n\t\tthis.#entries.delete(key);\n\t\tthis.#bytes -= entry.value.body.byteLength;\n\t\tfor (const tag of entry.value.tags) {\n\t\t\tconst keys = this.#tags.get(tag);\n\t\t\tkeys?.delete(key);\n\t\t\tif (keys?.size === 0) this.#tags.delete(tag);\n\t\t}\n\t}\n\n\tdeleteTag(tag: string): void {\n\t\tfor (const key of [...(this.#tags.get(tag) ?? [])]) this.delete(key);\n\t}\n\n\t/** How many responses are kept. */\n\tget size(): number {\n\t\treturn this.#entries.size;\n\t}\n}\n"
14
+ ],
15
+ "mappings": "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",
16
+ "debugId": "01EFC24056EEAC7464756E2164756E21",
17
+ "names": []
18
+ }
package/dist/keep.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Control } from './control';
2
+ import type { CachedResponse } from './store';
3
+ /**
4
+ * Whether a response may be kept: not skipped, of a kept status, nobody's
5
+ * own — no `no-store`, `private` or cookie — and not an event stream.
6
+ */
7
+ export declare function keepable(response: Response, control: Control | undefined, statuses: ReadonlySet<number>): boolean;
8
+ /**
9
+ * The response as it is kept: its body read, `Content-Length` and `Date`
10
+ * dropped, a weak `ETag` of its body when it has none, and `Vary` naming
11
+ * each header in `vary`. `tags` is asked once the body is read.
12
+ */
13
+ export declare function toCached(response: Response, keep: {
14
+ readonly ttl: number;
15
+ readonly stale: number;
16
+ readonly vary: readonly string[];
17
+ readonly tags: () => string[];
18
+ }): Promise<CachedResponse>;
19
+ //# sourceMappingURL=keep.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keep.d.ts","sourceRoot":"","sources":["../src/keep.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAK9C;;;GAGG;AACH,wBAAgB,QAAQ,CACvB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,GAC3B,OAAO,CAQT;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE;IACL,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,MAAM,EAAE,CAAC;CAC9B,GACC,OAAO,CAAC,cAAc,CAAC,CAkBzB"}
package/dist/keys.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The tag every kept response carries for its path — `/users/1?x=y`, as the
3
+ * request asked it — and what `invalidate(path)` forgets. Tags starting
4
+ * `alxia:` are the plugin's own. Exported for a store, or a job, that
5
+ * forgets a path without the `Cache` at hand: `store.deleteTag(pathTag(p))`.
6
+ */
7
+ export declare const pathTag: (path: string) => string;
8
+ /** The default key: the path and query, then each varying header's value. */
9
+ export declare function defaultKey(path: string, vary: readonly string[], headers: Headers): string;
10
+ //# sourceMappingURL=keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,MAAM,KAAG,MAA8B,CAAC;AAEtE,6EAA6E;AAC7E,wBAAgB,UAAU,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,OAAO,GACd,MAAM,CAGR"}
@@ -0,0 +1,7 @@
1
+ /** Which requests the cache answers, and what a kept response is worth now. */
2
+ import type { CachedResponse } from './store';
3
+ /** Whether a request goes straight to the route: not a `GET` or `HEAD`, or a `no-cache` the cache honors. */
4
+ export declare function bypasses(request: Request, honorClientNoCache: boolean): boolean;
5
+ /** A kept response is `fresh` within its ttl, `stale` within its stale window after, and worth nothing beyond. */
6
+ export declare function freshness(found: CachedResponse, now?: number): 'fresh' | 'stale' | undefined;
7
+ //# sourceMappingURL=lookup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,6GAA6G;AAC7G,wBAAgB,QAAQ,CACvB,OAAO,EAAE,OAAO,EAChB,kBAAkB,EAAE,OAAO,GACzB,OAAO,CAMT;AAED,kHAAkH;AAClH,wBAAgB,SAAS,CACxB,KAAK,EAAE,cAAc,EACrB,GAAG,GAAE,MAAmB,GACtB,OAAO,GAAG,OAAO,GAAG,SAAS,CAK/B"}
@@ -0,0 +1,4 @@
1
+ import type { CachedResponse } from './store';
2
+ /** A kept response, or a 304 to a client that has it. */
3
+ export declare function respond(request: Request, cached: CachedResponse, state: string | undefined): Response;
4
+ //# sourceMappingURL=respond.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"respond.d.ts","sourceRoot":"","sources":["../src/respond.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,yDAAyD;AACzD,wBAAgB,OAAO,CACtB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,MAAM,GAAG,SAAS,GACvB,QAAQ,CA4BV"}
@@ -0,0 +1,43 @@
1
+ /** A response as a store keeps it. */
2
+ export interface CachedResponse {
3
+ readonly status: number;
4
+ readonly headers: readonly (readonly [string, string])[];
5
+ readonly body: Uint8Array;
6
+ /** Milliseconds since the epoch. */
7
+ readonly storedAt: number;
8
+ /** Milliseconds it is fresh for, from `storedAt`. */
9
+ readonly ttl: number;
10
+ /** Milliseconds it may be served stale after, while it is refreshed. */
11
+ readonly stale: number;
12
+ readonly tags: readonly string[];
13
+ }
14
+ /**
15
+ * Where responses are kept. The memory store keeps them in one process;
16
+ * `@alxia/redis`'s `redisCacheStore` across every process sharing a Redis.
17
+ */
18
+ export interface CacheStore {
19
+ get(key: string): Promise<CachedResponse | undefined> | CachedResponse | undefined;
20
+ /** Keeps `value` for `keepFor` milliseconds: its freshness and its staleness together. */
21
+ set(key: string, value: CachedResponse, keepFor: number): Promise<void> | void;
22
+ delete(key: string): Promise<void> | void;
23
+ /** Forgets every response tagged `tag`. */
24
+ deleteTag(tag: string): Promise<void> | void;
25
+ }
26
+ export interface MemoryCacheOptions {
27
+ /** The most responses kept: the least recently read goes first. 1 000 by default. */
28
+ readonly maxEntries?: number;
29
+ /** The most bytes of bodies kept. 64 MiB by default. */
30
+ readonly maxBytes?: number;
31
+ }
32
+ /** A least-recently-used store in one process's memory. */
33
+ export declare class MemoryCacheStore implements CacheStore {
34
+ #private;
35
+ constructor(options?: MemoryCacheOptions);
36
+ get(key: string): CachedResponse | undefined;
37
+ set(key: string, value: CachedResponse, keepFor: number): void;
38
+ delete(key: string): void;
39
+ deleteTag(tag: string): void;
40
+ /** How many responses are kept. */
41
+ get size(): number;
42
+ }
43
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;IACzD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,qDAAqD;IACrD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IAC1B,GAAG,CACF,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,GAAG,cAAc,GAAG,SAAS,CAAC;IACpE,0FAA0F;IAC1F,GAAG,CACF,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,cAAc,EACrB,OAAO,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1C,2CAA2C;IAC3C,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,kBAAkB;IAClC,qFAAqF;IACrF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,2DAA2D;AAC3D,qBAAa,gBAAiB,YAAW,UAAU;;gBAUtC,OAAO,GAAE,kBAAuB;IAK5C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAa5C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAuB9D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAYzB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAI5B,mCAAmC;IACnC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACD"}
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @alxia/cache documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: a guide page per area, with the options, defaults, errors and
5
+ a realistic example for each.
6
+
7
+ ## Guide
8
+
9
+ | Page | Read it when |
10
+ | --- | --- |
11
+ | [Caching responses](guide/caching.md) | adding the cache, choosing `ttl` and `staleWhileRevalidate`, knowing which responses are kept, reading `X-Cache` and `ETag`, or tagging and skipping from a route |
12
+ | [Keys and Vary](guide/keys-and-vary.md) | caching a response that differs by language or another header, writing a `key` of your own, keying or tagging by what an earlier plugin added, or keeping personal responses out |
13
+ | [Invalidation](guide/invalidation.md) | emptying the cache after a write, by path or by tag, across several caches or several processes |
14
+ | [Stores](guide/stores.md) | sizing the memory store, sharing responses in Redis, or writing and testing a store of your own |
15
+ | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or the cache does not hit when you expected it to |
16
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
@@ -0,0 +1,326 @@
1
+ # Caching responses
2
+
3
+ This page covers the `cache()` plugin: which requests it answers, which
4
+ responses it keeps, each option, the headers it sends, and what a route
5
+ behind it reads.
6
+
7
+ ```ts
8
+ import { alxia } from '@alxia/core';
9
+ import { cache } from '@alxia/cache';
10
+
11
+ const app = alxia()
12
+ .get('/health', ({ reply }) => reply(200, 'ok')) // declared before: never cached
13
+ .use(cache({ ttl: 60, staleWhileRevalidate: 300 }))
14
+ .get('/products', ({ reply }) => reply(200, [{ id: 1, name: 'Lamp' }]));
15
+
16
+ app.listen({ port: 3000 });
17
+ ```
18
+
19
+ ```sh
20
+ curl -i localhost:3000/products # x-cache: MISS — the route ran
21
+ curl -i localhost:3000/products # x-cache: HIT, age: 0 — the route did not
22
+ ```
23
+
24
+ ## Which requests
25
+
26
+ The plugin is a route hook: it applies to the routes declared **after**
27
+ `use(cache(…))`, in the same app or group, and to no other. Within those:
28
+
29
+ - only `GET` and `HEAD` are looked up; every other method runs the route as
30
+ if there were no cache, and still reads [`ctx.cache`](#what-a-route-reads).
31
+ A `QUERY` is a read too, but not cached: its key would have to include
32
+ its body;
33
+ - a `HEAD` and a `GET` to the same URL share one key; a `HEAD` that misses
34
+ runs the `GET` route and keeps its whole body, so the next `GET` is a hit;
35
+ - a request whose [`key`](keys-and-vary.md#a-key-of-your-own) is
36
+ `undefined` is not looked up, nor kept;
37
+ - with `honorClientNoCache: true`, a request that says
38
+ `Cache-Control: no-cache` runs the route, and its response is not kept.
39
+
40
+ ## The three answers
41
+
42
+ | State | When | What happens | `X-Cache` |
43
+ | --- | --- | --- | --- |
44
+ | fresh | younger than `ttl` | answered from the store; the route does not run | `HIT` |
45
+ | stale | older than `ttl`, younger than `ttl + staleWhileRevalidate` | answered from the store at once; one request runs the route behind it and keeps the new response | `STALE` |
46
+ | missing | not in the store, or older than both | the route runs, and its response is kept if it [may be](#which-responses-are-kept) | `MISS` |
47
+
48
+ Concurrent misses of one key run the route **once**: every request that
49
+ arrives while it runs waits for the same response. This holds in one
50
+ process, for one `cache()`; two processes sharing a Redis store each run the
51
+ route once.
52
+
53
+ ```ts
54
+ import { expect, test } from 'bun:test';
55
+ import { alxia } from '@alxia/core';
56
+ import { cache } from '@alxia/cache';
57
+
58
+ test('ten concurrent misses run the route once', async () => {
59
+ let runs = 0;
60
+ const app = alxia()
61
+ .use(cache({ ttl: 60 }))
62
+ .get('/products', async ({ reply }) => {
63
+ await Bun.sleep(20);
64
+ return reply(200, { runs: ++runs });
65
+ });
66
+
67
+ const answers = await Promise.all(
68
+ Array.from({ length: 10 }, async () => (await app.request('/products')).json()),
69
+ );
70
+ expect(answers.every((answer) => answer.runs === 1)).toBe(true);
71
+ });
72
+ ```
73
+
74
+ Only a response that is kept is shared this way:
75
+ [Not kept is not shared](#not-kept-is-not-shared).
76
+
77
+ A route that throws during a miss answers its 500 to the request that ran
78
+ it, and nothing is kept; every request that was waiting on it runs the
79
+ route itself.
80
+
81
+ A request that ran the route itself, after waiting on a run that was not
82
+ kept or that threw, gets its answer as the route made it: not kept, with
83
+ no `X-Cache`. The next request is a miss. A route that throws while refreshing a stale
84
+ response is logged with `console.error`; the stale copy keeps being served,
85
+ and the next stale request tries again, until `ttl + staleWhileRevalidate`
86
+ has passed.
87
+
88
+ ## Which responses are kept
89
+
90
+ A response is kept only when all of these hold:
91
+
92
+ | Condition | Why |
93
+ | --- | --- |
94
+ | its status is in `statuses` (`[200]` by default) | a 500 or a 404 is not served again unless you say so |
95
+ | its `Cache-Control` has neither `private` nor `no-store` | the route said it belongs to one client |
96
+ | it sets no cookie | a `Set-Cookie` belongs to one client |
97
+ | it is not `text/event-stream` | a stream has no end to keep |
98
+ | the route did not call `cache.skip()` | the route said so |
99
+
100
+ Otherwise it is sent as the route answered it, with no `X-Cache`.
101
+
102
+ ### Not kept is not shared
103
+
104
+ Concurrent misses wait on one run of the route, but only a response that is
105
+ **kept** is handed to them. A response that is not kept answers the request
106
+ that ran the route, and every other waiting request runs the route itself:
107
+ three visitors asking `/me` together, behind the cache, each get their own
108
+ answer.
109
+
110
+ ```ts
111
+ import { alxia } from '@alxia/core';
112
+ import { cache } from '@alxia/cache';
113
+
114
+ const app = alxia()
115
+ .use(cache({ ttl: 60 }))
116
+ .get('/me', ({ request, reply }) =>
117
+ reply(200, { cookie: request.headers.get('cookie') }, { headers: { 'cache-control': 'private' } }),
118
+ ); // concurrent requests: one run each, each with its own answer
119
+ ```
120
+
121
+ A route that is always personal still belongs before the plugin: it saves
122
+ the store lookup, and the wait on another request's run.
123
+
124
+ ## Options
125
+
126
+ ```ts
127
+ cache<Requires extends object = Empty>(options: CacheOptions<Requires>): Alxia<…> & Requiring<Requires> & Cache
128
+ // an app, given to `use`, which checks `Requires`; and the hands to empty it
129
+ ```
130
+
131
+ `Requires` is what `key` and `tags` read beyond `BaseContext`, empty by
132
+ default; see [Reading the app's context](keys-and-vary.md#reading-the-apps-context).
133
+
134
+ | Option | Type | Default | Effect |
135
+ | --- | --- | --- | --- |
136
+ | `ttl` | `number` | required | **seconds** a response is fresh; fractions are allowed (`0.5`) |
137
+ | `staleWhileRevalidate` | `number` | `0` | seconds more it is served stale while one request refreshes it |
138
+ | `store` | `CacheStore` | a new `MemoryCacheStore()` | where responses are kept: [Stores](stores.md) |
139
+ | `key` | `(ctx: BaseContext & Requires) => string \| undefined` | the path and query, then each `vary` header | a request's key; `undefined` is not cached: [Keys and Vary](keys-and-vary.md) |
140
+ | `vary` | `readonly string[]` | none | request headers the response depends on: part of the default key, and appended to `Vary` |
141
+ | `statuses` | `readonly number[]` | `[200]` | the statuses kept |
142
+ | `tags` | `(ctx: BaseContext & Requires) => readonly string[]` | none | tags on every response kept: [Invalidation](invalidation.md) |
143
+ | `honorClientNoCache` | `boolean` | `false` | a request's `Cache-Control: no-cache` runs the route instead |
144
+ | `debugHeaders` | `boolean` | `true` | send `X-Cache` and `Age` |
145
+
146
+ ### `ttl` and `staleWhileRevalidate`
147
+
148
+ Both are **seconds**. With both, a response is served for
149
+ `ttl + staleWhileRevalidate` seconds, and the route runs at most about once
150
+ per `ttl`, never while a client waits — except on the first request, and
151
+ after a quiet period longer than both.
152
+
153
+ ```ts
154
+ cache({ ttl: 30, staleWhileRevalidate: 600 }); // fresh 30 s, then served while refreshed for 10 min more
155
+ ```
156
+
157
+ ### `statuses`
158
+
159
+ A "not found" that is expensive to compute is worth keeping too. A `204` is
160
+ kept and served without a body.
161
+
162
+ ```ts
163
+ cache({ ttl: 60, statuses: [200, 404] });
164
+ ```
165
+
166
+ ### `honorClientNoCache`
167
+
168
+ Off by default: a client cannot empty your cache, nor run your route at
169
+ will, by sending `Cache-Control: no-cache`. On, such a request runs the
170
+ route — but its response does **not** replace the stored one.
171
+
172
+ ```ts
173
+ cache({ ttl: 60, honorClientNoCache: true });
174
+ ```
175
+
176
+ A browser sends `Cache-Control: no-cache` on a hard reload (Shift-reload),
177
+ and not on a plain reload. curl sends it only when told to:
178
+ `curl -H 'cache-control: no-cache'`.
179
+
180
+ ### `debugHeaders`
181
+
182
+ On by default: every answer from the cache carries `X-Cache` (`HIT`,
183
+ `STALE` or `MISS`) and `Age` (whole seconds since it was kept). Turn them
184
+ off to say nothing of the cache to clients:
185
+
186
+ ```ts
187
+ cache({ ttl: 60, debugHeaders: false });
188
+ ```
189
+
190
+ ## ETags and 304s
191
+
192
+ Every kept response gets a weak `ETag` computed from its body, unless the
193
+ route set one. A request whose `If-None-Match` names it — or says `*` — is
194
+ answered `304 Not Modified` with no body, whether it was a hit, a stale hit
195
+ or a miss. Weak and strong forms of one tag match.
196
+
197
+ ```ts
198
+ import { expect, test } from 'bun:test';
199
+ import { alxia } from '@alxia/core';
200
+ import { cache } from '@alxia/cache';
201
+
202
+ test('a client whose copy is current gets a 304', async () => {
203
+ const app = alxia()
204
+ .use(cache({ ttl: 60 }))
205
+ .get('/products', ({ reply }) => reply(200, []));
206
+
207
+ const first = await app.request('/products');
208
+ const etag = first.headers.get('etag') ?? '';
209
+ expect(etag).toStartWith('W/"');
210
+
211
+ const again = await app.request('/products', { headers: { 'if-none-match': etag } });
212
+ expect(again.status).toBe(304);
213
+ });
214
+ ```
215
+
216
+ A browser sends `If-None-Match` on its own once it has the response with
217
+ an `ETag`; curl does not unless you pass the header.
218
+
219
+ The plugin sets no `Cache-Control` of its own: it caches on the server.
220
+ For a browser or a CDN to keep the response as well, the route says so —
221
+ `public` is not `private`, so the response is still kept here:
222
+
223
+ ```ts
224
+ app.get('/products', ({ reply }) =>
225
+ reply(200, [], { headers: { 'cache-control': 'public, max-age=60' } }),
226
+ );
227
+ ```
228
+
229
+ ## The headers kept
230
+
231
+ A kept response keeps the route's status and headers, without
232
+ `Content-Length` and `Date`, with its `ETag`, and with each `vary` header
233
+ appended to `Vary` once. Headers that global hooks add after the route
234
+ (`onResponse`, a CORS or compression plugin) are not kept: they are added
235
+ again to every answer, from the cache or not.
236
+
237
+ ## What a route reads
238
+
239
+ Every route after the plugin reads `ctx.cache`:
240
+
241
+ ```ts
242
+ interface CacheControls {
243
+ /** Tags the response being built, beyond the plugin's `tags`. */
244
+ tag(...tags: string[]): void;
245
+ /** Keeps this response out of the cache. */
246
+ skip(): void;
247
+ }
248
+ ```
249
+
250
+ ```ts
251
+ import { alxia } from '@alxia/core';
252
+ import { cache } from '@alxia/cache';
253
+
254
+ const app = alxia()
255
+ .use(cache({ ttl: 60 }))
256
+ .get('/products/:id', ({ params, cache, reply }) => {
257
+ cache.tag(`product:${params.id}`); // invalidateTag('product:1') forgets it
258
+ return reply(200, { id: params.id });
259
+ })
260
+ .get('/products/:id/stock', ({ params, cache, reply }) => {
261
+ const stock = params.id === '1' ? 0 : 5;
262
+ if (stock === 0) cache.skip(); // "sold out" is not kept
263
+ return reply(200, { stock });
264
+ });
265
+ ```
266
+
267
+ A route declared before the plugin has no `ctx.cache`; reading it is a
268
+ compile error ([Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type-context)).
269
+
270
+ ## The value `cache()` returns
271
+
272
+ `cache()` returns the plugin — an app to give to `use` — with the handles
273
+ of its store on it:
274
+
275
+ ```ts
276
+ interface Cache {
277
+ invalidate(path: string): Promise<void>;
278
+ invalidateTag(tag: string): Promise<void>;
279
+ readonly store: CacheStore;
280
+ }
281
+ ```
282
+
283
+ Keep it in a variable to reach them from elsewhere: a route that writes, a
284
+ job, a test. [Invalidation](invalidation.md) covers both.
285
+
286
+ ## A realistic app
287
+
288
+ A catalogue whose reads are cached and whose writes empty it, with the
289
+ personal routes kept out:
290
+
291
+ ```ts
292
+ import { alxia } from '@alxia/core';
293
+ import { cache, MemoryCacheStore } from '@alxia/cache';
294
+
295
+ const products = new Map<string, { id: string; name: string }>();
296
+
297
+ const catalogue = cache({
298
+ ttl: 60,
299
+ staleWhileRevalidate: 600,
300
+ store: new MemoryCacheStore({ maxEntries: 5_000 }),
301
+ tags: () => ['products'],
302
+ statuses: [200, 404],
303
+ });
304
+
305
+ export const app = alxia({ prefix: '/api' })
306
+ .post('/products', async ({ request, reply }) => {
307
+ const product = (await request.json()) as { id: string; name: string };
308
+ products.set(product.id, product);
309
+ await catalogue.invalidateTag('products'); // the next GET runs the route
310
+ return reply(201, product);
311
+ })
312
+ .get('/me', ({ reply }) => reply(200, { name: 'Grace' })) // before the cache: personal
313
+ .use(catalogue)
314
+ .get('/products', ({ reply }) => reply(200, [...products.values()]))
315
+ .get('/products/:id', ({ params, cache, reply }) => {
316
+ cache.tag(`product:${params.id}`);
317
+ const product = products.get(params.id);
318
+ return product ? reply(200, product) : reply(404, { error: 'not_found' });
319
+ });
320
+ ```
321
+
322
+ ## See also
323
+
324
+ - [Keys and Vary](keys-and-vary.md): what makes two requests the same one.
325
+ - [Invalidation](invalidation.md): emptying the cache when the data changes.
326
+ - [Stores](stores.md): memory, Redis, or your own.