@ayepi/cache 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Diffenderfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @ayepi/cache
2
+
3
+ Response-caching middleware for [`@ayepi/core`](../core). It keys a response by the
4
+ **request** (method + path + query, plus an optional dev-defined `vary` — e.g. the
5
+ authenticated user), and on a **hit** replays the stored response without running the
6
+ handler. Entries live in memory for a bounded **time** (`ttl`, with optional
7
+ `stale-while-revalidate`) and a bounded **space** (`maxBytes` / `maxEntryBytes` /
8
+ `maxEntries`, LRU-evicted).
9
+
10
+ ```sh
11
+ pnpm add @ayepi/cache @ayepi/core zod
12
+ ```
13
+
14
+ It ships as a **def / impl split** (like `@ayepi/rate`):
15
+
16
+ - `@ayepi/cache` (frontend-safe) exports `cache(opts?)`, a middleware **def factory**,
17
+ plus the standalone `memoryCache` / `cacheKey` / `cacheHeaders` / `isCacheableResult`
18
+ primitives. A spec importing only this entry is safe to bundle for the frontend.
19
+ - `@ayepi/cache/server` augments `cache` with **`.server(def, opts)`**, which binds the
20
+ policy (`ttl`, `vary`, bounds, …). Bind the pair with `implement(api).middleware(...)`.
21
+
22
+ ## Quick start
23
+
24
+ ```ts
25
+ // shared.ts — frontend-safe
26
+ import { cache } from '@ayepi/cache';
27
+ const cached = cache({ requires: [auth] }); // ctx.user typed in `vary`/`key`/`skip`
28
+ const api = spec({ endpoints: { ...cached.group({ report: { method: 'GET', response: Report } }) } });
29
+
30
+ // server.ts — bind the policy
31
+ import { cache } from '@ayepi/cache/server';
32
+ implement(api)
33
+ .middleware(auth, authImpl)
34
+ .middleware(cache.server(cached, {
35
+ ttl: 30_000, // fresh for 30s
36
+ staleWhileRevalidate: 60_000, // then serve stale up to 60s more while refreshing
37
+ vary: (io) => io.ctx.user.id, // per-user cache
38
+ maxBytes: 64 * 1024 * 1024, // bound the memory it can use
39
+ }))
40
+ .handlers({ report: ({ user }) => buildReport(user.id) });
41
+ ```
42
+
43
+ The first request runs the handler and stores the JSON response (`X-Cache: MISS`);
44
+ repeats within `ttl` are replayed (`X-Cache: HIT`, with `Age` / `Cache-Control`) — over
45
+ HTTP and (as a result frame) over WebSocket. Place `cache` **last** in a chain (closest
46
+ to the handler) so `auth` / `rateLimit` / telemetry still run on a hit.
47
+
48
+ ## Controls
49
+
50
+ - **Time** — `ttl` (freshness) and `staleWhileRevalidate` (serve-stale grace; the stale
51
+ response goes out immediately while a single background refresh updates the entry).
52
+ - **Space** — the default `memoryCache` is an LRU store bounded by `maxBytes` (total),
53
+ `maxEntryBytes` (per response — larger ones aren't cached), and `maxEntries` (count).
54
+ - **Key** — `method + path + query + body + vary` by default (the body is canonicalized, so
55
+ property order doesn't matter); `key(io)` overrides it entirely. Works over HTTP **and
56
+ WebSocket** (where the call args replace query+body). Only the endpoint's declared
57
+ `methods` (default `['GET']`) are cached; everything else passes through.
58
+ - **Big keys** — `hash` shrinks the key to a short store key (`hash: hashKey`, or a crypto
59
+ digest); `checkKey` (on by default when hashing) keeps + verifies the full key so a
60
+ collision misses safely.
61
+ - **Per-response** — `shouldCache(io, result)`, or from the handler via
62
+ `io.ctx.cache.noStore()` / `io.ctx.cache.ttl(ms)`.
63
+ - **Request `Cache-Control`** — `no-store` bypasses; `no-cache` revalidates.
64
+ - **Invalidation** — hold the `store` you pass in and call `store.delete(cacheKey(...))`,
65
+ `store.clear()`, or `store.invalidate(meta => …)` after a mutation.
66
+ - **Fail-open** — if any cache step throws (the store, key derivation, hashing), the request
67
+ falls through to the handler as if uncached; the endpoint never errors because of caching.
68
+ Pass `onError(err, phase)` to observe those swallowed errors (off by default).
69
+
70
+ ## What is cached
71
+
72
+ Single-response (`response:`) endpoints returning a JSON body with a 2xx status, over HTTP
73
+ or WebSocket, for the configured `methods`. Streams, multi-status (`responses:`) results,
74
+ downstream short-circuit `Response`s, empty (204) bodies, and **multipart/file-upload**
75
+ requests pass through uncached. Replays are emitted at status 200.
76
+
77
+ See **[`ayepi-cache.md`](./ayepi-cache.md)** for the full reference, and
78
+ [`examples/09-cache`](../../examples/09-cache) for a runnable demo.
79
+
80
+ ## For AI coding agents
81
+
82
+ This package ships dense, machine-oriented reference docs written for **AI coding agents**
83
+ (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
84
+
85
+ - [`ayepi-cache.md`](./ayepi-cache.md)
86
+
87
+ They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/cache) and are **not** shipped in the npm tarball.
88
+
package/dist/index.cjs ADDED
@@ -0,0 +1,216 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _ayepi_core = require("@ayepi/core");
3
+ //#region src/index.ts
4
+ /**
5
+ * # @ayepi/cache
6
+ *
7
+ * Response-caching middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
8
+ * {@link cache} builds a middleware that derives a **key from the request** (method +
9
+ * path + query, plus an optional dev-defined `vary` — e.g. the authenticated user) and,
10
+ * on a **hit**, replays the stored response without running the handler. Entries live in
11
+ * memory for a bounded **time** (`ttl`, with optional `stale-while-revalidate`) and a
12
+ * bounded **space** (`maxBytes` / `maxEntryBytes` / `maxEntries`, LRU-evicted).
13
+ *
14
+ * ```ts
15
+ * // shared.ts (frontend-safe): the def declares what it contributes
16
+ * import { cache } from '@ayepi/cache'
17
+ * const cached = cache() // provides { cache } to handlers
18
+ * spec({ endpoints: { ...cached.group({ … }) } })
19
+ *
20
+ * // server.ts: bind the policy (ttl, vary, bounds)
21
+ * import { cache } from '@ayepi/cache/server'
22
+ * implement(api).middleware(cache.server(cached, {
23
+ * ttl: 30_000, // fresh for 30s
24
+ * vary: (io) => io.ctx.user.id, // per-user cache
25
+ * maxBytes: 64 * 1024 * 1024,
26
+ * }))
27
+ * ```
28
+ *
29
+ * - **Bounded memory** — the default {@link memoryCache} is an LRU store with total/entry
30
+ * byte caps and an entry-count cap; dead entries are swept lazily.
31
+ * - **Time controls** — `ttl` (freshness) and `staleWhileRevalidate` (serve-stale grace).
32
+ * - **Customizable** — `key`/`vary`, `methods`, `shouldCache`, response headers, `skip`,
33
+ * request `Cache-Control` respect, and per-response opt-out via `io.ctx.cache`.
34
+ *
35
+ * @module
36
+ */
37
+ /** Default middleware name. */
38
+ const DEFAULT_NAME = "cache";
39
+ /** Milliseconds per second — `Age` / `Cache-Control: max-age` are expressed in seconds. */
40
+ const MS_PER_SECOND = 1e3;
41
+ /** Default total cache capacity (bytes) for {@link memoryCache}. */
42
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
43
+ /** Default per-entry cap (bytes) — larger responses are not cached. */
44
+ const DEFAULT_MAX_ENTRY_BYTES = 1024 * 1024;
45
+ /** Default entry-count cap. */
46
+ const DEFAULT_MAX_ENTRIES = 1e4;
47
+ /**
48
+ * Create an in-process LRU {@link CacheStore} bounded by total bytes, per-entry bytes,
49
+ * and entry count. The default store — fine for a single instance. Most-recently-used
50
+ * on `get`/`set`; when over a bound, evicts least-recently-used entries (or skips a
51
+ * `set` whose entry alone exceeds `maxEntryBytes`).
52
+ */
53
+ function memoryCache(opts = {}) {
54
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
55
+ const maxEntryBytes = opts.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
56
+ const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
57
+ const entries = /* @__PURE__ */ new Map();
58
+ let totalBytes = 0;
59
+ const drop = (key) => {
60
+ const e = entries.get(key);
61
+ if (e) {
62
+ totalBytes -= e.bytes;
63
+ entries.delete(key);
64
+ }
65
+ };
66
+ const evictLRU = () => {
67
+ for (const key of entries.keys()) {
68
+ if (totalBytes <= maxBytes && entries.size <= maxEntries) break;
69
+ drop(key);
70
+ }
71
+ };
72
+ return {
73
+ get(key) {
74
+ const e = entries.get(key);
75
+ if (!e) return;
76
+ entries.delete(key);
77
+ entries.set(key, e);
78
+ return e;
79
+ },
80
+ set(key, entry) {
81
+ drop(key);
82
+ if (entry.bytes > maxEntryBytes) return;
83
+ entries.set(key, entry);
84
+ totalBytes += entry.bytes;
85
+ evictLRU();
86
+ },
87
+ delete(key) {
88
+ const existed = entries.has(key);
89
+ drop(key);
90
+ return existed;
91
+ },
92
+ clear() {
93
+ entries.clear();
94
+ totalBytes = 0;
95
+ },
96
+ invalidate(pred) {
97
+ let removed = 0;
98
+ for (const e of [...entries.values()]) if (pred(e)) {
99
+ drop(e.key);
100
+ removed++;
101
+ }
102
+ return removed;
103
+ }
104
+ };
105
+ }
106
+ /** Normalize a query input into a stable, order-independent list of `[k, v]` pairs. */
107
+ function normalizeQuery(query) {
108
+ if (query === void 0) return [];
109
+ return [...typeof query === "string" ? new URLSearchParams(query) : query instanceof URLSearchParams ? query : new URLSearchParams([...query].map(([k, v]) => [k, v]))].sort((a, b) => a[0] === b[0] ? a[1] < b[1] ? -1 : 1 : a[0] < b[0] ? -1 : 1);
110
+ }
111
+ /** Recursively sort object keys so semantically-equal values serialize identically. */
112
+ function sortDeep(v) {
113
+ if (Array.isArray(v)) return v.map(sortDeep);
114
+ if (v && typeof v === "object") {
115
+ const out = {};
116
+ for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
117
+ return out;
118
+ }
119
+ return v;
120
+ }
121
+ /** Deterministic `JSON.stringify` — object keys sorted at every depth, so key order never matters. */
122
+ function stableStringify(value) {
123
+ return JSON.stringify(sortDeep(value)) ?? "null";
124
+ }
125
+ /**
126
+ * Build a stable cache key from request parts — the same string the middleware uses.
127
+ * Query parameters are sorted and the body is canonicalized (sorted keys at every depth),
128
+ * so equal requests share a key regardless of property order. Exported so you can target
129
+ * {@link CacheStore.delete} after a mutation (e.g. bust a user's cached report).
130
+ */
131
+ function cacheKey(parts) {
132
+ return stableStringify([
133
+ parts.method.toUpperCase(),
134
+ parts.path,
135
+ normalizeQuery(parts.query),
136
+ parts.body ?? null,
137
+ parts.vary ?? null
138
+ ]);
139
+ }
140
+ /**
141
+ * A fast, **non-cryptographic** hash (cyrb53 → base36) for shrinking a large cache key —
142
+ * pass it as the `hash` option so the store keys on a short digest instead of the full
143
+ * (possibly huge) JSON. Collisions are unlikely but possible; keep `checkKey` on (the
144
+ * default when hashing) to fall through on one, or supply a crypto hash (e.g. sha-256)
145
+ * for stronger guarantees.
146
+ */
147
+ function hashKey(key) {
148
+ let h1 = 3735928559;
149
+ let h2 = 1103547991;
150
+ for (let i = 0; i < key.length; i++) {
151
+ const ch = key.charCodeAt(i);
152
+ h1 = Math.imul(h1 ^ ch, 2654435761);
153
+ h2 = Math.imul(h2 ^ ch, 1597334677);
154
+ }
155
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
156
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
157
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
158
+ }
159
+ /**
160
+ * Compute the informational cache headers for an entry: `Age` (seconds since it was
161
+ * stored) and `Cache-Control: max-age` (seconds of freshness remaining). The middleware
162
+ * adds the `X-Cache: HIT|STALE|MISS` marker separately.
163
+ */
164
+ function cacheHeaders(entry, now) {
165
+ const age = Math.max(0, Math.floor((now - entry.storedAt) / MS_PER_SECOND));
166
+ const maxAge = Math.max(0, Math.ceil((entry.expires - now) / MS_PER_SECOND));
167
+ return {
168
+ age: String(age),
169
+ "cache-control": `max-age=${maxAge}`
170
+ };
171
+ }
172
+ /** The shape core produces for a multi-status (`responses:`) endpoint — `{ status, data }`. */
173
+ function looksMultiStatus(r) {
174
+ const keys = Object.keys(r);
175
+ return keys.length === 2 && keys.includes("status") && keys.includes("data") && typeof r.status === "number";
176
+ }
177
+ /**
178
+ * Whether a handler's result is a plain JSON response body the cache can store and
179
+ * replay. `false` for an empty body (`null`/`undefined` → 204), a short-circuit
180
+ * `Response`, a function, a streamed body (async-iterable / `ReadableStream`), or a
181
+ * multi-status `{ status, data }` wrapper (whose replay status would be wrong). Useful in
182
+ * a custom `shouldCache` or a custom {@link CacheStore}.
183
+ */
184
+ function isCacheableResult(result) {
185
+ if (result === null || result === void 0) return false;
186
+ if (result instanceof Response) return false;
187
+ if (typeof result === "function") return false;
188
+ if (typeof result === "object") {
189
+ const o = result;
190
+ if (typeof o[Symbol.asyncIterator] === "function" || typeof o.getReader === "function") return false;
191
+ if (looksMultiStatus(result)) return false;
192
+ }
193
+ return true;
194
+ }
195
+ /**
196
+ * Create a response-caching middleware **def**. The def declares what the middleware
197
+ * contributes (`{ cache: CacheControl }`) but **no** policy. Bind the
198
+ * ttl/vary/bounds with [`cache.server(def, { ttl })`](./server).
199
+ *
200
+ * @typeParam R - inferred from `requires`; their context types flow into the
201
+ * server-side `key`/`vary`/`skip`/`shouldCache`.
202
+ */
203
+ function cache(opts) {
204
+ return (0, _ayepi_core.middleware)(opts?.name ?? DEFAULT_NAME, {
205
+ provides: (0, _ayepi_core.ctx)(),
206
+ requires: opts?.requires ?? []
207
+ });
208
+ }
209
+ //#endregion
210
+ exports.cache = cache;
211
+ exports.cacheHeaders = cacheHeaders;
212
+ exports.cacheKey = cacheKey;
213
+ exports.hashKey = hashKey;
214
+ exports.isCacheableResult = isCacheableResult;
215
+ exports.memoryCache = memoryCache;
216
+ exports.stableStringify = stableStringify;
@@ -0,0 +1,156 @@
1
+ import { AnyMiddleware, Json, MaybePromise, MiddlewareDef } from "@ayepi/core";
2
+
3
+ //#region src/index.d.ts
4
+
5
+ /** A stored response, ready to replay. */
6
+ interface CacheEntry {
7
+ /** The serialized JSON response body. */
8
+ readonly body: string;
9
+ /** The HTTP status to replay (a 2xx). */
10
+ readonly status: number;
11
+ /** Response headers to replay (e.g. `content-type`). */
12
+ readonly headers: readonly (readonly [string, string])[];
13
+ /** When the entry was stored (ms epoch). */
14
+ readonly storedAt: number;
15
+ /** Fresh until this time (ms epoch) — served as a `HIT` before it. */
16
+ readonly expires: number;
17
+ /** Serve-stale grace boundary (ms epoch) — `>= expires`; a `STALE` hit until it, then dead. */
18
+ readonly staleUntil: number;
19
+ /** The serialized body's byte length (what counts toward the store's space bounds). */
20
+ readonly bytes: number;
21
+ /** The request method this entry answers. */
22
+ readonly method: string;
23
+ /** The request path this entry answers. */
24
+ readonly path: string;
25
+ /** The full cache key (used to double-check against hash collisions); the hashed store key when `checkKey` is off. */
26
+ readonly key: string;
27
+ }
28
+ /** The subset of an entry exposed to {@link CacheStore.invalidate} predicates. */
29
+ interface EntryMeta {
30
+ readonly key: string;
31
+ readonly method: string;
32
+ readonly path: string;
33
+ readonly storedAt: number;
34
+ readonly expires: number;
35
+ readonly staleUntil: number;
36
+ readonly bytes: number;
37
+ }
38
+ /**
39
+ * Pluggable cache backend. The default is the in-process {@link memoryCache}; implement
40
+ * this interface to back the cache with another store. The store owns **space** (which
41
+ * entries to keep); the middleware owns **time** (freshness vs `entry.expires`).
42
+ */
43
+ interface CacheStore {
44
+ /** Fetch a stored entry (and mark it most-recently-used), or `undefined`. */
45
+ get(key: string): MaybePromise<CacheEntry | undefined>;
46
+ /** Store an entry, evicting as needed to stay within the store's bounds. */
47
+ set(key: string, entry: CacheEntry): MaybePromise<void>;
48
+ /** Remove one entry; returns whether it existed. */
49
+ delete(key: string): MaybePromise<boolean>;
50
+ /** Drop every entry. */
51
+ clear(): MaybePromise<void>;
52
+ /** Remove every entry matching `pred`; returns how many were removed. Powers manual invalidation and the time-sweep. */
53
+ invalidate(pred: (meta: EntryMeta) => boolean): MaybePromise<number>;
54
+ }
55
+ /** Options for {@link memoryCache}. */
56
+ interface MemoryCacheOptions {
57
+ /** Total capacity in bytes (default 64 MiB) — LRU-evicted when exceeded. */
58
+ readonly maxBytes?: number;
59
+ /** Per-entry cap in bytes (default 1 MiB) — larger responses are skipped. */
60
+ readonly maxEntryBytes?: number;
61
+ /** Maximum number of entries (default 10 000) — LRU-evicted when exceeded. */
62
+ readonly maxEntries?: number;
63
+ }
64
+ /**
65
+ * Create an in-process LRU {@link CacheStore} bounded by total bytes, per-entry bytes,
66
+ * and entry count. The default store — fine for a single instance. Most-recently-used
67
+ * on `get`/`set`; when over a bound, evicts least-recently-used entries (or skips a
68
+ * `set` whose entry alone exceeds `maxEntryBytes`).
69
+ */
70
+ declare function memoryCache(opts?: MemoryCacheOptions): CacheStore;
71
+ /** The parts a default cache key is built from. */
72
+ interface CacheKeyParts {
73
+ /** The request method (upper-cased). */
74
+ readonly method: string;
75
+ /** The request path. */
76
+ readonly path: string;
77
+ /** The query string (raw, e.g. `io` URL search) or parsed entries — normalized by sorting. */
78
+ readonly query?: string | URLSearchParams | Iterable<readonly [string, string]>;
79
+ /** The request body (parsed JSON / form object) or ws call args — included so POST-style caches key on the payload. */
80
+ readonly body?: Json;
81
+ /** An optional extra discriminator (e.g. the authenticated user id) appended verbatim. */
82
+ readonly vary?: Json;
83
+ }
84
+ /** Deterministic `JSON.stringify` — object keys sorted at every depth, so key order never matters. */
85
+ declare function stableStringify(value: unknown): string;
86
+ /**
87
+ * Build a stable cache key from request parts — the same string the middleware uses.
88
+ * Query parameters are sorted and the body is canonicalized (sorted keys at every depth),
89
+ * so equal requests share a key regardless of property order. Exported so you can target
90
+ * {@link CacheStore.delete} after a mutation (e.g. bust a user's cached report).
91
+ */
92
+ declare function cacheKey(parts: CacheKeyParts): string;
93
+ /**
94
+ * A fast, **non-cryptographic** hash (cyrb53 → base36) for shrinking a large cache key —
95
+ * pass it as the `hash` option so the store keys on a short digest instead of the full
96
+ * (possibly huge) JSON. Collisions are unlikely but possible; keep `checkKey` on (the
97
+ * default when hashing) to fall through on one, or supply a crypto hash (e.g. sha-256)
98
+ * for stronger guarantees.
99
+ */
100
+ declare function hashKey(key: string): string;
101
+ /**
102
+ * Compute the informational cache headers for an entry: `Age` (seconds since it was
103
+ * stored) and `Cache-Control: max-age` (seconds of freshness remaining). The middleware
104
+ * adds the `X-Cache: HIT|STALE|MISS` marker separately.
105
+ */
106
+ declare function cacheHeaders(entry: CacheEntry, now: number): Record<string, string>;
107
+ /**
108
+ * Whether a handler's result is a plain JSON response body the cache can store and
109
+ * replay. `false` for an empty body (`null`/`undefined` → 204), a short-circuit
110
+ * `Response`, a function, a streamed body (async-iterable / `ReadableStream`), or a
111
+ * multi-status `{ status, data }` wrapper (whose replay status would be wrong). Useful in
112
+ * a custom `shouldCache` or a custom {@link CacheStore}.
113
+ */
114
+ declare function isCacheableResult(result: unknown): boolean;
115
+ /**
116
+ * The handle a cached endpoint's handler reads as `io.ctx.cache` — present only on a
117
+ * **miss** (a hit never runs the handler). Use it to opt this response out of caching
118
+ * ({@link CacheControl.noStore}) or override its lifetime ({@link CacheControl.ttl}).
119
+ */
120
+ interface CacheControl {
121
+ /** The cache key computed for this request. */
122
+ readonly key: string;
123
+ /** Always `false` here — the handler only runs when the cache missed. */
124
+ readonly hit: boolean;
125
+ /** Do not store this response (e.g. it depends on something the key doesn't capture). */
126
+ noStore(): void;
127
+ /** Override the freshness lifetime for this response (ms). */
128
+ ttl(ms: number): void;
129
+ }
130
+ /**
131
+ * Options for the {@link cache} **def** — frontend-safe only.
132
+ *
133
+ * @typeParam R - middleware this one depends on (their context is typed in the
134
+ * server-side `key`/`vary`/`skip`/`shouldCache`).
135
+ */
136
+ interface CacheDefOptions<R extends readonly AnyMiddleware[]> {
137
+ /** Middleware this one depends on — their context is available (and typed) in `key`/`vary`/`skip`. */
138
+ readonly requires?: R;
139
+ /** Middleware name for docs/debugging (default `'cache'`). */
140
+ readonly name?: string;
141
+ }
142
+ /**
143
+ * Create a response-caching middleware **def**. The def declares what the middleware
144
+ * contributes (`{ cache: CacheControl }`) but **no** policy. Bind the
145
+ * ttl/vary/bounds with [`cache.server(def, { ttl })`](./server).
146
+ *
147
+ * @typeParam R - inferred from `requires`; their context types flow into the
148
+ * server-side `key`/`vary`/`skip`/`shouldCache`.
149
+ */
150
+ declare function cache<const R extends readonly AnyMiddleware[] = readonly []>(opts?: CacheDefOptions<R>): CacheDef<R>;
151
+ /** The def type a {@link cache} call produces — what `cache.server` binds against. */
152
+ type CacheDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
153
+ cache: CacheControl;
154
+ }, R>;
155
+ //#endregion
156
+ export { CacheControl, CacheDef, CacheDefOptions, CacheEntry, CacheKeyParts, CacheStore, EntryMeta, MemoryCacheOptions, cache, cacheHeaders, cacheKey, hashKey, isCacheableResult, memoryCache, stableStringify };
@@ -0,0 +1,156 @@
1
+ import { AnyMiddleware, Json, MaybePromise, MiddlewareDef } from "@ayepi/core";
2
+
3
+ //#region src/index.d.ts
4
+
5
+ /** A stored response, ready to replay. */
6
+ interface CacheEntry {
7
+ /** The serialized JSON response body. */
8
+ readonly body: string;
9
+ /** The HTTP status to replay (a 2xx). */
10
+ readonly status: number;
11
+ /** Response headers to replay (e.g. `content-type`). */
12
+ readonly headers: readonly (readonly [string, string])[];
13
+ /** When the entry was stored (ms epoch). */
14
+ readonly storedAt: number;
15
+ /** Fresh until this time (ms epoch) — served as a `HIT` before it. */
16
+ readonly expires: number;
17
+ /** Serve-stale grace boundary (ms epoch) — `>= expires`; a `STALE` hit until it, then dead. */
18
+ readonly staleUntil: number;
19
+ /** The serialized body's byte length (what counts toward the store's space bounds). */
20
+ readonly bytes: number;
21
+ /** The request method this entry answers. */
22
+ readonly method: string;
23
+ /** The request path this entry answers. */
24
+ readonly path: string;
25
+ /** The full cache key (used to double-check against hash collisions); the hashed store key when `checkKey` is off. */
26
+ readonly key: string;
27
+ }
28
+ /** The subset of an entry exposed to {@link CacheStore.invalidate} predicates. */
29
+ interface EntryMeta {
30
+ readonly key: string;
31
+ readonly method: string;
32
+ readonly path: string;
33
+ readonly storedAt: number;
34
+ readonly expires: number;
35
+ readonly staleUntil: number;
36
+ readonly bytes: number;
37
+ }
38
+ /**
39
+ * Pluggable cache backend. The default is the in-process {@link memoryCache}; implement
40
+ * this interface to back the cache with another store. The store owns **space** (which
41
+ * entries to keep); the middleware owns **time** (freshness vs `entry.expires`).
42
+ */
43
+ interface CacheStore {
44
+ /** Fetch a stored entry (and mark it most-recently-used), or `undefined`. */
45
+ get(key: string): MaybePromise<CacheEntry | undefined>;
46
+ /** Store an entry, evicting as needed to stay within the store's bounds. */
47
+ set(key: string, entry: CacheEntry): MaybePromise<void>;
48
+ /** Remove one entry; returns whether it existed. */
49
+ delete(key: string): MaybePromise<boolean>;
50
+ /** Drop every entry. */
51
+ clear(): MaybePromise<void>;
52
+ /** Remove every entry matching `pred`; returns how many were removed. Powers manual invalidation and the time-sweep. */
53
+ invalidate(pred: (meta: EntryMeta) => boolean): MaybePromise<number>;
54
+ }
55
+ /** Options for {@link memoryCache}. */
56
+ interface MemoryCacheOptions {
57
+ /** Total capacity in bytes (default 64 MiB) — LRU-evicted when exceeded. */
58
+ readonly maxBytes?: number;
59
+ /** Per-entry cap in bytes (default 1 MiB) — larger responses are skipped. */
60
+ readonly maxEntryBytes?: number;
61
+ /** Maximum number of entries (default 10 000) — LRU-evicted when exceeded. */
62
+ readonly maxEntries?: number;
63
+ }
64
+ /**
65
+ * Create an in-process LRU {@link CacheStore} bounded by total bytes, per-entry bytes,
66
+ * and entry count. The default store — fine for a single instance. Most-recently-used
67
+ * on `get`/`set`; when over a bound, evicts least-recently-used entries (or skips a
68
+ * `set` whose entry alone exceeds `maxEntryBytes`).
69
+ */
70
+ declare function memoryCache(opts?: MemoryCacheOptions): CacheStore;
71
+ /** The parts a default cache key is built from. */
72
+ interface CacheKeyParts {
73
+ /** The request method (upper-cased). */
74
+ readonly method: string;
75
+ /** The request path. */
76
+ readonly path: string;
77
+ /** The query string (raw, e.g. `io` URL search) or parsed entries — normalized by sorting. */
78
+ readonly query?: string | URLSearchParams | Iterable<readonly [string, string]>;
79
+ /** The request body (parsed JSON / form object) or ws call args — included so POST-style caches key on the payload. */
80
+ readonly body?: Json;
81
+ /** An optional extra discriminator (e.g. the authenticated user id) appended verbatim. */
82
+ readonly vary?: Json;
83
+ }
84
+ /** Deterministic `JSON.stringify` — object keys sorted at every depth, so key order never matters. */
85
+ declare function stableStringify(value: unknown): string;
86
+ /**
87
+ * Build a stable cache key from request parts — the same string the middleware uses.
88
+ * Query parameters are sorted and the body is canonicalized (sorted keys at every depth),
89
+ * so equal requests share a key regardless of property order. Exported so you can target
90
+ * {@link CacheStore.delete} after a mutation (e.g. bust a user's cached report).
91
+ */
92
+ declare function cacheKey(parts: CacheKeyParts): string;
93
+ /**
94
+ * A fast, **non-cryptographic** hash (cyrb53 → base36) for shrinking a large cache key —
95
+ * pass it as the `hash` option so the store keys on a short digest instead of the full
96
+ * (possibly huge) JSON. Collisions are unlikely but possible; keep `checkKey` on (the
97
+ * default when hashing) to fall through on one, or supply a crypto hash (e.g. sha-256)
98
+ * for stronger guarantees.
99
+ */
100
+ declare function hashKey(key: string): string;
101
+ /**
102
+ * Compute the informational cache headers for an entry: `Age` (seconds since it was
103
+ * stored) and `Cache-Control: max-age` (seconds of freshness remaining). The middleware
104
+ * adds the `X-Cache: HIT|STALE|MISS` marker separately.
105
+ */
106
+ declare function cacheHeaders(entry: CacheEntry, now: number): Record<string, string>;
107
+ /**
108
+ * Whether a handler's result is a plain JSON response body the cache can store and
109
+ * replay. `false` for an empty body (`null`/`undefined` → 204), a short-circuit
110
+ * `Response`, a function, a streamed body (async-iterable / `ReadableStream`), or a
111
+ * multi-status `{ status, data }` wrapper (whose replay status would be wrong). Useful in
112
+ * a custom `shouldCache` or a custom {@link CacheStore}.
113
+ */
114
+ declare function isCacheableResult(result: unknown): boolean;
115
+ /**
116
+ * The handle a cached endpoint's handler reads as `io.ctx.cache` — present only on a
117
+ * **miss** (a hit never runs the handler). Use it to opt this response out of caching
118
+ * ({@link CacheControl.noStore}) or override its lifetime ({@link CacheControl.ttl}).
119
+ */
120
+ interface CacheControl {
121
+ /** The cache key computed for this request. */
122
+ readonly key: string;
123
+ /** Always `false` here — the handler only runs when the cache missed. */
124
+ readonly hit: boolean;
125
+ /** Do not store this response (e.g. it depends on something the key doesn't capture). */
126
+ noStore(): void;
127
+ /** Override the freshness lifetime for this response (ms). */
128
+ ttl(ms: number): void;
129
+ }
130
+ /**
131
+ * Options for the {@link cache} **def** — frontend-safe only.
132
+ *
133
+ * @typeParam R - middleware this one depends on (their context is typed in the
134
+ * server-side `key`/`vary`/`skip`/`shouldCache`).
135
+ */
136
+ interface CacheDefOptions<R extends readonly AnyMiddleware[]> {
137
+ /** Middleware this one depends on — their context is available (and typed) in `key`/`vary`/`skip`. */
138
+ readonly requires?: R;
139
+ /** Middleware name for docs/debugging (default `'cache'`). */
140
+ readonly name?: string;
141
+ }
142
+ /**
143
+ * Create a response-caching middleware **def**. The def declares what the middleware
144
+ * contributes (`{ cache: CacheControl }`) but **no** policy. Bind the
145
+ * ttl/vary/bounds with [`cache.server(def, { ttl })`](./server).
146
+ *
147
+ * @typeParam R - inferred from `requires`; their context types flow into the
148
+ * server-side `key`/`vary`/`skip`/`shouldCache`.
149
+ */
150
+ declare function cache<const R extends readonly AnyMiddleware[] = readonly []>(opts?: CacheDefOptions<R>): CacheDef<R>;
151
+ /** The def type a {@link cache} call produces — what `cache.server` binds against. */
152
+ type CacheDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
153
+ cache: CacheControl;
154
+ }, R>;
155
+ //#endregion
156
+ export { CacheControl, CacheDef, CacheDefOptions, CacheEntry, CacheKeyParts, CacheStore, EntryMeta, MemoryCacheOptions, cache, cacheHeaders, cacheKey, hashKey, isCacheableResult, memoryCache, stableStringify };