@ayepi/cache 0.1.0 → 0.2.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/README.md +1 -1
- package/ayepi-cache.md +289 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -84,5 +84,5 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
84
84
|
|
|
85
85
|
- [`ayepi-cache.md`](./ayepi-cache.md)
|
|
86
86
|
|
|
87
|
-
They
|
|
87
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/cache).
|
|
88
88
|
|
package/ayepi-cache.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-cache.md — reference for `@ayepi/cache`, written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/cache` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/cache`
|
|
11
|
+
|
|
12
|
+
Response-caching middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
|
|
13
|
+
It derives a **key from the request** (method + path + query, plus an optional dev-defined
|
|
14
|
+
`vary`), and on a **hit** short-circuits the middleware chain with the stored response —
|
|
15
|
+
**without running the handler** — over HTTP (and as a result frame over WebSocket). On a
|
|
16
|
+
**miss** it runs the handler, stores the serialized JSON response, and returns it. Entries
|
|
17
|
+
live in memory for a bounded **time** (`ttl` + optional `staleWhileRevalidate`) and a
|
|
18
|
+
bounded **space** (`maxBytes` / `maxEntryBytes` / `maxEntries`, LRU-evicted).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add @ayepi/cache @ayepi/core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It ships as a **def / impl split**:
|
|
25
|
+
|
|
26
|
+
- `@ayepi/cache` (frontend-safe) exports `cache(opts?)`, a middleware **def factory**, plus
|
|
27
|
+
the standalone `memoryCache` / `cacheKey` / `cacheHeaders` / `isCacheableResult`
|
|
28
|
+
primitives.
|
|
29
|
+
- `@ayepi/cache/server` augments `cache` with **`.server(def, opts)`**, which binds the
|
|
30
|
+
policy (`ttl`, `vary`, `store`, bounds, …). Bind the pair with
|
|
31
|
+
`implement(api).middleware(...)`.
|
|
32
|
+
|
|
33
|
+
Cross-reference: middleware composition (def vs impl, `requires`, `StackCtx`,
|
|
34
|
+
`.group()`/`.endpoint()`, short-circuit `Response` semantics) is documented in
|
|
35
|
+
**`ayepi-core-middleware.md`** — read it alongside this file.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## At a glance
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// shared.ts — frontend-safe
|
|
43
|
+
import { cache } from '@ayepi/cache'
|
|
44
|
+
const cached = cache({ requires: [auth] }) // ctx.user typed in vary/key/skip/shouldCache
|
|
45
|
+
const api = spec({ endpoints: { ...cached.group({ report: { method: 'GET', response: Report } }) } })
|
|
46
|
+
|
|
47
|
+
// server.ts — binds the policy, with implement(api)
|
|
48
|
+
import { cache } from '@ayepi/cache/server'
|
|
49
|
+
const app = implement(api)
|
|
50
|
+
.middleware(auth, authImpl)
|
|
51
|
+
.middleware(cache.server(cached, {
|
|
52
|
+
ttl: 30_000,
|
|
53
|
+
vary: (io) => io.ctx.user.id,
|
|
54
|
+
}))
|
|
55
|
+
.handlers({ report: ({ user }) => buildReport(user.id) })
|
|
56
|
+
.server()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> **Chain placement.** Put `cache` **last** (closest to the handler), after `auth` /
|
|
60
|
+
> `rateLimit` / telemetry — so those still run on a hit (auth + rate accounting preserved)
|
|
61
|
+
> and `vary` can read their context. A hit short-circuits only the handler.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Public API surface
|
|
66
|
+
|
|
67
|
+
### Main entry `@ayepi/cache` (frontend-safe)
|
|
68
|
+
|
|
69
|
+
| Export | Kind | Purpose |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `cache` | function | **Def factory** — declares the middleware contract (`{ cache: CacheControl }`). |
|
|
72
|
+
| `memoryCache` | function | The bundled in-process LRU store (bounded by bytes + count). |
|
|
73
|
+
| `cacheKey` | function | Build the stable string key from request parts (method/path/query/body/vary) — for targeted invalidation. |
|
|
74
|
+
| `cacheHeaders` | function | Compute the `Age` / `Cache-Control` header map from an entry. |
|
|
75
|
+
| `isCacheableResult` | function | Whether a handler result would be cached (not a stream/Response/empty/multi-status). |
|
|
76
|
+
| `stableStringify` | function | Deterministic `JSON.stringify` (keys sorted at every depth) — the canonicalizer behind `cacheKey`. |
|
|
77
|
+
| `hashKey` | function | Fast non-crypto hash (cyrb53) for the `hash` option — shrink a huge key to a short digest. |
|
|
78
|
+
| `CacheStore` | interface | Pluggable backend (`get`/`set`/`delete`/`clear`/`invalidate`). |
|
|
79
|
+
| `CacheEntry` / `EntryMeta` | interface | A stored response / the subset exposed to `invalidate`. |
|
|
80
|
+
| `CacheControl` | interface | The `io.ctx.cache` handle handed to handlers. |
|
|
81
|
+
| `MemoryCacheOptions` | interface | `{ maxBytes?, maxEntryBytes?, maxEntries? }`. |
|
|
82
|
+
| `CacheKeyParts` | interface | `{ method, path, query?, vary? }` for `cacheKey`. |
|
|
83
|
+
| `CacheDefOptions` | interface | Options for the `cache` def (`name`/`requires`). |
|
|
84
|
+
| `CacheDef` | type | The def type a `cache()` call produces. |
|
|
85
|
+
|
|
86
|
+
### Server subpath `@ayepi/cache/server`
|
|
87
|
+
|
|
88
|
+
| Export | Kind | Purpose |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `cache` | function | Same name, **augmented with `.server(def, opts)`** to bind the policy. |
|
|
91
|
+
| `CacheServerOptions` | interface | The policy options for `.server`. |
|
|
92
|
+
| `CacheIO` | interface | `{ req, ctx }` passed to `key`/`vary`/`skip`/`shouldCache`. |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## `cache` — the def + the `.server` impl
|
|
97
|
+
|
|
98
|
+
### The def (`@ayepi/cache`)
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
function cache<const R extends readonly AnyMiddleware[] = readonly []>(
|
|
102
|
+
opts?: { requires?: R; name?: string },
|
|
103
|
+
): CacheDef<R>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Declares a `@ayepi/core` middleware that contributes `{ cache: CacheControl }` to the
|
|
107
|
+
handler context (a miss only — a hit never runs the handler) and short-circuits with the
|
|
108
|
+
cached `Response` on a hit, once bound. Frontend-safe; carries no policy. Compose it like
|
|
109
|
+
any middleware: `cached.endpoint(...)`, `cached.group(...)`, `use(auth, cached)`, or list
|
|
110
|
+
it in another middleware's `requires`. `requires` flows context types into the
|
|
111
|
+
server-side `key`/`vary`/`skip`/`shouldCache`.
|
|
112
|
+
|
|
113
|
+
### The impl (`@ayepi/cache/server`) — `CacheServerOptions`
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
interface CacheServerOptions<M extends AnyMiddleware> {
|
|
117
|
+
ttl: number; // freshness lifetime (ms) — required
|
|
118
|
+
staleWhileRevalidate?: number; // grace after ttl (ms): serve stale, refresh in background
|
|
119
|
+
methods?: readonly string[]; // default ['GET'] — by the endpoint's declared method (http + ws)
|
|
120
|
+
vary?: (io: CacheIO<Ctx>) => Json; // extra key discriminator (e.g. io.ctx.user.id)
|
|
121
|
+
key?: (io: CacheIO<Ctx>) => Json; // replace the whole key derivation
|
|
122
|
+
hash?: (fullKey: string) => string; // shrink the key to a store key (e.g. hashKey or a sha-256)
|
|
123
|
+
checkKey?: boolean; // store + verify the full key on a hit (default: true when hash is set)
|
|
124
|
+
store?: CacheStore; // default memoryCache(opts below)
|
|
125
|
+
maxBytes?: number; // default-store total cap (default 64 MiB)
|
|
126
|
+
maxEntryBytes?: number; // default-store per-entry cap (default 1 MiB)
|
|
127
|
+
maxEntries?: number; // default-store count cap (default 10 000)
|
|
128
|
+
shouldCache?: (io: CacheIO<Ctx>, result: unknown) => boolean; // per-response decision
|
|
129
|
+
headers?: boolean; // emit X-Cache / Age / Cache-Control (default true)
|
|
130
|
+
skip?: (io: CacheIO<Ctx>) => boolean; // bypass entirely (neither read nor write)
|
|
131
|
+
onError?: (err, phase: 'read'|'write'|'revalidate') => void; // observe swallowed errors (off by default)
|
|
132
|
+
now?: () => number; // clock injection (default Date.now)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Notes grounded in the source:
|
|
137
|
+
|
|
138
|
+
- **`ttl` is required.** A cached response is a `HIT` until `now + ttl`.
|
|
139
|
+
- **`staleWhileRevalidate`** extends the entry's life by that many ms past `ttl`. A request
|
|
140
|
+
in that window gets the **stale** response immediately (`X-Cache: STALE`) while a single
|
|
141
|
+
background refresh (per store key — coalesced) re-runs the handler and updates the entry.
|
|
142
|
+
Caveat: the background refresh rides the request's abort signal; a runtime that aborts on
|
|
143
|
+
response completion may cut a refresh short (it simply retries on the next request).
|
|
144
|
+
- **Key = endpoint method + path + query + body + `vary`.** The default key includes the
|
|
145
|
+
**request body** (parsed JSON or urlencoded form), so POST-style caches key on the payload;
|
|
146
|
+
it's canonicalized (sorted keys at every depth) so property order doesn't matter. Over
|
|
147
|
+
**WebSocket** the call args (`io.ws.data`) take the place of query+body. **Multipart**
|
|
148
|
+
(file-upload) requests are never cached.
|
|
149
|
+
- **`vary`** is appended to the default key — use it for per-user/per-tenant caches.
|
|
150
|
+
Without it, the cache is shared across callers for a given URL (fine for public data;
|
|
151
|
+
a footgun for user-specific data — set `vary`).
|
|
152
|
+
- **`key`** replaces the whole derivation; its return value is `stableStringify`-d as the key.
|
|
153
|
+
- **`hash`** maps the (possibly huge) full key to a short store key — `hash: hashKey` for the
|
|
154
|
+
bundled fast hash, or a crypto digest. With `hash` set, **`checkKey`** defaults on: the full
|
|
155
|
+
key is stored and compared on a hit, so a hash collision falls through to a miss instead of
|
|
156
|
+
serving the wrong body. Set `checkKey: false` to drop the full key (leaner memory, accepts
|
|
157
|
+
collision risk).
|
|
158
|
+
- **`methods`** (default `['GET']`) gates which endpoints are cacheable, **by the endpoint's
|
|
159
|
+
declared method** — so the same policy governs HTTP and WebSocket calls. Others pass through.
|
|
160
|
+
- **`shouldCache`** runs on a miss after the handler; return `false` to skip storing.
|
|
161
|
+
- **Best-effort / fail-open.** Caching never breaks an endpoint. If any cache step throws —
|
|
162
|
+
key derivation, `vary`/`key`/`hash`, the `store`, serialization — the request falls through
|
|
163
|
+
to the handler **as if uncached** (no `X-Cache` header). The handler runs via `io.next()`
|
|
164
|
+
outside the cache's error handling, so its **own** errors still propagate to the client
|
|
165
|
+
normally; only the cache's bookkeeping is swallowed.
|
|
166
|
+
- **`onError`** lets you observe those swallowed errors (log them, count a metric) without
|
|
167
|
+
giving up fail-open. It's **off by default** (errors are silent). `phase` is `'read'`,
|
|
168
|
+
`'write'`, or `'revalidate'`. The callback itself is guarded — if it throws, that's ignored.
|
|
169
|
+
|
|
170
|
+
### `CacheControl` (the `io.ctx.cache` handle)
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
interface CacheControl {
|
|
174
|
+
readonly key: string; // the computed cache key for this request
|
|
175
|
+
readonly hit: boolean; // always false here (the handler runs only on a miss)
|
|
176
|
+
noStore(): void; // do not cache this particular response
|
|
177
|
+
ttl(ms: number): void; // override the freshness lifetime for this response
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A handler reads it as part of its payload: `({ user, cache }) => { if (isPrivate) cache.noStore(); … }`.
|
|
182
|
+
|
|
183
|
+
### What is cached
|
|
184
|
+
|
|
185
|
+
`isCacheableResult(result)` decides. Cached: a plain JSON body (object / array / primitive)
|
|
186
|
+
from a single-response (`response:`) endpoint, over HTTP or WebSocket, for an allowed
|
|
187
|
+
`methods` entry. **Not** cached (passed through): `null` / `undefined` (204), a short-circuit
|
|
188
|
+
`Response` from a downstream middleware, a function, a streamed body (async-iterable /
|
|
189
|
+
`ReadableStream`), a multi-status `{ status, data }` wrapper, or a **multipart/file-upload**
|
|
190
|
+
request. Replays are emitted at status **200** (a handler `io.status(201)` isn't captured —
|
|
191
|
+
attach the cache to plain single-response endpoints).
|
|
192
|
+
|
|
193
|
+
> Body keying relies on core's `io.body` (the raw, pre-validation body) — added so middleware
|
|
194
|
+
> can read the payload (the request body is consumed before the chain runs, so the cache can't
|
|
195
|
+
> re-read it from `io.req`). Over WebSocket the per-call args come from `io.ws.data` instead.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Stores & invalidation
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
interface CacheStore {
|
|
203
|
+
get(key: string): MaybePromise<CacheEntry | undefined>; // also marks most-recently-used
|
|
204
|
+
set(key: string, entry: CacheEntry): MaybePromise<void>;
|
|
205
|
+
delete(key: string): MaybePromise<boolean>;
|
|
206
|
+
clear(): MaybePromise<void>;
|
|
207
|
+
invalidate(pred: (meta: EntryMeta) => boolean): MaybePromise<number>; // returns count removed
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The store owns **space** (which entries to keep); the middleware owns **time** (freshness
|
|
212
|
+
vs `entry.expires`). `memoryCache({ maxBytes?, maxEntryBytes?, maxEntries? })` is an LRU
|
|
213
|
+
`Map`: most-recently-used on `get`/`set`, evicting least-recently-used when over `maxBytes`
|
|
214
|
+
or `maxEntries`, and skipping any single entry larger than `maxEntryBytes`.
|
|
215
|
+
|
|
216
|
+
**Manual invalidation** — hold the store you pass in and bust keys after a mutation:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { memoryCache, cacheKey } from '@ayepi/cache'
|
|
220
|
+
const store = memoryCache({ maxBytes: 64 * 1024 * 1024 })
|
|
221
|
+
implement(api).middleware(cache.server(cached, { ttl: 30_000, store, vary: (io) => io.ctx.user.id }))
|
|
222
|
+
|
|
223
|
+
// after a write that changes a user's report:
|
|
224
|
+
await store.delete(cacheKey({ method: 'GET', path: '/report', vary: userId }))
|
|
225
|
+
await store.invalidate((m) => m.path === '/report') // or by predicate
|
|
226
|
+
await store.clear() // or everything
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`cacheKey` takes `{ method, path, query?, body?, vary? }` — pass the same parts the middleware
|
|
230
|
+
used. With a `hash` configured, the store key is the digest, so wrap it: `store.delete(hashKey(cacheKey({…})))`,
|
|
231
|
+
or just use the predicate form (`invalidate` matches on the stored `method`/`path`, which are kept
|
|
232
|
+
regardless of hashing). `invalidate` also powers a time-sweep:
|
|
233
|
+
`store.invalidate(m => m.staleUntil <= Date.now())` drops dead entries (though `maxBytes`/`maxEntries`
|
|
234
|
+
already bound memory regardless).
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Examples
|
|
239
|
+
|
|
240
|
+
### Per-user cache with stale-while-revalidate
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
implement(api).middleware(cache.server(cached, {
|
|
244
|
+
ttl: 10_000,
|
|
245
|
+
staleWhileRevalidate: 30_000, // serve instantly, refresh behind the scenes
|
|
246
|
+
vary: (io) => io.ctx.user.id,
|
|
247
|
+
}))
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Public, shared cache (no vary), tighter bounds
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
implement(api).middleware(cache.server(cached, {
|
|
254
|
+
ttl: 60_000,
|
|
255
|
+
maxBytes: 8 * 1024 * 1024,
|
|
256
|
+
maxEntryBytes: 256 * 1024,
|
|
257
|
+
}))
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Exclude some responses
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
cache.server(cached, {
|
|
264
|
+
ttl: 30_000,
|
|
265
|
+
shouldCache: (io, result) => !(result as Report).partial, // don't cache partial reports
|
|
266
|
+
})
|
|
267
|
+
// …or from the handler:
|
|
268
|
+
({ user, cache }) => { const r = build(user.id); if (r.partial) cache.noStore(); return r }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Cache a POST with a large body, hashed key
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { hashKey } from '@ayepi/cache'
|
|
275
|
+
import { createHash } from 'node:crypto'
|
|
276
|
+
|
|
277
|
+
cache.server(searchCache, {
|
|
278
|
+
ttl: 10_000,
|
|
279
|
+
methods: ['POST'], // cache the search endpoint's POST body
|
|
280
|
+
hash: hashKey, // short store keys (or: (k) => createHash('sha256').update(k).digest('hex'))
|
|
281
|
+
// checkKey defaults true → the full key is kept and verified, so a hash collision misses safely
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
See also: **`ayepi-core-middleware.md`** (middleware composition, `requires`, `StackCtx`,
|
|
288
|
+
short-circuit `Response` semantics) and **`ayepi-rate.md`** (the sibling rate-limit
|
|
289
|
+
middleware — same def/impl split and bundled in-memory store).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/cache",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Response-caching middleware for @ayepi/core — keyed by request + a dev-defined vary, with time (ttl/stale-while-revalidate) and space (maxBytes/maxEntries) bounds",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ayepi-*.md"
|
|
22
23
|
],
|
|
23
24
|
"exports": {
|
|
24
25
|
".": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"node": ">=18"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
|
-
"@ayepi/core": "^0.
|
|
51
|
+
"@ayepi/core": "^0.2.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"tsdown": "^0.12.0",
|
|
56
57
|
"vitest": "^2.1.8",
|
|
57
58
|
"zod": "^4.4.3",
|
|
58
|
-
"@ayepi/core": "0.
|
|
59
|
+
"@ayepi/core": "0.2.0"
|
|
59
60
|
},
|
|
60
61
|
"keywords": [
|
|
61
62
|
"ayepi",
|