@blamejs/core 0.8.51 → 0.8.57

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 (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/local-db-thin.js +8 -7
  15. package/lib/mail-arf.js +343 -0
  16. package/lib/mail-auth.js +265 -40
  17. package/lib/mail-bimi.js +948 -33
  18. package/lib/mail-bounce.js +386 -4
  19. package/lib/mail-mdn.js +424 -0
  20. package/lib/mail-unsubscribe.js +265 -25
  21. package/lib/mail.js +403 -21
  22. package/lib/middleware/bearer-auth.js +1 -1
  23. package/lib/middleware/clear-site-data.js +122 -0
  24. package/lib/middleware/dpop.js +1 -1
  25. package/lib/middleware/index.js +9 -0
  26. package/lib/middleware/nel.js +214 -0
  27. package/lib/middleware/security-headers.js +56 -4
  28. package/lib/middleware/speculation-rules.js +323 -0
  29. package/lib/mime-parse.js +198 -0
  30. package/lib/network-dns.js +890 -27
  31. package/lib/network-tls.js +745 -0
  32. package/lib/object-store/sigv4.js +54 -0
  33. package/lib/public-suffix.js +414 -0
  34. package/lib/safe-buffer.js +7 -0
  35. package/lib/safe-json.js +1 -1
  36. package/lib/static.js +120 -0
  37. package/lib/storage.js +11 -0
  38. package/lib/vendor/MANIFEST.json +33 -0
  39. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  40. package/lib/vendor/public-suffix-list.dat +16376 -0
  41. package/package.json +1 -1
  42. package/sbom.cyclonedx.json +6 -6
@@ -0,0 +1,916 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.httpClient.cache
4
+ * @nav HTTP
5
+ * @title Http Client Cache
6
+ * @order 150
7
+ *
8
+ * @intro
9
+ * RFC 9111 outbound HTTP cache for `b.httpClient.request`. Stores
10
+ * GET/HEAD responses keyed on (URL, method, sorted Vary-header
11
+ * values), honours `Cache-Control` directives (`no-store`, `no-cache`,
12
+ * `private`, `max-age`, `s-maxage`, `must-revalidate`,
13
+ * `proxy-revalidate`, `immutable`, `stale-while-revalidate`,
14
+ * `stale-if-error`), legacy `Pragma: no-cache` and `Expires`,
15
+ * computes freshness per RFC 9111 §4.2 (heuristic 10% rule when no
16
+ * explicit lifetime), revalidates with `If-None-Match` /
17
+ * `If-Modified-Since` and merges 304 headers into the stored entry
18
+ * (RFC 9111 §5).
19
+ *
20
+ * Two store backends ship in-the-box: `memoryStore` (bounded LRU,
21
+ * per-byte and per-entry caps, eviction emits an audit event) and
22
+ * the explicit `Store` interface (`get` / `set` / `delete` / `clear`)
23
+ * so operators can wire their own — Redis, filesystem, etc. The
24
+ * memory store handles the common single-process case without
25
+ * pulling in an external dependency. Operators with shared-cache
26
+ * semantics across a fleet wire their own `Store` against a shared
27
+ * backing service.
28
+ *
29
+ * Composes through `b.httpClient.request({ ..., cache })`. Without
30
+ * `opts.cache`, behaviour is unchanged — zero overhead for callers
31
+ * who don't want caching. Failures inside the cache hot path
32
+ * (store throws, malformed entry, revalidation network error
33
+ * outside `stale-if-error`) drop silent and the request falls back
34
+ * to the network — caching is never allowed to surface as a request
35
+ * failure. The same audit / observability hooks emit on every cache
36
+ * decision (`hit` / `miss` / `stale` / `revalidated` / `evicted`)
37
+ * so operators get end-to-end visibility.
38
+ *
39
+ * @card
40
+ * RFC 9111 outbound HTTP cache with bounded LRU memory store, Vary
41
+ * handling, conditional revalidation (ETag / If-Modified-Since), and
42
+ * stale-while-revalidate / stale-if-error.
43
+ */
44
+
45
+ var C = require("./constants");
46
+ var canonicalJson = require("./canonical-json");
47
+ var safeUrl = require("./safe-url");
48
+ var validateOpts = require("./validate-opts");
49
+ var { HttpClientError } = require("./framework-error");
50
+
51
+ // ---- Tunables ----------------------------------------------------------
52
+
53
+ // Default max bytes for the in-memory store. Bounded so a runaway
54
+ // upstream serving 1GiB responses can't OOM the process. Operators
55
+ // pick a value matched to their RAM budget.
56
+ var DEFAULT_MAX_BYTES = C.BYTES.mib(64);
57
+ var DEFAULT_MAX_ENTRIES = 1024;
58
+
59
+ // Per RFC 9111 §4.2.2 — heuristic freshness must not exceed 24 hours
60
+ // without explicit operator opt-in (we don't expose that opt-in; if a
61
+ // downstream wants long-cached behaviour without explicit Cache-Control
62
+ // it's an upstream bug). Cap heuristic at 24h.
63
+ var HEURISTIC_MAX_AGE_MS = C.TIME.hours(24);
64
+
65
+ // Statuses RFC 9110 designates as heuristically cacheable. (Plus 200/206
66
+ // which are universally cacheable when a freshness lifetime is given.)
67
+ var CACHEABLE_STATUSES = new Set([
68
+ 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501, // allow:raw-byte-literal — HTTP status codes per RFC 9110 / allow:raw-time-literal — same line, status codes not seconds
69
+ ]);
70
+
71
+ // Headers that MUST not be forwarded when serving a 304-updated entry.
72
+ // RFC 9111 §5 — "Updating Stored Header Fields" — the listed
73
+ // hop-by-hop headers + Connection-named headers are stripped from the
74
+ // stored entry.
75
+ var HOP_BY_HOP = new Set([
76
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
77
+ "te", "trailer", "transfer-encoding", "upgrade",
78
+ ]);
79
+
80
+ function _hcErr(code, message) {
81
+ return new HttpClientError(code, message, true);
82
+ }
83
+
84
+ // ---- Cache-Control parsing --------------------------------------------
85
+
86
+ // Parse a Cache-Control header into a structured object.
87
+ // Returns { directives: Object, raw: string }. Directives are
88
+ // lowercased; values are strings (numeric values stay strings — caller
89
+ // parses with parseInt where it makes sense).
90
+ function _parseCacheControl(value) {
91
+ var out = Object.create(null);
92
+ if (typeof value !== "string" || value.length === 0) return out;
93
+ var parts = value.split(",");
94
+ for (var i = 0; i < parts.length; i++) {
95
+ var p = parts[i].trim();
96
+ if (!p) continue;
97
+ var eq = p.indexOf("=");
98
+ var k, v;
99
+ if (eq === -1) { k = p; v = ""; }
100
+ else { k = p.slice(0, eq).trim(); v = p.slice(eq + 1).trim(); }
101
+ // Strip surrounding quotes from value.
102
+ if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
103
+ v = v.slice(1, v.length - 1);
104
+ }
105
+ out[k.toLowerCase()] = v;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function _ccNumber(directives, name) {
111
+ if (!directives || !(name in directives)) return null;
112
+ var n = parseInt(directives[name], 10);
113
+ if (!isFinite(n) || n < 0) return null;
114
+ return n;
115
+ }
116
+
117
+ function _ccPresent(directives, name) {
118
+ return directives && Object.prototype.hasOwnProperty.call(directives, name);
119
+ }
120
+
121
+ // ---- HTTP date parsing -----------------------------------------------
122
+
123
+ function _parseHttpDate(s) {
124
+ if (typeof s !== "string" || s.length === 0) return null;
125
+ var t = Date.parse(s);
126
+ return isNaN(t) ? null : t;
127
+ }
128
+
129
+ // ---- Header helpers ---------------------------------------------------
130
+
131
+ function _lcHeaders(headers) {
132
+ var out = Object.create(null);
133
+ if (!headers || typeof headers !== "object") return out;
134
+ var keys = Object.keys(headers);
135
+ for (var i = 0; i < keys.length; i++) {
136
+ out[keys[i].toLowerCase()] = headers[keys[i]];
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // Return a header value as a string. h2 sometimes returns arrays for
142
+ // repeated headers (Set-Cookie); pick the first for cache directives.
143
+ function _headerOne(headers, name) {
144
+ if (!headers) return null;
145
+ var v = headers[name];
146
+ if (v === undefined || v === null) return null;
147
+ if (Array.isArray(v)) return v.length > 0 ? String(v[0]) : null;
148
+ return String(v);
149
+ }
150
+
151
+ // ---- Cache key -------------------------------------------------------
152
+
153
+ // Returns the canonical cache key for a (URL, method, vary-headers) tuple.
154
+ // Uses `b.canonicalJson` so the key is byte-stable regardless of property
155
+ // insertion order.
156
+ function _normalizeUrl(url) {
157
+ // Strip fragment per RFC 9111 §2 (cache uses URI without fragment).
158
+ // Sort query parameters for stable keying — RFC 3986 doesn't require
159
+ // it, but two URLs that differ only in query order are semantically
160
+ // equal for most upstreams; if an upstream genuinely cares about
161
+ // order, the operator wires per-route cache opt-out.
162
+ var u;
163
+ try {
164
+ u = safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
165
+ } catch (_e) {
166
+ return String(url);
167
+ }
168
+ // Reconstitute origin + path; sort search params.
169
+ var origin = u.protocol + "//" + u.host;
170
+ var pathOnly = u.pathname || "/";
171
+ var search = "";
172
+ if (u.search && u.search.length > 0) {
173
+ var entries = [];
174
+ u.searchParams.forEach(function (v, k) { entries.push([k, v]); });
175
+ entries.sort(function (a, b) {
176
+ if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
177
+ return a[1] < b[1] ? -1 : (a[1] > b[1] ? 1 : 0);
178
+ });
179
+ var parts = [];
180
+ for (var i = 0; i < entries.length; i++) {
181
+ parts.push(encodeURIComponent(entries[i][0]) + "=" + encodeURIComponent(entries[i][1]));
182
+ }
183
+ if (parts.length > 0) search = "?" + parts.join("&");
184
+ }
185
+ return origin + pathOnly + search;
186
+ }
187
+
188
+ function _buildCacheKey(method, url, varyHeaderValues) {
189
+ var vary = varyHeaderValues || null;
190
+ var keyShape = {
191
+ m: String(method || "GET").toUpperCase(),
192
+ u: _normalizeUrl(url),
193
+ v: vary,
194
+ };
195
+ return canonicalJson.stringify(keyShape);
196
+ }
197
+
198
+ // Returns a sorted-by-name array of [headerName, headerValue] pairs for
199
+ // every name listed in the response's Vary header (lowercased), reading
200
+ // values from the request headers. RFC 9110 §12.5.5: '*' means "vary on
201
+ // every request feature" — caller must treat the response as
202
+ // uncacheable.
203
+ function _extractVaryValues(varyHeader, requestHeaders) {
204
+ if (typeof varyHeader !== "string" || varyHeader.length === 0) return [];
205
+ var names = varyHeader.split(",").map(function (s) {
206
+ return s.trim().toLowerCase();
207
+ }).filter(function (s) { return s.length > 0; });
208
+ if (names.indexOf("*") !== -1) return null; // sentinel: "uncacheable"
209
+ names.sort();
210
+ var lcReq = _lcHeaders(requestHeaders);
211
+ var pairs = [];
212
+ for (var i = 0; i < names.length; i++) {
213
+ var name = names[i];
214
+ var v = lcReq[name];
215
+ pairs.push([name, v === undefined ? null : String(v)]);
216
+ }
217
+ return pairs;
218
+ }
219
+
220
+ // ---- Storage decision (RFC 9111 §3) -----------------------------------
221
+
222
+ // Returns { cacheable: bool, reason: string|null, freshnessMs: number,
223
+ // directives: Object, varyHeader: string|null }.
224
+ //
225
+ // freshnessMs is computed from the response — 0 means "store but
226
+ // always revalidate" (no-cache), -1 means "never cacheable".
227
+ function _evaluateStorage(method, statusCode, responseHeaders, sharedCache) {
228
+ var lcResp = _lcHeaders(responseHeaders);
229
+ var ccRaw = _headerOne(lcResp, "cache-control");
230
+ var directives = _parseCacheControl(ccRaw);
231
+ var varyHeader = _headerOne(lcResp, "vary");
232
+ var pragma = _headerOne(lcResp, "pragma");
233
+
234
+ // Method gate.
235
+ var methodU = String(method || "GET").toUpperCase();
236
+ if (methodU !== "GET" && methodU !== "HEAD") {
237
+ return { cacheable: false, reason: "method-not-cacheable", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
238
+ }
239
+
240
+ // Status gate.
241
+ if (!CACHEABLE_STATUSES.has(statusCode)) {
242
+ return { cacheable: false, reason: "status-not-cacheable", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
243
+ }
244
+
245
+ // no-store / private (when shared) refuse outright.
246
+ if (_ccPresent(directives, "no-store")) {
247
+ return { cacheable: false, reason: "no-store", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
248
+ }
249
+ if (sharedCache && _ccPresent(directives, "private")) {
250
+ return { cacheable: false, reason: "private", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
251
+ }
252
+
253
+ // Vary: * is uncacheable per RFC 9110 §12.5.5.
254
+ if (typeof varyHeader === "string" && varyHeader.indexOf("*") !== -1) {
255
+ var trimmed = varyHeader.split(",").map(function (s) { return s.trim(); });
256
+ if (trimmed.indexOf("*") !== -1) {
257
+ return { cacheable: false, reason: "vary-star", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
258
+ }
259
+ }
260
+
261
+ // Compute freshness lifetime per RFC 9111 §4.2.1.
262
+ // Order: s-maxage (shared) > max-age > Expires - Date > heuristic.
263
+ var sMaxage = sharedCache ? _ccNumber(directives, "s-maxage") : null;
264
+ var maxage = _ccNumber(directives, "max-age");
265
+ var dateHeader = _parseHttpDate(_headerOne(lcResp, "date"));
266
+ var expiresHeader = _parseHttpDate(_headerOne(lcResp, "expires"));
267
+ var lastModified = _parseHttpDate(_headerOne(lcResp, "last-modified"));
268
+
269
+ var freshnessMs = null;
270
+ if (sMaxage !== null) freshnessMs = C.TIME.seconds(sMaxage);
271
+ else if (maxage !== null) freshnessMs = C.TIME.seconds(maxage);
272
+ else if (expiresHeader !== null) {
273
+ if (dateHeader !== null) freshnessMs = expiresHeader - dateHeader;
274
+ else freshnessMs = expiresHeader - Date.now();
275
+ } else if (lastModified !== null && dateHeader !== null) {
276
+ // Heuristic: 10% of (Date - Last-Modified), capped at 24h. Negative
277
+ // values mean Last-Modified is in the future — refuse the heuristic.
278
+ var diff = dateHeader - lastModified;
279
+ if (diff > 0) {
280
+ freshnessMs = Math.min(Math.floor(diff * 0.1), HEURISTIC_MAX_AGE_MS);
281
+ }
282
+ }
283
+
284
+ // Pragma: no-cache + no Cache-Control: max-age = legacy "always
285
+ // revalidate". Treat like Cache-Control: no-cache.
286
+ if (pragma && /no-cache/i.test(pragma) && maxage === null && sMaxage === null && expiresHeader === null) {
287
+ if (!_ccPresent(directives, "max-age") && !_ccPresent(directives, "s-maxage")) {
288
+ // Cacheable but stale-on-arrival (must revalidate).
289
+ return {
290
+ cacheable: true, reason: "pragma-no-cache", freshnessMs: 0,
291
+ directives: directives, varyHeader: varyHeader,
292
+ };
293
+ }
294
+ }
295
+
296
+ // Cache-Control: no-cache (cache but require revalidation) — store
297
+ // with freshness 0 so every read forces a conditional GET.
298
+ if (_ccPresent(directives, "no-cache")) {
299
+ return {
300
+ cacheable: true, reason: "no-cache",
301
+ freshnessMs: 0, directives: directives, varyHeader: varyHeader,
302
+ };
303
+ }
304
+
305
+ // No explicit OR heuristic freshness AND no validators → uncacheable.
306
+ // Without max-age/Expires/Last-Modified or ETag the cache has nothing
307
+ // to validate against; storing is wasted bytes.
308
+ var etag = _headerOne(lcResp, "etag");
309
+ if (freshnessMs === null) {
310
+ if (!etag && !lastModified) {
311
+ return { cacheable: false, reason: "no-freshness-no-validator", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
312
+ }
313
+ // Has a validator but no lifetime — store with freshness 0 (force
314
+ // revalidation).
315
+ return {
316
+ cacheable: true, reason: "validator-only",
317
+ freshnessMs: 0, directives: directives, varyHeader: varyHeader,
318
+ };
319
+ }
320
+
321
+ if (freshnessMs < 0) {
322
+ return { cacheable: false, reason: "expires-in-past", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
323
+ }
324
+
325
+ return {
326
+ cacheable: true, reason: null,
327
+ freshnessMs: freshnessMs, directives: directives, varyHeader: varyHeader,
328
+ };
329
+ }
330
+
331
+ // ---- Age + freshness math (RFC 9111 §4.2.3) ---------------------------
332
+
333
+ // current_age = (now - response_time) + (response_time - date_value) +
334
+ // apparent_age (corrected)
335
+ // We approximate apparent_age as max(0, response_time - date_value) and
336
+ // drop the response_delay correction (small; not exposed by Node's HTTP
337
+ // layer at the resolution we care about).
338
+ function _currentAgeMs(entry, nowMs) {
339
+ var dateMs = entry.dateMs;
340
+ var ageHeader = entry.ageHeaderSec || 0;
341
+ var responseTime = entry.storedAtMs;
342
+ var apparent = Math.max(0, responseTime - (dateMs || responseTime));
343
+ var correctedInitial = Math.max(apparent, C.TIME.seconds(ageHeader));
344
+ var residentTime = nowMs - responseTime;
345
+ return correctedInitial + residentTime;
346
+ }
347
+
348
+ // ---- Memory store -----------------------------------------------------
349
+
350
+ /**
351
+ * @primitive b.httpClient.cache.memoryStore
352
+ * @signature b.httpClient.cache.memoryStore(opts)
353
+ * @since 0.8.53
354
+ * @status stable
355
+ * @related b.httpClient.cache.create, b.httpClient.request
356
+ *
357
+ * In-memory bounded-LRU cache store implementing the `Store` shape:
358
+ * `get(key)`, `set(key, entry)`, `delete(key)`, `clear()`. Eviction
359
+ * runs when the byte total or entry count exceeds the configured
360
+ * caps; eviction emits an audit event when an audit sink is wired
361
+ * (via `b.httpClient.cache.create({ audit })`). Stored values
362
+ * include the response body buffer, so the byte total reflects real
363
+ * memory pressure rather than a rough estimate.
364
+ *
365
+ * Suitable for single-process workloads. For shared-cache semantics
366
+ * across a fleet, wire your own `Store` against a shared backing
367
+ * service (Redis, filesystem, etc.) — the same shape applies.
368
+ *
369
+ * @opts
370
+ * maxBytes: number, // total stored body bytes; default: 64 MiB
371
+ * maxEntries: number, // count cap; default: 1024
372
+ * evictionPolicy: "lru", // currently the only policy; reserved
373
+ *
374
+ * @example
375
+ * var store = b.httpClient.cache.memoryStore({
376
+ * maxBytes: 16 * 1024 * 1024,
377
+ * maxEntries: 256,
378
+ * });
379
+ * var cache = b.httpClient.cache.create({ store: store });
380
+ * await b.httpClient.request({ url: "https://example.com/", cache: cache });
381
+ */
382
+ function memoryStore(opts) {
383
+ opts = opts || {};
384
+ if (typeof opts !== "object") {
385
+ throw _hcErr("httpclient/cache-bad-opts", "memoryStore: opts must be an object");
386
+ }
387
+ if (opts.maxBytes !== undefined &&
388
+ (typeof opts.maxBytes !== "number" || !isFinite(opts.maxBytes) ||
389
+ opts.maxBytes <= 0 || Math.floor(opts.maxBytes) !== opts.maxBytes)) {
390
+ throw _hcErr("httpclient/cache-bad-opts",
391
+ "memoryStore: maxBytes must be a positive integer");
392
+ }
393
+ if (opts.maxEntries !== undefined &&
394
+ (typeof opts.maxEntries !== "number" || !isFinite(opts.maxEntries) ||
395
+ opts.maxEntries <= 0 || Math.floor(opts.maxEntries) !== opts.maxEntries)) {
396
+ throw _hcErr("httpclient/cache-bad-opts",
397
+ "memoryStore: maxEntries must be a positive integer");
398
+ }
399
+ if (opts.evictionPolicy !== undefined && opts.evictionPolicy !== "lru") {
400
+ throw _hcErr("httpclient/cache-bad-opts",
401
+ "memoryStore: evictionPolicy must be 'lru' (got " + JSON.stringify(opts.evictionPolicy) + ")");
402
+ }
403
+ var maxBytes = opts.maxBytes || DEFAULT_MAX_BYTES;
404
+ var maxEntries = opts.maxEntries || DEFAULT_MAX_ENTRIES;
405
+
406
+ // Map iteration order is insertion order. We re-insert on `get` to
407
+ // promote the entry to the LRU tail; eviction removes from the head.
408
+ var map = new Map();
409
+ var totalBytes = 0;
410
+ var onEvict = null; // wired by the cache instance after construction.
411
+
412
+ function _entryBytes(entry) {
413
+ if (!entry) return 0;
414
+ var bodyLen = (entry.body && Buffer.isBuffer(entry.body)) ? entry.body.length : 0;
415
+ // Header byte estimate — small; included to keep totalBytes honest
416
+ // for response bodies of zero length (a 1KB header bundle still
417
+ // costs memory).
418
+ var headerLen = 0;
419
+ if (entry.headers) {
420
+ var keys = Object.keys(entry.headers);
421
+ for (var i = 0; i < keys.length; i++) {
422
+ headerLen += keys[i].length + String(entry.headers[keys[i]]).length + 4;
423
+ }
424
+ }
425
+ return bodyLen + headerLen;
426
+ }
427
+
428
+ function _evictOne(reason) {
429
+ var first = map.keys().next();
430
+ if (first.done) return false;
431
+ var k = first.value;
432
+ var v = map.get(k);
433
+ map.delete(k);
434
+ totalBytes -= _entryBytes(v);
435
+ if (totalBytes < 0) totalBytes = 0;
436
+ if (typeof onEvict === "function") {
437
+ try { onEvict({ key: k, reason: reason, bytes: _entryBytes(v) }); }
438
+ catch (_e) { /* eviction callback best-effort */ }
439
+ }
440
+ return true;
441
+ }
442
+
443
+ function _evictUntilFits(extraBytes) {
444
+ while ((map.size + 1) > maxEntries && _evictOne("max-entries")) { /* loop */ }
445
+ while ((totalBytes + extraBytes) > maxBytes && _evictOne("max-bytes")) { /* loop */ }
446
+ }
447
+
448
+ return {
449
+ kind: "memory",
450
+ get: function (key) {
451
+ if (!map.has(key)) return null;
452
+ var v = map.get(key);
453
+ // LRU promote.
454
+ map.delete(key);
455
+ map.set(key, v);
456
+ return v;
457
+ },
458
+ set: function (key, entry) {
459
+ if (typeof key !== "string" || !entry || typeof entry !== "object") return;
460
+ if (map.has(key)) {
461
+ var prev = map.get(key);
462
+ totalBytes -= _entryBytes(prev);
463
+ map.delete(key);
464
+ }
465
+ var bytes = _entryBytes(entry);
466
+ if (bytes > maxBytes) {
467
+ // Single entry larger than the cap — refuse rather than wipe.
468
+ if (typeof onEvict === "function") {
469
+ try { onEvict({ key: key, reason: "entry-too-large", bytes: bytes }); }
470
+ catch (_e) { /* eviction callback best-effort */ }
471
+ }
472
+ return;
473
+ }
474
+ _evictUntilFits(bytes);
475
+ map.set(key, entry);
476
+ totalBytes += bytes;
477
+ },
478
+ delete: function (key) {
479
+ if (!map.has(key)) return;
480
+ var v = map.get(key);
481
+ totalBytes -= _entryBytes(v);
482
+ if (totalBytes < 0) totalBytes = 0;
483
+ map.delete(key);
484
+ },
485
+ clear: function () {
486
+ map.clear();
487
+ totalBytes = 0;
488
+ },
489
+ // Internal hooks for the cache instance — not part of the operator-
490
+ // facing Store contract.
491
+ _stats: function () {
492
+ return { entries: map.size, bytes: totalBytes, maxBytes: maxBytes, maxEntries: maxEntries };
493
+ },
494
+ _setOnEvict: function (cb) { onEvict = (typeof cb === "function") ? cb : null; },
495
+ };
496
+ }
497
+
498
+ // ---- Cache instance --------------------------------------------------
499
+
500
+ /**
501
+ * @primitive b.httpClient.cache.create
502
+ * @signature b.httpClient.cache.create(opts)
503
+ * @since 0.8.53
504
+ * @status stable
505
+ * @related b.httpClient.cache.memoryStore, b.httpClient.request
506
+ *
507
+ * Builds an RFC 9111 cache instance for `b.httpClient.request`. The
508
+ * returned object plugs into a request via `opts.cache`. Without
509
+ * `opts.cache`, the request path is unchanged — no overhead for
510
+ * non-caching callers. The cache evaluates each response per
511
+ * RFC 9111 §3 (storage decision: method / status / Cache-Control /
512
+ * Vary), tracks freshness per §4.2 (s-maxage > max-age > Expires
513
+ * > heuristic 10% of (Date - Last-Modified) capped at 24h),
514
+ * revalidates conditionally per §4.3 (`If-None-Match` /
515
+ * `If-Modified-Since`), and merges 304 headers per §5.
516
+ *
517
+ * `sharedCache: true` (default) honours `s-maxage` over `max-age` and
518
+ * refuses to store responses with `Cache-Control: private` — operator
519
+ * services share a cache with each other, so a per-user `private`
520
+ * response must not leak across users via the cache. Single-tenant
521
+ * scripts pass `sharedCache: false` to behave as a private cache.
522
+ *
523
+ * `defaultMaxStale` lets the cache return a stored entry past its
524
+ * freshness lifetime (within the configured number of seconds) even
525
+ * without an explicit upstream `stale-while-revalidate` /
526
+ * `stale-if-error`. Default 0 — operators opt in.
527
+ *
528
+ * `revalidateInBackground` (default true): when an entry is fresh
529
+ * within its `stale-while-revalidate` window the stale response is
530
+ * returned immediately and a background revalidation kicks off so the
531
+ * next caller sees a refreshed entry. Pass false to revalidate inline
532
+ * (lower memory churn, higher request latency).
533
+ *
534
+ * @opts
535
+ * store: <required>, // Store: { get, set, delete, clear }
536
+ * sharedCache: true, // honour s-maxage; refuse Cache-Control: private
537
+ * defaultMaxStale: 0, // seconds — serve stale up to this far past expiry
538
+ * revalidateInBackground: true, // s-w-r kicks off background revalidation
539
+ * audit: undefined, // audit sink with safeEmit({...})
540
+ * observability: undefined, // optional { event, safeEvent }
541
+ *
542
+ * @example
543
+ * var cache = b.httpClient.cache.create({
544
+ * store: b.httpClient.cache.memoryStore({ maxBytes: 32 * 1024 * 1024 }),
545
+ * sharedCache: true,
546
+ * defaultMaxStale: 5,
547
+ * audit: b.audit,
548
+ * });
549
+ * var res = await b.httpClient.request({
550
+ * url: "https://api.example.com/users/42",
551
+ * cache: cache,
552
+ * });
553
+ * // res.headers["x-blamejs-cache"] === "MISS" (first call)
554
+ */
555
+ function create(opts) {
556
+ validateOpts.requireObject(opts, "cache.create: opts", HttpClientError, "httpclient/cache-bad-opts");
557
+ if (!opts.store || typeof opts.store !== "object" ||
558
+ typeof opts.store.get !== "function" ||
559
+ typeof opts.store.set !== "function" ||
560
+ typeof opts.store.delete !== "function" ||
561
+ typeof opts.store.clear !== "function") {
562
+ throw _hcErr("httpclient/cache-bad-opts",
563
+ "cache.create: store must implement { get, set, delete, clear }");
564
+ }
565
+ validateOpts.optionalBoolean(opts.sharedCache, "cache.create: sharedCache", HttpClientError, "httpclient/cache-bad-opts");
566
+ validateOpts.optionalFiniteNonNegative(opts.defaultMaxStale,
567
+ "cache.create: defaultMaxStale", HttpClientError, "httpclient/cache-bad-opts");
568
+ validateOpts.optionalBoolean(opts.revalidateInBackground,
569
+ "cache.create: revalidateInBackground", HttpClientError, "httpclient/cache-bad-opts");
570
+ if (opts.audit !== undefined && opts.audit !== null) {
571
+ validateOpts.auditShape(opts.audit, "cache.create",
572
+ HttpClientError, "httpclient/cache-bad-opts");
573
+ }
574
+
575
+ var store = opts.store;
576
+ var sharedCache = opts.sharedCache !== false; // default true
577
+ var defaultMaxStaleSec = opts.defaultMaxStale || 0;
578
+ var revalidateBackground = opts.revalidateInBackground !== false; // default true
579
+ var audit = opts.audit || null;
580
+ var obs = opts.observability || null;
581
+
582
+ function _emit(action, outcome, metadata) {
583
+ if (!audit || typeof audit.safeEmit !== "function") return;
584
+ try {
585
+ audit.safeEmit({
586
+ action: action,
587
+ outcome: outcome || "allowed",
588
+ resource: { kind: "outbound.http.cache", id: (metadata && metadata.url) || "" },
589
+ metadata: metadata || {},
590
+ });
591
+ } catch (_e) { /* audit best-effort — drop-silent */ }
592
+ }
593
+
594
+ function _obsEvent(name, value, labels) {
595
+ if (!obs) return;
596
+ var fn = obs.safeEvent || obs.event;
597
+ if (typeof fn !== "function") return;
598
+ try { fn(name, value, labels); } catch (_e) { /* drop-silent */ }
599
+ }
600
+
601
+ // Wire the eviction callback into the memory store so eviction emits
602
+ // a single audit + observability event. Stores that don't support the
603
+ // hook (operator-supplied) silently skip — the operator's own store
604
+ // emits its own metrics.
605
+ if (store && typeof store._setOnEvict === "function") {
606
+ store._setOnEvict(function (info) {
607
+ _emit("httpclient.cache.evicted", "allowed", {
608
+ reason: info.reason, bytes: info.bytes,
609
+ });
610
+ _obsEvent("httpclient.cache.evicted", 1, { reason: info.reason });
611
+ });
612
+ }
613
+
614
+ function _buildEntry(method, urlStr, requestHeaders, statusCode, responseHeaders, body, evaluation) {
615
+ var lcResp = _lcHeaders(responseHeaders);
616
+ var dateMs = _parseHttpDate(_headerOne(lcResp, "date"));
617
+ var ageSec = parseInt(_headerOne(lcResp, "age") || "0", 10);
618
+ if (!isFinite(ageSec) || ageSec < 0) ageSec = 0;
619
+ var varyValues = _extractVaryValues(evaluation.varyHeader, requestHeaders);
620
+ if (varyValues === null) return null; // vary: *
621
+ return {
622
+ method: String(method || "GET").toUpperCase(),
623
+ url: urlStr,
624
+ varyHeader: evaluation.varyHeader || null,
625
+ varyValues: varyValues,
626
+ statusCode: statusCode,
627
+ headers: responseHeaders,
628
+ body: Buffer.isBuffer(body) ? body : (body == null ? Buffer.alloc(0) : Buffer.from(body)),
629
+ storedAtMs: Date.now(),
630
+ dateMs: dateMs,
631
+ ageHeaderSec: ageSec,
632
+ freshnessMs: evaluation.freshnessMs,
633
+ directives: evaluation.directives,
634
+ etag: _headerOne(lcResp, "etag"),
635
+ lastModified: _headerOne(lcResp, "last-modified"),
636
+ };
637
+ }
638
+
639
+ // ---- Core cache surface --------------------------------------------
640
+
641
+ // Look up a stored entry that matches (method, url, vary). Returns
642
+ // { entry, key } | null. `vary` lookup is the standard "stored Vary
643
+ // values must equal request's values" check.
644
+ function _lookup(method, url, requestHeaders) {
645
+ // We don't know the upstream's Vary set without storing it. So we
646
+ // first try the no-vary key; if absent, we walk the index of vary
647
+ // keys for this method+url. For the simple case (no Vary), the
648
+ // first key hits.
649
+ var noVaryKey = _buildCacheKey(method, url, []);
650
+ var got = null;
651
+ try { got = store.get(noVaryKey); }
652
+ catch (_e) { /* drop-silent — store error means "miss" */ return null; }
653
+ if (got) return { key: noVaryKey, entry: got };
654
+
655
+ // Vary lookup — every entry stored under this method+url has the
656
+ // same shape but different vary values. We probe with the request's
657
+ // own values against the stored vary names. We need to know the
658
+ // names without scanning the whole store; encode them into a
659
+ // sidecar key under the no-vary slot. To keep the Store interface
660
+ // simple, we instead store every vary'd response under a key that
661
+ // includes the FULL request-header set hashed canonically. The
662
+ // lookup tries each plausible key by reading a "vary-names index"
663
+ // we maintain alongside.
664
+ //
665
+ // Simpler practical approach: store a small "varyNames" entry under
666
+ // the no-vary key when the response actually has Vary, then probe
667
+ // with the operator's request headers projected onto those names.
668
+ //
669
+ // Implementation: when we store under a vary'd key, we ALSO set
670
+ // a marker entry at the no-vary key with body=empty + a special
671
+ // header `x-blamejs-vary-names` listing the Vary names. _lookup
672
+ // reads that marker when no body entry was found above and probes
673
+ // the real key.
674
+ return null;
675
+ }
676
+
677
+ // Lookup respecting Vary — returns { entry, key } | null. The full
678
+ // index lives in the store under (method, url, varyValues) so we
679
+ // need both a "what Vary names apply" marker and the real entry.
680
+ function _lookupWithVary(method, url, requestHeaders) {
681
+ var noVary = _lookup(method, url, requestHeaders);
682
+ if (noVary) {
683
+ // Distinguish marker from real entry via __varyMarker flag.
684
+ if (noVary.entry && noVary.entry.__varyMarker) {
685
+ // Compute the real key from the marker's known vary names.
686
+ var names = noVary.entry.varyNames || [];
687
+ var lcReq = _lcHeaders(requestHeaders);
688
+ var pairs = names.slice().sort().map(function (n) {
689
+ var v = lcReq[n];
690
+ return [n, v === undefined ? null : String(v)];
691
+ });
692
+ var realKey = _buildCacheKey(method, url, pairs);
693
+ var realEntry;
694
+ try { realEntry = store.get(realKey); }
695
+ catch (_e) { return null; }
696
+ if (!realEntry) return null;
697
+ return { key: realKey, entry: realEntry };
698
+ }
699
+ return noVary;
700
+ }
701
+ return null;
702
+ }
703
+
704
+ function _store(method, url, requestHeaders, statusCode, responseHeaders, body, evaluation) {
705
+ var entry = _buildEntry(method, url, requestHeaders, statusCode, responseHeaders, body, evaluation);
706
+ if (!entry) return false;
707
+ var hasVary = entry.varyHeader && entry.varyValues && entry.varyValues.length > 0;
708
+ var key = _buildCacheKey(method, url, hasVary ? entry.varyValues : []);
709
+ try { store.set(key, entry); }
710
+ catch (_e) { /* store error — drop-silent */ return false; }
711
+
712
+ // When this response uses Vary, drop a marker entry at the no-vary
713
+ // key so subsequent lookups know which header names to project.
714
+ if (hasVary) {
715
+ var marker = {
716
+ __varyMarker: true,
717
+ varyNames: entry.varyValues.map(function (p) { return p[0]; }),
718
+ method: entry.method,
719
+ url: entry.url,
720
+ body: Buffer.alloc(0),
721
+ headers: {},
722
+ storedAtMs: entry.storedAtMs,
723
+ };
724
+ var noVaryKey = _buildCacheKey(method, url, []);
725
+ try { store.set(noVaryKey, marker); }
726
+ catch (_e) { /* drop-silent */ }
727
+ }
728
+ return true;
729
+ }
730
+
731
+ // Merge 304 headers into the stored entry per RFC 9111 §5. Strip
732
+ // hop-by-hop headers; replace Date, ETag, Last-Modified, Cache-Control
733
+ // with the new values.
734
+ function _merge304Headers(stored, fresh304Headers) {
735
+ var lcFresh = _lcHeaders(fresh304Headers);
736
+ var merged = Object.assign({}, stored.headers);
737
+ var keys = Object.keys(lcFresh);
738
+ for (var i = 0; i < keys.length; i++) {
739
+ var k = keys[i];
740
+ if (HOP_BY_HOP.has(k)) continue;
741
+ if (k === "content-length") continue; // body unchanged; keep stored CL
742
+ // Find existing key (case-preserving) or create lowercase.
743
+ var existing = null;
744
+ var origKeys = Object.keys(merged);
745
+ for (var j = 0; j < origKeys.length; j++) {
746
+ if (origKeys[j].toLowerCase() === k) { existing = origKeys[j]; break; }
747
+ }
748
+ if (existing) merged[existing] = lcFresh[k];
749
+ else merged[k] = lcFresh[k];
750
+ }
751
+ return merged;
752
+ }
753
+
754
+ // After a 304: re-derive freshness from merged headers and bump
755
+ // storedAt to "now" so age math restarts.
756
+ function _refreshFrom304(stored, fresh304Headers) {
757
+ var mergedHeaders = _merge304Headers(stored, fresh304Headers);
758
+ var evaluation = _evaluateStorage(stored.method, stored.statusCode, mergedHeaders, sharedCache);
759
+ var lcMerged = _lcHeaders(mergedHeaders);
760
+ var dateMs = _parseHttpDate(_headerOne(lcMerged, "date"));
761
+ var ageSec = parseInt(_headerOne(lcMerged, "age") || "0", 10);
762
+ if (!isFinite(ageSec) || ageSec < 0) ageSec = 0;
763
+ var refreshed = Object.assign({}, stored, {
764
+ headers: mergedHeaders,
765
+ storedAtMs: Date.now(),
766
+ dateMs: dateMs,
767
+ ageHeaderSec: ageSec,
768
+ freshnessMs: evaluation.freshnessMs >= 0 ? evaluation.freshnessMs : stored.freshnessMs,
769
+ directives: evaluation.directives,
770
+ etag: _headerOne(lcMerged, "etag") || stored.etag,
771
+ lastModified: _headerOne(lcMerged, "last-modified") || stored.lastModified,
772
+ });
773
+ var hasVary = refreshed.varyHeader && refreshed.varyValues && refreshed.varyValues.length > 0;
774
+ var key = _buildCacheKey(refreshed.method, refreshed.url, hasVary ? refreshed.varyValues : []);
775
+ try { store.set(key, refreshed); } catch (_e) { /* drop-silent */ }
776
+ return refreshed;
777
+ }
778
+
779
+ // Returns the "Age" header value (seconds) for serving an entry now.
780
+ function _serveAgeSeconds(entry, nowMs) {
781
+ var ageMs = _currentAgeMs(entry, nowMs);
782
+ return Math.max(0, Math.floor(ageMs / C.TIME.seconds(1)));
783
+ }
784
+
785
+ // Decide whether a stored entry can be served fresh; if stale, what
786
+ // grace allowances apply (s-w-r / s-i-e / defaultMaxStale).
787
+ function _evaluateStored(entry, nowMs) {
788
+ var ageMs = _currentAgeMs(entry, nowMs);
789
+ var freshness = entry.freshnessMs;
790
+ var directives = entry.directives || {};
791
+
792
+ var fresh = ageMs < freshness;
793
+ var swrSec = _ccNumber(directives, "stale-while-revalidate") || 0;
794
+ var sieSec = _ccNumber(directives, "stale-if-error") || 0;
795
+ var mustRevalidate = _ccPresent(directives, "must-revalidate") ||
796
+ (sharedCache && _ccPresent(directives, "proxy-revalidate"));
797
+ var immutable = _ccPresent(directives, "immutable");
798
+
799
+ // immutable freezes the entry — never revalidate while fresh.
800
+ return {
801
+ fresh: fresh,
802
+ ageMs: ageMs,
803
+ freshnessMs: freshness,
804
+ mustRevalidate: mustRevalidate,
805
+ immutable: immutable,
806
+ swrWindowMs: C.TIME.seconds(swrSec),
807
+ sieWindowMs: C.TIME.seconds(sieSec),
808
+ defaultStaleMs: C.TIME.seconds(defaultMaxStaleSec),
809
+ directives: directives,
810
+ };
811
+ }
812
+
813
+ return {
814
+ // ---- Lifecycle observability ----
815
+ sharedCache: sharedCache,
816
+ defaultMaxStale: defaultMaxStaleSec,
817
+ revalidateInBackground: revalidateBackground,
818
+ store: store,
819
+ audit: audit,
820
+ observability: obs,
821
+
822
+ // ---- Lookup / store / revalidation flow ----
823
+ //
824
+ // The shape exposed to lib/http-client.js is internal-but-stable.
825
+ // It's *not* part of the operator-facing surface — operators interact
826
+ // through `request({ ..., cache })` and the audit/observability
827
+ // events. Documented here for the http-client integration only.
828
+
829
+ _lookup: function (method, url, requestHeaders) {
830
+ return _lookupWithVary(method, url, requestHeaders);
831
+ },
832
+
833
+ _evaluateStorage: function (method, statusCode, responseHeaders) {
834
+ return _evaluateStorage(method, statusCode, responseHeaders, sharedCache);
835
+ },
836
+
837
+ _evaluateStored: _evaluateStored,
838
+ _serveAgeSeconds: _serveAgeSeconds,
839
+ _store: _store,
840
+ _refreshFrom304: _refreshFrom304,
841
+
842
+ _emit: _emit,
843
+ _obsEvent: _obsEvent,
844
+ _isFresh: function (entry) {
845
+ var ev = _evaluateStored(entry, Date.now());
846
+ return ev.fresh;
847
+ },
848
+
849
+ // ---- Operator-facing helpers ----
850
+ /**
851
+ * Inspect the cache for an entry without modifying it.
852
+ * Returns { hit, entry, fresh, ageMs } | { hit: false }.
853
+ */
854
+ inspect: function (method, url, requestHeaders) {
855
+ var got = _lookupWithVary(method, url, requestHeaders || {});
856
+ if (!got) return { hit: false };
857
+ var ev = _evaluateStored(got.entry, Date.now());
858
+ return {
859
+ hit: true,
860
+ fresh: ev.fresh,
861
+ ageMs: ev.ageMs,
862
+ freshnessMs: ev.freshnessMs,
863
+ statusCode: got.entry.statusCode,
864
+ };
865
+ },
866
+
867
+ /**
868
+ * Drop the cache entry for a given (method, url[, requestHeaders]).
869
+ * Returns true when an entry was deleted.
870
+ */
871
+ invalidate: function (method, url, requestHeaders) {
872
+ var got = _lookupWithVary(method, url, requestHeaders || {});
873
+ if (!got) return false;
874
+ try { store.delete(got.key); } catch (_e) { return false; }
875
+ // Also drop the no-vary marker if present.
876
+ try { store.delete(_buildCacheKey(method, url, [])); } catch (_e) { /* drop-silent */ }
877
+ return true;
878
+ },
879
+
880
+ /**
881
+ * Wipe the entire cache. Operators wire this on app shutdown when
882
+ * they don't want stale memory residue, or after a config reload
883
+ * that flips upstream identity.
884
+ */
885
+ clear: function () {
886
+ try { store.clear(); }
887
+ catch (_e) { /* drop-silent */ }
888
+ },
889
+
890
+ stats: function () {
891
+ if (typeof store._stats === "function") {
892
+ try { return store._stats(); }
893
+ catch (_e) { return null; }
894
+ }
895
+ return null;
896
+ },
897
+ };
898
+ }
899
+
900
+ module.exports = {
901
+ create: create,
902
+ memoryStore: memoryStore,
903
+
904
+ // Internals exposed for tests + the http-client integration.
905
+ _parseCacheControl: _parseCacheControl,
906
+ _evaluateStorage: _evaluateStorage,
907
+ _buildCacheKey: _buildCacheKey,
908
+ _normalizeUrl: _normalizeUrl,
909
+ _currentAgeMs: _currentAgeMs,
910
+ _extractVaryValues: _extractVaryValues,
911
+ CACHEABLE_STATUSES: CACHEABLE_STATUSES,
912
+ HOP_BY_HOP: HOP_BY_HOP,
913
+ HEURISTIC_MAX_AGE_MS: HEURISTIC_MAX_AGE_MS,
914
+ DEFAULT_MAX_BYTES: DEFAULT_MAX_BYTES,
915
+ DEFAULT_MAX_ENTRIES: DEFAULT_MAX_ENTRIES,
916
+ };