@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 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 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.
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.1.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.1.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.1.0"
59
+ "@ayepi/core": "0.2.0"
59
60
  },
60
61
  "keywords": [
61
62
  "ayepi",