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
@@ -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,87 @@ 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
+ }
299
+ /**
300
+ * The single size-limit policy, shared by the buffering reader and the
301
+ * streaming one.
302
+ *
303
+ * Both had the same four decisions — when to fire `onExceedCallback`, when to
304
+ * throw, how much of the crossing chunk to keep, when to stop — written out
305
+ * twice. They drifted: the streaming version cancelled with `"size limit"` where
306
+ * the buffered one used `"size limit exceeded"`, which is the kind of difference
307
+ * that only matters in a log nobody diffs.
308
+ *
309
+ * `makeError` is supplied by the caller because the two entry points raise
310
+ * different public classes (`ResponseSizeLimitError` here,
311
+ * `SizeLimitError` from `types.ts` via `core.ts`). Both carry the code
312
+ * `"ESIZELIMIT"`, but they are different classes with different prototypes and
313
+ * collapsing them is a breaking change, so each keeps its own.
314
+ */
315
+ export function limitAction(chunk, bytesRead, limit, onExceed, makeError) {
316
+ const newTotal = bytesRead + chunk.byteLength;
317
+ if (newTotal <= limit.maxBytes)
318
+ return { kind: "keep" };
319
+ limit.onExceedCallback?.(newTotal, limit.maxBytes);
320
+ if (onExceed === "throw")
321
+ throw makeError(newTotal, limit.maxBytes);
322
+ if (onExceed === "truncate") {
323
+ const remaining = limit.maxBytes - bytesRead;
324
+ // `remaining > 0` always holds here: `newTotal > maxBytes` with a
325
+ // non-negative chunk length implies `bytesRead < maxBytes`.
326
+ return { kind: "truncate", chunk: chunk.slice(0, remaining) };
327
+ }
328
+ return { kind: "stop" };
329
+ }
243
330
  /**
244
331
  * Read a ReadableStream with a hard size limit.
245
332
  *
@@ -252,7 +339,7 @@ export function isBinary(contentType) {
252
339
  * @throws {DOMException} If the signal fires.
253
340
  */
254
341
  export async function readBodyWithLimit(stream, url, limit, signal) {
255
- const onExceed = limit.onExceed ?? "throw";
342
+ const onExceed = normaliseOnExceed(limit.onExceed);
256
343
  const reader = stream.getReader();
257
344
  const chunks = [];
258
345
  let bytesRead = 0;
@@ -262,34 +349,45 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
262
349
  await reader.cancel("aborted");
263
350
  throw new DOMException("Response reading aborted", "AbortError");
264
351
  }
265
- const { done, value } = await reader.read();
266
- if (done)
267
- break;
268
- const newTotal = bytesRead + value.byteLength;
269
- if (newTotal > limit.maxBytes) {
270
- limit.onExceedCallback?.(newTotal, limit.maxBytes);
271
- if (onExceed === "throw") {
272
- await reader.cancel("size limit exceeded");
273
- throw new ResponseSizeLimitError(newTotal, limit.maxBytes, url);
274
- }
275
- if (onExceed === "abort") {
276
- await reader.cancel("size limit exceeded");
277
- break;
278
- }
279
- if (onExceed === "truncate") {
280
- const remaining = limit.maxBytes - bytesRead;
281
- if (remaining > 0) {
282
- chunks.push(value.slice(0, remaining));
283
- bytesRead += remaining;
284
- }
285
- break;
352
+ const { done, value } = await readWithAbort(reader, signal);
353
+ if (done) {
354
+ // `done` is also how an aborted read settles (see readWithAbort), so
355
+ // the signal has to be re-checked here or a partial body would be
356
+ // returned as a complete one.
357
+ if (signal?.aborted) {
358
+ throw new DOMException("Response reading aborted", "AbortError");
286
359
  }
360
+ break;
361
+ }
362
+ let action;
363
+ try {
364
+ action = limitAction(value, bytesRead, limit, onExceed, (n, max) => new ResponseSizeLimitError(n, max, url));
365
+ }
366
+ catch (err) {
367
+ await reader.cancel("size limit exceeded");
368
+ throw err;
369
+ }
370
+ if (action.kind === "stop") {
371
+ await reader.cancel("size limit exceeded");
372
+ break;
373
+ }
374
+ if (action.kind === "truncate") {
375
+ chunks.push(action.chunk);
376
+ bytesRead += action.chunk.byteLength;
377
+ break;
287
378
  }
288
379
  chunks.push(value);
289
380
  bytesRead += value.byteLength;
290
381
  }
291
382
  }
