kinetex 1.3.0 → 1.4.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 +246 -9
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +2545 -550
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +133 -19
- package/dist/cjs/cache.js +49 -7
- package/dist/cjs/circuit-breaker.js +45 -3
- package/dist/cjs/client.js +387 -104
- package/dist/cjs/cookie-parser.js +103 -5
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +465 -66
- package/dist/cjs/dedup.js +49 -11
- package/dist/cjs/digest.js +160 -24
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +303 -45
- package/dist/cjs/interceptors.js +221 -7
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +168 -15
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +177 -27
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +131 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/ws.js +19 -10
- package/dist/esm/aws-sigv4.js +133 -19
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +49 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +45 -3
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +387 -104
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +103 -5
- 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 +465 -66
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +49 -11
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +160 -24
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +303 -45
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +221 -7
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +168 -15
- 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 +177 -27
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +131 -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 +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +19 -10
- 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 +14 -1
- 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 +103 -25
- package/dist/types/core.d.ts.map +1 -1
- 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 +45 -27
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- 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 +7 -1
- 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 +114 -3
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cjs/aws-sigv4.js
CHANGED
|
@@ -162,12 +162,61 @@ export function imdsCredentials(options = {}) {
|
|
|
162
162
|
const credsRes = await fetchWithTimeout(`${endpoint}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, { headers: { "x-aws-ec2-metadata-token": token } }, timeout);
|
|
163
163
|
if (!credsRes.ok)
|
|
164
164
|
throw new NetworkError(`IMDS credentials fetch failed: ${credsRes.status}`);
|
|
165
|
-
|
|
165
|
+
// Two failures used to escape this function unlabelled, and both of them
|
|
166
|
+
// turned into a silently broken signature rather than an error:
|
|
167
|
+
//
|
|
168
|
+
// - `Response.json()` throws a bare `SyntaxError` on a non-JSON body, so
|
|
169
|
+
// a proxy's HTML 502 page arrived with no `code` — a caller branching
|
|
170
|
+
// on `err.code === "ENETWORK"` never saw it, and the message said
|
|
171
|
+
// nothing about IMDS. Every other failure here is a `NetworkError`.
|
|
172
|
+
// - A well-formed but shapeless body parsed fine and produced a
|
|
173
|
+
// "successful" result: `{}` yielded `accessKeyId: undefined`, and
|
|
174
|
+
// `{"AccessKeyId": null, …}` yielded nulls. Both then went on to
|
|
175
|
+
// produce a SigV4 signature AWS rejects with an opaque
|
|
176
|
+
// `SignatureDoesNotMatch`, pointing at the caller rather than at the
|
|
177
|
+
// metadata endpoint that had actually answered with nonsense.
|
|
178
|
+
let data;
|
|
179
|
+
try {
|
|
180
|
+
data = (await credsRes.json());
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
throw new NetworkError(`IMDS credentials response was not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
+
}
|
|
185
|
+
if (data === null || typeof data !== "object") {
|
|
186
|
+
throw new NetworkError(`IMDS credentials response was not an object: ${typeof data}`);
|
|
187
|
+
}
|
|
188
|
+
const { AccessKeyId, SecretAccessKey, Token, Expiration } = data;
|
|
189
|
+
const missing = [];
|
|
190
|
+
if (typeof AccessKeyId !== "string" || AccessKeyId.length === 0)
|
|
191
|
+
missing.push("AccessKeyId");
|
|
192
|
+
if (typeof SecretAccessKey !== "string" || SecretAccessKey.length === 0)
|
|
193
|
+
missing.push("SecretAccessKey");
|
|
194
|
+
if (typeof Token !== "string" || Token.length === 0)
|
|
195
|
+
missing.push("Token");
|
|
196
|
+
if (missing.length > 0) {
|
|
197
|
+
throw new NetworkError(`IMDS credentials response is missing ${missing.join(", ")} — ` +
|
|
198
|
+
"the metadata endpoint returned a body this client cannot sign with");
|
|
199
|
+
}
|
|
200
|
+
// Re-read through a narrowing helper: the checks above report every missing
|
|
201
|
+
// field at once, which is the right message but does not let the compiler
|
|
202
|
+
// carry the narrowing into this scope.
|
|
203
|
+
const requireString = (value, name) => {
|
|
204
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
205
|
+
throw new NetworkError(`IMDS credentials response is missing ${name}`);
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
};
|
|
209
|
+
const accessKeyId = requireString(AccessKeyId, "AccessKeyId");
|
|
210
|
+
const secretAccessKey = requireString(SecretAccessKey, "SecretAccessKey");
|
|
211
|
+
const sessionToken = requireString(Token, "Token");
|
|
166
212
|
return {
|
|
167
|
-
accessKeyId
|
|
168
|
-
secretAccessKey
|
|
169
|
-
sessionToken
|
|
170
|
-
|
|
213
|
+
accessKeyId,
|
|
214
|
+
secretAccessKey,
|
|
215
|
+
sessionToken,
|
|
216
|
+
// Optional: a role without an expiry is legal, and the signer treats a
|
|
217
|
+
// missing expiration as "do not cache". Omitted rather than set to
|
|
218
|
+
// `undefined` — the project uses exactOptionalPropertyTypes.
|
|
219
|
+
...(typeof Expiration === "string" ? { expiration: Expiration } : {}),
|
|
171
220
|
};
|
|
172
221
|
});
|
|
173
222
|
}
|
|
@@ -339,6 +388,12 @@ function buildCanonicalHeaders(headers, unsignedExtra) {
|
|
|
339
388
|
const entries = [];
|
|
340
389
|
for (const [name, value] of Object.entries(headers)) {
|
|
341
390
|
const lower = name.toLowerCase();
|
|
391
|
+
// An empty header name is not a valid HTTP field-name (RFC 9110 §5.1) and
|
|
392
|
+
// used to reach the canonical request verbatim, producing a `:value` line
|
|
393
|
+
// and a leading `;` in SignedHeaders. AWS answers that with an opaque
|
|
394
|
+
// SignatureDoesNotMatch rather than saying the header name was empty.
|
|
395
|
+
if (lower === "")
|
|
396
|
+
continue;
|
|
342
397
|
if (unsigned.has(lower) && !ALWAYS_SIGNED_HEADERS.has(lower))
|
|
343
398
|
continue;
|
|
344
399
|
// Trim + collapse internal whitespace
|
|
@@ -446,10 +501,19 @@ export async function signRequest(request, config) {
|
|
|
446
501
|
const amzDate = formatAmzDate(signingDate);
|
|
447
502
|
const dateStamp = formatDateStamp(signingDate);
|
|
448
503
|
const parsedUrl = new URL(request.url);
|
|
449
|
-
// Build the headers to sign — start from request headers
|
|
504
|
+
// Build the headers to sign — start from request headers.
|
|
505
|
+
//
|
|
506
|
+
// An explicitly-supplied `host` (in any casing) must WIN. Setting it is how
|
|
507
|
+
// you sign for a virtual-hosted-style bucket, a custom endpoint or a proxy.
|
|
508
|
+
// The URL host used to be injected unconditionally, which left both keys in
|
|
509
|
+
// the map whenever the caller used a different casing; `buildCanonicalHeaders`
|
|
510
|
+
// lowercased them and its dedupe step joined them, so the request was signed
|
|
511
|
+
// as `host:override.example,s3.amazonaws.com` — a host that can never
|
|
512
|
+
// validate, and one the library reported no error about.
|
|
513
|
+
const hasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
|
|
450
514
|
const headers = {
|
|
451
515
|
...request.headers,
|
|
452
|
-
host: parsedUrl.host,
|
|
516
|
+
...(hasExplicitHost ? {} : { host: parsedUrl.host }),
|
|
453
517
|
"x-amz-date": amzDate,
|
|
454
518
|
};
|
|
455
519
|
if (config.unsignedPayload) {
|
|
@@ -498,11 +562,22 @@ export async function presignRequest(request, config, options = {}) {
|
|
|
498
562
|
const signingDate = resolveSigningDate(config);
|
|
499
563
|
const amzDate = formatAmzDate(signingDate);
|
|
500
564
|
const dateStamp = formatDateStamp(signingDate);
|
|
501
|
-
|
|
502
|
-
//
|
|
565
|
+
// Validate and CLAMP expiresIn. AWS enforces a hard maximum per service and
|
|
566
|
+
// rejects an out-of-range or fractional value with an opaque
|
|
567
|
+
// AuthorizationQueryParametersError at use time, long after the URL was
|
|
568
|
+
// handed out. The old code logged a warning and then wrote the caller's
|
|
569
|
+
// number straight into the query string, so `expiresIn: 0` produced
|
|
570
|
+
// `X-Amz-Expires=0`, `expiresIn: -1` produced `=-1`, and `expiresIn: 1e9`
|
|
571
|
+
// produced a link valid for ~31 years — every one of them permanently
|
|
572
|
+
// unusable, with a console warning as the only signal. Clamping can only
|
|
573
|
+
// shorten a link, never lengthen one, so it is the safe direction.
|
|
503
574
|
const maxExpires = config.service === "s3" ? 604800 : 3600;
|
|
504
|
-
|
|
505
|
-
|
|
575
|
+
const requested = options.expiresIn ?? 3600;
|
|
576
|
+
const expiresIn = Number.isFinite(requested)
|
|
577
|
+
? Math.min(Math.max(Math.floor(requested), 1), maxExpires)
|
|
578
|
+
: maxExpires;
|
|
579
|
+
if (expiresIn !== requested) {
|
|
580
|
+
console.warn(`[aws-sigv4] presignRequest: expiresIn must be an integer in 1-${maxExpires} for ${config.service}, got ${requested} — clamped to ${expiresIn}`);
|
|
506
581
|
}
|
|
507
582
|
const parsedUrl = new URL(request.url);
|
|
508
583
|
const credentialScope = `${dateStamp}/${config.region}/${config.service}/aws4_request`;
|
|
@@ -520,10 +595,14 @@ export async function presignRequest(request, config, options = {}) {
|
|
|
520
595
|
parsedUrl.searchParams.set(k, v);
|
|
521
596
|
}
|
|
522
597
|
}
|
|
523
|
-
// Determine signed headers (only "host" for presigned URLs typically)
|
|
598
|
+
// Determine signed headers (only "host" for presigned URLs typically).
|
|
599
|
+
// An explicit `host` wins here for the same reason it does in sign() — a
|
|
600
|
+
// differently-cased caller header used to be merged with the URL host into
|
|
601
|
+
// `host:override.example,s3.amazonaws.com`, which can never validate.
|
|
602
|
+
const presignHasExplicitHost = Object.keys(request.headers).some((k) => k.toLowerCase() === "host");
|
|
524
603
|
const headers = {
|
|
525
604
|
...request.headers,
|
|
526
|
-
host: parsedUrl.host,
|
|
605
|
+
...(presignHasExplicitHost ? {} : { host: parsedUrl.host }),
|
|
527
606
|
};
|
|
528
607
|
const unsignedHdrs = config.unsignedHeaders ?? [];
|
|
529
608
|
// For presigned URLs, payload hash is always UNSIGNED-PAYLOAD
|
|
@@ -666,14 +745,49 @@ export async function signS3PostPolicy(policy, config) {
|
|
|
666
745
|
* Returns 0 if the header is absent or unparseable.
|
|
667
746
|
*/
|
|
668
747
|
export function detectClockSkew(responseHeaders) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
748
|
+
// `x-amz-date` is AWS's own signed timestamp and is the one present on the
|
|
749
|
+
// clock-skew error itself. `Date` is generated by whatever proxy fronts the
|
|
750
|
+
// endpoint and is frequently absent. Reading only `Date` meant a skew was
|
|
751
|
+
// silently undetectable on exactly the responses that carry it.
|
|
752
|
+
const amzDate = responseHeaders["x-amz-date"] ?? responseHeaders["X-Amz-Date"];
|
|
753
|
+
let serverTime;
|
|
754
|
+
if (amzDate !== undefined) {
|
|
755
|
+
serverTime = parseAmzDate(amzDate);
|
|
756
|
+
if (isNaN(serverTime))
|
|
757
|
+
return 0;
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
const dateHeader = responseHeaders["date"] ?? responseHeaders["Date"];
|
|
761
|
+
if (!dateHeader)
|
|
762
|
+
return 0;
|
|
763
|
+
serverTime = new Date(dateHeader).getTime();
|
|
764
|
+
if (isNaN(serverTime))
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
675
767
|
return Math.round((serverTime - Date.now()) / 1000);
|
|
676
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* Parse AWS's basic ISO 8601 timestamp (`20300101T120000Z`), which is not a
|
|
771
|
+
* form `Date` can parse.
|
|
772
|
+
*
|
|
773
|
+
* @param value - Candidate `x-amz-date` value
|
|
774
|
+
* @returns Epoch milliseconds, or `NaN` when the value is not in that form
|
|
775
|
+
*/
|
|
776
|
+
function parseAmzDate(value) {
|
|
777
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value.trim());
|
|
778
|
+
if (!m)
|
|
779
|
+
return NaN;
|
|
780
|
+
const [, y, mo, d, h, mi, sec] = m;
|
|
781
|
+
// Reject values that roll over silently (e.g. month 13) instead of
|
|
782
|
+
// producing a date in the following month.
|
|
783
|
+
const t = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(sec));
|
|
784
|
+
const dt = new Date(t);
|
|
785
|
+
if (dt.getUTCFullYear() !== Number(y) || dt.getUTCMonth() + 1 !== Number(mo))
|
|
786
|
+
return NaN;
|
|
787
|
+
if (dt.getUTCDate() !== Number(d))
|
|
788
|
+
return NaN;
|
|
789
|
+
return t;
|
|
790
|
+
}
|
|
677
791
|
/**
|
|
678
792
|
* Determine if an error response is a clock skew error.
|
|
679
793
|
*/
|
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);
|
|
@@ -453,7 +459,15 @@ function computeTTL(response, defaultTtl, honor) {
|
|
|
453
459
|
if (lm) {
|
|
454
460
|
const lmTime = Date.parse(lm);
|
|
455
461
|
if (!isNaN(lmTime)) {
|
|
456
|
-
|
|
462
|
+
// RFC 9111 §4.2.2: the heuristic lifetime is 10% of the interval
|
|
463
|
+
// between Last-Modified and the response's Date. That interval is
|
|
464
|
+
// negative whenever Last-Modified is in the future — routine, since
|
|
465
|
+
// the origin and the client rarely share a clock — and there was no
|
|
466
|
+
// lower clamp, so the entry was given a negative freshness lifetime
|
|
467
|
+
// and a staleUntil *before* its creation time. The clamp to 0 is the
|
|
468
|
+
// RFC's "already stale"; the stale windows then apply normally,
|
|
469
|
+
// instead of landing in the past and being unreachable.
|
|
470
|
+
ttlMs = Math.min(Math.max((Date.now() - lmTime) * 0.1, 0), defaultTtl);
|
|
457
471
|
}
|
|
458
472
|
else {
|
|
459
473
|
ttlMs = defaultTtl;
|
|
@@ -474,7 +488,11 @@ function computeTTL(response, defaultTtl, honor) {
|
|
|
474
488
|
ttlMs,
|
|
475
489
|
swrMs,
|
|
476
490
|
staleOnError,
|
|
477
|
-
|
|
491
|
+
// Storable if it is fresh now, or already stale but covered by a
|
|
492
|
+
// stale-while-revalidate / stale-if-error window. The two windows were
|
|
493
|
+
// missing from this decision, so `max-age=0, stale-if-error=60` — the
|
|
494
|
+
// point of the directive — was reported as uncacheable.
|
|
495
|
+
shouldCache: ttlMs > 0 || swrMs > 0 || staleOnError > 0,
|
|
478
496
|
};
|
|
479
497
|
}
|
|
480
498
|
// ============================================================================
|
|
@@ -674,7 +692,12 @@ export class HTTPCache {
|
|
|
674
692
|
this.stats.hits++;
|
|
675
693
|
this.lru.touch(key);
|
|
676
694
|
this._updateHitRate();
|
|
677
|
-
|
|
695
|
+
// RFC 9111 §5.2.2.4: a `no-cache` response may be stored but must not be
|
|
696
|
+
// reused without revalidating. It used to be reported as a plain fresh
|
|
697
|
+
// hit, so an origin that sent `Cache-Control: no-cache` had its response
|
|
698
|
+
// served indefinitely from cache. Reporting it as stale routes it into the
|
|
699
|
+
// existing serve-stale-and-revalidate path instead.
|
|
700
|
+
return { entry: cloneEntry(entry), stale: entry.mustRevalidate === true };
|
|
678
701
|
}
|
|
679
702
|
// Stale-While-Revalidate window
|
|
680
703
|
if (entry.staleUntil > now) {
|
|
@@ -722,7 +745,14 @@ export class HTTPCache {
|
|
|
722
745
|
// 9.10 — cap TTL to maxAbsoluteAgeMs so stored entries can never outlive the limit
|
|
723
746
|
const absoluteCap = this.cfg.maxAbsoluteAgeMs;
|
|
724
747
|
const cappedTtlMs = Math.min(ttlMs === Infinity ? absoluteCap : ttlMs, absoluteCap);
|
|
725
|
-
|
|
748
|
+
// A zero (or negative) freshness lifetime does not by itself mean
|
|
749
|
+
// "unstoreable": RFC 9111 §4.2 still permits storing a response that is
|
|
750
|
+
// already stale, provided a stale-while-revalidate or stale-if-error
|
|
751
|
+
// window covers it — and `computeTTL` has just told us exactly that via
|
|
752
|
+
// `shouldCache`. This guard used to discard that decision, so the canonical
|
|
753
|
+
// CDN recipe `Cache-Control: max-age=0, stale-while-revalidate=60` was
|
|
754
|
+
// never cached at all, defeating the one directive it exists to enable.
|
|
755
|
+
if (cappedTtlMs <= 0 && swrMs <= 0 && staleOnError <= 0 && !options.force)
|
|
726
756
|
return false;
|
|
727
757
|
const varyHeader = res.headers["vary"] ?? res.headers["Vary"] ?? null;
|
|
728
758
|
const varyKey = varyHeader ? buildVaryKey(varyHeader, req.headers) : "";
|
|
@@ -739,6 +769,7 @@ export class HTTPCache {
|
|
|
739
769
|
etag: res.headers["etag"] ?? res.headers["ETag"] ?? null,
|
|
740
770
|
lastModified: res.headers["last-modified"] ?? res.headers["Last-Modified"] ?? null,
|
|
741
771
|
varyKey,
|
|
772
|
+
mustRevalidate: computeMustRevalidate(res),
|
|
742
773
|
tags,
|
|
743
774
|
size: bodySize + 256,
|
|
744
775
|
};
|
|
@@ -909,16 +940,27 @@ export class HTTPCache {
|
|
|
909
940
|
getStats() {
|
|
910
941
|
return { ...this.stats };
|
|
911
942
|
}
|
|
912
|
-
/**
|
|
943
|
+
/**
|
|
944
|
+
* Reset the cache's counters to zero.
|
|
945
|
+
*
|
|
946
|
+
* `totalEntries` and `totalSizeBytes` are deliberately NOT reset: they are
|
|
947
|
+
* gauges of what is actually stored, not counters of events, and
|
|
948
|
+
* `_ensureCapacity()` reads them as the authoritative figures for the
|
|
949
|
+
* `maxEntries` / `maxSizeBytes` caps. Zeroing them while the entries are
|
|
950
|
+
* still in storage made eviction compare against a fiction — a cache with
|
|
951
|
+
* `maxSizeBytes: 600` was measured holding 1028 bytes across 4 entries
|
|
952
|
+
* after a reset — and made `getStats().totalEntries` report 0 for entries
|
|
953
|
+
* that were still there and still being served. Call `clear()` to empty the
|
|
954
|
+
* cache; use this to zero the hit/miss/eviction counters.
|
|
955
|
+
*/
|
|
913
956
|
resetStats() {
|
|
914
957
|
this.stats = {
|
|
958
|
+
...this.stats,
|
|
915
959
|
hits: 0,
|
|
916
960
|
misses: 0,
|
|
917
961
|
staleHits: 0,
|
|
918
962
|
errors: 0,
|
|
919
963
|
evictions: 0,
|
|
920
|
-
totalEntries: 0,
|
|
921
|
-
totalSizeBytes: 0,
|
|
922
964
|
hitRate: 0,
|
|
923
965
|
};
|
|
924
966
|
}
|
|
@@ -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,
|