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.
Files changed (123) hide show
  1. package/README.md +413 -52
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3134 -787
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +140 -33
  6. package/dist/cjs/cache.js +58 -7
  7. package/dist/cjs/circuit-breaker.js +53 -4
  8. package/dist/cjs/client.js +726 -188
  9. package/dist/cjs/cookie-parser.js +131 -6
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +691 -70
  12. package/dist/cjs/dedup.js +142 -21
  13. package/dist/cjs/digest.js +195 -38
  14. package/dist/cjs/graphql.js +166 -35
  15. package/dist/cjs/headers.js +327 -46
  16. package/dist/cjs/interceptors.js +227 -24
  17. package/dist/cjs/lifecycle.js +92 -59
  18. package/dist/cjs/logging.js +160 -24
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +178 -30
  22. package/dist/cjs/proxy.js +429 -0
  23. package/dist/cjs/response.js +506 -110
  24. package/dist/cjs/socks5.js +136 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +335 -73
  28. package/dist/cjs/ws.js +62 -12
  29. package/dist/esm/aws-sigv4.js +140 -33
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +58 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +53 -4
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +726 -188
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +131 -6
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +691 -70
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +142 -21
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +195 -38
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +166 -35
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +327 -46
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +227 -24
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +92 -59
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +160 -24
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +178 -30
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +430 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +506 -110
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +136 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +335 -73
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +62 -12
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +15 -2
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +216 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts +38 -3
  91. package/dist/types/dedup.d.ts.map +1 -1
  92. package/dist/types/digest.d.ts +17 -37
  93. package/dist/types/digest.d.ts.map +1 -1
  94. package/dist/types/graphql.d.ts.map +1 -1
  95. package/dist/types/headers.d.ts +68 -31
  96. package/dist/types/headers.d.ts.map +1 -1
  97. package/dist/types/interceptors.d.ts +126 -38
  98. package/dist/types/interceptors.d.ts.map +1 -1
  99. package/dist/types/lifecycle.d.ts +19 -2
  100. package/dist/types/lifecycle.d.ts.map +1 -1
  101. package/dist/types/logging.d.ts +22 -3
  102. package/dist/types/logging.d.ts.map +1 -1
  103. package/dist/types/mod.d.ts +5 -3
  104. package/dist/types/mod.d.ts.map +1 -1
  105. package/dist/types/pagination.d.ts +0 -25
  106. package/dist/types/pagination.d.ts.map +1 -1
  107. package/dist/types/progress.d.ts +1 -1
  108. package/dist/types/progress.d.ts.map +1 -1
  109. package/dist/types/proxy.d.ts +50 -0
  110. package/dist/types/proxy.d.ts.map +1 -0
  111. package/dist/types/response.d.ts +49 -2
  112. package/dist/types/response.d.ts.map +1 -1
  113. package/dist/types/socks5.d.ts.map +1 -1
  114. package/dist/types/sse.d.ts.map +1 -1
  115. package/dist/types/types.d.ts +219 -26
  116. package/dist/types/types.d.ts.map +1 -1
  117. package/dist/types/url.d.ts +0 -25
  118. package/dist/types/url.d.ts.map +1 -1
  119. package/dist/types/utils.d.ts +69 -1
  120. package/dist/types/utils.d.ts.map +1 -1
  121. package/dist/types/ws.d.ts +0 -1
  122. package/dist/types/ws.d.ts.map +1 -1
  123. package/package.json +10 -4
@@ -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
- const data = (await credsRes.json());
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: data.AccessKeyId,
168
- secretAccessKey: data.SecretAccessKey,
169
- sessionToken: data.Token,
170
- expiration: data.Expiration,
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
- const expiresIn = options.expiresIn ?? 3600;
502
- // Validate expiresIn range - different services have different limits
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
- if (expiresIn < 1 || expiresIn > maxExpires) {
505
- console.warn(`[aws-sigv4] presignRequest: expiresIn should be 1-${maxExpires} seconds for ${config.service}, got ${expiresIn}`);
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
- const dateHeader = responseHeaders["date"] ?? responseHeaders["Date"];
670
- if (!dateHeader)
671
- return 0;
672
- const serverTime = new Date(dateHeader).getTime();
673
- if (isNaN(serverTime))
674
- return 0;
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
- ttlMs = Math.min((Date.now() - lmTime) * 0.1, defaultTtl);
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
- shouldCache: ttlMs > 0 || swrMs > 0,
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
- return { entry: cloneEntry(entry), stale: false };
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
- if (cappedTtlMs <= 0 && !options.force)
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
- /** Reset all cache statistics to zero. */
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
- super(`Circuit breaker OPEN for "${key}" — request rejected`);
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
- /** The current circuit state. */
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: this._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
- if (this.failureFilter.timeouts && code === "ETIMEOUT")
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;