@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/server.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { cache as cache$1, cacheHeaders, cacheKey, isCacheableResult, memoryCache, stableStringify } from "./index.js";
|
|
2
|
+
//#region src/server.ts
|
|
3
|
+
/** The default methods whose responses are cached. */
|
|
4
|
+
const DEFAULT_METHODS = ["GET"];
|
|
5
|
+
/** Replayed responses are served as JSON. */
|
|
6
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
7
|
+
/** Parse the request `Cache-Control` directives we honor. */
|
|
8
|
+
function parseCacheControl(header) {
|
|
9
|
+
if (!header) return {
|
|
10
|
+
noStore: false,
|
|
11
|
+
noCache: false
|
|
12
|
+
};
|
|
13
|
+
const directives = header.toLowerCase().split(",").map((d) => d.trim());
|
|
14
|
+
return {
|
|
15
|
+
noStore: directives.includes("no-store"),
|
|
16
|
+
noCache: directives.includes("no-cache")
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Bind a {@link cache} def to its runtime policy. */
|
|
20
|
+
function cacheServer(def, opts) {
|
|
21
|
+
const store = opts.store ?? memoryCache(opts);
|
|
22
|
+
const ttl = opts.ttl;
|
|
23
|
+
const swr = opts.staleWhileRevalidate ?? 0;
|
|
24
|
+
const methods = new Set((opts.methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase()));
|
|
25
|
+
const emitHeaders = opts.headers !== false;
|
|
26
|
+
const now = opts.now ?? Date.now;
|
|
27
|
+
const checkKey = opts.checkKey ?? opts.hash !== void 0;
|
|
28
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
29
|
+
/** Hand a swallowed error to `onError` (best-effort — a throwing handler is itself ignored). */
|
|
30
|
+
const reportError = (err, phase) => {
|
|
31
|
+
try {
|
|
32
|
+
opts.onError?.(err, phase);
|
|
33
|
+
} catch {}
|
|
34
|
+
};
|
|
35
|
+
const makeControl = (key) => ({
|
|
36
|
+
key,
|
|
37
|
+
hit: false,
|
|
38
|
+
store: true,
|
|
39
|
+
noStore() {
|
|
40
|
+
this.store = false;
|
|
41
|
+
},
|
|
42
|
+
ttl(ms) {
|
|
43
|
+
this.ttlOverride = ms;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
/** The full (pre-hash) key — endpoint identity + the request's query/body (or ws args) + `vary`. */
|
|
47
|
+
const keyOf = (io, route) => {
|
|
48
|
+
const kio = {
|
|
49
|
+
req: io.req,
|
|
50
|
+
ctx: io.ctx
|
|
51
|
+
};
|
|
52
|
+
if (opts.key) return stableStringify(opts.key(kio));
|
|
53
|
+
const vary = opts.vary?.(kio);
|
|
54
|
+
if (io.transport === "ws") return cacheKey({
|
|
55
|
+
method: route.method,
|
|
56
|
+
path: route.path,
|
|
57
|
+
body: io.ws?.data,
|
|
58
|
+
vary
|
|
59
|
+
});
|
|
60
|
+
const url = new URL(io.req.url);
|
|
61
|
+
return cacheKey({
|
|
62
|
+
method: route.method,
|
|
63
|
+
path: route.path,
|
|
64
|
+
query: url.searchParams,
|
|
65
|
+
body: io.body,
|
|
66
|
+
vary
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const replay = (entry, marker, at) => {
|
|
70
|
+
const headers = new Headers(entry.headers);
|
|
71
|
+
if (emitHeaders) {
|
|
72
|
+
headers.set("x-cache", marker);
|
|
73
|
+
for (const [k, v] of Object.entries(cacheHeaders(entry, at))) headers.set(k, v);
|
|
74
|
+
}
|
|
75
|
+
return new Response(entry.body, {
|
|
76
|
+
status: entry.status,
|
|
77
|
+
headers
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/** Serialize + store a handler result, honoring the handler's `io.ctx.cache` opt-out and the store's bounds. */
|
|
81
|
+
const persist = async (io, storeKey, fullKey, control, result, route) => {
|
|
82
|
+
if (!control.store) return;
|
|
83
|
+
if (!isCacheableResult(result)) return;
|
|
84
|
+
if (opts.shouldCache && !opts.shouldCache({
|
|
85
|
+
req: io.req,
|
|
86
|
+
ctx: io.ctx
|
|
87
|
+
}, result)) return;
|
|
88
|
+
const body = JSON.stringify(result);
|
|
89
|
+
const bytes = new TextEncoder().encode(body).length;
|
|
90
|
+
const at = now();
|
|
91
|
+
const expires = at + (control.ttlOverride ?? ttl);
|
|
92
|
+
const entry = {
|
|
93
|
+
body,
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: [["content-type", JSON_CONTENT_TYPE]],
|
|
96
|
+
storedAt: at,
|
|
97
|
+
expires,
|
|
98
|
+
staleUntil: expires + swr,
|
|
99
|
+
bytes,
|
|
100
|
+
method: route.method,
|
|
101
|
+
path: route.path,
|
|
102
|
+
key: checkKey ? fullKey : storeKey
|
|
103
|
+
};
|
|
104
|
+
await store.set(storeKey, entry);
|
|
105
|
+
};
|
|
106
|
+
/** Background refresh for a stale entry — single-flight per store key; failures leave the stale entry in place. */
|
|
107
|
+
const revalidate = (io, storeKey, fullKey, route) => {
|
|
108
|
+
if (inflight.has(storeKey)) return;
|
|
109
|
+
inflight.add(storeKey);
|
|
110
|
+
const control = makeControl(fullKey);
|
|
111
|
+
Promise.resolve(io.next({ cache: control })).then((result) => persist(io, storeKey, fullKey, control, result, route)).catch((err) => reportError(err, "revalidate")).finally(() => inflight.delete(storeKey));
|
|
112
|
+
};
|
|
113
|
+
/** The read phase — key derivation + store lookup. Runs no handler, so any throw here is a pure cache failure. */
|
|
114
|
+
const decide = async (io) => {
|
|
115
|
+
const kio = {
|
|
116
|
+
req: io.req,
|
|
117
|
+
ctx: io.ctx
|
|
118
|
+
};
|
|
119
|
+
const route = io.route;
|
|
120
|
+
const cc = parseCacheControl(io.req.headers.get("cache-control"));
|
|
121
|
+
const multipart = io.transport === "http" && (io.req.headers.get("content-type") ?? "").toLowerCase().includes("multipart/form-data");
|
|
122
|
+
if (route.kind !== "endpoint" || !methods.has(route.method) || opts.skip?.(kio) || cc.noStore || multipart) return { bypass: true };
|
|
123
|
+
const fullKey = keyOf(io, route);
|
|
124
|
+
const storeKey = opts.hash ? opts.hash(fullKey) : fullKey;
|
|
125
|
+
const control = makeControl(fullKey);
|
|
126
|
+
if (!cc.noCache) {
|
|
127
|
+
const entry = await store.get(storeKey);
|
|
128
|
+
if (entry && (!checkKey || entry.key === fullKey)) {
|
|
129
|
+
const at = now();
|
|
130
|
+
if (at < entry.expires) return { serve: replay(entry, "HIT", at) };
|
|
131
|
+
if (at < entry.staleUntil) {
|
|
132
|
+
revalidate(io, storeKey, fullKey, route);
|
|
133
|
+
return { serve: replay(entry, "STALE", at) };
|
|
134
|
+
}
|
|
135
|
+
await store.delete(storeKey);
|
|
136
|
+
} else if (entry) await store.delete(storeKey);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
proceed: true,
|
|
140
|
+
storeKey,
|
|
141
|
+
fullKey,
|
|
142
|
+
route,
|
|
143
|
+
control
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const run = async (io) => {
|
|
147
|
+
let decision;
|
|
148
|
+
try {
|
|
149
|
+
decision = await decide(io);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
reportError(err, "read");
|
|
152
|
+
return io.next({ cache: makeControl("") });
|
|
153
|
+
}
|
|
154
|
+
if ("serve" in decision) return decision.serve;
|
|
155
|
+
if ("bypass" in decision) return io.next({ cache: makeControl("") });
|
|
156
|
+
const result = await io.next({ cache: decision.control });
|
|
157
|
+
try {
|
|
158
|
+
if (emitHeaders) io.setHeader("x-cache", "MISS");
|
|
159
|
+
await persist(io, decision.storeKey, decision.fullKey, decision.control, result, decision.route);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
reportError(err, "write");
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
def,
|
|
167
|
+
impl: run
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The {@link cache} def factory, augmented with a `.server(def, opts)` binder. Import
|
|
172
|
+
* from `@ayepi/cache/server` in your server entry to bind a def created in a
|
|
173
|
+
* frontend-safe spec.
|
|
174
|
+
*/
|
|
175
|
+
const cache = Object.assign(cache$1, { server: cacheServer });
|
|
176
|
+
//#endregion
|
|
177
|
+
export { cache };
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/cache",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/cache"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/cache#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"import": {
|
|
36
|
+
"types": "./dist/server.d.ts",
|
|
37
|
+
"default": "./dist/server.js"
|
|
38
|
+
},
|
|
39
|
+
"require": {
|
|
40
|
+
"types": "./dist/server.d.cts",
|
|
41
|
+
"default": "./dist/server.cjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@ayepi/core": "^0.1.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
54
|
+
"publint": "^0.3.0",
|
|
55
|
+
"tsdown": "^0.12.0",
|
|
56
|
+
"vitest": "^2.1.8",
|
|
57
|
+
"zod": "^4.4.3",
|
|
58
|
+
"@ayepi/core": "0.1.0"
|
|
59
|
+
},
|
|
60
|
+
"keywords": [
|
|
61
|
+
"ayepi",
|
|
62
|
+
"@ayepi/core",
|
|
63
|
+
"cache",
|
|
64
|
+
"caching",
|
|
65
|
+
"middleware",
|
|
66
|
+
"response-cache",
|
|
67
|
+
"stale-while-revalidate"
|
|
68
|
+
],
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsdown",
|
|
71
|
+
"typecheck": "tsc --noEmit",
|
|
72
|
+
"test": "vitest run --coverage",
|
|
73
|
+
"publint": "publint"
|
|
74
|
+
}
|
|
75
|
+
}
|