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.
Files changed (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  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 +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  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 +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -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 +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  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 +14 -1
  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 +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
package/dist/cjs/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
@@ -712,7 +769,24 @@ export function deepClone(value) {
712
769
  value.forEach((v) => cloned.add(deepClone(v)));
713
770
  return cloned;
714
771
  }
715
- const cloned = {};
772
+ // A class instance is rebuilt on *its own* prototype. The walk below
773
+ // produced a bare `{}` for every object that was not a plain one, so
774
+ // `deepClone(new (class { m() { return 1 } })())` came back as an `Object`
775
+ // with `m` gone — the clone was no longer the thing it was cloned from, and
776
+ // the failure was silent until something called a method. `new (proto)()` is
777
+ // not generally callable, so the instance is created with its prototype
778
+ // attached and its own properties copied across.
779
+ //
780
+ // A *host* object — RegExp, URL, a typed array, an Error — is not: those
781
+ // carry internal slots that `Object.create` cannot produce, and
782
+ // `Object.create(RegExp.prototype)` satisfies `instanceof` while throwing
783
+ // "called on non-RegExp object" the moment a getter is touched. The brand is
784
+ // the discriminator, and it is exact: a class instance reports the plain
785
+ // `[object Object]`, because the brand comes from the runtime, not the
786
+ // constructor. Those keep the dictionary form they always had.
787
+ const proto = Object.getPrototypeOf(value);
788
+ const isClassInstance = Object.prototype.toString.call(value) === "[object Object]";
789
+ const cloned = proto === Object.prototype || proto === null || !isClassInstance ? {} : Object.create(proto);
716
790
  for (const key in value) {
717
791
  // Prototype-pollution guard: never copy __proto__ / constructor / prototype
718
792
  if (key === "__proto__" || key === "constructor" || key === "prototype")
@@ -742,7 +816,27 @@ export function isPromise(value) {
742
816
  */
743
817
  export function createStructuredError(message, context) {
744
818
  const error = new Error(message);
745
- Object.assign(error, context);
819
+ // `Object.assign` writes through [[Set]], so a context carrying an *own*
820
+ // `__proto__` key — which is exactly what `JSON.parse('{"__proto\u003a…}')`
821
+ // produces, and what a caller forwarding a remote error payload hands over —
822
+ // did not add a field. It reached the inherited `__proto__` setter and
823
+ // replaced the error's prototype, so the attacker-supplied object sat in the
824
+ // prototype chain of every error the client then went on to format, log or
825
+ // inspect. The one key that has to be expressible is written as a data
826
+ // property, which is what was meant.
827
+ for (const key of Object.keys(context)) {
828
+ const value = context[key];
829
+ if (key === "__proto__") {
830
+ Object.defineProperty(error, key, {
831
+ value,
832
+ writable: true,
833
+ enumerable: true,
834
+ configurable: true,
835
+ });
836
+ continue;
837
+ }
838
+ error[key] = value;
839
+ }
746
840
  return error;
747
841
  }
748
842
  /**
@@ -758,11 +852,40 @@ export function formatError(error) {
758
852
  context[key] = value;
759
853
  }
760
854
  }
761
- const contextStr = Object.keys(context).length > 0 ? ` | ${JSON.stringify(context)}` : "";
855
+ const contextStr = Object.keys(context).length > 0 ? ` | ${safeStringify(context)}` : "";
762
856
  return `${error.name}: ${error.message}${contextStr}`;
763
857
  }
764
858
  return String(error);
765
859
  }
860
+ /**
861
+ * `JSON.stringify` for a log line, on a value that is very likely to be
862
+ * hostile.
863
+ *
864
+ * The context attached by {@link createStructuredError} is whatever the failing
865
+ * call had in hand — a `request`, a `response`, a `cause` — and all three
866
+ * routinely point back at each other. `JSON.stringify` answers that with a
867
+ * thrown `TypeError: Converting circular structure to JSON`, and a BigInt with
868
+ * another, so the function whose entire job is to turn an error into a string
869
+ * threw instead, taking the `catch` that was logging it down with it.
870
+ */
871
+ function safeStringify(value) {
872
+ const seen = new Set();
873
+ try {
874
+ return (JSON.stringify(value, (_key, v) => {
875
+ if (typeof v === "bigint")
876
+ return v.toString();
877
+ if (typeof v === "object" && v !== null) {
878
+ if (seen.has(v))
879
+ return "[Circular]";
880
+ seen.add(v);
881
+ }
882
+ return v;
883
+ }) ?? String(value));
884
+ }
885
+ catch {
886
+ return String(value);
887
+ }
888
+ }
766
889
  // ============================================================================
767
890
  // §5 TIME UTILITIES
768
891
  // ============================================================================
@@ -865,10 +988,16 @@ export function concatUint8Arrays(chunks) {
865
988
  * @returns Uint8Array or null if unsupported type
866
989
  */
867
990
  export function toUint8Array(data) {
991
+ // Both branches copy. The Uint8Array branch sliced but the ArrayBuffer
992
+ // branch wrapped, so the same call handed back a copy for one input type and
993
+ // a live view for the other: writing to the result of
994
+ // `toUint8Array(buffer)` wrote through to the caller's buffer, and two calls
995
+ // given the same buffer shared it. A function whose output is "the bytes" has
996
+ // to be the same kind of thing whichever type it was handed.
868
997
  if (data instanceof Uint8Array)
869
998
  return data.slice();
870
999
  if (data instanceof ArrayBuffer)
871
- return new Uint8Array(data);
1000
+ return new Uint8Array(data.slice(0));
872
1001
  const b = getBuffer();
873
1002
  if (b && b.isBuffer(data)) {
874
1003
  return new Uint8Array(data);
@@ -929,12 +1058,37 @@ export function mergeSignals(...signals) {
929
1058
  }
930
1059
  if (validSignals.length === 1)
931
1060
  return validSignals[0];
932
- // Check if any signal is already aborted
933
- if (validSignals.some((s) => s.aborted)) {
1061
+ // Check if any signal is already aborted.
1062
+ //
1063
+ // `controller.abort()` with no argument installs the platform's generic
1064
+ // `AbortError: This operation was aborted`, which threw away *why* the call
1065
+ // was aborted. The two-live-signal branch below preserves the reason, and so
1066
+ // does the `AbortSignal.any` path this function prefers on every current
1067
+ // runtime — so the one case a caller is most likely to hit (a signal that
1068
+ // already fired, e.g. `AbortSignal.timeout()`) was the only one that lost
1069
+ // it. `interceptors.ts` re-aborts with `existing.reason` when merging, and
1070
+ // anything reading the merged signal's reason saw a bare AbortError where it
1071
+ // should have seen the caller's TimeoutError or their own Error.
1072
+ const preAborted = validSignals.find((s) => s.aborted);
1073
+ if (preAborted) {
934
1074
  const controller = new AbortController();
935
- controller.abort();
1075
+ controller.abort(preAborted.reason);
936
1076
  return controller.signal;
937
1077
  }
1078
+ // The platform primitive, where it exists. The hand-rolled version below
1079
+ // attached an `abort` listener to every input and only ever removed it when
1080
+ // one of those inputs fired — so merging a long-lived caller signal (a
1081
+ // request-scoped controller is the usual one) accumulated one listener per
1082
+ // merge, forever, and tripped Node's MaxListenersExceededWarning at 11.
1083
+ // The "9.11" self-cleanup that was supposed to release them could not run:
1084
+ // the merged controller is never handed out, so nothing but the inputs can
1085
+ // ever abort it. `AbortSignal.any` holds its inputs weakly and adds nothing
1086
+ // observable to them. Node 20.3+, Deno, Bun and current browsers have it;
1087
+ // the manual path remains for Node 18 and older.
1088
+ const nativeAny = AbortSignal.any;
1089
+ if (typeof nativeAny === "function") {
1090
+ return nativeAny.call(AbortSignal, validSignals);
1091
+ }
938
1092
  const controller = new AbortController();
939
1093
  // abort: fire the controller and clean up ALL listeners immediately.
940
1094
  const abort = () => {
@@ -942,12 +1096,6 @@ export function mergeSignals(...signals) {
942
1096
  s.removeEventListener("abort", abort);
943
1097
  controller.abort(_abortError());
944
1098
  };
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
1099
  for (const s of validSignals)
952
1100
  s.addEventListener("abort", abort, { once: true });
953
1101
  return controller.signal;
@@ -1088,7 +1236,33 @@ export function hasNativeFetch() {
1088
1236
  export function normalizeHeaders(headers) {
1089
1237
  const result = {};
1090
1238
  headers.forEach((value, key) => {
1091
- result[key.toLowerCase()] = value;
1239
+ const name = key.toLowerCase();
1240
+ // `__proto__` is a legal header name — it is made of token characters — and
1241
+ // `result[name] = value` is a [[Set]], so a response carrying it went
1242
+ // through the inherited setter, which ignores a primitive. The header did
1243
+ // not overwrite anything; it simply disappeared, and the caller reading the
1244
+ // normalized record never learned the response had sent it.
1245
+ if (name === "__proto__") {
1246
+ Object.defineProperty(result, name, {
1247
+ value,
1248
+ writable: true,
1249
+ enumerable: true,
1250
+ configurable: true,
1251
+ });
1252
+ return;
1253
+ }
1254
+ // `Set-Cookie` is the one header the Fetch spec does NOT combine: it
1255
+ // yields each cookie as a separate `forEach` entry, while every other
1256
+ // repeated header arrives already joined with ", ". Assigning therefore
1257
+ // overwrote: a response setting two cookies normalized to the last one
1258
+ // only, and the first vanished with no error anywhere. `Headers.get()`
1259
+ // reports them combined as "a=1, b=2", so accumulate to match it — the
1260
+ // cookie jar splits this form back apart with `splitSetCookieHeaders`.
1261
+ if (name === "set-cookie" && Object.hasOwn(result, name)) {
1262
+ result[name] = `${result[name]}, ${value}`;
1263
+ return;
1264
+ }
1265
+ result[name] = value;
1092
1266
  });
1093
1267
  return result;
1094
1268
  }
package/dist/cjs/ws.js CHANGED
@@ -273,15 +273,15 @@ export class WSClient {
273
273
  if (this._state === "OPEN")
274
274
  return Promise.resolve();
275
275
  return new Promise((resolve, reject) => {
276
- const tid = timeoutMs > 0
277
- ? setTimeout(() => {
278
- const i = this._openWaiters.findIndex((w) => w.resolve === resolve);
279
- if (i !== -1)
280
- this._openWaiters.splice(i, 1);
281
- reject(new WSConnectTimeoutError(this._url, timeoutMs));
282
- }, timeoutMs)
283
- : null;
284
- this._openWaiters.push({
276
+ // The waiter is built first so the timeout can remove *this* entry by
277
+ // identity. It used to search for `w.resolve === resolve`, but the
278
+ // resolver stored on the queue is the wrapper below, not the promise's
279
+ // own `resolve` — so the search never matched, the timed-out waiter was
280
+ // never spliced out, and it stayed in `_openWaiters` for the lifetime
281
+ // of the client. A client whose `waitForOpen` timed out repeatedly grew
282
+ // that array without bound, and every later `open()`/`close()` walked
283
+ // the dead entries.
284
+ const waiter = {
285
285
  resolve: () => {
286
286
  if (tid)
287
287
  clearTimeout(tid);
@@ -292,7 +292,16 @@ export class WSClient {
292
292
  clearTimeout(tid);
293
293
  reject(e);
294
294
  },
295
- });
295
+ };
296
+ const tid = timeoutMs > 0
297
+ ? setTimeout(() => {
298
+ const i = this._openWaiters.indexOf(waiter);
299
+ if (i !== -1)
300
+ this._openWaiters.splice(i, 1);
301
+ reject(new WSConnectTimeoutError(this._url, timeoutMs));
302
+ }, timeoutMs)
303
+ : null;
304
+ this._openWaiters.push(waiter);
296
305
  });
297
306
  }
298
307
  /**
@@ -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
- const data = (await credsRes.json());
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: data.AccessKeyId,
168
- secretAccessKey: data.SecretAccessKey,
169
- sessionToken: data.Token,
170
- expiration: data.Expiration,
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
- const expiresIn = options.expiresIn ?? 3600;
502
- // Validate expiresIn range - different services have different limits
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
- if (expiresIn < 1 || expiresIn > maxExpires) {
505
- console.warn(`[aws-sigv4] presignRequest: expiresIn should be 1-${maxExpires} seconds for ${config.service}, got ${expiresIn}`);
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
- 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;
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
  */