@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/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
+ }