@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 +21 -0
- package/README.md +88 -0
- package/dist/index.cjs +216 -0
- package/dist/index.d.cts +156 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +209 -0
- package/dist/server.cjs +178 -0
- package/dist/server.d.cts +78 -0
- package/dist/server.d.ts +78 -0
- package/dist/server.js +177 -0
- package/package.json +75 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|