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
@@ -57,9 +57,15 @@ export class HTTPResponseError extends Error {
57
57
  get isClientError() {
58
58
  return this.status >= 400 && this.status < 500;
59
59
  }
60
- /** Returns true if the status code is a server error (5xx). */
60
+ /**
61
+ * Returns true if the status code is a server error (5xx).
62
+ *
63
+ * Bounded above, like `isClientError` is at both ends. Unbounded, any
64
+ * status at or past 600 — a non-standard code, or anything a caller invents
65
+ * — reported itself as a server error.
66
+ */
61
67
  get isServerError() {
62
- return this.status >= 500;
68
+ return this.status >= 500 && this.status < 600;
63
69
  }
64
70
  /** Returns true if the status is 404 Not Found. */
65
71
  get isNotFound() {
@@ -240,6 +246,56 @@ export function isBinary(contentType) {
240
246
  // ============================================================================
241
247
  // §5 SIZE-LIMITED BODY READER
242
248
  // ============================================================================
249
+ /**
250
+ * One `reader.read()` that also settles when the signal aborts.
251
+ *
252
+ * Checking `signal.aborted` at the top of the read loop is not enough, because
253
+ * the loop only gets another turn when the source produces a chunk or closes.
254
+ * A connection that stalls after its last byte leaves the read pending
255
+ * forever, so the abort was never observed and the promise never settled at
256
+ * all — `readText`/`readJSON`/`readBytes` simply hung, which is the one thing
257
+ * a signal exists to prevent. Racing the signal against the read is what makes
258
+ * an abort actually abort.
259
+ *
260
+ * An aborted read resolves as `{ done: true }` rather than rejecting, so each
261
+ * caller keeps its own established behaviour for an abort: the collecting
262
+ * readers re-check the signal and throw, while the streaming parsers stop
263
+ * iterating. `cancel()` is still issued so the source is released.
264
+ */
265
+ function readWithAbort(reader, signal) {
266
+ if (!signal)
267
+ return reader.read();
268
+ if (signal.aborted)
269
+ return Promise.resolve({ done: true, value: undefined });
270
+ return new Promise((resolve, reject) => {
271
+ const onAbort = () => {
272
+ void reader.cancel("aborted").catch(() => { });
273
+ resolve({ done: true, value: undefined });
274
+ };
275
+ signal.addEventListener("abort", onAbort, { once: true });
276
+ reader
277
+ .read()
278
+ .then(resolve, reject)
279
+ .finally(() => {
280
+ signal.removeEventListener("abort", onAbort);
281
+ });
282
+ });
283
+ }
284
+ /**
285
+ * Normalise the `onExceed` mode, failing closed.
286
+ *
287
+ * The three modes were tested as a chain of `if`s that each ended in a
288
+ * `break`/`return`. A value matching none of them therefore fell straight
289
+ * through to the ordinary "push the chunk" line below and the size limit did
290
+ * nothing at all: `onExceed: "ignore"` against a 10-byte cap returned all
291
+ * 1000 bytes, through both `readBodyWithLimit` and `readText`. That is a
292
+ * fail-open on the guard this option exists to provide, and the config is a
293
+ * plain interface, so a value read from JSON or asserted at a call site
294
+ * reaches it. An unrecognised mode is treated as `"throw"`.
295
+ */
296
+ function normaliseOnExceed(mode) {
297
+ return mode === "truncate" || mode === "abort" ? mode : "throw";
298
+ }
243
299
  /**
244
300
  * Read a ReadableStream with a hard size limit.
245
301
  *
@@ -252,7 +308,7 @@ export function isBinary(contentType) {
252
308
  * @throws {DOMException} If the signal fires.
253
309
  */
254
310
  export async function readBodyWithLimit(stream, url, limit, signal) {
255
- const onExceed = limit.onExceed ?? "throw";
311
+ const onExceed = normaliseOnExceed(limit.onExceed);
256
312
  const reader = stream.getReader();
257
313
  const chunks = [];
258
314
  let bytesRead = 0;
@@ -262,9 +318,16 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
262
318
  await reader.cancel("aborted");
263
319
  throw new DOMException("Response reading aborted", "AbortError");
264
320
  }
265
- const { done, value } = await reader.read();
266
- if (done)
321
+ const { done, value } = await readWithAbort(reader, signal);
322
+ if (done) {
323
+ // `done` is also how an aborted read settles (see readWithAbort), so
324
+ // the signal has to be re-checked here or a partial body would be
325
+ // returned as a complete one.
326
+ if (signal?.aborted) {
327
+ throw new DOMException("Response reading aborted", "AbortError");
328
+ }
267
329
  break;
330
+ }
268
331
  const newTotal = bytesRead + value.byteLength;
269
332
  if (newTotal > limit.maxBytes) {
270
333
  limit.onExceedCallback?.(newTotal, limit.maxBytes);
@@ -314,7 +377,20 @@ const CHARSET_ALIASES = {
314
377
  function resolveCharset(charset) {
315
378
  if (!charset)
316
379
  return "utf-8";
317
- return CHARSET_ALIASES[charset.toLowerCase()] ?? "utf-8";
380
+ const key = charset.toLowerCase().trim();
381
+ // Own-property check, not a bare index. `CHARSET_ALIASES` is a plain object
382
+ // literal, so an unrecognised charset naming an `Object.prototype` member
383
+ // resolved to that member instead of falling back: `charset=constructor`
384
+ // reached `TextDecoder` as `function Object() { [native code] }` and
385
+ // `charset=__proto__` as `[object Object]`, either of which throws
386
+ // ERR_ENCODING_NOT_SUPPORTED and fails the whole body with a decode error
387
+ // naming a charset nobody sent. The charset is attacker-controlled — it
388
+ // comes straight off a response header — so that is a denial of service on
389
+ // body parsing, and `__proto__` is the kind of value a scanner fuzzes.
390
+ if (Object.prototype.hasOwnProperty.call(CHARSET_ALIASES, key)) {
391
+ return CHARSET_ALIASES[key];
392
+ }
393
+ return "utf-8";
318
394
  }
319
395
  /**
320
396
  * Detect charset from BOM (byte order mark) in the body.
@@ -397,6 +473,14 @@ export function decompressStream(stream, encoding) {
397
473
  if (normalized === "br") {
398
474
  return brotliDecompressStream(stream);
399
475
  }
476
+ // ── zstd ─────────────────────────────────────────────────────────────────
477
+ // `DecompressionStream` has no "zstd" format in Node or the browsers as of
478
+ // this writing, but does in Bun and newer edge runtimes. Prefer it when
479
+ // present, otherwise fall back to node:zlib on Node 22+, and only then give
480
+ // up and pass the bytes through.
481
+ if (normalized === "zstd") {
482
+ return zstdDecompressStream(stream);
483
+ }
400
484
  // ── gzip / deflate ────────────────────────────────────────────────────────
401
485
  if (typeof DecompressionStream === "undefined")
402
486
  return stream;
@@ -420,34 +504,87 @@ export function decompressStream(stream, encoding) {
420
504
  * Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
421
505
  */
422
506
  function brotliDecompressStream(stream) {
423
- // Detect Node.js safely - process may not exist in edge runtimes
424
- let isNode = false;
507
+ // Non-Node runtime: we cannot decompress brotli natively. Return raw bytes —
508
+ // this will likely cause a parse error downstream, which is the correct
509
+ // behaviour (the server shouldn't have sent br in the first place).
510
+ if (!isNodeRuntime())
511
+ return stream;
512
+ return nodeZlibDecompressStream(stream, (zlib) => zlib.createBrotliDecompress());
513
+ }
514
+ /**
515
+ * True when running on Node.js.
516
+ *
517
+ * Read defensively: `process` does not exist in edge runtimes and reading it
518
+ * through a hostile global can throw.
519
+ */
520
+ function isNodeRuntime() {
425
521
  try {
426
- isNode =
427
- typeof globalThis.process?.versions
428
- ?.node === "string";
522
+ return (typeof globalThis.process?.versions
523
+ ?.node === "string");
429
524
  }
430
525
  catch {
431
- /* not Node */
432
- }
433
- if (!isNode) {
434
- // Non-Node runtime: we cannot decompress brotli natively.
435
- // Return raw bytes — this will likely cause a parse error downstream,
436
- // which is the correct behaviour (server shouldn't have sent br).
437
- return stream;
526
+ return false;
438
527
  }
439
- // Node.js: pipe through zlib.createBrotliDecompress() via a TransformStream bridge
528
+ }
529
+ /**
530
+ * Pipe a web ReadableStream through a `node:zlib` decompressor.
531
+ *
532
+ * @param stream - Compressed input.
533
+ * @param create - Builds the zlib transform from the imported module.
534
+ * @returns The decompressed stream.
535
+ */
536
+ function nodeZlibDecompressStream(stream, create) {
537
+ // The node stream and the read loop can both try to settle the controller,
538
+ // and `cancel()` settles it too. Whichever arrives second would throw
539
+ // ERR_INVALID_STATE on an already closed controller — an unhandled throw
540
+ // that takes the whole process down, not just the request. Two reachable
541
+ // ways to get there:
542
+ // * an upstream error rejects `reader.read()`, we error the controller,
543
+ // and the `br.destroy()` below makes node emit "end" afterwards;
544
+ // * the consumer cancels (an aborted request), and cancelling the source
545
+ // makes zlib drain and emit "end" against a closed controller.
546
+ // Neither is exotic — a server aborting a response mid-body, or a client
547
+ // cancelling one, is ordinary traffic on any content-encoding response.
548
+ let settled = false;
549
+ // Hoisted so `cancel()` can reach it: the bridge holds a reader lock for the
550
+ // whole stream, and `ReadableStream.cancel()` rejects with ERR_INVALID_STATE
551
+ // on a locked stream. `reader.cancel()` is the supported way to abort it.
552
+ let reader = null;
553
+ // `start()` awaits the dynamic import before locking the source, so a cancel
554
+ // can land in that window — an immediately-aborted request is the common
555
+ // case, not an edge one. Remember it and apply it once the reader exists.
556
+ let cancelRequested = false;
440
557
  return new ReadableStream({
441
558
  async start(controller) {
442
559
  // Dynamic import so non-Node runtimes never load node:zlib
443
560
  const zlib = await import("node:zlib");
444
- const br = zlib.createBrotliDecompress();
445
- const reader = stream.getReader();
561
+ const br = create(zlib);
562
+ reader = stream.getReader();
563
+ if (cancelRequested) {
564
+ // The source may already be gone; there is nothing left to release.
565
+ void reader.cancel().catch(() => undefined);
566
+ reader.releaseLock();
567
+ reader = null;
568
+ return;
569
+ }
570
+ const settleError = (e) => {
571
+ if (settled)
572
+ return;
573
+ settled = true;
574
+ controller.error(e);
575
+ };
446
576
  br.on("data", (chunk) => {
577
+ if (settled)
578
+ return;
447
579
  controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
448
580
  });
449
- br.on("end", () => controller.close());
450
- br.on("error", (e) => controller.error(e));
581
+ br.on("end", () => {
582
+ if (settled)
583
+ return;
584
+ settled = true;
585
+ controller.close();
586
+ });
587
+ br.on("error", settleError);
451
588
  try {
452
589
  while (true) {
453
590
  const { done, value } = await reader.read();
@@ -460,17 +597,57 @@ function brotliDecompressStream(stream) {
460
597
  }
461
598
  catch (e) {
462
599
  br.destroy(e instanceof Error ? e : new Error(String(e)));
463
- controller.error(e);
600
+ settleError(e);
464
601
  }
465
602
  finally {
466
603
  reader.releaseLock();
604
+ reader = null;
467
605
  }
468
606
  },
469
607
  cancel() {
470
- stream.cancel();
608
+ // Mark settled first: cancelling the reader resolves the loop's pending
609
+ // read as `done`, which ends the zlib transform and emits "end" against
610
+ // an already-closed controller.
611
+ settled = true;
612
+ cancelRequested = true;
613
+ void reader?.cancel().catch(() => undefined);
471
614
  },
472
615
  });
473
616
  }
617
+ /**
618
+ * Decompress a zstd-encoded ReadableStream.
619
+ *
620
+ * Tries, in order: the standard `DecompressionStream("zstd")` (Bun, newer edge
621
+ * runtimes), then `node:zlib`'s `createZstdDecompress` (Node 22+), then passes
622
+ * the bytes through unchanged. That last case is a genuine failure mode rather
623
+ * than a formality — kinetex does not advertise `zstd` in its default
624
+ * `accept-encoding`, so a server should never choose it, but a caller that sets
625
+ * the header explicitly (or proxies a response through) can still hit it.
626
+ */
627
+ function zstdDecompressStream(stream) {
628
+ if (typeof DecompressionStream !== "undefined") {
629
+ try {
630
+ // "zstd" is a real CompressionFormat at runtime but is not yet in the
631
+ // TypeScript lib union, so it is asserted here rather than widened.
632
+ const Ctor = DecompressionStream;
633
+ return stream.pipeThrough(new Ctor("zstd"));
634
+ }
635
+ catch {
636
+ // Format not recognised by this runtime — try node:zlib below.
637
+ }
638
+ }
639
+ if (!isNodeRuntime())
640
+ return stream;
641
+ // node:zlib gained zstd in Node 22; older versions have no such export.
642
+ return nodeZlibDecompressStream(stream, (zlib) => {
643
+ const create = zlib
644
+ .createZstdDecompress;
645
+ if (typeof create !== "function") {
646
+ throw new Error("zstd decompression is not available in this Node.js version");
647
+ }
648
+ return create();
649
+ });
650
+ }
474
651
  /**
475
652
  * Detect and apply decompression based on Content-Encoding header.
476
653
  *
@@ -620,7 +797,7 @@ export async function* readNDJSON(response, options = {}) {
620
797
  while (true) {
621
798
  if (options.signal?.aborted)
622
799
  break;
623
- const { done, value } = await reader.read();
800
+ const { done, value } = await readWithAbort(reader, options.signal);
624
801
  if (done) {
625
802
  // Process remaining buffer
626
803
  const trimmed = buffer.trim();
@@ -684,6 +861,12 @@ export async function* readJSONStream(response, options = {}) {
684
861
  let depth = 0;
685
862
  let buffer = "";
686
863
  let startIdx = -1;
864
+ // JSON string state for the scan below. Braces and brackets inside a string
865
+ // are DATA: the counter closed on a lone `}` in a value, cut the object in
866
+ // half, and reported both halves as parse errors — so the object was
867
+ // silently dropped. `{"note":"use } to close"}` yielded nothing at all.
868
+ let inString = false;
869
+ let escaped = false;
687
870
  const flushBuffer = () => {
688
871
  if (startIdx === -1)
689
872
  return null;
@@ -695,7 +878,7 @@ export async function* readJSONStream(response, options = {}) {
695
878
  while (true) {
696
879
  if (options.signal?.aborted)
697
880
  break;
698
- const { done, value } = await reader.read();
881
+ const { done, value } = await readWithAbort(reader, options.signal);
699
882
  if (done) {
700
883
  // Try to parse remaining content as final object
701
884
  const remaining = flushBuffer();
@@ -714,6 +897,19 @@ export async function* readJSONStream(response, options = {}) {
714
897
  buffer += decoder.decode(value, { stream: true });
715
898
  for (let i = 0; i < buffer.length; i++) {
716
899
  const char = buffer[i];
900
+ if (inString) {
901
+ if (escaped)
902
+ escaped = false;
903
+ else if (char === "\\")
904
+ escaped = true;
905
+ else if (char === '"')
906
+ inString = false;
907
+ continue;
908
+ }
909
+ if (char === '"') {
910
+ inString = true;
911
+ continue;
912
+ }
717
913
  if (char === "{" || char === "[") {
718
914
  if (depth === 0)
719
915
  startIdx = i;
@@ -732,6 +928,12 @@ export async function* readJSONStream(response, options = {}) {
732
928
  options.onParseError?.(err, objStr);
733
929
  }
734
930
  buffer = buffer.slice(i + 1);
931
+ // The consumed object's index must not survive into the next
932
+ // object: the buffer was resliced underneath it, and a stale
933
+ // startIdx is an offset into a string it no longer indexes.
934
+ startIdx = -1;
935
+ inString = false;
936
+ escaped = false;
735
937
  i = -1;
736
938
  }
737
939
  }
@@ -843,7 +1045,9 @@ export class ResponseCache {
843
1045
  const entry = this.cache.get(key);
844
1046
  if (!entry)
845
1047
  return null;
846
- if (Date.now() > entry.expiresAt) {
1048
+ // `>=`, not `>`: an entry is expired AT its expiry instant, so
1049
+ // `ttlMs: 0` served a cached response instead of expiring immediately.
1050
+ if (Date.now() >= entry.expiresAt) {
847
1051
  this.cache.delete(key);
848
1052
  return null;
849
1053
  }
@@ -892,14 +1096,20 @@ export async function diffResponses(a, b) {
892
1096
  const headersChanged = {};
893
1097
  const headersAdded = {};
894
1098
  const headersRemoved = {};
1099
+ // Own-property tests, not `in`. `in` walks the prototype chain, and these
1100
+ // are plain object literals, so a header whose lowercased name is an
1101
+ // `Object.prototype` member read as PRESENT in the other response when it
1102
+ // was absent: a `constructor` header only on `a` came back as
1103
+ // `headersChanged: { constructor: ["yes", null] }` — a removed header
1104
+ // reported as changed-to-nothing, in a field typed `[string, string]`.
895
1105
  for (const [k, v] of Object.entries(ha)) {
896
- if (!(k in hb))
1106
+ if (!Object.prototype.hasOwnProperty.call(hb, k))
897
1107
  headersRemoved[k] = v;
898
1108
  else if (hb[k] !== v)
899
1109
  headersChanged[k] = [v, hb[k]];
900
1110
  }
901
1111
  for (const [k, v] of Object.entries(hb)) {
902
- if (!(k in ha))
1112
+ if (!Object.prototype.hasOwnProperty.call(ha, k))
903
1113
  headersAdded[k] = v;
904
1114
  }
905
1115
  // Body diff (only if both are text)
@@ -942,7 +1152,10 @@ export async function parseMultipartResponse(response) {
942
1152
  // Split on boundary
943
1153
  // offset tracking via start variable
944
1154
  const findBoundary = (from) => {
945
- for (let i = from; i < body.length - boundary.length; i++) {
1155
+ // `<=`, not `<`: the loop needs `i + boundary.length <= body.length`, so
1156
+ // a boundary sitting flush against the end of the body — no trailing
1157
+ // newline at all — was the one position the search could not reach.
1158
+ for (let i = from; i <= body.length - boundary.length; i++) {
946
1159
  if (body.subarray(i, i + boundary.length).every((b, j) => b === boundary[j]))
947
1160
  return i;
948
1161
  }
@@ -984,17 +1197,25 @@ export async function parseMultipartResponse(response) {
984
1197
  // The closing boundary break happens below after parsing
985
1198
  }
986
1199
  }
987
- const partBytes = body.subarray(start, end - 2); // strip trailing CRLF
988
- const crlfPos = findDoubleCRLF(partBytes);
989
- if (crlfPos === -1) {
1200
+ // Strip the delimiter that precedes this boundary. It was a blind `- 2`
1201
+ // on the assumption of CRLF, so a bare-LF body lost its last two real
1202
+ // bytes, and a part framed some other way lost them too. Measured on the
1203
+ // same two parts: `end - 2` gave "hell" and "worl".
1204
+ let partEnd = end;
1205
+ if (partEnd >= 2 && body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a)
1206
+ partEnd -= 2;
1207
+ else if (partEnd >= 1 && body[partEnd - 1] === 0x0a)
1208
+ partEnd -= 1;
1209
+ const brk = findHeaderBreak(body, start, partEnd);
1210
+ if (!brk) {
990
1211
  start = end + boundary.length;
991
1212
  continue;
992
1213
  }
993
- const headerBytes = partBytes.subarray(0, crlfPos);
994
- const partBody = partBytes.subarray(crlfPos + 4);
1214
+ const headerBytes = body.subarray(start, brk.headerEnd);
1215
+ const partBody = body.subarray(brk.bodyStart, partEnd);
995
1216
  const headerText = new TextDecoder().decode(headerBytes);
996
1217
  const headers = {};
997
- for (const line of headerText.split(/\r\n/)) {
1218
+ for (const line of headerText.split(/\r\n|\n/)) {
998
1219
  const colon = line.indexOf(":");
999
1220
  if (colon !== -1) {
1000
1221
  headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
@@ -1009,16 +1230,32 @@ export async function parseMultipartResponse(response) {
1009
1230
  }
1010
1231
  return parts;
1011
1232
  }
1012
- function findDoubleCRLF(bytes) {
1013
- for (let i = 0; i < bytes.length - 3; i++) {
1233
+ /**
1234
+ * Locate the blank line separating a part's headers from its body, accepting
1235
+ * both CRLF and bare-LF framing.
1236
+ *
1237
+ * The old helper matched `\r\n\r\n` and nothing else, so a multipart body
1238
+ * that used bare LF — non-conformant per RFC 2046, and produced in practice —
1239
+ * parsed as ZERO parts rather than as a slightly-off one: every part failed
1240
+ * the header/body split, each was skipped, and the caller got an empty array
1241
+ * with no error. Both framings are now accepted, and the returned body offset
1242
+ * is the real start of the content either way.
1243
+ */
1244
+ function findHeaderBreak(bytes, from, to) {
1245
+ for (let i = from; i < to; i++) {
1246
+ // Bare LF: checked first because in `\r\n\r\n` the byte after the
1247
+ // first LF is CR, so the two rules cannot both match at one position.
1248
+ if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) {
1249
+ return { headerEnd: i, bodyStart: i + 2 };
1250
+ }
1014
1251
  if (bytes[i] === 0x0d &&
1015
1252
  bytes[i + 1] === 0x0a &&
1016
1253
  bytes[i + 2] === 0x0d &&
1017
1254
  bytes[i + 3] === 0x0a) {
1018
- return i;
1255
+ return { headerEnd: i, bodyStart: i + 4 };
1019
1256
  }
1020
1257
  }
1021
- return -1;
1258
+ return null;
1022
1259
  }
1023
1260
  /**
1024
1261
  * Read a multipart/form-data response as a FormData object.
@@ -1080,9 +1317,15 @@ export function extractServerTiming(headers) {
1080
1317
  const name = (segments[0] ?? "").trim();
1081
1318
  const params = parseParams(segments.slice(1).join(";"));
1082
1319
  const dur = params.get("dur");
1320
+ // `Number.isFinite`, not `dur !== undefined`: `parseFloat` returns NaN
1321
+ // for anything non-numeric, and `Server-Timing: x;dur=abc` is a
1322
+ // malformed header a server can send. The NaN went straight into the
1323
+ // metrics array, where it is not a number, fails every comparison, and
1324
+ // vanishes from a chart with nothing to indicate why.
1325
+ const parsed = dur !== undefined ? parseFloat(dur) : NaN;
1083
1326
  return {
1084
1327
  name,
1085
- duration: dur !== undefined ? parseFloat(dur) : null,
1328
+ duration: Number.isFinite(parsed) ? parsed : null,
1086
1329
  description: params.get("desc") ?? null,
1087
1330
  };
1088
1331
  })
@@ -1114,19 +1357,29 @@ async function readBodyBytes(response, url, options) {
1114
1357
  if (options.signal?.aborted) {
1115
1358
  throw new DOMException("Response reading aborted", "AbortError");
1116
1359
  }
1117
- const { done, value } = await reader.read();
1118
- if (done)
1360
+ const { done, value } = await readWithAbort(reader, options.signal);
1361
+ if (done) {
1362
+ // See readWithAbort: an aborted read also resolves as done.
1363
+ if (options.signal?.aborted) {
1364
+ throw new DOMException("Response reading aborted", "AbortError");
1365
+ }
1119
1366
  break;
1367
+ }
1120
1368
  chunks.push(value);
1121
1369
  }
1122
1370
  }
1123
1371
  finally {
1372
+ // Cancel before releasing when we are bailing out, so the unread remainder
1373
+ // of the body is discarded rather than left buffered on a released lock.
1374
+ if (options.signal?.aborted) {
1375
+ await reader.cancel("aborted").catch(() => { });
1376
+ }
1124
1377
  reader.releaseLock();
1125
1378
  }
1126
1379
  return concatUint8Arrays(chunks);
1127
1380
  }
1128
1381
  function applySizeLimit(stream, url, limit, signal) {
1129
- const onExceed = limit.onExceed ?? "throw";
1382
+ const onExceed = normaliseOnExceed(limit.onExceed);
1130
1383
  let bytesRead = 0;
1131
1384
  return new ReadableStream({
1132
1385
  async start(controller) {
@@ -1138,8 +1391,15 @@ function applySizeLimit(stream, url, limit, signal) {
1138
1391
  controller.error(new DOMException("Response reading aborted", "AbortError"));
1139
1392
  return;
1140
1393
  }
1141
- const { done, value } = await reader.read();
1394
+ const { done, value } = await readWithAbort(reader, signal);
1142
1395
  if (done) {
1396
+ // See readWithAbort: an aborted read also resolves as done, and
1397
+ // here the caller must see the error rather than a clean close.
1398
+ if (signal?.aborted) {
1399
+ await reader.cancel("aborted").catch(() => { });
1400
+ controller.error(new DOMException("Response reading aborted", "AbortError"));
1401
+ return;
1402
+ }
1143
1403
  controller.close();
1144
1404
  break;
1145
1405
  }
@@ -1180,7 +1440,16 @@ function validateContentType(actual, expected, url) {
1180
1440
  throw new ContentTypeError(expected, "(none)", url);
1181
1441
  const normalizedActual = actual.split(";")[0]?.trim().toLowerCase() ?? "";
1182
1442
  const normalizedExpected = expected.split(";")[0]?.trim().toLowerCase() ?? "";
1183
- if (!normalizedActual.startsWith(normalizedExpected)) {
1443
+ // Media-type equality, not a string prefix. `startsWith` accepted
1444
+ // `text/plaintext` for `text/plain` and `application/json-seq` for
1445
+ // `application/json` — an `expectedContentType` set to narrow the parse to
1446
+ // one format accepted every format whose name merely begins with it. A bare
1447
+ // top-level type still matches the whole tree, which is the one prefix case
1448
+ // that means something: `image` for `image/png`.
1449
+ const actualType = normalizedActual.split("/")[0] ?? "";
1450
+ const matches = normalizedActual === normalizedExpected ||
1451
+ (normalizedExpected.indexOf("/") === -1 && actualType === normalizedExpected);
1452
+ if (!matches) {
1184
1453
  throw new ContentTypeError(expected, actual, url);
1185
1454
  }
1186
1455
  }