kinetex 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +413 -52
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3134 -787
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +140 -33
- package/dist/cjs/cache.js +58 -7
- package/dist/cjs/circuit-breaker.js +53 -4
- package/dist/cjs/client.js +726 -188
- package/dist/cjs/cookie-parser.js +131 -6
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +691 -70
- package/dist/cjs/dedup.js +142 -21
- package/dist/cjs/digest.js +195 -38
- package/dist/cjs/graphql.js +166 -35
- package/dist/cjs/headers.js +327 -46
- package/dist/cjs/interceptors.js +227 -24
- package/dist/cjs/lifecycle.js +92 -59
- package/dist/cjs/logging.js +160 -24
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +178 -30
- package/dist/cjs/proxy.js +429 -0
- package/dist/cjs/response.js +506 -110
- package/dist/cjs/socks5.js +136 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +335 -73
- package/dist/cjs/ws.js +62 -12
- package/dist/esm/aws-sigv4.js +140 -33
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +58 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +53 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +726 -188
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +131 -6
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +125 -28
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +691 -70
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +142 -21
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +195 -38
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +166 -35
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +327 -46
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +227 -24
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +92 -59
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +160 -24
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +247 -22
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +178 -30
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +430 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +506 -110
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +136 -15
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +173 -43
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +335 -73
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +62 -12
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +19 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +15 -2
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +69 -11
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +216 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +38 -3
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +17 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +68 -31
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +126 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +22 -3
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +49 -2
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +219 -26
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -25
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts +69 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts +0 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +10 -4
package/dist/cjs/aws-sigv4.js
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
*/
|
|
23
23
|
// Node.js globals accessed via globalThis for cross-runtime compatibility
|
|
24
24
|
import { TimeoutError, NetworkError } from "./types.js";
|
|
25
|
+
import { sha256Hex } from "./utils.js";
|
|
26
|
+
/** Convert a Uint8Array to a lowercase hex string. */
|
|
27
|
+
function toHex(bytes) {
|
|
28
|
+
return Array.from(bytes)
|
|
29
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
30
|
+
.join("");
|
|
31
|
+
}
|
|
25
32
|
/**
|
|
26
33
|
* Static credential provider — wraps a fixed set of credentials.
|
|
27
34
|
*/
|
|
@@ -162,12 +169,61 @@ export function imdsCredentials(options = {}) {
|
|
|
162
169
|
const credsRes = await fetchWithTimeout(`${endpoint}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, { headers: { "x-aws-ec2-metadata-token": token } }, timeout);
|
|
163
170
|
if (!credsRes.ok)
|
|
164
171
|
throw new NetworkError(`IMDS credentials fetch failed: ${credsRes.status}`);
|
|
165
|
-
|
|
172
|
+
// Two failures used to escape this function unlabelled, and both of them
|
|
173
|
+
// turned into a silently broken signature rather than an error:
|
|
174
|
+
//
|
|
175
|
+
// - `Response.json()` throws a bare `SyntaxError` on a non-JSON body, so
|
|
176
|
+
// a proxy's HTML 502 page arrived with no `code` — a caller branching
|
|
177
|
+
// on `err.code === "ENETWORK"` never saw it, and the message said
|
|
178
|
+
// nothing about IMDS. Every other failure here is a `NetworkError`.
|
|
179
|
+
// - A well-formed but shapeless body parsed fine and produced a
|
|
180
|
+
// "successful" result: `{}` yielded `accessKeyId: undefined`, and
|
|
181
|
+
// `{"AccessKeyId": null, …}` yielded nulls. Both then went on to
|
|
182
|
+
// produce a SigV4 signature AWS rejects with an opaque
|
|
183
|
+
// `SignatureDoesNotMatch`, pointing at the caller rather than at the
|
|
184
|
+
// metadata endpoint that had actually answered with nonsense.
|
|
185
|
+
let data;
|
|
186
|
+
try {
|
|
187
|
+
data = (await credsRes.json());
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw new NetworkError(`IMDS credentials response was not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
191
|
+
}
|
|
192
|
+
if (data === null || typeof data !== "object") {
|
|
193
|
+
throw new NetworkError(`IMDS credentials response was not an object: ${typeof data}`);
|
|
194
|
+
}
|
|
195
|
+
const { AccessKeyId, SecretAccessKey, Token, Expiration } = data;
|
|
196
|
+
const missing = [];
|
|
197
|
+
if (typeof AccessKeyId !== "string" || AccessKeyId.length === 0)
|
|
198
|
+
missing.push("AccessKeyId");
|
|
199
|
+
if (typeof SecretAccessKey !== "string" || SecretAccessKey.length === 0)
|
|
200
|
+
missing.push("SecretAccessKey");
|
|
201
|
+
if (typeof Token !== "string" || Token.length === 0)
|
|
202
|
+
missing.push("Token");
|
|
203
|
+
if (missing.length > 0) {
|
|
204
|
+
throw new NetworkError(`IMDS credentials response is missing ${missing.join(", ")} — ` +
|
|
205
|
+
"the metadata endpoint returned a body this client cannot sign with");
|
|
206
|
+
}
|
|
207
|
+
// Re-read through a narrowing helper: the checks above report every missing
|
|
208
|
+
// field at once, which is the right message but does not let the compiler
|
|
209
|
+
// carry the narrowing into this scope.
|
|
210
|
+
const requireString = (value, name) => {
|
|
211
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
212
|
+
throw new NetworkError(`IMDS credentials response is missing ${name}`);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
};
|
|
216
|
+
const accessKeyId = requireString(AccessKeyId, "AccessKeyId");
|
|
217
|
+
const secretAccessKey = requireString(SecretAccessKey, "SecretAccessKey");
|
|
218
|
+
const sessionToken = requireString(Token, "Token");
|
|
166
219
|
return {
|
|
167
|
-
accessKeyId
|
|
168
|
-
secretAccessKey
|
|
169
|
-
sessionToken
|
|
170
|
-
|
|
220
|
+
accessKeyId,
|
|
221
|
+
secretAccessKey,
|
|
222
|
+
sessionToken,
|
|
223
|
+
// Optional: a role without an expiry is legal, and the signer treats a
|
|
224
|
+
// missing expiration as "do not cache". Omitted rather than set to
|
|
225
|
+
// `undefined` — the project uses exactOptionalPropertyTypes.
|
|
226
|
+
...(typeof Expiration === "string" ? { expiration: Expiration } : {}),
|
|
171
227
|
};
|
|
172
228
|
});
|
|
173
229
|
}
|
|
@@ -228,20 +284,6 @@ async function hmacSHA256(key, data) {
|
|
|
228
284
|
/**
|
|
229
285
|
* SHA-256 hash of a string or bytes. Returns lowercase hex.
|
|
230
286
|
*/
|
|
231
|
-
async function sha256Hex(data) {
|
|
232
|
-
const enc = new TextEncoder();
|
|
233
|
-
const raw = typeof data === "string" ? enc.encode(data) : data;
|
|
234
|
-
// Cast to ArrayBuffer for WebCrypto digest overload
|
|
235
|
-
const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
|
|
236
|
-
const hashBuf = await crypto.subtle.digest("SHA-256", buf);
|
|
237
|
-
return toHex(new Uint8Array(hashBuf));
|
|
238
|
-
}
|
|
239
|
-
/** Convert a Uint8Array to a lowercase hex string. */
|
|
240
|
-
function toHex(bytes) {
|
|
241
|
-
return Array.from(bytes)
|
|
242
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
243
|
-
.join("");
|
|
244
|
-
}
|
|
245
287
|
// ============================================================================
|
|
246
288
|
// #5 CANONICAL REQUEST
|
|
247
289
|
// ============================================================================
|
|
@@ -339,6 +381,12 @@ function buildCanonicalHeaders(headers, unsignedExtra) {
|
|
|
339
381
|
const entries = [];
|
|
340
382
|
for (const [name, value] of Object.entries(headers)) {
|
|
341
383
|
const lower = name.toLowerCase();
|
|
384
|
+
// An empty header name is not a valid HTTP field-name (RFC 9110 §5.1) and
|
|
385
|
+
// used to reach the canonical request verbatim, producing a `:value` line
|
|
386
|
+
// and a leading `;` in SignedHeaders. AWS answers that with an opaque
|
|
387
|
+
// SignatureDoesNotMatch rather than saying the header name was empty.
|
|
388
|
+
if (lower === "")
|
|
389
|
+
continue;
|
|
342
390
|
if (unsigned.has(lower) && !ALWAYS_SIGNED_HEADERS.has(lower))
|
|
343
391
|
continue;
|
|
344
392
|
// Trim + collapse internal whitespace
|
|
@@ -446,10 +494,19 @@ export async function signRequest(request, config) {
|
|
|
446
494
|
const amzDate = formatAmzDate(signingDate);
|
|
447
495
|
const dateStamp = formatDateStamp(signingDate);
|
|
448
496
|
const parsedUrl = new URL(request.url);
|
|
449
|
-
// Build the headers to sign — start from request headers
|
|
497
|
+
// Build the headers to sign — start from request headers.
|
|
498
|
+
//
|
|
499
|
+
// An explicitly-supplied `host` (in any casing) must WIN. Setting it is how
|
|
500
|
+
// you sign for a virtual-hosted-style bucket, a custom endpoint or a proxy.
|
|
501
|
+
// The URL host used to be injected unconditionally, which left both keys in
|
|
502
|
+
// the map whenever the caller used a different casing; `buildCanonicalHeaders`
|
|
503
|
+
// lowercased them and its dedupe step joined them, so the request was signed
|
|
504
|
+
// as `host:override.example,s3.amazonaws.com` — a host that can never
|
|
505
|
+
// validate, and one the library reported no error about.
|
|
506
|
+
const hasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
|
|
450
507
|
const headers = {
|
|
451
508
|
...request.headers,
|
|
452
|
-
host: parsedUrl.host,
|
|
509
|
+
...(hasExplicitHost ? {} : { host: parsedUrl.host }),
|
|
453
510
|
"x-amz-date": amzDate,
|
|
454
511
|
};
|
|
455
512
|
if (config.unsignedPayload) {
|
|
@@ -498,11 +555,22 @@ export async function presignRequest(request, config, options = {}) {
|
|
|
498
555
|
const signingDate = resolveSigningDate(config);
|
|
499
556
|
const amzDate = formatAmzDate(signingDate);
|
|
500
557
|
const dateStamp = formatDateStamp(signingDate);
|
|
501
|
-
|
|
502
|
-
//
|
|
558
|
+
// Validate and CLAMP expiresIn. AWS enforces a hard maximum per service and
|
|
559
|
+
// rejects an out-of-range or fractional value with an opaque
|
|
560
|
+
// AuthorizationQueryParametersError at use time, long after the URL was
|
|
561
|
+
// handed out. The old code logged a warning and then wrote the caller's
|
|
562
|
+
// number straight into the query string, so `expiresIn: 0` produced
|
|
563
|
+
// `X-Amz-Expires=0`, `expiresIn: -1` produced `=-1`, and `expiresIn: 1e9`
|
|
564
|
+
// produced a link valid for ~31 years — every one of them permanently
|
|
565
|
+
// unusable, with a console warning as the only signal. Clamping can only
|
|
566
|
+
// shorten a link, never lengthen one, so it is the safe direction.
|
|
503
567
|
const maxExpires = config.service === "s3" ? 604800 : 3600;
|
|
504
|
-
|
|
505
|
-
|
|
568
|
+
const requested = options.expiresIn ?? 3600;
|
|
569
|
+
const expiresIn = Number.isFinite(requested)
|
|
570
|
+
? Math.min(Math.max(Math.floor(requested), 1), maxExpires)
|
|
571
|
+
: maxExpires;
|
|
572
|
+
if (expiresIn !== requested) {
|
|
573
|
+
console.warn(`[aws-sigv4] presignRequest: expiresIn must be an integer in 1-${maxExpires} for ${config.service}, got ${requested} — clamped to ${expiresIn}`);
|
|
506
574
|
}
|
|
507
575
|
const parsedUrl = new URL(request.url);
|
|
508
576
|
const credentialScope = `${dateStamp}/${config.region}/${config.service}/aws4_request`;
|
|
@@ -520,10 +588,14 @@ export async function presignRequest(request, config, options = {}) {
|
|
|
520
588
|
parsedUrl.searchParams.set(k, v);
|
|
521
589
|
}
|
|
522
590
|
}
|
|
523
|
-
// Determine signed headers (only "host" for presigned URLs typically)
|
|
591
|
+
// Determine signed headers (only "host" for presigned URLs typically).
|
|
592
|
+
// An explicit `host` wins here for the same reason it does in sign() — a
|
|
593
|
+
// differently-cased caller header used to be merged with the URL host into
|
|
594
|
+
// `host:override.example,s3.amazonaws.com`, which can never validate.
|
|
595
|
+
const presignHasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
|
|
524
596
|
const headers = {
|
|
525
597
|
...request.headers,
|
|
526
|
-
host: parsedUrl.host,
|
|
598
|
+
...(presignHasExplicitHost ? {} : { host: parsedUrl.host }),
|
|
527
599
|
};
|
|
528
600
|
const unsignedHdrs = config.unsignedHeaders ?? [];
|
|
529
601
|
// For presigned URLs, payload hash is always UNSIGNED-PAYLOAD
|
|
@@ -666,14 +738,49 @@ export async function signS3PostPolicy(policy, config) {
|
|
|
666
738
|
* Returns 0 if the header is absent or unparseable.
|
|
667
739
|
*/
|
|
668
740
|
export function detectClockSkew(responseHeaders) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
741
|
+
// `x-amz-date` is AWS's own signed timestamp and is the one present on the
|
|
742
|
+
// clock-skew error itself. `Date` is generated by whatever proxy fronts the
|
|
743
|
+
// endpoint and is frequently absent. Reading only `Date` meant a skew was
|
|
744
|
+
// silently undetectable on exactly the responses that carry it.
|
|
745
|
+
const amzDate = responseHeaders["x-amz-date"] ?? responseHeaders["X-Amz-Date"];
|
|
746
|
+
let serverTime;
|
|
747
|
+
if (amzDate !== undefined) {
|
|
748
|
+
serverTime = parseAmzDate(amzDate);
|
|
749
|
+
if (isNaN(serverTime))
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
const dateHeader = responseHeaders["date"] ?? responseHeaders["Date"];
|
|
754
|
+
if (!dateHeader)
|
|
755
|
+
return 0;
|
|
756
|
+
serverTime = new Date(dateHeader).getTime();
|
|
757
|
+
if (isNaN(serverTime))
|
|
758
|
+
return 0;
|
|
759
|
+
}
|
|
675
760
|
return Math.round((serverTime - Date.now()) / 1000);
|
|
676
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* Parse AWS's basic ISO 8601 timestamp (`20300101T120000Z`), which is not a
|
|
764
|
+
* form `Date` can parse.
|
|
765
|
+
*
|
|
766
|
+
* @param value - Candidate `x-amz-date` value
|
|
767
|
+
* @returns Epoch milliseconds, or `NaN` when the value is not in that form
|
|
768
|
+
*/
|
|
769
|
+
function parseAmzDate(value) {
|
|
770
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value.trim());
|
|
771
|
+
if (!m)
|
|
772
|
+
return NaN;
|
|
773
|
+
const [, y, mo, d, h, mi, sec] = m;
|
|
774
|
+
// Reject values that roll over silently (e.g. month 13) instead of
|
|
775
|
+
// producing a date in the following month.
|
|
776
|
+
const t = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(sec));
|
|
777
|
+
const dt = new Date(t);
|
|
778
|
+
if (dt.getUTCFullYear() !== Number(y) || dt.getUTCMonth() + 1 !== Number(mo))
|
|
779
|
+
return NaN;
|
|
780
|
+
if (dt.getUTCDate() !== Number(d))
|
|
781
|
+
return NaN;
|
|
782
|
+
return t;
|
|
783
|
+
}
|
|
677
784
|
/**
|
|
678
785
|
* Determine if an error response is a clock skew error.
|
|
679
786
|
*/
|
package/dist/cjs/cache.js
CHANGED
|
@@ -425,6 +425,12 @@ async function defaultCacheKey(req) {
|
|
|
425
425
|
* Compute cache TTL, SWR window, and stale-on-error window
|
|
426
426
|
* from Cache-Control headers, Expires header, or Last-Modified heuristic.
|
|
427
427
|
*/
|
|
428
|
+
/** True when the response may be stored but must be revalidated before reuse. */
|
|
429
|
+
function computeMustRevalidate(response) {
|
|
430
|
+
const cc = response.headers["cache-control"] ?? response.headers["Cache-Control"] ?? "";
|
|
431
|
+
const d = parseCacheControl(cc);
|
|
432
|
+
return d.noCache || d.mustRevalidate;
|
|
433
|
+
}
|
|
428
434
|
function computeTTL(response, defaultTtl, honor) {
|
|
429
435
|
const cc = response.headers["cache-control"] ?? response.headers["Cache-Control"] ?? "";
|
|
430
436
|
const d = parseCacheControl(cc);
|
|
@@ -432,6 +438,15 @@ function computeTTL(response, defaultTtl, honor) {
|
|
|
432
438
|
if (honor && d.noStore) {
|
|
433
439
|
return { ttlMs: 0, swrMs: 0, staleOnError: 0, shouldCache: false };
|
|
434
440
|
}
|
|
441
|
+
// `private` marks a response as belonging to a single user. A browser's cache
|
|
442
|
+
// *is* per-user, so storing it there is correct — but this cache is a Map in
|
|
443
|
+
// the calling process, shared by every request that process makes. Honouring
|
|
444
|
+
// the directive is the difference between one user's `private` response and
|
|
445
|
+
// another user's. `d.private` was parsed and then never read, so a response
|
|
446
|
+
// that explicitly asked not to be shared was stored and replayed anyway.
|
|
447
|
+
if (honor && d.private) {
|
|
448
|
+
return { ttlMs: 0, swrMs: 0, staleOnError: 0, shouldCache: false };
|
|
449
|
+
}
|
|
435
450
|
// s-maxage takes priority (for shared/proxy caches)
|
|
436
451
|
const maxAgeSecs = d.sMaxAge ?? d.maxAge;
|
|
437
452
|
let ttlMs;
|
|
@@ -453,7 +468,15 @@ function computeTTL(response, defaultTtl, honor) {
|
|
|
453
468
|
if (lm) {
|
|
454
469
|
const lmTime = Date.parse(lm);
|
|
455
470
|
if (!isNaN(lmTime)) {
|
|
456
|
-
|
|
471
|
+
// RFC 9111 §4.2.2: the heuristic lifetime is 10% of the interval
|
|
472
|
+
// between Last-Modified and the response's Date. That interval is
|
|
473
|
+
// negative whenever Last-Modified is in the future — routine, since
|
|
474
|
+
// the origin and the client rarely share a clock — and there was no
|
|
475
|
+
// lower clamp, so the entry was given a negative freshness lifetime
|
|
476
|
+
// and a staleUntil *before* its creation time. The clamp to 0 is the
|
|
477
|
+
// RFC's "already stale"; the stale windows then apply normally,
|
|
478
|
+
// instead of landing in the past and being unreachable.
|
|
479
|
+
ttlMs = Math.min(Math.max((Date.now() - lmTime) * 0.1, 0), defaultTtl);
|
|
457
480
|
}
|
|
458
481
|
else {
|
|
459
482
|
ttlMs = defaultTtl;
|
|
@@ -474,7 +497,11 @@ function computeTTL(response, defaultTtl, honor) {
|
|
|
474
497
|
ttlMs,
|
|
475
498
|
swrMs,
|
|
476
499
|
staleOnError,
|
|
477
|
-
|
|
500
|
+
// Storable if it is fresh now, or already stale but covered by a
|
|
501
|
+
// stale-while-revalidate / stale-if-error window. The two windows were
|
|
502
|
+
// missing from this decision, so `max-age=0, stale-if-error=60` — the
|
|
503
|
+
// point of the directive — was reported as uncacheable.
|
|
504
|
+
shouldCache: ttlMs > 0 || swrMs > 0 || staleOnError > 0,
|
|
478
505
|
};
|
|
479
506
|
}
|
|
480
507
|
// ============================================================================
|
|
@@ -674,7 +701,12 @@ export class HTTPCache {
|
|
|
674
701
|
this.stats.hits++;
|
|
675
702
|
this.lru.touch(key);
|
|
676
703
|
this._updateHitRate();
|
|
677
|
-
|
|
704
|
+
// RFC 9111 §5.2.2.4: a `no-cache` response may be stored but must not be
|
|
705
|
+
// reused without revalidating. It used to be reported as a plain fresh
|
|
706
|
+
// hit, so an origin that sent `Cache-Control: no-cache` had its response
|
|
707
|
+
// served indefinitely from cache. Reporting it as stale routes it into the
|
|
708
|
+
// existing serve-stale-and-revalidate path instead.
|
|
709
|
+
return { entry: cloneEntry(entry), stale: entry.mustRevalidate === true };
|
|
678
710
|
}
|
|
679
711
|
// Stale-While-Revalidate window
|
|
680
712
|
if (entry.staleUntil > now) {
|
|
@@ -722,7 +754,14 @@ export class HTTPCache {
|
|
|
722
754
|
// 9.10 — cap TTL to maxAbsoluteAgeMs so stored entries can never outlive the limit
|
|
723
755
|
const absoluteCap = this.cfg.maxAbsoluteAgeMs;
|
|
724
756
|
const cappedTtlMs = Math.min(ttlMs === Infinity ? absoluteCap : ttlMs, absoluteCap);
|
|
725
|
-
|
|
757
|
+
// A zero (or negative) freshness lifetime does not by itself mean
|
|
758
|
+
// "unstoreable": RFC 9111 §4.2 still permits storing a response that is
|
|
759
|
+
// already stale, provided a stale-while-revalidate or stale-if-error
|
|
760
|
+
// window covers it — and `computeTTL` has just told us exactly that via
|
|
761
|
+
// `shouldCache`. This guard used to discard that decision, so the canonical
|
|
762
|
+
// CDN recipe `Cache-Control: max-age=0, stale-while-revalidate=60` was
|
|
763
|
+
// never cached at all, defeating the one directive it exists to enable.
|
|
764
|
+
if (cappedTtlMs <= 0 && swrMs <= 0 && staleOnError <= 0 && !options.force)
|
|
726
765
|
return false;
|
|
727
766
|
const varyHeader = res.headers["vary"] ?? res.headers["Vary"] ?? null;
|
|
728
767
|
const varyKey = varyHeader ? buildVaryKey(varyHeader, req.headers) : "";
|
|
@@ -739,6 +778,7 @@ export class HTTPCache {
|
|
|
739
778
|
etag: res.headers["etag"] ?? res.headers["ETag"] ?? null,
|
|
740
779
|
lastModified: res.headers["last-modified"] ?? res.headers["Last-Modified"] ?? null,
|
|
741
780
|
varyKey,
|
|
781
|
+
mustRevalidate: computeMustRevalidate(res),
|
|
742
782
|
tags,
|
|
743
783
|
size: bodySize + 256,
|
|
744
784
|
};
|
|
@@ -909,16 +949,27 @@ export class HTTPCache {
|
|
|
909
949
|
getStats() {
|
|
910
950
|
return { ...this.stats };
|
|
911
951
|
}
|
|
912
|
-
/**
|
|
952
|
+
/**
|
|
953
|
+
* Reset the cache's counters to zero.
|
|
954
|
+
*
|
|
955
|
+
* `totalEntries` and `totalSizeBytes` are deliberately NOT reset: they are
|
|
956
|
+
* gauges of what is actually stored, not counters of events, and
|
|
957
|
+
* `_ensureCapacity()` reads them as the authoritative figures for the
|
|
958
|
+
* `maxEntries` / `maxSizeBytes` caps. Zeroing them while the entries are
|
|
959
|
+
* still in storage made eviction compare against a fiction — a cache with
|
|
960
|
+
* `maxSizeBytes: 600` was measured holding 1028 bytes across 4 entries
|
|
961
|
+
* after a reset — and made `getStats().totalEntries` report 0 for entries
|
|
962
|
+
* that were still there and still being served. Call `clear()` to empty the
|
|
963
|
+
* cache; use this to zero the hit/miss/eviction counters.
|
|
964
|
+
*/
|
|
913
965
|
resetStats() {
|
|
914
966
|
this.stats = {
|
|
967
|
+
...this.stats,
|
|
915
968
|
hits: 0,
|
|
916
969
|
misses: 0,
|
|
917
970
|
staleHits: 0,
|
|
918
971
|
errors: 0,
|
|
919
972
|
evictions: 0,
|
|
920
|
-
totalEntries: 0,
|
|
921
|
-
totalSizeBytes: 0,
|
|
922
973
|
hitRate: 0,
|
|
923
974
|
};
|
|
924
975
|
}
|
|
@@ -29,7 +29,11 @@ export class CircuitOpenError extends Error {
|
|
|
29
29
|
* @param state - Snapshot of breaker state at rejection time
|
|
30
30
|
*/
|
|
31
31
|
constructor(key, state) {
|
|
32
|
-
|
|
32
|
+
// The half-open probe limit rejects callers too, and in that case
|
|
33
|
+
// `state.state` is HALF_OPEN: the circuit is recovering, not open. Saying
|
|
34
|
+
// "OPEN" there pointed operators at the wrong problem during exactly the
|
|
35
|
+
// window they were watching to see recovery.
|
|
36
|
+
super(`Circuit breaker ${state.state} for "${key}" — request rejected`);
|
|
33
37
|
this.name = "CircuitOpenError";
|
|
34
38
|
this.state = state;
|
|
35
39
|
}
|
|
@@ -97,6 +101,22 @@ export class CircuitBreaker {
|
|
|
97
101
|
this.key = key;
|
|
98
102
|
this.failureThreshold = config.failureThreshold ?? 5;
|
|
99
103
|
this.windowSize = config.windowSize ?? 10;
|
|
104
|
+
// The window is a sliding buffer of the most recent `windowSize` results,
|
|
105
|
+
// so it can hold at most `windowSize` failures. If the window is smaller
|
|
106
|
+
// than the threshold, `failures >= failureThreshold` is unreachable and
|
|
107
|
+
// the circuit can never open — a breaker that silently does nothing, with
|
|
108
|
+
// no error and no other symptom. `{ windowSize: 2, failureThreshold: 5 }`
|
|
109
|
+
// stayed CLOSED through 50 consecutive failures. The window is widened to
|
|
110
|
+
// the threshold instead, which is the smallest coherent reading of the
|
|
111
|
+
// configuration. windowSize: 0 is the documented consecutive-count mode
|
|
112
|
+
// and is left alone.
|
|
113
|
+
if (this.windowSize > 0 && this.windowSize < this.failureThreshold) {
|
|
114
|
+
console.warn(`[CircuitBreaker] "${key}": windowSize (${this.windowSize}) is smaller than ` +
|
|
115
|
+
`failureThreshold (${this.failureThreshold}), so the threshold could never be ` +
|
|
116
|
+
`reached and the circuit would never open. Raising windowSize to ` +
|
|
117
|
+
`${this.failureThreshold}.`);
|
|
118
|
+
this.windowSize = this.failureThreshold;
|
|
119
|
+
}
|
|
100
120
|
this.resetTimeoutMs = config.resetTimeoutMs ?? 30_000;
|
|
101
121
|
this.successThreshold = config.successThreshold ?? 2;
|
|
102
122
|
this.halfOpenConcurrency = config.halfOpenConcurrency ?? 1;
|
|
@@ -109,8 +129,27 @@ export class CircuitBreaker {
|
|
|
109
129
|
this.cfg = config;
|
|
110
130
|
}
|
|
111
131
|
// ── Public API ──────────────────────────────────────────────────────────
|
|
112
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The current circuit state, as of right now.
|
|
134
|
+
*
|
|
135
|
+
* The OPEN → HALF_OPEN transition is driven by elapsed time, but it was only
|
|
136
|
+
* ever evaluated inside `execute()`. A caller reading `state` — a health
|
|
137
|
+
* endpoint, a dashboard, the `onRejected` snapshot — therefore saw a stale
|
|
138
|
+
* `OPEN` for as long as no traffic arrived, and could not tell a circuit
|
|
139
|
+
* that was about to probe from one that had been down for an hour. The
|
|
140
|
+
* elapsed check is applied on read as well. It is a pure read: the
|
|
141
|
+
* `onHalfOpen` callback still fires from `execute()`, so observing the state
|
|
142
|
+
* never mutates the breaker.
|
|
143
|
+
*/
|
|
113
144
|
get state() {
|
|
145
|
+
return this._effectiveState();
|
|
146
|
+
}
|
|
147
|
+
/** The state implied by `_state` and the elapsed time, without mutating anything. */
|
|
148
|
+
_effectiveState() {
|
|
149
|
+
if (this._state === "OPEN" && this._openedAt !== null) {
|
|
150
|
+
if (Date.now() - this._openedAt >= this.resetTimeoutMs)
|
|
151
|
+
return "HALF_OPEN";
|
|
152
|
+
}
|
|
114
153
|
return this._state;
|
|
115
154
|
}
|
|
116
155
|
/** Snapshot of all counters — suitable for logging or dashboards. */
|
|
@@ -125,7 +164,10 @@ export class CircuitBreaker {
|
|
|
125
164
|
failureCount++;
|
|
126
165
|
}
|
|
127
166
|
return {
|
|
128
|
-
state:
|
|
167
|
+
// Time-derived, for the same reason as the `state` getter: a snapshot
|
|
168
|
+
// taken between the reset window elapsing and the next request would
|
|
169
|
+
// otherwise report a circuit as OPEN when it is ready to probe.
|
|
170
|
+
state: this._effectiveState(),
|
|
129
171
|
failureCount,
|
|
130
172
|
successCount: this._consecutiveSucc,
|
|
131
173
|
lastFailureAt: this._lastFailureAt,
|
|
@@ -285,7 +327,14 @@ export class CircuitBreaker {
|
|
|
285
327
|
const code = err.code;
|
|
286
328
|
if (this.failureFilter.networkErrors && code === "ENETWORK")
|
|
287
329
|
return true;
|
|
288
|
-
|
|
330
|
+
// `ETIMEDOUT` as well as `ETIMEOUT`. The timeout interceptor raises its own
|
|
331
|
+
// `TimeoutError` (src/interceptors.ts), which carries `ETIMEDOUT`, so a
|
|
332
|
+
// request that timed out at the interceptor was never counted toward the
|
|
333
|
+
// threshold no matter what `failureFilter.timeouts` was set to. Both codes
|
|
334
|
+
// are kept because `ETIMEDOUT` is also a node:net errno (see
|
|
335
|
+
// `TRANSIENT_DIAL_CODES` in socks5.ts), so accepting it is right
|
|
336
|
+
// independently of the interceptor.
|
|
337
|
+
if (this.failureFilter.timeouts && (code === "ETIMEOUT" || code === "ETIMEDOUT"))
|
|
289
338
|
return true;
|
|
290
339
|
if (this.failureFilter.serverErrors) {
|
|
291
340
|
const status = err.status;
|