292
383
  finally {
384
+ // Cancel before releasing when bailing out, so the unread remainder of the
385
+ // body is discarded rather than left buffered on a released lock. The loop
386
+ // already cancels on every abort it observes; this covers the race where the
387
+ // signal fired between the last read and the loop exiting.
388
+ if (signal?.aborted) {
389
+ await reader.cancel("aborted").catch(() => { });
390
+ }
293
391
  reader.releaseLock();
294
392
  }
295
393
  return concatUint8Arrays(chunks);
@@ -314,7 +412,20 @@ const CHARSET_ALIASES = {
314
412
  function resolveCharset(charset) {
315
413
  if (!charset)
316
414
  return "utf-8";
317
- return CHARSET_ALIASES[charset.toLowerCase()] ?? "utf-8";
415
+ const key = charset.toLowerCase().trim();
416
+ // Own-property check, not a bare index. `CHARSET_ALIASES` is a plain object
417
+ // literal, so an unrecognised charset naming an `Object.prototype` member
418
+ // resolved to that member instead of falling back: `charset=constructor`
419
+ // reached `TextDecoder` as `function Object() { [native code] }` and
420
+ // `charset=__proto__` as `[object Object]`, either of which throws
421
+ // ERR_ENCODING_NOT_SUPPORTED and fails the whole body with a decode error
422
+ // naming a charset nobody sent. The charset is attacker-controlled — it
423
+ // comes straight off a response header — so that is a denial of service on
424
+ // body parsing, and `__proto__` is the kind of value a scanner fuzzes.
425
+ if (Object.prototype.hasOwnProperty.call(CHARSET_ALIASES, key)) {
426
+ return CHARSET_ALIASES[key];
427
+ }
428
+ return "utf-8";
318
429
  }
319
430
  /**
320
431
  * Detect charset from BOM (byte order mark) in the body.
@@ -337,6 +448,13 @@ function detectBOM(bytes) {
337
448
  * @returns The decoded string.
338
449
  * @throws {ResponseDecodeError} If decoding fails even with lenient fallback.
339
450
  */
451
+ /**
452
+ * Decode a response body to a string.
453
+ *
454
+ * `url` is optional: it is only used to name the originating URL on a
455
+ * `ResponseDecodeError`, and requiring every caller to thread one through just
456
+ * to decode a body it already has in hand made the third parameter a formality.
457
+ */
340
458
  export function decodeBody(bytes, charset, url) {
341
459
  const detected = detectBOM(bytes);
342
460
  const resolved = detected ?? resolveCharset(charset);
@@ -351,7 +469,7 @@ export function decodeBody(bytes, charset, url) {
351
469
  return new TextDecoder(resolved, { fatal: false }).decode(data);
352
470
  }
353
471
  catch (err2) {
354
- throw new ResponseDecodeError(String(err2), resolved, url);
472
+ throw new ResponseDecodeError(String(err2), resolved, url ?? "<unknown url>");
355
473
  }
356
474
  }
357
475
  }
