@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/dist/index.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { ctx, middleware } from "@ayepi/core";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* # @ayepi/cache
|
|
5
|
+
*
|
|
6
|
+
* Response-caching middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
|
|
7
|
+
* {@link cache} builds a middleware that derives a **key from the request** (method +
|
|
8
|
+
* path + query, plus an optional dev-defined `vary` — e.g. the authenticated user) and,
|
|
9
|
+
* on a **hit**, replays the stored response without running the handler. Entries live in
|
|
10
|
+
* memory for a bounded **time** (`ttl`, with optional `stale-while-revalidate`) and a
|
|
11
|
+
* bounded **space** (`maxBytes` / `maxEntryBytes` / `maxEntries`, LRU-evicted).
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // shared.ts (frontend-safe): the def declares what it contributes
|
|
15
|
+
* import { cache } from '@ayepi/cache'
|
|
16
|
+
* const cached = cache() // provides { cache } to handlers
|
|
17
|
+
* spec({ endpoints: { ...cached.group({ … }) } })
|
|
18
|
+
*
|
|
19
|
+
* // server.ts: bind the policy (ttl, vary, bounds)
|
|
20
|
+
* import { cache } from '@ayepi/cache/server'
|
|
21
|
+
* implement(api).middleware(cache.server(cached, {
|
|
22
|
+
* ttl: 30_000, // fresh for 30s
|
|
23
|
+
* vary: (io) => io.ctx.user.id, // per-user cache
|
|
24
|
+
* maxBytes: 64 * 1024 * 1024,
|
|
25
|
+
* }))
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* - **Bounded memory** — the default {@link memoryCache} is an LRU store with total/entry
|
|
29
|
+
* byte caps and an entry-count cap; dead entries are swept lazily.
|
|
30
|
+
* - **Time controls** — `ttl` (freshness) and `staleWhileRevalidate` (serve-stale grace).
|
|
31
|
+
* - **Customizable** — `key`/`vary`, `methods`, `shouldCache`, response headers, `skip`,
|
|
32
|
+
* request `Cache-Control` respect, and per-response opt-out via `io.ctx.cache`.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
/** Default middleware name. */
|
|
37
|
+
const DEFAULT_NAME = "cache";
|
|
38
|
+
/** Milliseconds per second — `Age` / `Cache-Control: max-age` are expressed in seconds. */
|
|
39
|
+
const MS_PER_SECOND = 1e3;
|
|
40
|
+
/** Default total cache capacity (bytes) for {@link memoryCache}. */
|
|
41
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
42
|
+
/** Default per-entry cap (bytes) — larger responses are not cached. */
|
|
43
|
+
const DEFAULT_MAX_ENTRY_BYTES = 1024 * 1024;
|
|
44
|
+
/** Default entry-count cap. */
|
|
45
|
+
const DEFAULT_MAX_ENTRIES = 1e4;
|
|
46
|
+
/**
|
|
47
|
+
* Create an in-process LRU {@link CacheStore} bounded by total bytes, per-entry bytes,
|
|
48
|
+
* and entry count. The default store — fine for a single instance. Most-recently-used
|
|
49
|
+
* on `get`/`set`; when over a bound, evicts least-recently-used entries (or skips a
|
|
50
|
+
* `set` whose entry alone exceeds `maxEntryBytes`).
|
|
51
|
+
*/
|
|
52
|
+
function memoryCache(opts = {}) {
|
|
53
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
54
|
+
const maxEntryBytes = opts.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
|
55
|
+
const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
56
|
+
const entries = /* @__PURE__ */ new Map();
|
|
57
|
+
let totalBytes = 0;
|
|
58
|
+
const drop = (key) => {
|
|
59
|
+
const e = entries.get(key);
|
|
60
|
+
if (e) {
|
|
61
|
+
totalBytes -= e.bytes;
|
|
62
|
+
entries.delete(key);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const evictLRU = () => {
|
|
66
|
+
for (const key of entries.keys()) {
|
|
67
|
+
if (totalBytes <= maxBytes && entries.size <= maxEntries) break;
|
|
68
|
+
drop(key);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
get(key) {
|
|
73
|
+
const e = entries.get(key);
|
|
74
|
+
if (!e) return;
|
|
75
|
+
entries.delete(key);
|
|
76
|
+
entries.set(key, e);
|
|
77
|
+
return e;
|
|
78
|
+
},
|
|
79
|
+
set(key, entry) {
|
|
80
|
+
drop(key);
|
|
81
|
+
if (entry.bytes > maxEntryBytes) return;
|
|
82
|
+
entries.set(key, entry);
|
|
83
|
+
totalBytes += entry.bytes;
|
|
84
|
+
evictLRU();
|
|
85
|
+
},
|
|
86
|
+
delete(key) {
|
|
87
|
+
const existed = entries.has(key);
|
|
88
|
+
drop(key);
|
|
89
|
+
return existed;
|
|
90
|
+
},
|
|
91
|
+
clear() {
|
|
92
|
+
entries.clear();
|
|
93
|
+
totalBytes = 0;
|
|
94
|
+
},
|
|
95
|
+
invalidate(pred) {
|
|
96
|
+
let removed = 0;
|
|
97
|
+
for (const e of [...entries.values()]) if (pred(e)) {
|
|
98
|
+
drop(e.key);
|
|
99
|
+
removed++;
|
|
100
|
+
}
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Normalize a query input into a stable, order-independent list of `[k, v]` pairs. */
|
|
106
|
+
function normalizeQuery(query) {
|
|
107
|
+
if (query === void 0) return [];
|
|
108
|
+
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);
|
|
109
|
+
}
|
|
110
|
+
/** Recursively sort object keys so semantically-equal values serialize identically. */
|
|
111
|
+
function sortDeep(v) {
|
|
112
|
+
if (Array.isArray(v)) return v.map(sortDeep);
|
|
113
|
+
if (v && typeof v === "object") {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
return v;
|
|
119
|
+
}
|
|
120
|
+
/** Deterministic `JSON.stringify` — object keys sorted at every depth, so key order never matters. */
|
|
121
|
+
function stableStringify(value) {
|
|
122
|
+
return JSON.stringify(sortDeep(value)) ?? "null";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build a stable cache key from request parts — the same string the middleware uses.
|
|
126
|
+
* Query parameters are sorted and the body is canonicalized (sorted keys at every depth),
|
|
127
|
+
* so equal requests share a key regardless of property order. Exported so you can target
|
|
128
|
+
* {@link CacheStore.delete} after a mutation (e.g. bust a user's cached report).
|
|
129
|
+
*/
|
|
130
|
+
function cacheKey(parts) {
|
|
131
|
+
return stableStringify([
|
|
132
|
+
parts.method.toUpperCase(),
|
|
133
|
+
parts.path,
|
|
134
|
+
normalizeQuery(parts.query),
|
|
135
|
+
parts.body ?? null,
|
|
136
|
+
parts.vary ?? null
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A fast, **non-cryptographic** hash (cyrb53 → base36) for shrinking a large cache key —
|
|
141
|
+
* pass it as the `hash` option so the store keys on a short digest instead of the full
|
|
142
|
+
* (possibly huge) JSON. Collisions are unlikely but possible; keep `checkKey` on (the
|
|
143
|
+
* default when hashing) to fall through on one, or supply a crypto hash (e.g. sha-256)
|
|
144
|
+
* for stronger guarantees.
|
|
145
|
+
*/
|
|
146
|
+
function hashKey(key) {
|
|
147
|
+
let h1 = 3735928559;
|
|
148
|
+
let h2 = 1103547991;
|
|
149
|
+
for (let i = 0; i < key.length; i++) {
|
|
150
|
+
const ch = key.charCodeAt(i);
|
|
151
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
152
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
153
|
+
}
|
|
154
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
155
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
156
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Compute the informational cache headers for an entry: `Age` (seconds since it was
|
|
160
|
+
* stored) and `Cache-Control: max-age` (seconds of freshness remaining). The middleware
|
|
161
|
+
* adds the `X-Cache: HIT|STALE|MISS` marker separately.
|
|
162
|
+
*/
|
|
163
|
+
function cacheHeaders(entry, now) {
|
|
164
|
+
const age = Math.max(0, Math.floor((now - entry.storedAt) / MS_PER_SECOND));
|
|
165
|
+
const maxAge = Math.max(0, Math.ceil((entry.expires - now) / MS_PER_SECOND));
|
|
166
|
+
return {
|
|
167
|
+
age: String(age),
|
|
168
|
+
"cache-control": `max-age=${maxAge}`
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** The shape core produces for a multi-status (`responses:`) endpoint — `{ status, data }`. */
|
|
172
|
+
function looksMultiStatus(r) {
|
|
173
|
+
const keys = Object.keys(r);
|
|
174
|
+
return keys.length === 2 && keys.includes("status") && keys.includes("data") && typeof r.status === "number";
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether a handler's result is a plain JSON response body the cache can store and
|
|
178
|
+
* replay. `false` for an empty body (`null`/`undefined` → 204), a short-circuit
|
|
179
|
+
* `Response`, a function, a streamed body (async-iterable / `ReadableStream`), or a
|
|
180
|
+
* multi-status `{ status, data }` wrapper (whose replay status would be wrong). Useful in
|
|
181
|
+
* a custom `shouldCache` or a custom {@link CacheStore}.
|
|
182
|
+
*/
|
|
183
|
+
function isCacheableResult(result) {
|
|
184
|
+
if (result === null || result === void 0) return false;
|
|
185
|
+
if (result instanceof Response) return false;
|
|
186
|
+
if (typeof result === "function") return false;
|
|
187
|
+
if (typeof result === "object") {
|
|
188
|
+
const o = result;
|
|
189
|
+
if (typeof o[Symbol.asyncIterator] === "function" || typeof o.getReader === "function") return false;
|
|
190
|
+
if (looksMultiStatus(result)) return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Create a response-caching middleware **def**. The def declares what the middleware
|
|
196
|
+
* contributes (`{ cache: CacheControl }`) but **no** policy. Bind the
|
|
197
|
+
* ttl/vary/bounds with [`cache.server(def, { ttl })`](./server).
|
|
198
|
+
*
|
|
199
|
+
* @typeParam R - inferred from `requires`; their context types flow into the
|
|
200
|
+
* server-side `key`/`vary`/`skip`/`shouldCache`.
|
|
201
|
+
*/
|
|
202
|
+
function cache(opts) {
|
|
203
|
+
return middleware(opts?.name ?? DEFAULT_NAME, {
|
|
204
|
+
provides: ctx(),
|
|
205
|
+
requires: opts?.requires ?? []
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
export { cache, cacheHeaders, cacheKey, hashKey, isCacheableResult, memoryCache, stableStringify };
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_index = require("./index.cjs");
|
|
3
|
+
//#region src/server.ts
|
|
4
|
+
/** The default methods whose responses are cached. */
|
|
5
|
+
const DEFAULT_METHODS = ["GET"];
|
|
6
|
+
/** Replayed responses are served as JSON. */
|
|
7
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
8
|
+
/** Parse the request `Cache-Control` directives we honor. */
|
|
9
|
+
function parseCacheControl(header) {
|
|
10
|
+
if (!header) return {
|
|
11
|
+
noStore: false,
|
|
12
|
+
noCache: false
|
|
13
|
+
};
|
|
14
|
+
const directives = header.toLowerCase().split(",").map((d) => d.trim());
|
|
15
|
+
return {
|
|
16
|
+
noStore: directives.includes("no-store"),
|
|
17
|
+
noCache: directives.includes("no-cache")
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Bind a {@link cache} def to its runtime policy. */
|
|
21
|
+
function cacheServer(def, opts) {
|
|
22
|
+
const store = opts.store ?? require_index.memoryCache(opts);
|
|
23
|
+
const ttl = opts.ttl;
|
|
24
|
+
const swr = opts.staleWhileRevalidate ?? 0;
|
|
25
|
+
const methods = new Set((opts.methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase()));
|
|
26
|
+
const emitHeaders = opts.headers !== false;
|
|
27
|
+
const now = opts.now ?? Date.now;
|
|
28
|
+
const checkKey = opts.checkKey ?? opts.hash !== void 0;
|
|
29
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
30
|
+
/** Hand a swallowed error to `onError` (best-effort — a throwing handler is itself ignored). */
|
|
31
|
+
const reportError = (err, phase) => {
|
|
32
|
+
try {
|
|
33
|
+
opts.onError?.(err, phase);
|
|
34
|
+
} catch {}
|
|
35
|
+
};
|
|
36
|
+
const makeControl = (key) => ({
|
|
37
|
+
key,
|
|
38
|
+
hit: false,
|
|
39
|
+
store: true,
|
|
40
|
+
noStore() {
|
|
41
|
+
this.store = false;
|
|
42
|
+
},
|
|
43
|
+
ttl(ms) {
|
|
44
|
+
this.ttlOverride = ms;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
/** The full (pre-hash) key — endpoint identity + the request's query/body (or ws args) + `vary`. */
|
|
48
|
+
const keyOf = (io, route) => {
|
|
49
|
+
const kio = {
|
|
50
|
+
req: io.req,
|
|
51
|
+
ctx: io.ctx
|
|
52
|
+
};
|
|
53
|
+
if (opts.key) return require_index.stableStringify(opts.key(kio));
|
|
54
|
+
const vary = opts.vary?.(kio);
|
|
55
|
+
if (io.transport === "ws") return require_index.cacheKey({
|
|
56
|
+
method: route.method,
|
|
57
|
+
path: route.path,
|
|
58
|
+
body: io.ws?.data,
|
|
59
|
+
vary
|
|
60
|
+
});
|
|
61
|
+
const url = new URL(io.req.url);
|
|
62
|
+
return require_index.cacheKey({
|
|
63
|
+
method: route.method,
|
|
64
|
+
path: route.path,
|
|
65
|
+
query: url.searchParams,
|
|
66
|
+
body: io.body,
|
|
67
|
+
vary
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const replay = (entry, marker, at) => {
|
|
71
|
+
const headers = new Headers(entry.headers);
|
|
72
|
+
if (emitHeaders) {
|
|
73
|
+
headers.set("x-cache", marker);
|
|
74
|
+
for (const [k, v] of Object.entries(require_index.cacheHeaders(entry, at))) headers.set(k, v);
|
|
75
|
+
}
|
|
76
|
+
return new Response(entry.body, {
|
|
77
|
+
status: entry.status,
|
|
78
|
+
headers
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
/** Serialize + store a handler result, honoring the handler's `io.ctx.cache` opt-out and the store's bounds. */
|
|
82
|
+
const persist = async (io, storeKey, fullKey, control, result, route) => {
|
|
83
|
+
if (!control.store) return;
|
|
84
|
+
if (!require_index.isCacheableResult(result)) return;
|
|
85
|
+
if (opts.shouldCache && !opts.shouldCache({
|
|
86
|
+
req: io.req,
|
|
87
|
+
ctx: io.ctx
|
|
88
|
+
}, result)) return;
|
|
89
|
+
const body = JSON.stringify(result);
|
|
90
|
+
const bytes = new TextEncoder().encode(body).length;
|
|
91
|
+
const at = now();
|
|
92
|
+
const expires = at + (control.ttlOverride ?? ttl);
|
|
93
|
+
const entry = {
|
|
94
|
+
body,
|
|
95
|
+
status: 200,
|
|
96
|
+
headers: [["content-type", JSON_CONTENT_TYPE]],
|
|
97
|
+
storedAt: at,
|
|
98
|
+
expires,
|
|
99
|
+
staleUntil: expires + swr,
|
|
100
|
+
bytes,
|
|
101
|
+
method: route.method,
|
|
102
|
+
path: route.path,
|
|
103
|
+
key: checkKey ? fullKey : storeKey
|
|
104
|
+
};
|
|
105
|
+
await store.set(storeKey, entry);
|
|
106
|
+
};
|
|
107
|
+
/** Background refresh for a stale entry — single-flight per store key; failures leave the stale entry in place. */
|
|
108
|
+
const revalidate = (io, storeKey, fullKey, route) => {
|
|
109
|
+
if (inflight.has(storeKey)) return;
|
|
110
|
+
inflight.add(storeKey);
|
|
111
|
+
const control = makeControl(fullKey);
|
|
112
|
+
Promise.resolve(io.next({ cache: control })).then((result) => persist(io, storeKey, fullKey, control, result, route)).catch((err) => reportError(err, "revalidate")).finally(() => inflight.delete(storeKey));
|
|
113
|
+
};
|
|
114
|
+
/** The read phase — key derivation + store lookup. Runs no handler, so any throw here is a pure cache failure. */
|
|
115
|
+
const decide = async (io) => {
|
|
116
|
+
const kio = {
|
|
117
|
+
req: io.req,
|
|
118
|
+
ctx: io.ctx
|
|
119
|
+
};
|
|
120
|
+
const route = io.route;
|
|
121
|
+
const cc = parseCacheControl(io.req.headers.get("cache-control"));
|
|
122
|
+
const multipart = io.transport === "http" && (io.req.headers.get("content-type") ?? "").toLowerCase().includes("multipart/form-data");
|
|
123
|
+
if (route.kind !== "endpoint" || !methods.has(route.method) || opts.skip?.(kio) || cc.noStore || multipart) return { bypass: true };
|
|
124
|
+
const fullKey = keyOf(io, route);
|
|
125
|
+
const storeKey = opts.hash ? opts.hash(fullKey) : fullKey;
|
|
126
|
+
const control = makeControl(fullKey);
|
|
127
|
+
if (!cc.noCache) {
|
|
128
|
+
const entry = await store.get(storeKey);
|
|
129
|
+
if (entry && (!checkKey || entry.key === fullKey)) {
|
|
130
|
+
const at = now();
|
|
131
|
+
if (at < entry.expires) return { serve: replay(entry, "HIT", at) };
|
|
132
|
+
if (at < entry.staleUntil) {
|
|
133
|
+
revalidate(io, storeKey, fullKey, route);
|
|
134
|
+
return { serve: replay(entry, "STALE", at) };
|
|
135
|
+
}
|
|
136
|
+
await store.delete(storeKey);
|
|
137
|
+
} else if (entry) await store.delete(storeKey);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
proceed: true,
|
|
141
|
+
storeKey,
|
|
142
|
+
fullKey,
|
|
143
|
+
route,
|
|
144
|
+
control
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
const run = async (io) => {
|
|
148
|
+
let decision;
|
|
149
|
+
try {
|
|
150
|
+
decision = await decide(io);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
reportError(err, "read");
|
|
153
|
+
return io.next({ cache: makeControl("") });
|
|
154
|
+
}
|
|
155
|
+
if ("serve" in decision) return decision.serve;
|
|
156
|
+
if ("bypass" in decision) return io.next({ cache: makeControl("") });
|
|
157
|
+
const result = await io.next({ cache: decision.control });
|
|
158
|
+
try {
|
|
159
|
+
if (emitHeaders) io.setHeader("x-cache", "MISS");
|
|
160
|
+
await persist(io, decision.storeKey, decision.fullKey, decision.control, result, decision.route);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
reportError(err, "write");
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
def,
|
|
168
|
+
impl: run
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The {@link cache} def factory, augmented with a `.server(def, opts)` binder. Import
|
|
173
|
+
* from `@ayepi/cache/server` in your server entry to bind a def created in a
|
|
174
|
+
* frontend-safe spec.
|
|
175
|
+
*/
|
|
176
|
+
const cache = Object.assign(require_index.cache, { server: cacheServer });
|
|
177
|
+
//#endregion
|
|
178
|
+
exports.cache = cache;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CacheStore, cache as cache$1 } from "./index.cjs";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
|
|
3
|
+
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
|
|
6
|
+
/** The `requires` chain of a middleware def. */
|
|
7
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
8
|
+
/** The argument passed to `key`/`vary`/`skip`/`shouldCache` — the request plus accumulated context. */
|
|
9
|
+
interface CacheIO<Ctx extends object> {
|
|
10
|
+
readonly req: Request;
|
|
11
|
+
readonly ctx: Ctx;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Server-side options for binding a {@link cache} def — the caching policy, with
|
|
15
|
+
* `key`/`vary`/`skip`/`shouldCache` typed against the def's `requires` context. Extends
|
|
16
|
+
* {@link MemoryCacheOptions}, whose bounds configure the default {@link memoryCache} when
|
|
17
|
+
* no `store` is supplied.
|
|
18
|
+
*
|
|
19
|
+
* @typeParam M - the cache def being bound.
|
|
20
|
+
*/
|
|
21
|
+
interface CacheServerOptions<M extends AnyMiddleware> {
|
|
22
|
+
/** Freshness lifetime in milliseconds — a cached response is a `HIT` until it expires. */
|
|
23
|
+
readonly ttl: number;
|
|
24
|
+
/** Extra grace (ms) after `ttl` during which a stale entry is served immediately while it refreshes in the background. */
|
|
25
|
+
readonly staleWhileRevalidate?: number;
|
|
26
|
+
/** Which endpoint methods to cache (default `['GET']`) — keyed off the endpoint's declared method, so it governs HTTP and ws alike. */
|
|
27
|
+
readonly methods?: readonly string[];
|
|
28
|
+
/** An extra discriminator appended to the key (e.g. `io.ctx.user.id`) — for per-user/per-tenant caches. */
|
|
29
|
+
readonly vary?: (io: CacheIO<StackCtx<ReqOf<M>>>) => Json;
|
|
30
|
+
/** Replace the whole key derivation (default `method + path + query + body + vary`). Returns any JSON value. */
|
|
31
|
+
readonly key?: (io: CacheIO<StackCtx<ReqOf<M>>>) => Json;
|
|
32
|
+
/**
|
|
33
|
+
* Shrink the (possibly large) key to a store key — e.g. `hash: hashKey` or a crypto
|
|
34
|
+
* digest. Default: the full key is the store key. When set, {@link checkKey} defaults on.
|
|
35
|
+
*/
|
|
36
|
+
readonly hash?: (fullKey: string) => string;
|
|
37
|
+
/**
|
|
38
|
+
* Store the full key in the entry and verify it on a hit, so a {@link hash} collision
|
|
39
|
+
* falls through to a miss instead of serving the wrong body. Defaults to `true` when
|
|
40
|
+
* `hash` is set; set `false` to drop the full key (leaner memory, accepts collision risk).
|
|
41
|
+
*/
|
|
42
|
+
readonly checkKey?: boolean;
|
|
43
|
+
/** Backend store (default an in-process {@link memoryCache} built from the bounds below). */
|
|
44
|
+
readonly store?: CacheStore;
|
|
45
|
+
/** Total cache capacity in bytes for the default store (default 64 MiB). */
|
|
46
|
+
readonly maxBytes?: number;
|
|
47
|
+
/** Per-response cap in bytes for the default store (default 1 MiB) — larger responses aren't cached. */
|
|
48
|
+
readonly maxEntryBytes?: number;
|
|
49
|
+
/** Entry-count cap for the default store (default 10 000). */
|
|
50
|
+
readonly maxEntries?: number;
|
|
51
|
+
/** Decide per-response whether to cache it (runs on a miss, after the handler). */
|
|
52
|
+
readonly shouldCache?: (io: CacheIO<StackCtx<ReqOf<M>>>, result: unknown) => boolean;
|
|
53
|
+
/** Emit `X-Cache` / `Age` / `Cache-Control` response headers (default `true`). */
|
|
54
|
+
readonly headers?: boolean;
|
|
55
|
+
/** Bypass the cache for some requests (neither read nor write). */
|
|
56
|
+
readonly skip?: (io: CacheIO<StackCtx<ReqOf<M>>>) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Observe an error the cache **swallowed** to stay fail-open — to log it, count a metric,
|
|
59
|
+
* etc. Not called by default (errors are silent). `phase` is where it happened: `'read'`
|
|
60
|
+
* (key/lookup → request served uncached), `'write'` (storing the result), or `'revalidate'`
|
|
61
|
+
* (a background stale refresh). It must not throw; if it does, the throw is ignored.
|
|
62
|
+
*/
|
|
63
|
+
readonly onError?: (err: unknown, phase: 'read' | 'write' | 'revalidate') => void;
|
|
64
|
+
/** Clock injection (default `Date.now`) — for tests. */
|
|
65
|
+
readonly now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
/** Bind a {@link cache} def to its runtime policy. */
|
|
68
|
+
declare function cacheServer<M extends AnyMiddleware>(def: M, opts: CacheServerOptions<M>): BoundMiddleware<M>;
|
|
69
|
+
/**
|
|
70
|
+
* The {@link cache} def factory, augmented with a `.server(def, opts)` binder. Import
|
|
71
|
+
* from `@ayepi/cache/server` in your server entry to bind a def created in a
|
|
72
|
+
* frontend-safe spec.
|
|
73
|
+
*/
|
|
74
|
+
declare const cache: typeof cache$1 & {
|
|
75
|
+
server: typeof cacheServer;
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
export { CacheIO, CacheServerOptions, cache };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CacheStore, cache as cache$1 } from "./index.js";
|
|
2
|
+
import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
|
|
3
|
+
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
|
|
6
|
+
/** The `requires` chain of a middleware def. */
|
|
7
|
+
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
8
|
+
/** The argument passed to `key`/`vary`/`skip`/`shouldCache` — the request plus accumulated context. */
|
|
9
|
+
interface CacheIO<Ctx extends object> {
|
|
10
|
+
readonly req: Request;
|
|
11
|
+
readonly ctx: Ctx;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Server-side options for binding a {@link cache} def — the caching policy, with
|
|
15
|
+
* `key`/`vary`/`skip`/`shouldCache` typed against the def's `requires` context. Extends
|
|
16
|
+
* {@link MemoryCacheOptions}, whose bounds configure the default {@link memoryCache} when
|
|
17
|
+
* no `store` is supplied.
|
|
18
|
+
*
|
|
19
|
+
* @typeParam M - the cache def being bound.
|
|
20
|
+
*/
|
|
21
|
+
interface CacheServerOptions<M extends AnyMiddleware> {
|
|
22
|
+
/** Freshness lifetime in milliseconds — a cached response is a `HIT` until it expires. */
|
|
23
|
+
readonly ttl: number;
|
|
24
|
+
/** Extra grace (ms) after `ttl` during which a stale entry is served immediately while it refreshes in the background. */
|
|
25
|
+
readonly staleWhileRevalidate?: number;
|
|
26
|
+
/** Which endpoint methods to cache (default `['GET']`) — keyed off the endpoint's declared method, so it governs HTTP and ws alike. */
|
|
27
|
+
readonly methods?: readonly string[];
|
|
28
|
+
/** An extra discriminator appended to the key (e.g. `io.ctx.user.id`) — for per-user/per-tenant caches. */
|
|
29
|
+
readonly vary?: (io: CacheIO<StackCtx<ReqOf<M>>>) => Json;
|
|
30
|
+
/** Replace the whole key derivation (default `method + path + query + body + vary`). Returns any JSON value. */
|
|
31
|
+
readonly key?: (io: CacheIO<StackCtx<ReqOf<M>>>) => Json;
|
|
32
|
+
/**
|
|
33
|
+
* Shrink the (possibly large) key to a store key — e.g. `hash: hashKey` or a crypto
|
|
34
|
+
* digest. Default: the full key is the store key. When set, {@link checkKey} defaults on.
|
|
35
|
+
*/
|
|
36
|
+
readonly hash?: (fullKey: string) => string;
|
|
37
|
+
/**
|
|
38
|
+
* Store the full key in the entry and verify it on a hit, so a {@link hash} collision
|
|
39
|
+
* falls through to a miss instead of serving the wrong body. Defaults to `true` when
|
|
40
|
+
* `hash` is set; set `false` to drop the full key (leaner memory, accepts collision risk).
|
|
41
|
+
*/
|
|
42
|
+
readonly checkKey?: boolean;
|
|
43
|
+
/** Backend store (default an in-process {@link memoryCache} built from the bounds below). */
|
|
44
|
+
readonly store?: CacheStore;
|
|
45
|
+
/** Total cache capacity in bytes for the default store (default 64 MiB). */
|
|
46
|
+
readonly maxBytes?: number;
|
|
47
|
+
/** Per-response cap in bytes for the default store (default 1 MiB) — larger responses aren't cached. */
|
|
48
|
+
readonly maxEntryBytes?: number;
|
|
49
|
+
/** Entry-count cap for the default store (default 10 000). */
|
|
50
|
+
readonly maxEntries?: number;
|
|
51
|
+
/** Decide per-response whether to cache it (runs on a miss, after the handler). */
|
|
52
|
+
readonly shouldCache?: (io: CacheIO<StackCtx<ReqOf<M>>>, result: unknown) => boolean;
|
|
53
|
+
/** Emit `X-Cache` / `Age` / `Cache-Control` response headers (default `true`). */
|
|
54
|
+
readonly headers?: boolean;
|
|
55
|
+
/** Bypass the cache for some requests (neither read nor write). */
|
|
56
|
+
readonly skip?: (io: CacheIO<StackCtx<ReqOf<M>>>) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Observe an error the cache **swallowed** to stay fail-open — to log it, count a metric,
|
|
59
|
+
* etc. Not called by default (errors are silent). `phase` is where it happened: `'read'`
|
|
60
|
+
* (key/lookup → request served uncached), `'write'` (storing the result), or `'revalidate'`
|
|
61
|
+
* (a background stale refresh). It must not throw; if it does, the throw is ignored.
|
|
62
|
+
*/
|
|
63
|
+
readonly onError?: (err: unknown, phase: 'read' | 'write' | 'revalidate') => void;
|
|
64
|
+
/** Clock injection (default `Date.now`) — for tests. */
|
|
65
|
+
readonly now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
/** Bind a {@link cache} def to its runtime policy. */
|
|
68
|
+
declare function cacheServer<M extends AnyMiddleware>(def: M, opts: CacheServerOptions<M>): BoundMiddleware<M>;
|
|
69
|
+
/**
|
|
70
|
+
* The {@link cache} def factory, augmented with a `.server(def, opts)` binder. Import
|
|
71
|
+
* from `@ayepi/cache/server` in your server entry to bind a def created in a
|
|
72
|
+
* frontend-safe spec.
|
|
73
|
+
*/
|
|
74
|
+
declare const cache: typeof cache$1 & {
|
|
75
|
+
server: typeof cacheServer;
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
export { CacheIO, CacheServerOptions, cache };
|