@blamejs/core 0.8.52 → 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.
- package/CHANGELOG.md +5 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- 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
|
+
};
|