kinetex 0.0.2 → 1.0.0-rc.2

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.
Files changed (231) hide show
  1. package/LICENSE +16 -4
  2. package/README.md +2434 -701
  3. package/dist/browser/kinetex.esm.js +86 -0
  4. package/dist/browser/kinetex.js +19056 -1999
  5. package/dist/browser/kinetex.min.js +85 -8
  6. package/dist/cjs/aws-sigv4.js +903 -0
  7. package/dist/cjs/cache.js +1099 -0
  8. package/dist/cjs/circuit-breaker.js +362 -0
  9. package/dist/cjs/client.js +2890 -0
  10. package/dist/cjs/cookie-parser.js +2182 -0
  11. package/dist/cjs/cookie-store.js +783 -0
  12. package/dist/cjs/cookiejar.js +175 -0
  13. package/dist/cjs/core.js +1267 -0
  14. package/dist/cjs/dedup.js +267 -0
  15. package/dist/cjs/digest.js +552 -0
  16. package/dist/cjs/graphql.js +1086 -0
  17. package/dist/cjs/headers.js +2264 -0
  18. package/dist/cjs/interceptors.js +1115 -0
  19. package/dist/cjs/lifecycle.js +886 -0
  20. package/dist/cjs/logging.js +887 -0
  21. package/dist/cjs/mod.js +98 -0
  22. package/dist/cjs/package.json +1 -3
  23. package/dist/cjs/pagination.js +535 -0
  24. package/dist/cjs/progress.js +694 -0
  25. package/dist/cjs/response.js +1212 -0
  26. package/dist/cjs/socks5.js +681 -0
  27. package/dist/cjs/sse.js +827 -0
  28. package/dist/cjs/types.js +217 -0
  29. package/dist/cjs/url.js +1269 -0
  30. package/dist/cjs/utils.js +901 -0
  31. package/dist/cjs/worker.js +56 -0
  32. package/dist/cjs/ws.js +1099 -0
  33. package/dist/esm/aws-sigv4.js +904 -0
  34. package/dist/esm/aws-sigv4.js.map +1 -0
  35. package/dist/esm/cache.js +1100 -0
  36. package/dist/esm/cache.js.map +1 -0
  37. package/dist/esm/circuit-breaker.js +363 -0
  38. package/dist/esm/circuit-breaker.js.map +1 -0
  39. package/dist/esm/client.js +2891 -0
  40. package/dist/esm/client.js.map +1 -0
  41. package/dist/esm/cookie-parser.js +2183 -0
  42. package/dist/esm/cookie-parser.js.map +1 -0
  43. package/dist/esm/cookie-store.js +784 -0
  44. package/dist/esm/cookie-store.js.map +1 -0
  45. package/dist/esm/cookiejar.js +176 -0
  46. package/dist/esm/cookiejar.js.map +1 -0
  47. package/dist/esm/core.js +1268 -0
  48. package/dist/esm/core.js.map +1 -0
  49. package/dist/esm/dedup.js +268 -0
  50. package/dist/esm/dedup.js.map +1 -0
  51. package/dist/esm/digest.js +553 -0
  52. package/dist/esm/digest.js.map +1 -0
  53. package/dist/esm/graphql.js +1087 -0
  54. package/dist/esm/graphql.js.map +1 -0
  55. package/dist/esm/headers.js +2265 -0
  56. package/dist/esm/headers.js.map +1 -0
  57. package/dist/esm/interceptors.js +1116 -0
  58. package/dist/esm/interceptors.js.map +1 -0
  59. package/dist/esm/lifecycle.js +887 -0
  60. package/dist/esm/lifecycle.js.map +1 -0
  61. package/dist/esm/logging.js +888 -0
  62. package/dist/esm/logging.js.map +1 -0
  63. package/dist/esm/mod.js +99 -0
  64. package/dist/esm/mod.js.map +1 -0
  65. package/dist/esm/package.json +1 -0
  66. package/dist/esm/pagination.js +536 -0
  67. package/dist/esm/pagination.js.map +1 -0
  68. package/dist/esm/progress.js +695 -0
  69. package/dist/esm/progress.js.map +1 -0
  70. package/dist/esm/response.js +1213 -0
  71. package/dist/esm/response.js.map +1 -0
  72. package/dist/esm/socks5.js +682 -0
  73. package/dist/esm/socks5.js.map +1 -0
  74. package/dist/esm/sse.js +828 -0
  75. package/dist/esm/sse.js.map +1 -0
  76. package/dist/esm/types.js +218 -0
  77. package/dist/esm/types.js.map +1 -0
  78. package/dist/esm/url.js +1270 -0
  79. package/dist/esm/url.js.map +1 -0
  80. package/dist/esm/utils.js +902 -0
  81. package/dist/esm/utils.js.map +1 -0
  82. package/dist/esm/worker.js +57 -0
  83. package/dist/esm/worker.js.map +1 -0
  84. package/dist/esm/ws.js +1100 -0
  85. package/dist/esm/ws.js.map +1 -0
  86. package/dist/types/aws-sigv4.d.ts +382 -0
  87. package/dist/types/aws-sigv4.d.ts.map +1 -0
  88. package/dist/types/cache.d.ts +397 -0
  89. package/dist/types/cache.d.ts.map +1 -0
  90. package/dist/types/circuit-breaker.d.ts +241 -0
  91. package/dist/types/circuit-breaker.d.ts.map +1 -0
  92. package/dist/types/client.d.ts +616 -0
  93. package/dist/types/client.d.ts.map +1 -0
  94. package/dist/types/cookie-parser.d.ts +203 -0
  95. package/dist/types/cookie-parser.d.ts.map +1 -0
  96. package/dist/types/cookie-store.d.ts +316 -0
  97. package/dist/types/cookie-store.d.ts.map +1 -0
  98. package/dist/types/cookiejar.d.ts +165 -0
  99. package/dist/types/cookiejar.d.ts.map +1 -0
  100. package/dist/types/core.d.ts +297 -0
  101. package/dist/types/core.d.ts.map +1 -0
  102. package/dist/types/dedup.d.ts +164 -0
  103. package/dist/types/dedup.d.ts.map +1 -0
  104. package/dist/types/digest.d.ts +136 -0
  105. package/dist/types/digest.d.ts.map +1 -0
  106. package/dist/types/graphql.d.ts +530 -0
  107. package/dist/types/graphql.d.ts.map +1 -0
  108. package/dist/types/headers.d.ts +1119 -0
  109. package/dist/types/headers.d.ts.map +1 -0
  110. package/dist/types/interceptors.d.ts +681 -0
  111. package/dist/types/interceptors.d.ts.map +1 -0
  112. package/dist/types/lifecycle.d.ts +553 -0
  113. package/dist/types/lifecycle.d.ts.map +1 -0
  114. package/dist/types/logging.d.ts +518 -0
  115. package/dist/types/logging.d.ts.map +1 -0
  116. package/dist/types/mod.d.ts +92 -0
  117. package/dist/types/mod.d.ts.map +1 -0
  118. package/dist/types/pagination.d.ts +401 -0
  119. package/dist/types/pagination.d.ts.map +1 -0
  120. package/dist/types/progress.d.ts +344 -0
  121. package/dist/types/progress.d.ts.map +1 -0
  122. package/dist/types/response.d.ts +484 -0
  123. package/dist/types/response.d.ts.map +1 -0
  124. package/dist/types/socks5.d.ts +218 -0
  125. package/dist/types/socks5.d.ts.map +1 -0
  126. package/dist/types/sse.d.ts +399 -0
  127. package/dist/types/sse.d.ts.map +1 -0
  128. package/dist/types/types.d.ts +967 -0
  129. package/dist/types/types.d.ts.map +1 -0
  130. package/dist/types/url.d.ts +530 -0
  131. package/dist/types/url.d.ts.map +1 -0
  132. package/dist/types/utils.d.ts +300 -0
  133. package/dist/types/utils.d.ts.map +1 -0
  134. package/dist/types/worker.d.ts +55 -0
  135. package/dist/types/worker.d.ts.map +1 -0
  136. package/dist/types/ws.d.ts +480 -0
  137. package/dist/types/ws.d.ts.map +1 -0
  138. package/package.json +194 -127
  139. package/CHANGELOG.md +0 -50
  140. package/SECURITY.md +0 -31
  141. package/dist/browser/kinetex.cjs.js +0 -9
  142. package/dist/browser/kinetex.esm.min.js +0 -9
  143. package/dist/browser/kinetex.js.map +0 -7
  144. package/dist/cjs/auth/index.cjs +0 -333
  145. package/dist/cjs/auth/index.d.cts +0 -41
  146. package/dist/cjs/auth/index.d.ts.map +0 -1
  147. package/dist/cjs/auth/index.js.map +0 -1
  148. package/dist/cjs/core/chain.cjs +0 -127
  149. package/dist/cjs/core/chain.d.cts +0 -44
  150. package/dist/cjs/core/chain.d.ts.map +0 -1
  151. package/dist/cjs/core/chain.js.map +0 -1
  152. package/dist/cjs/core/transport.cjs +0 -544
  153. package/dist/cjs/core/transport.d.cts +0 -11
  154. package/dist/cjs/core/transport.d.ts.map +0 -1
  155. package/dist/cjs/core/transport.js.map +0 -1
  156. package/dist/cjs/core/types.cjs +0 -54
  157. package/dist/cjs/core/types.d.cts +0 -322
  158. package/dist/cjs/core/types.d.ts.map +0 -1
  159. package/dist/cjs/core/types.js.map +0 -1
  160. package/dist/cjs/index.cjs +0 -272
  161. package/dist/cjs/index.d.cts +0 -12
  162. package/dist/cjs/index.d.ts.map +0 -1
  163. package/dist/cjs/index.js.map +0 -1
  164. package/dist/cjs/middleware/pipeline.cjs +0 -451
  165. package/dist/cjs/middleware/pipeline.d.cts +0 -32
  166. package/dist/cjs/middleware/pipeline.d.ts.map +0 -1
  167. package/dist/cjs/middleware/pipeline.js.map +0 -1
  168. package/dist/cjs/plugins/cookies.cjs +0 -192
  169. package/dist/cjs/plugins/cookies.d.cts +0 -57
  170. package/dist/cjs/plugins/cookies.d.ts.map +0 -1
  171. package/dist/cjs/plugins/cookies.js.map +0 -1
  172. package/dist/cjs/plugins/extras.cjs +0 -316
  173. package/dist/cjs/plugins/extras.d.cts +0 -87
  174. package/dist/cjs/plugins/extras.d.ts.map +0 -1
  175. package/dist/cjs/plugins/extras.js.map +0 -1
  176. package/dist/cjs/plugins/index.cjs +0 -18
  177. package/dist/cjs/plugins/index.d.cts +0 -7
  178. package/dist/cjs/plugins/index.d.ts.map +0 -1
  179. package/dist/cjs/plugins/index.js.map +0 -1
  180. package/dist/cjs/plugins/proxy.cjs +0 -161
  181. package/dist/cjs/plugins/proxy.d.cts +0 -25
  182. package/dist/cjs/plugins/proxy.d.ts.map +0 -1
  183. package/dist/cjs/plugins/proxy.js.map +0 -1
  184. package/dist/cjs/utils/index.cjs +0 -12
  185. package/dist/cjs/utils/index.d.cts +0 -6
  186. package/dist/cjs/utils/index.d.ts.map +0 -1
  187. package/dist/cjs/utils/index.js.map +0 -1
  188. package/dist/esm/auth/index.js +0 -324
  189. package/dist/esm/auth/index.js.map +0 -1
  190. package/dist/esm/core/chain.js +0 -123
  191. package/dist/esm/core/chain.js.map +0 -1
  192. package/dist/esm/core/transport.js +0 -536
  193. package/dist/esm/core/transport.js.map +0 -1
  194. package/dist/esm/core/types.js +0 -46
  195. package/dist/esm/core/types.js.map +0 -1
  196. package/dist/esm/index.js +0 -241
  197. package/dist/esm/index.js.map +0 -1
  198. package/dist/esm/middleware/pipeline.js +0 -438
  199. package/dist/esm/middleware/pipeline.js.map +0 -1
  200. package/dist/esm/plugins/cookies.js +0 -187
  201. package/dist/esm/plugins/cookies.js.map +0 -1
  202. package/dist/esm/plugins/extras.js +0 -306
  203. package/dist/esm/plugins/extras.js.map +0 -1
  204. package/dist/esm/plugins/index.js +0 -4
  205. package/dist/esm/plugins/index.js.map +0 -1
  206. package/dist/esm/plugins/proxy.js +0 -124
  207. package/dist/esm/plugins/proxy.js.map +0 -1
  208. package/dist/esm/utils/index.js +0 -6
  209. package/dist/esm/utils/index.js.map +0 -1
  210. package/dist/types/auth/index.d.ts +0 -41
  211. package/dist/types/auth/index.d.ts.map +0 -1
  212. package/dist/types/core/chain.d.ts +0 -44
  213. package/dist/types/core/chain.d.ts.map +0 -1
  214. package/dist/types/core/transport.d.ts +0 -11
  215. package/dist/types/core/transport.d.ts.map +0 -1
  216. package/dist/types/core/types.d.ts +0 -322
  217. package/dist/types/core/types.d.ts.map +0 -1
  218. package/dist/types/index.d.ts +0 -12
  219. package/dist/types/index.d.ts.map +0 -1
  220. package/dist/types/middleware/pipeline.d.ts +0 -32
  221. package/dist/types/middleware/pipeline.d.ts.map +0 -1
  222. package/dist/types/plugins/cookies.d.ts +0 -57
  223. package/dist/types/plugins/cookies.d.ts.map +0 -1
  224. package/dist/types/plugins/extras.d.ts +0 -87
  225. package/dist/types/plugins/extras.d.ts.map +0 -1
  226. package/dist/types/plugins/index.d.ts +0 -7
  227. package/dist/types/plugins/index.d.ts.map +0 -1
  228. package/dist/types/plugins/proxy.d.ts +0 -25
  229. package/dist/types/plugins/proxy.d.ts.map +0 -1
  230. package/dist/types/utils/index.d.ts +0 -6
  231. package/dist/types/utils/index.d.ts.map +0 -1