@@ -388,6 +506,13 @@ function createSizeLimitedDecompression(stream, encoding, maxDecompressedSize =
388
506
  */
389
507
  export function decompressStream(stream, encoding) {
390
508
  const normalized = encoding.trim().toLowerCase();
509
+ // An empty body carrying a Content-Encoding is an ordinary response — an
510
+ // empty 204, an empty error body — and every decoder here rejects it,
511
+ // `DecompressionStream("gzip")` and node:zlib alike, with `Z_BUF_ERROR:
512
+ // unexpected end of file` because there is no frame to parse. Without this
513
+ // check the client turned a normal empty reply into an exception. zstd
514
+ // happened to tolerate it on Node 22, which made the defect look
515
+ // runtime-specific when it was encoding-independent and had always been there.
391
516
  // ── Brotli (br) ──────────────────────────────────────────────────────────
392
517
  // WHATWG DecompressionStream does NOT support brotli in any current runtime.
393
518
  // On Node.js we use node:zlib.createBrotliDecompress() via a stream adapter.
@@ -397,6 +522,14 @@ export function decompressStream(stream, encoding) {
397
522
  if (normalized === "br") {
398
523
  return brotliDecompressStream(stream);
399
524
  }
525
+ // ── zstd ─────────────────────────────────────────────────────────────────
526
+ // `DecompressionStream` has no "zstd" format in Node or the browsers as of
527
+ // this writing, but does in Bun and newer edge runtimes. Prefer it when
528
+ // present, otherwise fall back to node:zlib on Node 22+, and only then give
529
+ // up and pass the bytes through.
530
+ if (normalized === "zstd") {
531
+ return zstdDecompressStream(stream);
532
+ }
400
533
  // ── gzip / deflate ────────────────────────────────────────────────────────
401
534
  if (typeof DecompressionStream === "undefined")
402
535
  return stream;
@@ -404,73 +537,272 @@ export function decompressStream(stream, encoding) {
404
537
  // CompressionFormat is a browser type, we normalize to it
405
538
  // Type assertion is safe as we validated the encoding above
406
539
  const format = normalized;
407
- const ds = new DecompressionStream(format);
408
- // DecompressionStream and TransformStream have compatible pipeThrough signatures
409
- // in all modern runtimes, but TypeScript doesn't recognize this type compatibility
410
- // Type assertion is safe here as we've validated the format above
411
- return stream.pipeThrough(ds);
540
+ // Constructed here, inside this `try`, so an unrecognised format still
541
+ // throws synchronously and falls through to the pass-through below. Built
542
+ // lazily inside the stream it would throw from `start()`, where nothing
543
+ // catches it, and an unknown encoding would surface as an unhandled
544
+ // TypeError instead of the raw bytes.
545
+ const decoder = new DecompressionStream(format);
546
+ return decodeUnlessEmpty(stream, decoder);
412
547
  }
413
548
  catch {
414
549
  // Unsupported format — return raw (caller sent Accept-Encoding conservatively)
415
550
  return stream;
416
551
  }
417
552
  }
553
+ /**
554
+ * Decodes `source`, or yields nothing at all when `source` is empty.
555
+ *
556
+ * `DecompressionStream("gzip")` rejects a zero-byte stream with the same
557
+ * `Z_BUF_ERROR: unexpected end of file` the node:zlib decoders raise, because
558
+ * there is no frame to parse. An empty body carrying `Content-Encoding` is an
559
+ * ordinary response — an empty 204, an empty error body — so it must produce
560
+ * nothing rather than fail. zstd happened to tolerate this on Node 22, which
561
+ * made the defect look runtime-specific when it was encoding-independent.
562
+ *
563
+ * The decoder is constructed on the first non-empty chunk rather than up front,
564
+ * because that is the only point at which emptiness is known and this has to
565
+ * stay synchronous. Only the chunk that was already in flight is held, so a
566
+ * streaming body still streams.
567
+ */
568
+ function decodeUnlessEmpty(source, decoder) {
569
+ const reader = source.getReader();
570
+ return new ReadableStream({
571
+ async start(controller) {
572
+ // A source may hand over zero-length chunks before its real payload, or
573
+ // instead of one — counting the chunk rather than its bytes would send an
574
+ // empty body to the decoder and get `Z_BUF_ERROR` back. So: read until
575
+ // something with bytes arrives, or the source ends.
576
+ let first = null;
577
+ for (;;) {
578
+ const next = await reader
579
+ .read()
580
+ .catch(() => ({ done: true, value: undefined }));
581
+ if (next.done || next.value.byteLength > 0) {
582
+ first = next;
583
+ break;
584
+ }
585
+ }
586
+ if (first.done) {
587
+ // Nothing to decode: an empty body carrying a Content-Encoding is an
588
+ // ordinary response, not a malformed one.
589
+ controller.close();
590
+ return;
591
+ }
592
+ // Re-hydrate the peeked chunk in front of the rest of the source, then
593
+ // let `pipeThrough` do the piping. Doing the write/read interleaving by
594
+ // hand deadlocked on backpressure: a TransformStream's `write()` does not
595
+ // settle until its readable queue drains, and nothing was reading yet.
596
+ const rehydrated = new ReadableStream({
597
+ start(c) {
598
+ c.enqueue(first.value);
599
+ },
600
+ async pull(c) {
601
+ const next = await reader.read();
602
+ if (next.done)
603
+ c.close();
604
+ else
605
+ c.enqueue(next.value);
606
+ },
607
+ cancel(reason) {
608
+ void reader.cancel(reason).catch(() => undefined);
609
+ },
610
+ });
611
+ const out = rehydrated.pipeThrough(decoder);
612
+ const pump = out.getReader();
613
+ void (async () => {
614
+ try {
615
+ for (;;) {
616
+ const chunk = await pump.read();
617
+ if (chunk.done) {
618
+ controller.close();
619
+ return;
620
+ }
621
+ controller.enqueue(chunk.value);
622
+ }
623
+ }
624
+ catch (err) {
625
+ controller.error(err);
626
+ }
627
+ })();
628
+ },
629
+ cancel(reason) {
630
+ void reader.cancel(reason).catch(() => undefined);
631
+ },
632
+ });
633
+ }
418
634
  /**
419
635
  * Decompress a brotli-encoded ReadableStream.
420
636
  * Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
421
637
  */
422
638
  function brotliDecompressStream(stream) {
423
- // Detect Node.js safely - process may not exist in edge runtimes
424
- let isNode = false;
639
+ // Non-Node runtime: we cannot decompress brotli natively. Return raw bytes —
640
+ // this will likely cause a parse error downstream, which is the correct
641
+ // behaviour (the server shouldn't have sent br in the first place).
642
+ if (!isNodeRuntime())
643
+ return stream;
644
+ return nodeZlibDecompressStream(stream, (zlib) => zlib.createBrotliDecompress());
645
+ }
646
+ /**
647
+ * True when running on Node.js.
648
+ *
649
+ * Read defensively: `process` does not exist in edge runtimes and reading it
650
+ * through a hostile global can throw.
651
+ */
652
+ function isNodeRuntime() {
425
653
  try {
426
- isNode =
427
- typeof globalThis.process?.versions
428
- ?.node === "string";
654
+ return (typeof globalThis.process?.versions
655
+ ?.node === "string");
429
656
  }
430
657
  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;
658
+ return false;
438
659
  }
439
- // Node.js: pipe through zlib.createBrotliDecompress() via a TransformStream bridge
660
+ }
661
+ /**
662
+ * Pipe a web ReadableStream through a `node:zlib` decompressor.
663
+ *
664
+ * @param stream - Compressed input.
665
+ * @param create - Builds the zlib transform from the imported module.
666
+ * @returns The decompressed stream.
667
+ */
668
+ function nodeZlibDecompressStream(stream, create) {
669
+ // The node stream and the read loop can both try to settle the controller,
670
+ // and `cancel()` settles it too. Whichever arrives second would throw
671
+ // ERR_INVALID_STATE on an already closed controller — an unhandled throw
672
+ // that takes the whole process down, not just the request. Two reachable
673
+ // ways to get there:
674
+ // * an upstream error rejects `reader.read()`, we error the controller,
675
+ // and the `br.destroy()` below makes node emit "end" afterwards;
676
+ // * the consumer cancels (an aborted request), and cancelling the source
677
+ // makes zlib drain and emit "end" against a closed controller.
678
+ // Neither is exotic — a server aborting a response mid-body, or a client
679
+ // cancelling one, is ordinary traffic on any content-encoding response.
680
+ let settled = false;
681
+ // Hoisted so `cancel()` can reach it: the bridge holds a reader lock for the
682
+ // whole stream, and `ReadableStream.cancel()` rejects with ERR_INVALID_STATE
683
+ // on a locked stream. `reader.cancel()` is the supported way to abort it.
684
+ let reader = null;
685
+ // `start()` awaits the dynamic import before locking the source, so a cancel
686
+ // can land in that window — an immediately-aborted request is the common
687
+ // case, not an edge one. Remember it and apply it once the reader exists.
688
+ let cancelRequested = false;
689
+ // A zero-byte source is a legitimate response — an empty 204, an empty error
690
+ // body — and it carries a Content-Encoding header like any other. Every
691
+ // zlib decoder rejects it (`Z_BUF_ERROR: unexpected end of file`), so
692
+ // without this the client turned an ordinary empty reply into an exception.
693
+ // zstd happened to tolerate it on Node 22 only, which is why this went
694
+ // unnoticed: the failure looked runtime-specific and was actually encoding-
695
+ // independent, and had been there for gzip, deflate and br the whole time.
696
+ let sawBytes = false;
440
697
  return new ReadableStream({
441
698
  async start(controller) {
442
699
  // Dynamic import so non-Node runtimes never load node:zlib
443
700
  const zlib = await import("node:zlib");
444
- const br = zlib.createBrotliDecompress();
445
- const reader = stream.getReader();
701
+ const br = create(zlib);
702
+ reader = stream.getReader();
703
+ if (cancelRequested) {
704
+ // The source may already be gone; there is nothing left to release.
705
+ void reader.cancel().catch(() => undefined);
706
+ reader.releaseLock();
707
+ reader = null;
708
+ return;
709
+ }
710
+ const settleError = (e) => {
711
+ if (settled)
712
+ return;
713
+ settled = true;
714
+ controller.error(e);
715
+ };
446
716
  br.on("data", (chunk) => {
717
+ if (settled)
718
+ return;
447
719
  controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
448
720
  });
449
- br.on("end", () => controller.close());
450
- br.on("error", (e) => controller.error(e));
721
+ br.on("end", () => {
722
+ if (settled)
723
+ return;
724
+ settled = true;
725
+ controller.close();
726
+ });
727
+ br.on("error", settleError);
451
728
  try {
452
729
  while (true) {
453
730
  const { done, value } = await reader.read();
454
731
  if (done) {
732
+ // Nothing was ever fed to the decoder, so there is no frame to
733
+ // parse and nothing to fail on. Close with what we have: nothing.
734
+ if (!sawBytes) {
735
+ if (!settled) {
736
+ settled = true;
737
+ controller.close();
738
+ }
739
+ br.destroy();
740
+ break;
741
+ }
455
742
  br.end();
456
743
  break;
457
744
  }
745
+ // Count bytes, not chunks: a source may hand over a zero-length
746
+ // chunk and then close, which is still an empty body. Counting the
747
+ // chunk instead let exactly that case reach the decoder and fail.
748
+ if (value.byteLength > 0)
749
+ sawBytes = true;
458
750
  br.write(value);
459
751
  }
460
752
  }
461
753
  catch (e) {
462
754
  br.destroy(e instanceof Error ? e : new Error(String(e)));
463
- controller.error(e);
755
+ settleError(e);
464
756
  }
465
757
  finally {
466
758
  reader.releaseLock();
759
+ reader = null;
467
760
  }
468
761
  },
469
762
  cancel() {
470
- stream.cancel();
763
+ // Mark settled first: cancelling the reader resolves the loop's pending
764
+ // read as `done`, which ends the zlib transform and emits "end" against
765
+ // an already-closed controller.
766
+ settled = true;
767
+ cancelRequested = true;
768
+ void reader?.cancel().catch(() => undefined);
471
769
  },
472
770
  });
473
771
  }
772
+ /**
773
+ * Decompress a zstd-encoded ReadableStream.
774
+ *
775
+ * Tries, in order: the standard `DecompressionStream("zstd")` (Bun, newer edge
776
+ * runtimes), then `node:zlib`'s `createZstdDecompress` (Node 22+), then passes
777
+ * the bytes through unchanged. That last case is a genuine failure mode rather
778
+ * than a formality — kinetex does not advertise `zstd` in its default
779
+ * `accept-encoding`, so a server should never choose it, but a caller that sets
780
+ * the header explicitly (or proxies a response through) can still hit it.
781
+ */
782
+ function zstdDecompressStream(stream) {
783
+ if (typeof DecompressionStream !== "undefined") {
784
+ try {
785
+ // "zstd" is a real CompressionFormat at runtime but is not yet in the
786
+ // TypeScript lib union, so it is asserted here rather than widened.
787
+ const Ctor = DecompressionStream;
788
+ return stream.pipeThrough(new Ctor("zstd"));
789
+ }
790
+ catch {
791
+ // Format not recognised by this runtime — try node:zlib below.
792
+ }
793
+ }
794
+ if (!isNodeRuntime())
795
+ return stream;
796
+ // node:zlib gained zstd in Node 22; older versions have no such export.
797
+ return nodeZlibDecompressStream(stream, (zlib) => {
798
+ const create = zlib
799
+ .createZstdDecompress;
800
+ if (typeof create !== "function") {
801
+ throw new Error("zstd decompression is not available in this Node.js version");
802
+ }
803
+ return create();
804
+ });
805
+ }
474
806
  /**
475
807
  * Detect and apply decompression based on Content-Encoding header.
476
808
  *
@@ -620,7 +952,7 @@ export async function* readNDJSON(response, options = {}) {
620
952
  while (true) {
621
953
  if (options.signal?.aborted)
622
954
  break;
623
- const { done, value } = await reader.read();
955
+ const { done, value } = await readWithAbort(reader, options.signal);
624
956
  if (done) {
625
957
  // Process remaining buffer
626
958
  const trimmed = buffer.trim();
@@ -684,6 +1016,12 @@ export async function* readJSONStream(response, options = {}) {
684
1016
  let depth = 0;
685
1017
  let buffer = "";
686
1018
  let startIdx = -1;
1019
+ // JSON string state for the scan below. Braces and brackets inside a string
1020
+ // are DATA: the counter closed on a lone `}` in a value, cut the object in
1021
+ // half, and reported both halves as parse errors — so the object was
1022
+ // silently dropped. `{"note":"use } to close"}` yielded nothing at all.
1023
+ let inString = false;
1024
+ let escaped = false;
687
1025
  const flushBuffer = () => {
688
1026
  if (startIdx === -1)
689
1027
  return null;
@@ -695,7 +1033,7 @@ export async function* readJSONStream(response, options = {}) {
695
1033
  while (true) {
696
1034
  if (options.signal?.aborted)
697
1035
  break;
698
- const { done, value } = await reader.read();
1036
+ const { done, value } = await readWithAbort(reader, options.signal);
699
1037
  if (done) {
700
1038
  // Try to parse remaining content as final object
701
1039
  const remaining = flushBuffer();
@@ -714,6 +1052,19 @@ export async function* readJSONStream(response, options = {}) {
714
1052
  buffer += decoder.decode(value, { stream: true });
715
1053
  for (let i = 0; i < buffer.length; i++) {
716
1054
  const char = buffer[i];
1055
+ if (inString) {
1056
+ if (escaped)
1057
+ escaped = false;
1058
+ else if (char === "\\")
1059
+ escaped = true;
1060
+ else if (char === '"')
1061
+ inString = false;
1062
+ continue;
1063
+ }
1064
+ if (char === '"') {
1065
+ inString = true;
1066
+ continue;
1067
+ }
717
1068
  if (char === "{" || char === "[") {
718
1069
  if (depth === 0)
719
1070
  startIdx = i;
@@ -732,6 +1083,12 @@ export async function* readJSONStream(response, options = {}) {
732
1083
  options.onParseError?.(err, objStr);
733
1084
  }
734
1085
  buffer = buffer.slice(i + 1);
1086
+ // The consumed object's index must not survive into the next
1087
+ // object: the buffer was resliced underneath it, and a stale
1088
+ // startIdx is an offset into a string it no longer indexes.
1089
+ startIdx = -1;
1090
+ inString = false;
1091
+ escaped = false;
735
1092
  i = -1;
736
1093
  }
737
1094
  }
@@ -843,7 +1200,9 @@ export class ResponseCache {
843
1200
  const entry = this.cache.get(key);
844
1201
  if (!entry)
845
1202
  return null;
846
- if (Date.now() > entry.expiresAt) {
1203
+ // `>=`, not `>`: an entry is expired AT its expiry instant, so
1204
+ // `ttlMs: 0` served a cached response instead of expiring immediately.
1205
+ if (Date.now() >= entry.expiresAt) {
847
1206
  this.cache.delete(key);
848
1207
  return null;
849
1208
  }
@@ -892,14 +1251,20 @@ export async function diffResponses(a, b) {
892
1251
  const headersChanged = {};
893
1252
  const headersAdded = {};
894
1253
  const headersRemoved = {};
1254
+ // Own-property tests, not `in`. `in` walks the prototype chain, and these
1255
+ // are plain object literals, so a header whose lowercased name is an
1256
+ // `Object.prototype` member read as PRESENT in the other response when it
1257
+ // was absent: a `constructor` header only on `a` came back as
1258
+ // `headersChanged: { constructor: ["yes", null] }` — a removed header
1259
+ // reported as changed-to-nothing, in a field typed `[string, string]`.
895
1260
  for (const [k, v] of Object.entries(ha)) {
896
- if (!(k in hb))
1261
+ if (!Object.prototype.hasOwnProperty.call(hb, k))
897
1262
  headersRemoved[k] = v;
898
1263
  else if (hb[k] !== v)
899
1264
  headersChanged[k] = [v, hb[k]];
900
1265
  }
901
1266
  for (const [k, v] of Object.entries(hb)) {
902
- if (!(k in ha))
1267
+ if (!Object.prototype.hasOwnProperty.call(ha, k))
903
1268
  headersAdded[k] = v;
904
1269
  }
905
1270
  // Body diff (only if both are text)
@@ -942,7 +1307,10 @@ export async function parseMultipartResponse(response) {
942
1307
  // Split on boundary
943
1308
  // offset tracking via start variable
944
1309
  const findBoundary = (from) => {
945
- for (let i = from; i < body.length - boundary.length; i++) {
1310
+ // `<=`, not `<`: the loop needs `i + boundary.length <= body.length`, so
1311
+ // a boundary sitting flush against the end of the body — no trailing
1312
+ // newline at all — was the one position the search could not reach.
1313
+ for (let i = from; i <= body.length - boundary.length; i++) {
946
1314
  if (body.subarray(i, i + boundary.length).every((b, j) => b === boundary[j]))
947
1315
  return i;
948
1316
  }
@@ -984,17 +1352,25 @@ export async function parseMultipartResponse(response) {
984
1352
  // The closing boundary break happens below after parsing
985
1353
  }
986
1354
  }
987
- const partBytes = body.subarray(start, end - 2); // strip trailing CRLF
988
- const crlfPos = findDoubleCRLF(partBytes);
989
- if (crlfPos === -1) {
1355
+ // Strip the delimiter that precedes this boundary. It was a blind `- 2`
1356
+ // on the assumption of CRLF, so a bare-LF body lost its last two real
1357
+ // bytes, and a part framed some other way lost them too. Measured on the
1358
+ // same two parts: `end - 2` gave "hell" and "worl".
1359
+ let partEnd = end;
1360
+ if (partEnd >= 2 && body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a)
1361
+ partEnd -= 2;
1362
+ else if (partEnd >= 1 && body[partEnd - 1] === 0x0a)
1363
+ partEnd -= 1;
1364
+ const brk = findHeaderBreak(body, start, partEnd);
1365
+ if (!brk) {
990
1366
  start = end + boundary.length;
991
1367
  continue;
992
1368
  }
993
- const headerBytes = partBytes.subarray(0, crlfPos);
994
- const partBody = partBytes.subarray(crlfPos + 4);
1369
+ const headerBytes = body.subarray(start, brk.headerEnd);
1370
+ const partBody = body.subarray(brk.bodyStart, partEnd);
995
1371
  const headerText = new TextDecoder().decode(headerBytes);
996
1372
  const headers = {};
997
- for (const line of headerText.split(/\r\n/)) {
1373
+ for (const line of headerText.split(/\r\n|\n/)) {
998
1374
  const colon = line.indexOf(":");
999
1375
  if (colon !== -1) {
1000
1376
  headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
@@ -1009,16 +1385,32 @@ export async function parseMultipartResponse(response) {
1009
1385
  }
1010
1386
  return parts;
1011
1387
  }
1012
- function findDoubleCRLF(bytes) {
1013
- for (let i = 0; i < bytes.length - 3; i++) {
1388
+ /**
1389
+ * Locate the blank line separating a part's headers from its body, accepting
1390
+ * both CRLF and bare-LF framing.
1391
+ *
1392
+ * The old helper matched `\r\n\r\n` and nothing else, so a multipart body
1393
+ * that used bare LF — non-conformant per RFC 2046, and produced in practice —
1394
+ * parsed as ZERO parts rather than as a slightly-off one: every part failed
1395
+ * the header/body split, each was skipped, and the caller got an empty array
1396
+ * with no error. Both framings are now accepted, and the returned body offset
1397
+ * is the real start of the content either way.
1398
+ */
1399
+ function findHeaderBreak(bytes, from, to) {
1400
+ for (let i = from; i < to; i++) {
1401
+ // Bare LF: checked first because in `\r\n\r\n` the byte after the
1402
+ // first LF is CR, so the two rules cannot both match at one position.
1403
+ if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) {
1404
+ return { headerEnd: i, bodyStart: i + 2 };
1405
+ }
1014
1406
  if (bytes[i] === 0x0d &&
1015
1407
  bytes[i + 1] === 0x0a &&
1016
1408
  bytes[i + 2] === 0x0d &&
1017
1409
  bytes[i + 3] === 0x0a) {
1018
- return i;
1410
+ return { headerEnd: i, bodyStart: i + 4 };
1019
1411
  }
1020
1412
  }
1021
- return -1;
1413
+ return null;
1022
1414
  }
