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
package/dist/esm/utils.js CHANGED
@@ -1,9 +1,7 @@
1
1
  /**
2
2
  * Cross-runtime utilities for type safety, validation, and security.
3
3
  */
4
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
4
  let _process;
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
5
  let _Buffer;
8
6
  // Check globalThis.process for Node.js runtime detection (no dynamic import needed)
9
7
  function getProcess() {
@@ -47,7 +45,17 @@ const DEFAULT_LIMITS = {
47
45
  * @returns Parse result with success/failure information
48
46
  */
49
47
  export function safeJSONParse(text, options = {}) {
50
- const limits = { ...DEFAULT_LIMITS, ...options };
48
+ // `{ ...DEFAULT_LIMITS, ...options }` copies an *explicitly present*
49
+ // `undefined` straight over a real limit, and every comparison this file
50
+ // makes is `<=` / `>` against a number — so `{ maxDepth: undefined }` did not
51
+ // mean "use the default", it meant "there is no depth limit", silently. That
52
+ // is the shape config plumbing produces (`{ maxDepth: env.MAX_DEPTH }`), and
53
+ // the `Required<>` on `limits` says the opposite. Only defined values merge.
54
+ const limits = { ...DEFAULT_LIMITS };
55
+ for (const [key, value] of Object.entries(options)) {
56
+ if (value !== undefined)
57
+ limits[key] = value;
58
+ }
51
59
  // Check string length first
52
60
  if (text.length > limits.maxStringLength) {
53
61
  return {
@@ -241,6 +249,44 @@ export function parseUntrustedJSON(text) {
241
249
  // ============================================================================
242
250
  // §2 TYPE GUARDS
243
251
  // ============================================================================
252
+ /**
253
+ * Exact brand test for a platform type.
254
+ *
255
+ * `instanceof` is authoritative in the current realm and — unlike a
256
+ * constructor-name check — survives subclasses, so `new File()` is still a
257
+ * `Blob`. It fails across realms (an `iframe`'s `Headers` is a different
258
+ * constructor), so the platform's own `Symbol.toStringTag` is consulted as a
259
+ * fallback: that is the tag the runtime sets on the real object, and the one
260
+ * thing a `Map` or a `Set` does not carry.
261
+ *
262
+ * The previous guards duck-typed on a *single* method name, which made the
263
+ * most common built-ins in the language pass for types they are not:
264
+ * `Map` and `Set` both have `has`, `FormData` has `forEach`, so
265
+ * `isURLSearchParams(new Map())`, `isURLSearchParams(new Set())` and
266
+ * `isHeaders(new FormData())` were all `true`. Meanwhile `isBlob` compared
267
+ * `constructor.name === "Blob"` and rejected every `File`, which is a `Blob`.
268
+ */
269
+ function hasBrand(value, ctor, tag, required) {
270
+ if (value === null || typeof value !== "object")
271
+ return false;
272
+ if (typeof ctor === "function") {
273
+ try {
274
+ if (value instanceof ctor)
275
+ return true;
276
+ }
277
+ catch {
278
+ // A Proxy with a hostile getPrototypeOf — fall through to the brand.
279
+ }
280
+ }
281
+ // Cross-realm: the runtime's own brand, plus the methods that make the
282
+ // value usable as the type. The brand alone is one getter away from
283
+ // anything, and a guard whose whole job is to route a value to the right
284
+ // code path is not one that should hand it out on a say-so.
285
+ if (Object.prototype.toString.call(value) !== `[object ${tag}]`)
286
+ return false;
287
+ const obj = value;
288
+ return required.every((m) => typeof obj[m] === "function");
289
+ }
244
290
  /**
245
291
  * Type guard for Uint8Array.
246
292
  *
@@ -266,10 +312,7 @@ export function isArrayBuffer(value) {
266
312
  * @returns True if the value is a ReadableStream.
267
313
  */
268
314
  export function isReadableStream(value) {
269
- return (value !== null &&
270
- typeof value === "object" &&
271
- "getReader" in value &&
272
- typeof value.getReader === "function");
315
+ return hasBrand(value, globalThis.ReadableStream, "ReadableStream", ["getReader", "cancel"]);
273
316
  }
274
317
  /**
275
318
  * Type guard for Headers.
@@ -278,10 +321,7 @@ export function isReadableStream(value) {
278
321
  * @returns True if the value is a Headers instance.
279
322
  */
280
323
  export function isHeaders(value) {
281
- return (value !== null &&
282
- typeof value === "object" &&
283
- "forEach" in value &&
284
- typeof value.forEach === "function");
324
+ return hasBrand(value, globalThis.Headers, "Headers", ["get", "set", "append", "forEach"]);
285
325
  }
286
326
  /**
287
327
  * Type guard for AbortSignal.
@@ -290,10 +330,10 @@ export function isHeaders(value) {
290
330
  * @returns True if the value is an AbortSignal.
291
331
  */
292
332
  export function isAbortSignal(value) {
293
- return (value !== null &&
294
- typeof value === "object" &&
295
- "aborted" in value &&
296
- typeof value.aborted === "boolean");
333
+ return hasBrand(value, globalThis.AbortSignal, "AbortSignal", [
334
+ "addEventListener",
335
+ "removeEventListener",
336
+ ]);
297
337
  }
298
338
  /**
299
339
  * Type guard for plain objects (\[object Object\]).
@@ -302,9 +342,17 @@ export function isAbortSignal(value) {
302
342
  * @returns True if the value is a plain Object.
303
343
  */
304
344
  export function isPlainObject(value) {
305
- return (value !== null &&
306
- typeof value === "object" &&
307
- Object.prototype.toString.call(value) === "[object Object]");
345
+ if (value === null || typeof value !== "object")
346
+ return false;
347
+ // `Object.prototype.toString.call(x) === "[object Object]"` is the classic
348
+ // wrong way to write this: it is true for *every* object whose class does
349
+ // not override Symbol.toStringTag, so `new (class { constructor() { this.a = 1 } })()`
350
+ // passed — which is the opposite of what "plain" means, and the reason the
351
+ // guard cannot be trusted for the copy/merge decisions its name invites.
352
+ // What makes an object plain is its prototype: Object.prototype, or none at
353
+ // all (a null-prototype object is a dictionary, and still plain).
354
+ const proto = Object.getPrototypeOf(value);
355
+ return proto === Object.prototype || proto === null;
308
356
  }
309
357
  /**
310
358
  * Type guard for FormData.
@@ -313,9 +361,7 @@ export function isPlainObject(value) {
313
361
  * @returns True if the value is a FormData instance.
314
362
  */
315
363
  export function isFormData(value) {
316
- return (value !== null &&
317
- typeof value === "object" &&
318
- value.constructor?.name === "FormData");
364
+ return hasBrand(value, globalThis.FormData, "FormData", ["append", "get", "getAll", "set"]);
319
365
  }
320
366
  /**
321
367
  * Type guard for Blob.
@@ -324,7 +370,10 @@ export function isFormData(value) {
324
370
  * @returns True if the value is a Blob instance.
325
371
  */
326
372
  export function isBlob(value) {
327
- return (value !== null && typeof value === "object" && value.constructor?.name === "Blob");
373
+ // File extends Blob, and its own brand is "[object File]", so this is
374
+ // `instanceof`-first on purpose: a constructor-name check rejected every
375
+ // File, which is the single most common Blob-shaped value in a browser.
376
+ return hasBrand(value, globalThis.Blob, "Blob", ["arrayBuffer", "slice", "text", "stream"]);
328
377
  }
329
378
  /**
330
379
  * Type guard for URLSearchParams.
@@ -333,10 +382,12 @@ export function isBlob(value) {
333
382
  * @returns True if the value is a URLSearchParams instance.
334
383
  */
335
384
  export function isURLSearchParams(value) {
336
- return (value !== null &&
337
- typeof value === "object" &&
338
- "has" in value &&
339
- typeof value.has === "function");
385
+ return hasBrand(value, globalThis.URLSearchParams, "URLSearchParams", [
386
+ "append",
387
+ "get",
388
+ "getAll",
389
+ "sort",
390
+ ]);
340
391
  }
341
392
  // ============================================================================
342
393
  // §3 VALIDATION UTILITIES
@@ -366,13 +417,21 @@ export function isValidHeaderValue(value) {
366
417
  return false;
367
418
  if (value.length > 8192)
368
419
  return false; // Reasonable length limit
369
- // Header values can contain any ASCII except CTLs and CRLF (header injection)
370
- // Per RFC 7230, HT (0x09) is allowed in header values
420
+ // RFC 9110 §5.5: field-value is VCHAR / SP / HTAB / obs-text, where obs-text
421
+ // is %x80-FF. So a value may hold the Latin-1 range and nothing above it —
422
+ // there is no upper bound in the loop, which accepted an emoji and then let
423
+ // the very next `new Headers({ "X-A": "\u{1F600}" })` throw
424
+ // "Cannot convert argument to a ByteString because the character at index 0
425
+ // has a value of 55357". This guard is what a caller checks first, so a
426
+ // "valid" verdict that the runtime then refuses is worse than no verdict.
371
427
  for (let i = 0; i < value.length; i++) {
372
428
  const code = value.charCodeAt(i);
373
- // No control characters (0-31 except 9=HT), 127
374
- // No CRLF (13 = \r, 10 = \n) - prevents header injection
375
- if ((code < 32 && code !== 9) || code === 127 || code === 13 || code === 10)
429
+ // No control characters (0-31 except 9=HT) and no DEL — CR and LF are
430
+ // inside that range, so this is also the header-injection guard.
431
+ if ((code < 32 && code !== 9) || code === 127)
432
+ return false;
433
+ // Above obs-text: a code point no ByteString header value can carry.
434
+ if (code > 0xff)
376
435
  return false;
377
436
  }
378
437
  return true;
@@ -581,9 +640,7 @@ function isBlockedIPv6(b) {
581
640
  if (b[0] === 0x20 && b[1] === 0x01 && b[2] === 0x0d && b[3] === 0xb8)
582
641
  return true;
583
642
  // ::ffff:0:0/96 — IPv4-mapped → apply the IPv4 checks to the tail
584
- if (b.slice(0, 10).every((x) => x === 0) &&
585
- b[10] === 0xff &&
586
- b[11] === 0xff) {
643
+ if (b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff) {
587
644
  return isBlockedIPv4(read32(12));
588
645
  }
589
646
  // ::/96 — deprecated IPv4-compatible → apply the IPv4 checks to the tail
@@ -611,7 +668,7 @@ function isBlockedIPv6(b) {
611
668
  * @param allowedSchemes - Allowed URL schemes
612
669
  * @returns True if URL is safe
613
670
  */
614
- export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
671
+ export function isSafeURL(url, allowedSchemes = ["http", "https"], options = {}) {
615
672
  try {
616
673
  const parsed = typeof url === "string" ? new URL(url) : url;
617
674
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -623,25 +680,31 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
623
680
  if (FORBIDDEN_SCHEMES.has(scheme)) {
624
681
  return false;
625
682
  }
626
- let host = parsed.hostname.toLowerCase();
627
- const isV6Literal = host.startsWith("[") && host.endsWith("]");
628
- if (isV6Literal)
629
- host = host.slice(1, -1);
630
- // Block loopback hostnames (including *.localhost subdomains)
631
- if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
632
- return false;
633
- // Block private/reserved IPs (SSRF prevention) — numeric comparison over
634
- // expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
635
- if (isV6Literal || host.includes(":")) {
636
- const v6 = parseIPv6Host(host);
637
- // Unparseable IPv6 literal → reject defensively
638
- if (v6 === null || isBlockedIPv6(v6))
639
- return false;
640
- }
641
- else {
642
- const v4 = parseIPv4Host(host);
643
- if (v4 !== null && isBlockedIPv4(v4))
683
+ // `allowPrivateNetwork` relaxes *only* the address checks below. Scheme,
684
+ // path traversal, hostname length, URL length and port validation are not
685
+ // about where the socket lands, so they still apply — otherwise opting in to
686
+ // a loopback fixture would also silently opt in to `file:` and `../`.
687
+ if (!options.allowPrivateNetwork) {
688
+ let host = parsed.hostname.toLowerCase();
689
+ const isV6Literal = host.startsWith("[") && host.endsWith("]");
690
+ if (isV6Literal)
691
+ host = host.slice(1, -1);
692
+ // Block loopback hostnames (including *.localhost subdomains)
693
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
644
694
  return false;
695
+ // Block private/reserved IPs (SSRF prevention) — numeric comparison over
696
+ // expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
697
+ if (isV6Literal || host.includes(":")) {
698
+ const v6 = parseIPv6Host(host);
699
+ // Unparseable IPv6 literal → reject defensively
700
+ if (v6 === null || isBlockedIPv6(v6))
701
+ return false;
702
+ }
703
+ else {
704
+ const v4 = parseIPv4Host(host);
705
+ if (v4 !== null && isBlockedIPv4(v4))
706
+ return false;
707
+ }
645
708
  }
646
709
  // Check for suspicious patterns (path traversal)
647
710
  if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
@@ -666,6 +729,35 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
666
729
  return false;
667
730
  }
668
731
  }
732
+ /**
733
+ * True when a literal IPv4/IPv6 address falls in a blocked range.
734
+ *
735
+ * The same decision `isSafeURL` makes about a hostname, applied to an address
736
+ * that has already been resolved. Kept separate from `isSafeURL` because the two
737
+ * answer different questions: `isSafeURL` reads what the caller wrote, this
738
+ * reads what the resolver returned. Both are needed — see
739
+ * `createPinnedLookup` in `core.ts`, which is the only thing that stops a
740
+ * hostname that resolves to a private address from being dialled.
741
+ *
742
+ * IPv4 literals are accepted in every form a resolver can return (dotted-quad
743
+ * and numeric); anything unparseable is treated as blocked, so an address this
744
+ * cannot understand fails closed rather than open.
745
+ */
746
+ export function isBlockedAddress(address) {
747
+ const host = address.trim().toLowerCase();
748
+ if (host.startsWith("[") && host.endsWith("]"))
749
+ return true;
750
+ if (host === "" || host === "localhost" || host.endsWith(".localhost"))
751
+ return true;
752
+ if (host.includes(":")) {
753
+ const v6 = parseIPv6Host(host);
754
+ return v6 === null || isBlockedIPv6(v6);
755
+ }
756
+ // A resolver returns dotted-quad, but `parseIPv4Host` also accepts the
757
+ // numeric spellings, and a non-IP string must not be read as "safe".
758
+ const v4 = parseIPv4Host(host);
759
+ return v4 === null || isBlockedIPv4(v4);
760
+ }
669
761
  /**
670
762
  * Check if a URL is absolute (has a scheme).
671
763
  *
@@ -712,7 +804,24 @@ export function deepClone(value) {
712
804
  value.forEach((v) => cloned.add(deepClone(v)));
713
805
  return cloned;
714
806
  }
715
- const cloned = {};
807
+ // A class instance is rebuilt on *its own* prototype. The walk below
808
+ // produced a bare `{}` for every object that was not a plain one, so
809
+ // `deepClone(new (class { m() { return 1 } })())` came back as an `Object`
810
+ // with `m` gone — the clone was no longer the thing it was cloned from, and
811
+ // the failure was silent until something called a method. `new (proto)()` is
812
+ // not generally callable, so the instance is created with its prototype
813
+ // attached and its own properties copied across.
814
+ //
815
+ // A *host* object — RegExp, URL, a typed array, an Error — is not: those
816
+ // carry internal slots that `Object.create` cannot produce, and
817
+ // `Object.create(RegExp.prototype)` satisfies `instanceof` while throwing
818
+ // "called on non-RegExp object" the moment a getter is touched. The brand is
819
+ // the discriminator, and it is exact: a class instance reports the plain
820
+ // `[object Object]`, because the brand comes from the runtime, not the
821
+ // constructor. Those keep the dictionary form they always had.
822
+ const proto = Object.getPrototypeOf(value);
823
+ const isClassInstance = Object.prototype.toString.call(value) === "[object Object]";
824
+ const cloned = proto === Object.prototype || proto === null || !isClassInstance ? {} : Object.create(proto);
716
825
  for (const key in value) {
717
826
  // Prototype-pollution guard: never copy __proto__ / constructor / prototype
718
827
  if (key === "__proto__" || key === "constructor" || key === "prototype")
@@ -742,7 +851,27 @@ export function isPromise(value) {
742
851
  */
743
852
  export function createStructuredError(message, context) {
744
853
  const error = new Error(message);
745
- Object.assign(error, context);
854
+ // `Object.assign` writes through [[Set]], so a context carrying an *own*
855
+ // `__proto__` key — which is exactly what `JSON.parse('{"__proto\u003a…}')`
856
+ // produces, and what a caller forwarding a remote error payload hands over —
857
+ // did not add a field. It reached the inherited `__proto__` setter and
858
+ // replaced the error's prototype, so the attacker-supplied object sat in the
859
+ // prototype chain of every error the client then went on to format, log or
860
+ // inspect. The one key that has to be expressible is written as a data
861
+ // property, which is what was meant.
862
+ for (const key of Object.keys(context)) {
863
+ const value = context[key];
864
+ if (key === "__proto__") {
865
+ Object.defineProperty(error, key, {
866
+ value,
867
+ writable: true,
868
+ enumerable: true,
869
+ configurable: true,
870
+ });
871
+ continue;
872
+ }
873
+ error[key] = value;
874
+ }
746
875
  return error;
747
876
  }
748
877
  /**
@@ -758,11 +887,40 @@ export function formatError(error) {
758
887
  context[key] = value;
759
888
  }
760
889
  }
761
- const contextStr = Object.keys(context).length > 0 ? ` | ${JSON.stringify(context)}` : "";
890
+ const contextStr = Object.keys(context).length > 0 ? ` | ${safeStringify(context)}` : "";
762
891
  return `${error.name}: ${error.message}${contextStr}`;
763
892
  }
764
893
  return String(error);
765
894
  }
895
+ /**
896
+ * `JSON.stringify` for a log line, on a value that is very likely to be
897
+ * hostile.
898
+ *
899
+ * The context attached by {@link createStructuredError} is whatever the failing
900
+ * call had in hand — a `request`, a `response`, a `cause` — and all three
901
+ * routinely point back at each other. `JSON.stringify` answers that with a
902
+ * thrown `TypeError: Converting circular structure to JSON`, and a BigInt with
903
+ * another, so the function whose entire job is to turn an error into a string
904
+ * threw instead, taking the `catch` that was logging it down with it.
905
+ */
906
+ export function safeStringify(value, circularMarker = "[Circular]") {
907
+ const seen = new Set();
908
+ try {
909
+ return (JSON.stringify(value, (_key, v) => {
910
+ if (typeof v === "bigint")
911
+ return v.toString();
912
+ if (typeof v === "object" && v !== null) {
913
+ if (seen.has(v))
914
+ return circularMarker;
915
+ seen.add(v);
916
+ }
917
+ return v;
918
+ }) ?? String(value));
919
+ }
920
+ catch {
921
+ return String(value);
922
+ }
923
+ }
766
924
  // ============================================================================
767
925
  // §5 TIME UTILITIES
768
926
  // ============================================================================
@@ -823,12 +981,12 @@ export function perfNow() {
823
981
  */
824
982
  export function sleep(ms, signal) {
825
983
  if (signal?.aborted) {
826
- return Promise.reject(_abortError());
984
+ return Promise.reject(abortError());
827
985
  }
828
986
  return new Promise((resolve, reject) => {
829
987
  const onAbort = () => {
830
988
  clearTimeout(timer);
831
- reject(_abortError());
989
+ reject(abortError());
832
990
  };
833
991
  const timer = setTimeout(() => {
834
992
  signal?.removeEventListener("abort", onAbort); // cleanup on normal completion
@@ -865,10 +1023,16 @@ export function concatUint8Arrays(chunks) {
865
1023
  * @returns Uint8Array or null if unsupported type
866
1024
  */
867
1025
  export function toUint8Array(data) {
1026
+ // Both branches copy. The Uint8Array branch sliced but the ArrayBuffer
1027
+ // branch wrapped, so the same call handed back a copy for one input type and
1028
+ // a live view for the other: writing to the result of
1029
+ // `toUint8Array(buffer)` wrote through to the caller's buffer, and two calls
1030
+ // given the same buffer shared it. A function whose output is "the bytes" has
1031
+ // to be the same kind of thing whichever type it was handed.
868
1032
  if (data instanceof Uint8Array)
869
1033
  return data.slice();
870
1034
  if (data instanceof ArrayBuffer)
871
- return new Uint8Array(data);
1035
+ return new Uint8Array(data.slice(0));
872
1036
  const b = getBuffer();
873
1037
  if (b && b.isBuffer(data)) {
874
1038
  return new Uint8Array(data);
@@ -888,6 +1052,50 @@ export function toUint8Array(data) {
888
1052
  * @param bytes The buffer to encode.
889
1053
  * @returns Base64-encoded string.
890
1054
  */
1055
+ /**
1056
+ * SHA-256 of a string or byte array, as lowercase hex.
1057
+ *
1058
+ * The `ArrayBuffer` slice is not optional. `crypto.subtle.digest` takes an
1059
+ * `ArrayBuffer`, and a `Uint8Array`'s `.buffer` is the whole backing store —
1060
+ * pass it directly and a view that starts partway into a larger buffer gets
1061
+ * hashed from offset zero, hashing the wrong bytes. Three copies of this
1062
+ * function existed and one of them made exactly that mistake.
1063
+ */
1064
+ /**
1065
+ * Copy `headers`, replacing the value of any name in `sensitive` with `mask`.
1066
+ *
1067
+ * The comparison is case-insensitive because HTTP header names are, and the
1068
+ * caller's set is folded once here rather than at each call site — which is
1069
+ * what `noUncheckedIndexedAccess` makes expensive elsewhere, where every
1070
+ * consumer ends up writing `headers["x"] ?? headers["X"]` to find a header that
1071
+ * was only ever stored under one spelling.
1072
+ *
1073
+ * `mask` is a parameter rather than a constant because four call sites already
1074
+ * disagreed on it: the logger and the lifecycle hooks wrote `"***"`, the
1075
+ * logging interceptor wrote `"**REDACTED**"`, and the HAR recorder wrote
1076
+ * `"***REDACTED***"`. Those strings are observable in log output, so unifying
1077
+ * them is a behaviour change and is deliberately not made here — but they are
1078
+ * now in one function, so a future decision to converge is a one-line edit.
1079
+ */
1080
+ /** True when `name` is one of `sensitive`, compared case-insensitively. */
1081
+ export function isSensitiveHeader(name, sensitive) {
1082
+ return sensitive.has(name.toLowerCase());
1083
+ }
1084
+ export function redactHeaders(headers, sensitive, mask) {
1085
+ const out = {};
1086
+ for (const [k, v] of Object.entries(headers)) {
1087
+ out[k] = isSensitiveHeader(k, sensitive) ? mask : v;
1088
+ }
1089
+ return out;
1090
+ }
1091
+ export async function sha256Hex(data) {
1092
+ const raw = typeof data === "string" ? new TextEncoder().encode(data) : data;
1093
+ const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
1094
+ const hash = await crypto.subtle.digest("SHA-256", buf);
1095
+ return Array.from(new Uint8Array(hash))
1096
+ .map((b) => b.toString(16).padStart(2, "0"))
1097
+ .join("");
1098
+ }
891
1099
  export function uint8ArrayToBase64(bytes) {
892
1100
  // Process in 3-byte (24-bit) aligned chunks to avoid padding issues mid-stream.
893
1101
  // 3 × 2¹³ = 24 576 bytes per chunk — keeps individual strings small.
@@ -929,25 +1137,44 @@ export function mergeSignals(...signals) {
929
1137
  }
930
1138
  if (validSignals.length === 1)
931
1139
  return validSignals[0];
932
- // Check if any signal is already aborted
933
- if (validSignals.some((s) => s.aborted)) {
1140
+ // Check if any signal is already aborted.
1141
+ //
1142
+ // `controller.abort()` with no argument installs the platform's generic
1143
+ // `AbortError: This operation was aborted`, which threw away *why* the call
1144
+ // was aborted. The two-live-signal branch below preserves the reason, and so
1145
+ // does the `AbortSignal.any` path this function prefers on every current
1146
+ // runtime — so the one case a caller is most likely to hit (a signal that
1147
+ // already fired, e.g. `AbortSignal.timeout()`) was the only one that lost
1148
+ // it. `interceptors.ts` re-aborts with `existing.reason` when merging, and
1149
+ // anything reading the merged signal's reason saw a bare AbortError where it
1150
+ // should have seen the caller's TimeoutError or their own Error.
1151
+ const preAborted = validSignals.find((s) => s.aborted);
1152
+ if (preAborted) {
934
1153
  const controller = new AbortController();
935
- controller.abort();
1154
+ controller.abort(preAborted.reason);
936
1155
  return controller.signal;
937
1156
  }
1157
+ // The platform primitive, where it exists. The hand-rolled version below
1158
+ // attached an `abort` listener to every input and only ever removed it when
1159
+ // one of those inputs fired — so merging a long-lived caller signal (a
1160
+ // request-scoped controller is the usual one) accumulated one listener per
1161
+ // merge, forever, and tripped Node's MaxListenersExceededWarning at 11.
1162
+ // The "9.11" self-cleanup that was supposed to release them could not run:
1163
+ // the merged controller is never handed out, so nothing but the inputs can
1164
+ // ever abort it. `AbortSignal.any` holds its inputs weakly and adds nothing
1165
+ // observable to them. Node 20.3+, Deno, Bun and current browsers have it;
1166
+ // the manual path remains for Node 18 and older.
1167
+ const nativeAny = AbortSignal.any;
1168
+ if (typeof nativeAny === "function") {
1169
+ return nativeAny.call(AbortSignal, validSignals);
1170
+ }
938
1171
  const controller = new AbortController();
939
1172
  // abort: fire the controller and clean up ALL listeners immediately.
940
1173
  const abort = () => {
941
1174
  for (const s of validSignals)
942
1175
  s.removeEventListener("abort", abort);
943
- controller.abort(_abortError());
1176
+ controller.abort(abortError());
944
1177
  };
945
- // 9.11: when the merged signal itself aborts (e.g. from another path), also clean up.
946
- // This prevents listener accumulation when callers abort the controller externally.
947
- controller.signal.addEventListener("abort", () => {
948
- for (const s of validSignals)
949
- s.removeEventListener("abort", abort);
950
- }, { once: true });
951
1178
  for (const s of validSignals)
952
1179
  s.addEventListener("abort", abort, { once: true });
953
1180
  return controller.signal;
@@ -1001,11 +1228,20 @@ export function randomBytes(byteCount) {
1001
1228
  .join("");
1002
1229
  }
1003
1230
  // Cross-runtime error creation: DOMException may not exist in all runtimes.
1004
- function _abortError() {
1231
+ /**
1232
+ * A platform-shaped abort rejection: a `DOMException` named `AbortError` where
1233
+ * one exists, and a plain `Error` carrying the same name where it does not.
1234
+ *
1235
+ * `message` is a parameter because two callers need different text for the same
1236
+ * event, and they had each written this function. It is deliberately not
1237
+ * `AbortError` from `types.ts`: that is a `KinetexError` with code `EABORT`, a
1238
+ * different and equally deliberate contract used by the request pipeline.
1239
+ */
1240
+ export function abortError(message = "Aborted") {
1005
1241
  if (typeof DOMException !== "undefined") {
1006
- return new DOMException("Aborted", "AbortError");
1242
+ return new DOMException(message, "AbortError");
1007
1243
  }
1008
- const err = new Error("Aborted");
1244
+ const err = new Error(message);
1009
1245
  err.name = "AbortError";
1010
1246
  return err;
1011
1247
  }
@@ -1088,7 +1324,33 @@ export function hasNativeFetch() {
1088
1324
  export function normalizeHeaders(headers) {
1089
1325
  const result = {};
1090
1326
  headers.forEach((value, key) => {
1091
- result[key.toLowerCase()] = value;
1327
+ const name = key.toLowerCase();
1328
+ // `__proto__` is a legal header name — it is made of token characters — and
1329
+ // `result[name] = value` is a [[Set]], so a response carrying it went
1330
+ // through the inherited setter, which ignores a primitive. The header did
1331
+ // not overwrite anything; it simply disappeared, and the caller reading the
1332
+ // normalized record never learned the response had sent it.
1333
+ if (name === "__proto__") {
1334
+ Object.defineProperty(result, name, {
1335
+ value,
1336
+ writable: true,
1337
+ enumerable: true,
1338
+ configurable: true,
1339
+ });
1340
+ return;
1341
+ }
1342
+ // `Set-Cookie` is the one header the Fetch spec does NOT combine: it
1343
+ // yields each cookie as a separate `forEach` entry, while every other
1344
+ // repeated header arrives already joined with ", ". Assigning therefore
1345
+ // overwrote: a response setting two cookies normalized to the last one
1346
+ // only, and the first vanished with no error anywhere. `Headers.get()`
1347
+ // reports them combined as "a=1, b=2", so accumulate to match it — the
1348
+ // cookie jar splits this form back apart with `splitSetCookieHeaders`.
1349
+ if (name === "set-cookie" && Object.hasOwn(result, name)) {
1350
+ result[name] = `${result[name]}, ${value}`;
1351
+ return;
1352
+ }
1353
+ result[name] = value;
1092
1354
  });
1093
1355
  return result;
1094
1356
  }