@@ -0,0 +1,1099 @@
1
+ /**
2
+ * HTTP cache implementation.
3
+ * Cross-runtime: Deno · Bun · Node.js · Cloudflare Workers ·
4
+ * Vercel Edge · AWS Lambda · Browser.
5
+ *
6
+ * Features:
7
+ * - RFC 7234 compliant HTTP caching
8
+ * - In-memory LRU cache with TTL
9
+ * - Cache-Control full directive parsing + enforcement
10
+ * - Vary header support
11
+ * - ETag / Last-Modified conditional request support
12
+ * - Stale-While-Revalidate (SWR) background refresh
13
+ * - Stale-If-Error fallback
14
+ * - Cache key normalization (sorted params, stripped fragments)
15
+ * - Namespace / prefix isolation
16
+ * - Per-entry TTL override
17
+ * - Cache warming / preloading
18
+ * - Cache persistence (serialize/deserialize)
19
+ * - Cache statistics (hit rate, size, evictions)
20
+ * - Multi-tier cache (L1 memory + L2 storage adapter)
21
+ * - Storage adapters: memory, localStorage, sessionStorage, KV (CF Workers)
22
+ * - Tag-based invalidation
23
+ * - Manual invalidation (by URL pattern / tag / all)
24
+ * - Response cloning for safe reads
25
+ * - Max body size enforcement
26
+ */
27
+ import { safeJSONParse } from "./utils.js";
28
+ // ── 2.1 In-memory (default) ──────────────────────────────────────────────────
29
+ /**
30
+ * In-memory cache storage adapter.
31
+ * Eviction is the sole responsibility of HTTPCache._ensureCapacity() —
32
+ * this class is a pure KV store to avoid tag-index / stats drift.
33
+ */
34
+ export class MemoryStorageAdapter {
35
+ store = new Map();
36
+ // Pure KV store — eviction is HTTPCache._ensureCapacity()'s responsibility.
37
+ // This avoids the tag-index / stats drift that occurs when the adapter
38
+ // evicts entries that HTTPCache doesn't know about (audit §3.6 / §8.10).
39
+ /** Retrieve an entry by key. Returns null when missing. */
40
+ get(key) {
41
+ return Promise.resolve(this.store.get(key) ?? null);
42
+ }
43
+ /** Store an entry. */
44
+ set(key, entry) {
45
+ this.store.set(key, entry);
46
+ return Promise.resolve();
47
+ }
48
+ /** Remove an entry by key. */
49
+ delete(key) {
50
+ this.store.delete(key);
51
+ return Promise.resolve();
52
+ }
53
+ /** List all stored keys. */
54
+ keys() {
55
+ return Promise.resolve(Array.from(this.store.keys()));
56
+ }
57
+ /** Remove all entries. */
58
+ clear() {
59
+ this.store.clear();
60
+ return Promise.resolve();
61
+ }
62
+ /** Number of entries currently stored. */
63
+ get size() {
64
+ return this.store.size;
65
+ }
66
+ }
67
+ // ── 2.2 Web Storage (localStorage / sessionStorage) ─────────────────────────
68
+ /**
69
+ * Web Storage (localStorage / sessionStorage) cache adapter.
70
+ * Prefixes keys to avoid collisions with other data.
71
+ * Returns false from set() on QuotaExceededError.
72
+ */
73
+ export class WebStorageAdapter {
74
+ storage;
75
+ prefix;
76
+ constructor(
77
+ /** The Storage instance (localStorage or sessionStorage) */
78
+ storage,
79
+ /** Key prefix to isolate cache entries. Default: "hc:" */
80
+ prefix = "hc:") {
81
+ this.storage = storage;
82
+ this.prefix = prefix;
83
+ }
84
+ /** Retrieve an entry by key. Returns null when missing or unparseable. */
85
+ get(key) {
86
+ try {
87
+ const raw = this.storage.getItem(this.prefix + key);
88
+ if (!raw)
89
+ return Promise.resolve(null);
90
+ const result = safeJSONParse(raw, { maxDepth: 32 });
91
+ return Promise.resolve(result.success && result.value !== undefined ? result.value : null);
92
+ }
93
+ catch {
94
+ return Promise.resolve(null);
95
+ }
96
+ }
97
+ /** Store an entry. Returns false when quota is exceeded. */
98
+ set(key, entry) {
99
+ try {
100
+ this.storage.setItem(this.prefix + key, JSON.stringify(entry));
101
+ return Promise.resolve(true);
102
+ }
103
+ catch (err) {
104
+ // QuotaExceededError — notify caller instead of silently dropping
105
+ if (err instanceof Error &&
106
+ (err.name === "QuotaExceededError" || err.name === "NS_ERROR_DOM_QUOTA_REACHED")) {
107
+ console.warn("[kinetex] WebStorageAdapter: storage quota exceeded. Entry was NOT cached. " +
108
+ "Consider reducing maxBodySizeBytes or using a different storage adapter.");
109
+ return Promise.resolve(false);
110
+ }
111
+ return Promise.resolve(false);
112
+ }
113
+ }
114
+ /** Remove an entry by key. */
115
+ delete(key) {
116
+ this.storage.removeItem(this.prefix + key);
117
+ return Promise.resolve();
118
+ }
119
+ /** List all stored keys (prefixed entries only). */
120
+ keys() {
121
+ const out = [];
122
+ for (let i = 0; i < this.storage.length; i++) {
123
+ const k = this.storage.key(i);
124
+ if (k?.startsWith(this.prefix))
125
+ out.push(k.slice(this.prefix.length));
126
+ }
127
+ return Promise.resolve(out);
128
+ }
129
+ /** Remove all prefixed entries. */
130
+ clear() {
131
+ const toDelete = [];
132
+ for (let i = 0; i < this.storage.length; i++) {
133
+ const k = this.storage.key(i);
134
+ if (k?.startsWith(this.prefix))
135
+ toDelete.push(k);
136
+ }
137
+ for (const k of toDelete)
138
+ this.storage.removeItem(k);
139
+ return Promise.resolve();
140
+ }
141
+ }
142
+ /**
143
+ * Cloudflare Workers KV storage adapter.
144
+ * Uses KV's built-in expirationTtl for automatic TTL-based eviction.
145
+ */
146
+ export class CloudflareKVAdapter {
147
+ kv;
148
+ prefix;
149
+ constructor(
150
+ /** The Cloudflare KV namespace binding */
151
+ kv,
152
+ /** Key prefix to isolate cache entries. Default: "hc:" */
153
+ prefix = "hc:") {
154
+ this.kv = kv;
155
+ this.prefix = prefix;
156
+ }
157
+ /** Retrieve an entry by key from KV. Returns null when missing. */
158
+ async get(key) {
159
+ const val = (await this.kv.get(this.prefix + key, "json"));
160
+ return val;
161
+ }
162
+ /** Store an entry in KV with automatic expirationTtl derived from entry.expiresAt. */
163
+ async set(key, entry) {
164
+ const ttlSecs = entry.expiresAt === Infinity
165
+ ? undefined
166
+ : Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000));
167
+ await this.kv.put(this.prefix + key, JSON.stringify(entry), {
168
+ ...(ttlSecs !== undefined ? { expirationTtl: ttlSecs } : {}),
169
+ });
170
+ }
171
+ /** Remove an entry by key from KV. */
172
+ async delete(key) {
173
+ await this.kv.delete(this.prefix + key);
174
+ }
175
+ /** List all cached keys currently stored in KV. */
176
+ async keys() {
177
+ const result = await this.kv.list({ prefix: this.prefix });
178
+ return result.keys.map((k) => k.name.slice(this.prefix.length));
179
+ }
180
+ /** Remove all cached entries from KV. */
181
+ async clear() {
182
+ const keys = await this.keys();
183
+ await Promise.all(keys.map((k) => this.kv.delete(this.prefix + k)));
184
+ }
185
+ }
186
+ // ── 2.4 Two-tier (L1 memory + L2 persistent) ────────────────────────────────
187
+ /**
188
+ * Two-tier cache: fast in-memory L1 backed by a persistent L2 adapter.
189
+ * Reads promote entries from L2 to L1. Writes go to both tiers in parallel.
190
+ */
191
+ export class TwoTierStorageAdapter {
192
+ l2;
193
+ l1 = new MemoryStorageAdapter();
194
+ constructor(
195
+ /** The persistent backend storage adapter */
196
+ l2) {
197
+ this.l2 = l2;
198
+ }
199
+ /** Retrieve an entry from L1 (fast) with L2 fallback. Promotes L2 hits into L1. */
200
+ async get(key) {
201
+ const l1Hit = await this.l1.get(key);
202
+ if (l1Hit)
203
+ return l1Hit;
204
+ const l2Hit = await this.l2.get(key);
205
+ if (l2Hit) {
206
+ await this.l1.set(key, l2Hit); // promote to L1
207
+ }
208
+ return l2Hit;
209
+ }
210
+ /** Store an entry in both L1 (memory) and L2 (persistent) in parallel. */
211
+ async set(key, entry) {
212
+ await Promise.all([this.l1.set(key, entry), this.l2.set(key, entry)]);
213
+ }
214
+ /** Remove an entry from both tiers in parallel. */
215
+ async delete(key) {
216
+ await Promise.all([this.l1.delete(key), this.l2.delete(key)]);
217
+ }
218
+ /** List all keys from L2 (source of truth for key enumeration). */
219
+ async keys() {
220
+ return await this.l2.keys();
221
+ }
222
+ /** Clear both tiers in parallel. */
223
+ async clear() {
224
+ await Promise.all([this.l1.clear(), this.l2.clear()]);
225
+ }
226
+ }
227
+ /** Parse a Cache-Control header value into structured directives. */
228
+ function parseCacheControl(value) {
229
+ const d = {
230
+ noStore: false,
231
+ noCache: false,
232
+ mustRevalidate: false,
233
+ proxyRevalidate: false,
234
+ public: false,
235
+ private: false,
236
+ maxAge: null,
237
+ sMaxAge: null,
238
+ staleWhileRevalidate: null,
239
+ staleIfError: null,
240
+ immutable: false,
241
+ };
242
+ for (const part of value.split(",")) {
243
+ const t = part.trim();
244
+ const eq = t.indexOf("=");
245
+ const k = (eq === -1 ? t : t.slice(0, eq)).trim().toLowerCase();
246
+ const v = eq === -1
247
+ ? null
248
+ : t
249
+ .slice(eq + 1)
250
+ .trim()
251
+ .replace(/^"|"$/g, "");
252
+ const n = v !== null ? parseInt(v, 10) : null;
253
+ switch (k) {
254
+ case "no-store":
255
+ d.noStore = true;
256
+ break;
257
+ case "no-cache":
258
+ d.noCache = true;
259
+ break;
260
+ case "must-revalidate":
261
+ d.mustRevalidate = true;
262
+ break;
263
+ case "proxy-revalidate":
264
+ d.proxyRevalidate = true;
265
+ break;
266
+ case "public":
267
+ d.public = true;
268
+ break;
269
+ case "private":
270
+ d.private = true;
271
+ break;
272
+ case "immutable":
273
+ d.immutable = true;
274
+ break;
275
+ case "max-age":
276
+ d.maxAge = n;
277
+ break;
278
+ case "s-maxage":
279
+ d.sMaxAge = n;
280
+ break;
281
+ case "stale-while-revalidate":
282
+ d.staleWhileRevalidate = n;
283
+ break;
284
+ case "stale-if-error":
285
+ d.staleIfError = n;
286
+ break;
287
+ case "no-transform":
288
+ break;
289
+ case "only-if-cached":
290
+ break;
291
+ case "must-understand":
292
+ break;
293
+ default:
294
+ break;
295
+ }
296
+ }
297
+ return d;
298
+ }
299
+ // ============================================================================
300
+ // #4 VARY HEADER SUPPORT
301
+ // ============================================================================
302
+ /**
303
+ * Build a Vary key for cache differentiation.
304
+ * Returns `null` when `Vary: *` is present — callers must treat this as "never cache".
305
+ * Returns a deterministic string of field=value pairs for all other Vary headers.
306
+ */
307
+ function buildVaryKey(varyHeader, requestHeaders) {
308
+ if (!varyHeader)
309
+ return "";
310
+ // RFC 7231 §7.1.4: Vary: * means the response is uncacheable
311
+ if (varyHeader.trim() === "*")
312
+ return null;
313
+ const fields = varyHeader
314
+ .split(",")
315
+ .map((f) => f.trim().toLowerCase())
316
+ .sort();
317
+ const parts = fields.map((f) => {
318
+ const val = requestHeaders[f] ?? requestHeaders[f.toLowerCase()] ?? "";
319
+ return `${f}=${val}`;
320
+ });
321
+ return parts.join(";");
322
+ }
323
+ // ============================================================================
324
+ // #5 CACHE KEY BUILDING
325
+ // ============================================================================
326
+ /**
327
+ * Normalize a string for use in a cache key to prevent injection attacks.
328
+ * Removes control characters and normalizes whitespace.
329
+ */
330
+ function normalizeCacheKeyPart(part) {
331
+ const controlRanges = [
332
+ String.fromCharCode(0x00) + "-" + String.fromCharCode(0x1f),
333
+ String.fromCharCode(0x7f) + "-" + String.fromCharCode(0x9f),
334
+ ].join("");
335
+ const controlRegex = new RegExp(`[${controlRanges}]`, "g");
336
+ return part.replaceAll(controlRegex, "").replaceAll(/\s+/g, " ").trim();
337
+ }
338
+ /**
339
+ * Generate a cryptographic fingerprint of auth-sensitive headers using SHA-256.
340
+ * Async, but the result is always a stable hex string usable as a cache key segment.
341
+ * Returns "" when no auth headers are present (shared/anonymous cache).
342
+ */
343
+ export async function getAuthFingerprint(headers) {
344
+ const authHeaders = ["authorization", "cookie", "x-api-key", "x-auth-token"];
345
+ const parts = [];
346
+ for (const header of authHeaders) {
347
+ const value = headers[header.toLowerCase()];
348
+ if (value)
349
+ parts.push(`${header}=${value}`);
350
+ }
351
+ if (parts.length === 0)
352
+ return "";
353
+ const data = new TextEncoder().encode(parts.join("|"));
354
+ const digest = await crypto.subtle.digest("SHA-256", data);
355
+ const hex = Array.from(new Uint8Array(digest))
356
+ .map((b) => b.toString(16).padStart(2, "0"))
357
+ .join("")
358
+ .slice(0, 32); // 128-bit prefix — ample for auth isolation
359
+ return `auth:${hex}`;
360
+ }
361
+ /**
362
+ * Default cache key function.
363
+ * Normalizes: method, URL (sorted query params, stripped hash), auth fingerprint.
364
+ * Format: "METHOD:normalized_url[:auth:hex]"
365
+ */
366
+ async function defaultCacheKey(req) {
367
+ try {
368
+ const u = new URL(req.url);
369
+ // Normalize directly instead of re-parsing sanitized string
370
+ const hostname = normalizeCacheKeyPart(u.hostname);
371
+ const pathname = normalizeCacheKeyPart(u.pathname);
372
+ // Sort params in place and build normalized URL
373
+ u.searchParams.sort();
374
+ const search = normalizeCacheKeyPart(u.search);
375
+ u.hash = "";
376
+ // Build key from already-normalized URL components
377
+ const normalized = `${u.protocol}//${hostname}${u.port ? ":" + u.port : ""}${pathname}${search}`;
378
+ const method = normalizeCacheKeyPart(req.method.toUpperCase());
379
+ const authFp = await getAuthFingerprint(req.headers ?? {});
380
+ const key = `${method}:${normalized}`;
381
+ return authFp ? `${key}:${authFp}` : key;
382
+ }
383
+ catch {
384
+ const method = normalizeCacheKeyPart(req.method.toUpperCase());
385
+ const url = normalizeCacheKeyPart(req.url);
386
+ const authFp = await getAuthFingerprint(req.headers ?? {});
387
+ const key = `${method}:${url}`;
388
+ return authFp ? `${key}:${authFp}` : key;
389
+ }
390
+ }
391
+ /**
392
+ * Compute cache TTL, SWR window, and stale-on-error window
393
+ * from Cache-Control headers, Expires header, or Last-Modified heuristic.
394
+ */
395
+ function computeTTL(response, defaultTtl, honor) {
396
+ const cc = response.headers["cache-control"] ?? response.headers["Cache-Control"] ?? "";
397
+ const d = parseCacheControl(cc);
398
+ // Hard no-cache
399
+ if (honor && d.noStore) {
400
+ return { ttlMs: 0, swrMs: 0, staleOnError: 0, shouldCache: false };
401
+ }
402
+ // s-maxage takes priority (for shared/proxy caches)
403
+ const maxAgeSecs = d.sMaxAge ?? d.maxAge;
404
+ let ttlMs;
405
+ if (maxAgeSecs !== null) {
406
+ // Respect Age header (already-elapsed time)
407
+ const age = parseInt(response.headers["age"] ?? response.headers["Age"] ?? "0", 10) || 0;
408
+ ttlMs = Math.max(0, (maxAgeSecs - age) * 1000);
409
+ }
410
+ else {
411
+ // Try Expires header
412
+ const expires = response.headers["expires"] ?? response.headers["Expires"];
413
+ if (expires) {
414
+ const exp = Date.parse(expires);
415
+ ttlMs = isNaN(exp) ? defaultTtl : Math.max(0, exp - Date.now());
416
+ }
417
+ else {
418
+ // Heuristic: 10% of Last-Modified age
419
+ const lm = response.headers["last-modified"] ?? response.headers["Last-Modified"];
420
+ if (lm) {
421
+ const lmTime = Date.parse(lm);
422
+ if (!isNaN(lmTime)) {
423
+ ttlMs = Math.min((Date.now() - lmTime) * 0.1, defaultTtl);
424
+ }
425
+ else {
426
+ ttlMs = defaultTtl;
427
+ }
428
+ }
429
+ else {
430
+ ttlMs = defaultTtl;
431
+ }
432
+ }
433
+ }
434
+ // immutable: treat as "very long TTL" but still respect maxAbsoluteAgeMs cap applied in set()
435
+ if (d.immutable && ttlMs > 0) {
436
+ ttlMs = 365 * 24 * 60 * 60 * 1000; // cap will be applied by HTTPCache.set()
437
+ }
438
+ const swrMs = d.staleWhileRevalidate !== null ? d.staleWhileRevalidate * 1000 : 0;
439
+ const staleOnError = d.staleIfError !== null ? d.staleIfError * 1000 : 0;
440
+ return {
441
+ ttlMs,
442
+ swrMs,
443
+ staleOnError,
444
+ shouldCache: ttlMs > 0 || swrMs > 0,
445
+ };
446
+ }
447
+ // ============================================================================
448
+ // #7 LRU EVICTION — O(1) doubly-linked list + Map
449
+ // ============================================================================
450
+ /** Node in the O(1) LRU doubly-linked list. */
451
+ class LRUNode {
452
+ key;
453
+ prev;
454
+ next;
455
+ constructor(
456
+ /** Cache key this node represents */
457
+ key,
458
+ /** Previous node (toward LRU end) */
459
+ prev = null,
460
+ /** Next node (toward MRU end) */
461
+ next = null) {
462
+ this.key = key;
463
+ this.prev = prev;
464
+ this.next = next;
465
+ }
466
+ }
467
+ /**
468
+ * O(1) LRU eviction tracker using a doubly-linked list + Map.
469
+ * Head = Most-Recently-Used, Tail = Least-Recently-Used.
470
+ */
471
+ class LRUTracker {
472
+ map = new Map();
473
+ head = null; // Most-Recently-Used
474
+ tail = null; // Least-Recently-Used
475
+ /** Record access to a key (moves it to MRU position). */
476
+ touch(key) {
477
+ const existing = this.map.get(key);
478
+ if (existing) {
479
+ this._unlink(existing);
480
+ this._prepend(existing);
481
+ }
482
+ else {
483
+ const node = new LRUNode(key);
484
+ this.map.set(key, node);
485
+ this._prepend(node);
486
+ }
487
+ }
488
+ /** Evict the Least-Recently-Used key. Returns the evicted key or null if empty. */
489
+ evict() {
490
+ if (!this.tail)
491
+ return null;
492
+ const key = this.tail.key;
493
+ this._unlink(this.tail);
494
+ this.map.delete(key);
495
+ return key;
496
+ }
497
+ /** Remove a specific key from the tracker. */
498
+ remove(key) {
499
+ const node = this.map.get(key);
500
+ if (!node)
501
+ return;
502
+ this._unlink(node);
503
+ this.map.delete(key);
504
+ }
505
+ /** Clear all tracked entries. */
506
+ clear() {
507
+ this.map.clear();
508
+ this.head = null;
509
+ this.tail = null;
510
+ }
511
+ /** Number of tracked entries. */
512
+ get size() {
513
+ return this.map.size;
514
+ }
515
+ _prepend(node) {
516
+ node.prev = null;
517
+ node.next = this.head;
518
+ if (this.head)
519
+ this.head.prev = node;
520
+ this.head = node;
521
+ if (!this.tail)
522
+ this.tail = node;
523
+ }
524
+ _unlink(node) {
525
+ if (node.prev)
526
+ node.prev.next = node.next;
527
+ else
528
+ this.head = node.next;
529
+ if (node.next)
530
+ node.next.prev = node.prev;
531
+ else
532
+ this.tail = node.prev;
533
+ node.prev = null;
534
+ node.next = null;
535
+ }
536
+ }
537
+ // ============================================================================
538
+ // #8 HTTP CACHE
539
+ // ============================================================================
540
+ /**
541
+ * RFC 7234-compliant HTTP cache with LRU eviction, Vary support,
542
+ * stale-while-revalidate, stale-if-error, tag-based invalidation,
543
+ * and pluggable storage backends.
544
+ *
545
+ * @example
546
+ * ```typescript
547
+ * const cache = new HTTPCache({ maxEntries: 200, defaultTtlMs: 30_000 });
548
+ * await cache.set(req, res);
549
+ * const result = await cache.get(req);
550
+ * ```
551
+ */
552
+ export class HTTPCache {
553
+ /** Resolved cache configuration with defaults applied */
554
+ cfg;
555
+ /** Storage adapter (in-memory or pluggable backend) */
556
+ storage;
557
+ /** O(1) LRU eviction tracker */
558
+ lru = new LRUTracker();
559
+ /** Tag → set of cache keys for tag-based invalidation */
560
+ tagIndex = new Map();
561
+ /** Keys currently being revalidated (prevents duplicate SWR fetches) */
562
+ swrInFlight = new Set();
563
+ /** Cache performance statistics */
564
+ stats = {
565
+ hits: 0,
566
+ misses: 0,
567
+ staleHits: 0,
568
+ errors: 0,
569
+ evictions: 0,
570
+ totalEntries: 0,
571
+ totalSizeBytes: 0,
572
+ hitRate: 0,
573
+ };
574
+ /**
575
+ * @param config - Cache configuration
576
+ */
577
+ constructor(config = {}) {
578
+ this.cfg = {
579
+ maxEntries: config.maxEntries ?? 500,
580
+ maxSizeBytes: config.maxSizeBytes ?? 50 * 1024 * 1024,
581
+ maxBodySizeBytes: config.maxBodySizeBytes ?? 5 * 1024 * 1024,
582
+ defaultTtlMs: config.defaultTtlMs ?? 60_000,
583
+ maxAbsoluteAgeMs: config.maxAbsoluteAgeMs ?? 7 * 24 * 60 * 60 * 1000, // 7 days
584
+ honorCacheControl: config.honorCacheControl ?? true,
585
+ cacheMethods: config.cacheMethods ?? ["GET", "HEAD"],
586
+ cacheStatuses: config.cacheStatuses ?? [
587
+ 200, 203, 204, 206, 300, 301, 304, 404, 405, 410, 414, 501,
588
+ ],
589
+ cacheKey: config.cacheKey ?? defaultCacheKey,
590
+ storage: config.storage ?? new MemoryStorageAdapter(),
591
+ namespace: config.namespace ?? "",
592
+ };
593
+ // Cap default TTL to maximum 1 year to prevent infinite cache entries
594
+ if (this.cfg.defaultTtlMs === Infinity || this.cfg.defaultTtlMs > 365 * 24 * 60 * 60 * 1000) {
595
+ console.warn(`[Kinetex Cache] Capping defaultTtlMs from ${this.cfg.defaultTtlMs}ms to 1 year (${365 * 24 * 60 * 60 * 1000}ms)`);
596
+ this.cfg.defaultTtlMs = 365 * 24 * 60 * 60 * 1000;
597
+ }
598
+ this.storage = this.cfg.storage;
599
+ }
600
+ // ──8.1 get ─────────────────────────────────────────────────────────────
601
+ /**
602
+ * Look up a cached response.
603
+ *
604
+ * @returns An object with `entry` (the cached {@link CacheEntry}) and `stale` (boolean indicating
605
+ * the entry is expired but within the stale-while-revalidate or stale-if-error window),
606
+ * or `null` if no matching entry was found.
607
+ */
608
+ async get(req) {
609
+ if (!this._isCacheable(req.method))
610
+ return null;
611
+ const key = await this._key(req);
612
+ const entry = await this.storage.get(key);
613
+ if (!entry) {
614
+ this.stats.misses++;
615
+ this._updateHitRate();
616
+ return null;
617
+ }
618
+ // Vary check
619
+ if (entry.varyKey !== null && entry.varyKey !== "") {
620
+ const reqVaryKey = buildVaryKey(entry.response.headers["vary"] ?? entry.response.headers["Vary"] ?? "", req.headers);
621
+ // null means Vary:* — never serve from cache
622
+ if (reqVaryKey === null || reqVaryKey !== entry.varyKey) {
623
+ this.stats.misses++;
624
+ this._updateHitRate();
625
+ return null;
626
+ }
627
+ }
628
+ const now = Date.now();
629
+ // Check absolute age limit — entries older than maxAbsoluteAgeMs are always expired
630
+ const age = now - entry.createdAt;
631
+ if (age > this.cfg.maxAbsoluteAgeMs) {
632
+ await this._delete(key, entry);
633
+ this.stats.misses++;
634
+ this._updateHitRate();
635
+ return null;
636
+ }
637
+ // Fresh
638
+ if (entry.expiresAt === Infinity || entry.expiresAt > now) {
639
+ this.stats.hits++;
640
+ this.lru.touch(key);
641
+ this._updateHitRate();
642
+ return { entry: cloneEntry(entry), stale: false };
643
+ }
644
+ // Stale-While-Revalidate window
645
+ if (entry.staleUntil > now) {
646
+ this.stats.staleHits++;
647
+ this.lru.touch(key);
648
+ this._updateHitRate();
649
+ return { entry: cloneEntry(entry), stale: true };
650
+ }
651
+ // Stale-If-Error window (caller checks this case)
652
+ if (entry.staleOnError > now) {
653
+ this.stats.staleHits++;
654
+ this._updateHitRate();
655
+ return { entry: cloneEntry(entry), stale: true };
656
+ }
657
+ // Expired — delete
658
+ await this._delete(key, entry);
659
+ this.stats.misses++;
660
+ this._updateHitRate();
661
+ return null;
662
+ }
663
+ // ──8.2 set ─────────────────────────────────────────────────────────────
664
+ /**
665
+ * Store a response in the cache.
666
+ *
667
+ * @param req - The request used as the cache key
668
+ * @param res - The response to cache
669
+ * @param options - Optional tags, per-entry TTL override, or force flag to bypass no-store
670
+ * @returns true if the entry was stored, false if rejected (uncacheable, too large, or quota exceeded)
671
+ */
672
+ async set(req, res, options = {}) {
673
+ if (!this._isCacheable(req.method))
674
+ return false;
675
+ if (!this.cfg.cacheStatuses.includes(res.status))
676
+ return false;
677
+ const { ttlMs, swrMs, staleOnError, shouldCache } = options.ttlMs !== undefined
678
+ ? { ttlMs: options.ttlMs, swrMs: 0, staleOnError: 0, shouldCache: true }
679
+ : computeTTL(res, this.cfg.defaultTtlMs, this.cfg.honorCacheControl);
680
+ if (!shouldCache && !options.force)
681
+ return false;
682
+ // Body size check
683
+ const bodySize = bodyBytes(res.body);
684
+ if (bodySize > this.cfg.maxBodySizeBytes)
685
+ return false;
686
+ const now = Date.now();
687
+ // 9.10 — cap TTL to maxAbsoluteAgeMs so stored entries can never outlive the limit
688
+ const absoluteCap = this.cfg.maxAbsoluteAgeMs;
689
+ const cappedTtlMs = Math.min(ttlMs === Infinity ? absoluteCap : ttlMs, absoluteCap);
690
+ if (cappedTtlMs <= 0 && !options.force)
691
+ return false;
692
+ const varyHeader = res.headers["vary"] ?? res.headers["Vary"] ?? null;
693
+ const varyKey = varyHeader ? buildVaryKey(varyHeader, req.headers) : "";
694
+ // Vary: * means this response is per-user and must never be shared from cache
695
+ if (varyKey === null)
696
+ return false;
697
+ const tags = options.tags ?? [];
698
+ const entry = {
699
+ response: cloneResponse(res),
700
+ createdAt: now,
701
+ expiresAt: cappedTtlMs === Infinity ? Infinity : now + cappedTtlMs,
702
+ staleUntil: now + cappedTtlMs + swrMs,
703
+ staleOnError: now + cappedTtlMs + staleOnError,
704
+ etag: res.headers["etag"] ?? res.headers["ETag"] ?? null,
705
+ lastModified: res.headers["last-modified"] ?? res.headers["Last-Modified"] ?? null,
706
+ varyKey,
707
+ tags,
708
+ size: bodySize + 256,
709
+ };
710
+ const key = await this._key(req);
711
+ // Evict if needed
712
+ await this._ensureCapacity(entry.size);
713
+ const stored = await this.storage.set(key, entry);
714
+ // WebStorageAdapter returns false on quota exceeded — don't update stats if not stored
715
+ if (stored === false)
716
+ return false;
717
+ this.lru.touch(key);
718
+ this.stats.totalEntries++;
719
+ this.stats.totalSizeBytes += entry.size;
720
+ // Update tag index
721
+ for (const tag of tags) {
722
+ const set = this.tagIndex.get(tag) ?? new Set();
723
+ set.add(key);
724
+ this.tagIndex.set(tag, set);
725
+ }
726
+ return true;
727
+ }
728
+ // ──8.3 Conditional request headers ─────────────────────────────────────
729
+ /**
730
+ * Build conditional request headers (If-None-Match / If-Modified-Since)
731
+ * for cache revalidation.
732
+ */
733
+ buildConditionalHeaders(entry) {
734
+ const headers = {};
735
+ if (entry.etag)
736
+ headers["if-none-match"] = entry.etag;
737
+ if (entry.lastModified)
738
+ headers["if-modified-since"] = entry.lastModified;
739
+ return headers;
740
+ }
741
+ /**
742
+ * Handle a 304 Not Modified response — refresh the entry TTL in place.
743
+ */
744
+ async revalidate(req, res) {
745
+ const key = await this._key(req);
746
+ const entry = await this.storage.get(key);
747
+ if (!entry)
748
+ return null;
749
+ // Merge new headers into cached response
750
+ const mergedHeaders = { ...entry.response.headers, ...res.headers };
751
+ const updatedRes = { ...entry.response, headers: mergedHeaders };
752
+ const { ttlMs, swrMs, staleOnError } = computeTTL(updatedRes, this.cfg.defaultTtlMs, this.cfg.honorCacheControl);
753
+ const now = Date.now();
754
+ // 9.4 — cap refreshed TTL the same way set() does
755
+ const cappedTtl = Math.min(ttlMs === Infinity ? this.cfg.maxAbsoluteAgeMs : ttlMs, this.cfg.maxAbsoluteAgeMs);
756
+ const updated = {
757
+ ...entry,
758
+ response: updatedRes,
759
+ createdAt: now,
760
+ expiresAt: cappedTtl <= 0 ? now : now + cappedTtl,
761
+ staleUntil: now + cappedTtl + swrMs,
762
+ staleOnError: now + cappedTtl + staleOnError,
763
+ etag: mergedHeaders["etag"] ?? mergedHeaders["ETag"] ?? entry.etag,
764
+ lastModified: mergedHeaders["last-modified"] ?? mergedHeaders["Last-Modified"] ?? entry.lastModified,
765
+ };
766
+ await this.storage.set(key, updated);
767
+ this.lru.touch(key);
768
+ return cloneEntry(updated);
769
+ }
770
+ // ──8.4 Stale-While-Revalidate ──────────────────────────────────────────
771
+ /**
772
+ * Mark a request as being revalidated for stale-while-revalidate.
773
+ * Returns false if the key is already in flight.
774
+ */
775
+ async markSWRInFlight(req) {
776
+ const key = await this._key(req);
777
+ if (this.swrInFlight.has(key))
778
+ return false;
779
+ this.swrInFlight.add(key);
780
+ return true;
781
+ }
782
+ /** Clear the SWR in-flight flag for a request. */
783
+ async clearSWRInFlight(req) {
784
+ const key = await this._key(req);
785
+ this.swrInFlight.delete(key);
786
+ }
787
+ /** Check whether a request is currently being revalidated. */
788
+ async isSWRInFlight(req) {
789
+ const key = await this._key(req);
790
+ return this.swrInFlight.has(key);
791
+ }
792
+ // ──8.5 Invalidation ────────────────────────────────────────────────────
793
+ /**
794
+ * Delete a single entry from the cache.
795
+ * @returns true if the entry existed and was deleted
796
+ */
797
+ async delete(req) {
798
+ const key = await this._key(req);
799
+ const entry = await this.storage.get(key);
800
+ if (!entry)
801
+ return false;
802
+ await this._delete(key, entry);
803
+ return true;
804
+ }
805
+ /** Delete all entries whose URLs start with a given prefix. Works with namespaces. */
806
+ async invalidateByURL(urlPrefix) {
807
+ const keys = await this.storage.keys();
808
+ let count = 0;
809
+ // Reconstruct the key prefix that would be produced by defaultCacheKey for this URL prefix.
810
+ // Since the key format is "[namespace:]METHOD:url[:auth]" we search for any key that
811
+ // contains the url prefix after the first colon-separated segment (the method).
812
+ for (const key of keys) {
813
+ // Strip namespace prefix if present
814
+ const bare = this.cfg.namespace
815
+ ? key.startsWith(this.cfg.namespace + ":")
816
+ ? key.slice(this.cfg.namespace.length + 1)
817
+ : null
818
+ : key;
819
+ if (bare === null)
820
+ continue;
821
+ // bare is now "METHOD:url[:auth]" — check if the url segment starts with urlPrefix
822
+ const colonIdx = bare.indexOf(":");
823
+ if (colonIdx === -1)
824
+ continue;
825
+ const urlPart = bare.slice(colonIdx + 1);
826
+ // Strip auth fingerprint suffix (auth:HEX) for prefix comparison
827
+ const authIdx = urlPart.lastIndexOf(":auth:");
828
+ const urlOnly = authIdx !== -1 ? urlPart.slice(0, authIdx) : urlPart;
829
+ // Normalize trailing slashes for consistent prefix matching
830
+ const normalizedPrefix = urlPrefix.endsWith("/") ? urlPrefix.slice(0, -1) : urlPrefix;
831
+ const normalizedUrl = urlOnly.endsWith("/") ? urlOnly.slice(0, -1) : urlOnly;
832
+ if (normalizedUrl.startsWith(normalizedPrefix)) {
833
+ const entry = await this.storage.get(key);
834
+ await this._delete(key, entry ?? undefined);
835
+ count++;
836
+ }
837
+ }
838
+ return count;
839
+ }
840
+ /** Delete all entries with a specific tag. */
841
+ async invalidateByTag(tag) {
842
+ const keys = this.tagIndex.get(tag);
843
+ if (!keys || keys.size === 0)
844
+ return 0;
845
+ let count = 0;
846
+ for (const key of [...keys]) {
847
+ const entry = await this.storage.get(key);
848
+ await this._delete(key, entry ?? undefined);
849
+ count++;
850
+ }
851
+ this.tagIndex.delete(tag);
852
+ return count;
853
+ }
854
+ /** Clear all cached entries. */
855
+ async clear() {
856
+ await this.storage.clear();
857
+ this.lru.clear();
858
+ this.tagIndex.clear();
859
+ this.swrInFlight.clear();
860
+ this.stats.totalEntries = 0;
861
+ this.stats.totalSizeBytes = 0;
862
+ }
863
+ // ──8.6 Cache warming ───────────────────────────────────────────────────
864
+ /**
865
+ * Pre-populate the cache with a set of request/response pairs.
866
+ */
867
+ async warm(entries) {
868
+ for (const { req, res, tags } of entries) {
869
+ await this.set(req, res, { ...(tags !== undefined ? { tags } : {}), force: true });
870
+ }
871
+ }
872
+ // ──8.7 Stats ───────────────────────────────────────────────────────────
873
+ /** Get a snapshot of current cache statistics. */
874
+ getStats() {
875
+ return { ...this.stats };
876
+ }
877
+ /** Reset all cache statistics to zero. */
878
+ resetStats() {
879
+ this.stats = {
880
+ hits: 0,
881
+ misses: 0,
882
+ staleHits: 0,
883
+ errors: 0,
884
+ evictions: 0,
885
+ totalEntries: 0,
886
+ totalSizeBytes: 0,
887
+ hitRate: 0,
888
+ };
889
+ }
890
+ // ──8.8 Serialization ───────────────────────────────────────────────────
891
+ /**
892
+ * Serialize cache entries to a JSON string for persistence.
893
+ * Expired entries are excluded. Format: { version: 1, entries: [[key, entry], ...] }.
894
+ */
895
+ async serialize() {
896
+ const keys = await this.storage.keys();
897
+ const entries = [];
898
+ const now = Date.now();
899
+ for (const key of keys) {
900
+ const entry = await this.storage.get(key);
901
+ if (!entry)
902
+ continue;
903
+ // Skip expired entries
904
+ if (entry.expiresAt !== Infinity && entry.expiresAt < now)
905
+ continue;
906
+ entries.push([key, entry]);
907
+ }
908
+ return JSON.stringify({ version: 1, entries });
909
+ }
910
+ /**
911
+ * Restore cache entries from a previously serialized JSON string.
912
+ * Validates structure, enforces max age and body size limits, skips expired entries.
913
+ * @throws {Error} If the JSON format is invalid or unexpected.
914
+ */
915
+ async deserialize(data) {
916
+ const parsed = safeJSONParse(data, { maxDepth: 32 });
917
+ if (!parsed.success) {
918
+ throw new Error("[kinetex] HTTPCache.deserialize: invalid JSON");
919
+ }
920
+ const obj = parsed.value;
921
+ if (typeof obj !== "object" ||
922
+ obj === null ||
923
+ !("version" in obj) ||
924
+ !("entries" in obj) ||
925
+ obj.version !== 1 ||
926
+ !Array.isArray(obj.entries)) {
927
+ throw new Error("[kinetex] HTTPCache.deserialize: unexpected format (expected {version:1, entries:[…]})");
928
+ }
929
+ const { entries } = obj;
930
+ const now = Date.now();
931
+ for (const raw of entries) {
932
+ if (!Array.isArray(raw) || raw.length !== 2)
933
+ continue;
934
+ const [key, entry] = raw;
935
+ // Validate key
936
+ if (typeof key !== "string" || key.length === 0 || key.length > 2048)
937
+ continue;
938
+ // Validate entry shape — reject anything missing required numeric fields
939
+ // Use try-catch to handle Proxy objects that might throw on property access
940
+ let validEntry = null;
941
+ try {
942
+ if (typeof entry === "object" &&
943
+ entry !== null &&
944
+ typeof entry.createdAt === "number" &&
945
+ typeof entry.expiresAt === "number" &&
946
+ typeof entry.staleUntil === "number" &&
947
+ typeof entry.staleOnError === "number" &&
948
+ typeof entry.size === "number" &&
949
+ Array.isArray(entry.tags)) {
950
+ // Validate tags are all strings
951
+ const tags = entry.tags;
952
+ if (tags.every((t) => typeof t === "string")) {
953
+ validEntry = entry;
954
+ }
955
+ }
956
+ }
957
+ catch {
958
+ /* skip invalid entries */
959
+ }
960
+ if (!validEntry)
961
+ continue;
962
+ const e = entry;
963
+ // Skip expired entries
964
+ if (e.expiresAt !== Infinity && e.expiresAt < now)
965
+ continue;
966
+ // Enforce maxAbsoluteAgeMs on import
967
+ if (now - e.createdAt > this.cfg.maxAbsoluteAgeMs)
968
+ continue;
969
+ // Validate body size limit
970
+ if (e.size > this.cfg.maxBodySizeBytes + 512)
971
+ continue;
972
+ await this.storage.set(key, e);
973
+ this.lru.touch(key);
974
+ this.stats.totalEntries++;
975
+ this.stats.totalSizeBytes += e.size;
976
+ // Rebuild tag index
977
+ for (const tag of e.tags) {
978
+ const set = this.tagIndex.get(tag) ?? new Set();
979
+ set.add(key);
980
+ this.tagIndex.set(tag, set);
981
+ }
982
+ }
983
+ }
984
+ // ── Private ───────────────────────────────────────────────────────────────
985
+ async _key(req) {
986
+ const base = await this.cfg.cacheKey(req);
987
+ return this.cfg.namespace ? `${this.cfg.namespace}:${base}` : base;
988
+ }
989
+ _isCacheable(method) {
990
+ return this.cfg.cacheMethods.includes(method.toUpperCase());
991
+ }
992
+ async _delete(key, entry) {
993
+ await this.storage.delete(key);
994
+ this.lru.remove(key);
995
+ if (entry) {
996
+ this.stats.totalEntries = Math.max(0, this.stats.totalEntries - 1);
997
+ this.stats.totalSizeBytes = Math.max(0, this.stats.totalSizeBytes - entry.size);
998
+ for (const tag of entry.tags) {
999
+ this.tagIndex.get(tag)?.delete(key);
1000
+ }
1001
+ }
1002
+ /* NOTE: If entry is undefined (e.g., not found during LRU eviction),
1003
+ the tag index entry for that key will not be cleaned up.
1004
+ This is a minor leak on disk-based storage miss, acceptable trade-off
1005
+ since the key is gone from LRU and storage anyway. */
1006
+ }
1007
+ async _ensureCapacity(newEntrySize) {
1008
+ // Entry count cap
1009
+ while (this.stats.totalEntries >= this.cfg.maxEntries) {
1010
+ const evictKey = this.lru.evict();
1011
+ if (!evictKey)
1012
+ break;
1013
+ const entry = await this.storage.get(evictKey);
1014
+ await this._delete(evictKey, entry ?? undefined);
1015
+ this.stats.evictions++;
1016
+ }
1017
+ // Size cap
1018
+ while (this.stats.totalSizeBytes + newEntrySize > this.cfg.maxSizeBytes) {
1019
+ const evictKey = this.lru.evict();
1020
+ if (!evictKey)
1021
+ break;
1022
+ const entry = await this.storage.get(evictKey);
1023
+ await this._delete(evictKey, entry ?? undefined);
1024
+ this.stats.evictions++;
1025
+ }
1026
+ }
1027
+ _updateHitRate() {
1028
+ const total = this.stats.hits + this.stats.misses + this.stats.staleHits;
1029
+ this.stats.hitRate = total > 0 ? (this.stats.hits + this.stats.staleHits) / total : 0;
1030
+ }
1031
+ }
1032
+ // ============================================================================
1033
+ // #9 UTILITIES
1034
+ // ============================================================================
1035
+ /** Deep-clone a cache entry (response + tags array, no shared references). */
1036
+ function cloneEntry(entry) {
1037
+ return {
1038
+ ...entry,
1039
+ response: cloneResponse(entry.response),
1040
+ tags: [...entry.tags],
1041
+ };
1042
+ }
1043
+ /** Deep-clone a cacheable response (headers object + copy Uint8Array body). */
1044
+ function cloneResponse(res) {
1045
+ return {
1046
+ ...res,
1047
+ headers: { ...res.headers },
1048
+ body: res.body instanceof Uint8Array ? new Uint8Array(res.body) : res.body,
1049
+ };
1050
+ }
1051
+ /** Compute the actual byte length of a body value (accounts for UTF-8 multi-byte). */
1052
+ function bodyBytes(body) {
1053
+ if (!body)
1054
+ return 0;
1055
+ // TextEncoder to get actual UTF-8 byte count, not JS char count.
1056
+ // For ASCII this is identical; for multi-byte characters it can be 2–4× larger.
1057
+ if (typeof body === "string")
1058
+ return new TextEncoder().encode(body).byteLength;
1059
+ return body.byteLength;
1060
+ }
1061
+ // ============================================================================
1062
+ // #10 FACTORY HELPERS
1063
+ // ============================================================================
1064
+ /**
1065
+ * Create an HTTP cache backed by in-memory storage.
1066
+ */
1067
+ export function createMemoryCache(config) {
1068
+ return new HTTPCache({ ...config, storage: new MemoryStorageAdapter() });
1069
+ }
1070
+ /**
1071
+ * Create an HTTP cache backed by localStorage.
1072
+ * @throws {Error} If localStorage is not available in the current runtime.
1073
+ */
1074
+ export function createLocalStorageCache(prefix, config) {
1075
+ if (typeof localStorage === "undefined")
1076
+ throw new Error("localStorage is not available");
1077
+ return new HTTPCache({ ...config, storage: new WebStorageAdapter(localStorage, prefix) });
1078
+ }
1079
+ /**
1080
+ * Create an HTTP cache backed by sessionStorage.
1081
+ * @throws {Error} If sessionStorage is not available in the current runtime.
1082
+ */
1083
+ export function createSessionStorageCache(prefix, config) {
1084
+ if (typeof sessionStorage === "undefined")
1085
+ throw new Error("sessionStorage is not available");
1086
+ return new HTTPCache({ ...config, storage: new WebStorageAdapter(sessionStorage, prefix) });
1087
+ }
1088
+ /**
1089
+ * Create an HTTP cache backed by Cloudflare Workers KV.
1090
+ */
1091
+ export function createKVCache(kv, config) {
1092
+ return new HTTPCache({ ...config, storage: new CloudflareKVAdapter(kv) });
1093
+ }
1094
+ /**
1095
+ * Create a two-tier HTTP cache (L1: memory, L2: persistent adapter).
1096
+ */
1097
+ export function createTwoTierCache(l2, config) {
1098
+ return new HTTPCache({ ...config, storage: new TwoTierStorageAdapter(l2) });
1099
+ }