1023
1415
  /**
1024
1416
  * Read a multipart/form-data response as a FormData object.
@@ -1080,9 +1472,15 @@ export function extractServerTiming(headers) {
1080
1472
  const name = (segments[0] ?? "").trim();
1081
1473
  const params = parseParams(segments.slice(1).join(";"));
1082
1474
  const dur = params.get("dur");
1475
+ // `Number.isFinite`, not `dur !== undefined`: `parseFloat` returns NaN
1476
+ // for anything non-numeric, and `Server-Timing: x;dur=abc` is a
1477
+ // malformed header a server can send. The NaN went straight into the
1478
+ // metrics array, where it is not a number, fails every comparison, and
1479
+ // vanishes from a chart with nothing to indicate why.
1480
+ const parsed = dur !== undefined ? parseFloat(dur) : NaN;
1083
1481
  return {
1084
1482
  name,
1085
- duration: dur !== undefined ? parseFloat(dur) : null,
1483
+ duration: Number.isFinite(parsed) ? parsed : null,
1086
1484
  description: params.get("desc") ?? null,
1087
1485
  };
1088
1486
  })
@@ -1091,9 +1489,9 @@ export function extractServerTiming(headers) {
1091
1489
  // ============================================================================
1092
1490
  // §14 UTILITIES
1093
1491
  // ============================================================================
1094
- async function readBodyBytes(response, url, options) {
1492
+ function readBodyBytes(response, url, options) {
1095
1493
  if (!response.body)
1096
- return new Uint8Array(0);
1494
+ return Promise.resolve(new Uint8Array(0));
1097
1495
  const headers = normalizeHeaders(response.headers);
1098
1496
  let stream = response.body;
1099
1497
  // Decompression
@@ -1107,26 +1505,14 @@ async function readBodyBytes(response, url, options) {
1107
1505
  if (options.sizeLimit) {
1108
1506
  return readBodyWithLimit(stream, url, options.sizeLimit, options.signal);
1109
1507
  }
1110
- const reader = stream.getReader();
1111
- const chunks = [];
1112
- try {
1113
- while (true) {
1114
- if (options.signal?.aborted) {
1115
- throw new DOMException("Response reading aborted", "AbortError");
1116
- }
1117
- const { done, value } = await reader.read();
1118
- if (done)
1119
- break;
1120
- chunks.push(value);
1121
- }
1122
- }
1123
- finally {
1124
- reader.releaseLock();
1125
- }
1126
- return concatUint8Arrays(chunks);
1508
+ // No configured limit is the same read with an unreachable one. `newTotal`
1509
+ // can never exceed `Infinity`, so the limit policy never engages and
1510
+ // `onExceedCallback` never fires — which is exactly the previous behaviour,
1511
+ // minus a second copy of the read loop to keep in step with the first.
1512
+ return readBodyWithLimit(stream, url, { maxBytes: Infinity }, options.signal);
1127
1513
  }
1128
1514
  function applySizeLimit(stream, url, limit, signal) {
1129
- const onExceed = limit.onExceed ?? "throw";
1515
+ const onExceed = normaliseOnExceed(limit.onExceed);
1130
1516
  let bytesRead = 0;
1131
1517
  return new ReadableStream({
1132
1518
  async start(controller) {
@@ -1138,32 +1524,33 @@ function applySizeLimit(stream, url, limit, signal) {
1138
1524
  controller.error(new DOMException("Response reading aborted", "AbortError"));
1139
1525
  return;
1140
1526
  }
1141
- const { done, value } = await reader.read();
1527
+ const { done, value } = await readWithAbort(reader, signal);
1142
1528
  if (done) {
1529
+ // See readWithAbort: an aborted read also resolves as done, and
1530
+ // here the caller must see the error rather than a clean close.
1531
+ if (signal?.aborted) {
1532
+ await reader.cancel("aborted").catch(() => { });
1533
+ controller.error(new DOMException("Response reading aborted", "AbortError"));
1534
+ return;
1535
+ }
1143
1536
  controller.close();
1144
1537
  break;
1145
1538
  }
1146
- const newTotal = bytesRead + value.byteLength;
1147
- if (newTotal > limit.maxBytes) {
1148
- limit.onExceedCallback?.(newTotal, limit.maxBytes);
1149
- if (onExceed === "throw") {
1150
- await reader.cancel("size limit");
1151
- controller.error(new ResponseSizeLimitError(newTotal, limit.maxBytes, url));
1152
- return;
1153
- }
1154
- if (onExceed === "abort") {
1155
- await reader.cancel("size limit");
1156
- controller.close();
1157
- return;
1158
- }
1159
- if (onExceed === "truncate") {
1160
- const remaining = limit.maxBytes - bytesRead;
1161
- if (remaining > 0)
1162
- controller.enqueue(value.slice(0, remaining));
1163
- controller.close();
1164
- await reader.cancel("size limit");
1165
- return;
1166
- }
1539
+ let action;
1540
+ try {
1541
+ action = limitAction(value, bytesRead, limit, onExceed, (n, max) => new ResponseSizeLimitError(n, max, url));
1542
+ }
1543
+ catch (err) {
1544
+ await reader.cancel("size limit exceeded");
1545
+ controller.error(err);
1546
+ return;
1547
+ }
1548
+ if (action.kind !== "keep") {
1549
+ if (action.kind === "truncate")
1550
+ controller.enqueue(action.chunk);
1551
+ controller.close();
1552
+ await reader.cancel("size limit exceeded");
1553
+ return;
1167
1554
  }
1168
1555
  bytesRead += value.byteLength;
1169
1556
  controller.enqueue(value);
@@ -1180,7 +1567,16 @@ function validateContentType(actual, expected, url) {
1180
1567
  throw new ContentTypeError(expected, "(none)", url);
1181
1568
  const normalizedActual = actual.split(";")[0]?.trim().toLowerCase() ?? "";
1182
1569
  const normalizedExpected = expected.split(";")[0]?.trim().toLowerCase() ?? "";
1183
- if (!normalizedActual.startsWith(normalizedExpected)) {
1570
+ // Media-type equality, not a string prefix. `startsWith` accepted
1571
+ // `text/plaintext` for `text/plain` and `application/json-seq` for
1572
+ // `application/json` — an `expectedContentType` set to narrow the parse to
1573
+ // one format accepted every format whose name merely begins with it. A bare
1574
+ // top-level type still matches the whole tree, which is the one prefix case
1575
+ // that means something: `image` for `image/png`.
1576
+ const actualType = normalizedActual.split("/")[0] ?? "";
1577
+ const matches = normalizedActual === normalizedExpected ||
1578
+ (normalizedExpected.indexOf("/") === -1 && actualType === normalizedExpected);
1579
+ if (!matches) {
1184
1580
  throw new ContentTypeError(expected, actual, url);
1185
1581
  }
1186
1582
  }