kinetex 1.4.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 (95) hide show
  1. package/README.md +170 -46
  2. package/dist/browser/kinetex.esm.js +29 -29
  3. package/dist/browser/kinetex.js +1143 -791
  4. package/dist/browser/kinetex.min.js +29 -29
  5. package/dist/cjs/aws-sigv4.js +7 -14
  6. package/dist/cjs/cache.js +9 -0
  7. package/dist/cjs/circuit-breaker.js +8 -1
  8. package/dist/cjs/client.js +350 -95
  9. package/dist/cjs/cookie-parser.js +28 -1
  10. package/dist/cjs/core.js +227 -5
  11. package/dist/cjs/dedup.js +93 -10
  12. package/dist/cjs/digest.js +36 -15
  13. package/dist/cjs/graphql.js +2 -11
  14. package/dist/cjs/headers.js +24 -1
  15. package/dist/cjs/interceptors.js +6 -17
  16. package/dist/cjs/lifecycle.js +3 -19
  17. package/dist/cjs/logging.js +12 -29
  18. package/dist/cjs/progress.js +1 -3
  19. package/dist/cjs/proxy.js +19 -2
  20. package/dist/cjs/response.js +202 -75
  21. package/dist/cjs/socks5.js +5 -0
  22. package/dist/cjs/utils.js +115 -27
  23. package/dist/cjs/ws.js +43 -2
  24. package/dist/esm/aws-sigv4.js +7 -14
  25. package/dist/esm/aws-sigv4.js.map +1 -1
  26. package/dist/esm/cache.js +9 -0
  27. package/dist/esm/cache.js.map +1 -1
  28. package/dist/esm/circuit-breaker.js +8 -1
  29. package/dist/esm/circuit-breaker.js.map +1 -1
  30. package/dist/esm/client.js +350 -95
  31. package/dist/esm/client.js.map +1 -1
  32. package/dist/esm/cookie-parser.js +28 -1
  33. package/dist/esm/cookie-parser.js.map +1 -1
  34. package/dist/esm/core.js +227 -5
  35. package/dist/esm/core.js.map +1 -1
  36. package/dist/esm/dedup.js +93 -10
  37. package/dist/esm/dedup.js.map +1 -1
  38. package/dist/esm/digest.js +36 -15
  39. package/dist/esm/digest.js.map +1 -1
  40. package/dist/esm/graphql.js +2 -11
  41. package/dist/esm/graphql.js.map +1 -1
  42. package/dist/esm/headers.js +24 -1
  43. package/dist/esm/headers.js.map +1 -1
  44. package/dist/esm/interceptors.js +6 -17
  45. package/dist/esm/interceptors.js.map +1 -1
  46. package/dist/esm/lifecycle.js +3 -19
  47. package/dist/esm/lifecycle.js.map +1 -1
  48. package/dist/esm/logging.js +12 -29
  49. package/dist/esm/logging.js.map +1 -1
  50. package/dist/esm/progress.js +1 -3
  51. package/dist/esm/progress.js.map +1 -1
  52. package/dist/esm/proxy.js +19 -2
  53. package/dist/esm/proxy.js.map +1 -1
  54. package/dist/esm/response.js +202 -75
  55. package/dist/esm/response.js.map +1 -1
  56. package/dist/esm/socks5.js +5 -0
  57. package/dist/esm/socks5.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/url.js.map +1 -1
  60. package/dist/esm/utils.js +115 -27
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/ws.js +43 -2
  63. package/dist/esm/ws.js.map +1 -1
  64. package/dist/types/aws-sigv4.d.ts.map +1 -1
  65. package/dist/types/cache.d.ts.map +1 -1
  66. package/dist/types/circuit-breaker.d.ts +1 -1
  67. package/dist/types/circuit-breaker.d.ts.map +1 -1
  68. package/dist/types/client.d.ts.map +1 -1
  69. package/dist/types/cookie-parser.d.ts.map +1 -1
  70. package/dist/types/core.d.ts +113 -0
  71. package/dist/types/core.d.ts.map +1 -1
  72. package/dist/types/dedup.d.ts +38 -3
  73. package/dist/types/dedup.d.ts.map +1 -1
  74. package/dist/types/digest.d.ts.map +1 -1
  75. package/dist/types/graphql.d.ts.map +1 -1
  76. package/dist/types/headers.d.ts +23 -4
  77. package/dist/types/headers.d.ts.map +1 -1
  78. package/dist/types/interceptors.d.ts +24 -38
  79. package/dist/types/interceptors.d.ts.map +1 -1
  80. package/dist/types/lifecycle.d.ts.map +1 -1
  81. package/dist/types/logging.d.ts.map +1 -1
  82. package/dist/types/progress.d.ts.map +1 -1
  83. package/dist/types/proxy.d.ts.map +1 -1
  84. package/dist/types/response.d.ts +42 -1
  85. package/dist/types/response.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/types.d.ts +105 -23
  88. package/dist/types/types.d.ts.map +1 -1
  89. package/dist/types/url.d.ts +0 -11
  90. package/dist/types/url.d.ts.map +1 -1
  91. package/dist/types/utils.d.ts +69 -1
  92. package/dist/types/utils.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +0 -1
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +10 -4
@@ -296,6 +296,37 @@ function readWithAbort(reader, signal) {
296
296
  function normaliseOnExceed(mode) {
297
297
  return mode === "truncate" || mode === "abort" ? mode : "throw";
298
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
+ }
299
330
  /**
300
331
  * Read a ReadableStream with a hard size limit.
301
332
  *
@@ -328,31 +359,35 @@ export async function readBodyWithLimit(stream, url, limit, signal) {
328
359
  }
329
360
  break;
330
361
  }
331
- const newTotal = bytesRead + value.byteLength;
332
- if (newTotal > limit.maxBytes) {
333
- limit.onExceedCallback?.(newTotal, limit.maxBytes);
334
- if (onExceed === "throw") {
335
- await reader.cancel("size limit exceeded");
336
- throw new ResponseSizeLimitError(newTotal, limit.maxBytes, url);
337
- }
338
- if (onExceed === "abort") {
339
- await reader.cancel("size limit exceeded");
340
- break;
341
- }
342
- if (onExceed === "truncate") {
343
- const remaining = limit.maxBytes - bytesRead;
344
- if (remaining > 0) {
345
- chunks.push(value.slice(0, remaining));
346
- bytesRead += remaining;
347
- }
348
- break;
349
- }
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;
350
378
  }
351
379
  chunks.push(value);
352
380
  bytesRead += value.byteLength;
353
381
  }
354
382
  }
355
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
+ }
356
391
  reader.releaseLock();
357
392
  }
358
393
  return concatUint8Arrays(chunks);
@@ -413,6 +448,13 @@ function detectBOM(bytes) {
413
448
  * @returns The decoded string.
414
449
  * @throws {ResponseDecodeError} If decoding fails even with lenient fallback.
415
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
+ */
416
458
  export function decodeBody(bytes, charset, url) {
417
459
  const detected = detectBOM(bytes);
418
460
  const resolved = detected ?? resolveCharset(charset);
@@ -427,7 +469,7 @@ export function decodeBody(bytes, charset, url) {
427
469
  return new TextDecoder(resolved, { fatal: false }).decode(data);
428
470
  }
429
471
  catch (err2) {
430
- throw new ResponseDecodeError(String(err2), resolved, url);
472
+ throw new ResponseDecodeError(String(err2), resolved, url ?? "<unknown url>");
431
473
  }
432
474
  }
433
475
  }
@@ -464,6 +506,13 @@ function createSizeLimitedDecompression(stream, encoding, maxDecompressedSize =
464
506
  */
465
507
  export function decompressStream(stream, encoding) {
466
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.
467
516
  // ── Brotli (br) ──────────────────────────────────────────────────────────
468
517
  // WHATWG DecompressionStream does NOT support brotli in any current runtime.
469
518
  // On Node.js we use node:zlib.createBrotliDecompress() via a stream adapter.
@@ -488,17 +537,100 @@ export function decompressStream(stream, encoding) {
488
537
  // CompressionFormat is a browser type, we normalize to it
489
538
  // Type assertion is safe as we validated the encoding above
490
539
  const format = normalized;
491
- const ds = new DecompressionStream(format);
492
- // DecompressionStream and TransformStream have compatible pipeThrough signatures
493
- // in all modern runtimes, but TypeScript doesn't recognize this type compatibility
494
- // Type assertion is safe here as we've validated the format above
495
- 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);
496
547
  }
497
548
  catch {
498
549
  // Unsupported format — return raw (caller sent Accept-Encoding conservatively)
499
550
  return stream;
500
551
  }
501
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
+ }
502
634
  /**
503
635
  * Decompress a brotli-encoded ReadableStream.
504
636
  * Uses node:zlib on Node.js. Returns the stream unchanged on other runtimes.
@@ -554,6 +686,14 @@ function nodeZlibDecompressStream(stream, create) {
554
686
  // can land in that window — an immediately-aborted request is the common
555
687
  // case, not an edge one. Remember it and apply it once the reader exists.
556
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;
557
697
  return new ReadableStream({
558
698
  async start(controller) {
559
699
  // Dynamic import so non-Node runtimes never load node:zlib
@@ -589,9 +729,24 @@ function nodeZlibDecompressStream(stream, create) {
589
729
  while (true) {
590
730
  const { done, value } = await reader.read();
591
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
+ }
592
742
  br.end();
593
743
  break;
594
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;
595
750
  br.write(value);
596
751
  }
597
752
  }
@@ -1334,9 +1489,9 @@ export function extractServerTiming(headers) {
1334
1489
  // ============================================================================
1335
1490
  // §14 UTILITIES
1336
1491
  // ============================================================================
1337
- async function readBodyBytes(response, url, options) {
1492
+ function readBodyBytes(response, url, options) {
1338
1493
  if (!response.body)
1339
- return new Uint8Array(0);
1494
+ return Promise.resolve(new Uint8Array(0));
1340
1495
  const headers = normalizeHeaders(response.headers);
1341
1496
  let stream = response.body;
1342
1497
  // Decompression
@@ -1350,33 +1505,11 @@ async function readBodyBytes(response, url, options) {
1350
1505
  if (options.sizeLimit) {
1351
1506
  return readBodyWithLimit(stream, url, options.sizeLimit, options.signal);
1352
1507
  }
1353
- const reader = stream.getReader();
1354
- const chunks = [];
1355
- try {
1356
- while (true) {
1357
- if (options.signal?.aborted) {
1358
- throw new DOMException("Response reading aborted", "AbortError");
1359
- }
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
- }
1366
- break;
1367
- }
1368
- chunks.push(value);
1369
- }
1370
- }
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
- }
1377
- reader.releaseLock();
1378
- }
1379
- 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);
1380
1513
  }
1381
1514
  function applySizeLimit(stream, url, limit, signal) {
1382
1515
  const onExceed = normaliseOnExceed(limit.onExceed);
@@ -1403,27 +1536,21 @@ function applySizeLimit(stream, url, limit, signal) {
1403
1536
  controller.close();
1404
1537
  break;
1405
1538
  }
1406
- const newTotal = bytesRead + value.byteLength;
1407
- if (newTotal > limit.maxBytes) {
1408
- limit.onExceedCallback?.(newTotal, limit.maxBytes);
1409
- if (onExceed === "throw") {
1410
- await reader.cancel("size limit");
1411
- controller.error(new ResponseSizeLimitError(newTotal, limit.maxBytes, url));
1412
- return;
1413
- }
1414
- if (onExceed === "abort") {
1415
- await reader.cancel("size limit");
1416
- controller.close();
1417
- return;
1418
- }
1419
- if (onExceed === "truncate") {
1420
- const remaining = limit.maxBytes - bytesRead;
1421
- if (remaining > 0)
1422
- controller.enqueue(value.slice(0, remaining));
1423
- controller.close();
1424
- await reader.cancel("size limit");
1425
- return;
1426
- }
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;
1427
1554
  }
1428
1555
  bytesRead += value.byteLength;
1429
1556
  controller.enqueue(value);
@@ -397,6 +397,11 @@ async function withRetry(fn, maxRetries, delayMs) {
397
397
  }
398
398
  throw lastError;
399
399
  }
400
+ // Deliberately kept local. This module has no imports at all, so it can be
401
+ // loaded on its own in restricted runtimes where a relative import of
402
+ // `utils.ts` would pull the whole package graph in with it. `sleep(ms)` with
403
+ // no signal is the one shape that cannot diverge from the shared copy — it
404
+ // resolves after `ms` and attaches nothing.
400
405
  function sleep(ms) {
401
406
  return new Promise((r) => setTimeout(r, ms));
402
407
  }
package/dist/cjs/utils.js CHANGED
@@ -668,7 +668,7 @@ function isBlockedIPv6(b) {
668
668
  * @param allowedSchemes - Allowed URL schemes
669
669
  * @returns True if URL is safe
670
670
  */
671
- export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
671
+ export function isSafeURL(url, allowedSchemes = ["http", "https"], options = {}) {
672
672
  try {
673
673
  const parsed = typeof url === "string" ? new URL(url) : url;
674
674
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -680,25 +680,31 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
680
680
  if (FORBIDDEN_SCHEMES.has(scheme)) {
681
681
  return false;
682
682
  }
683
- let host = parsed.hostname.toLowerCase();
684
- const isV6Literal = host.startsWith("[") && host.endsWith("]");
685
- if (isV6Literal)
686
- host = host.slice(1, -1);
687
- // Block loopback hostnames (including *.localhost subdomains)
688
- if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
689
- return false;
690
- // Block private/reserved IPs (SSRF prevention) — numeric comparison over
691
- // expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
692
- if (isV6Literal || host.includes(":")) {
693
- const v6 = parseIPv6Host(host);
694
- // Unparseable IPv6 literal → reject defensively
695
- if (v6 === null || isBlockedIPv6(v6))
696
- return false;
697
- }
698
- else {
699
- const v4 = parseIPv4Host(host);
700
- if (v4 !== null && isBlockedIPv4(v4))
683
+ // `allowPrivateNetwork` relaxes *only* the address checks below. Scheme,
684
+ // path traversal, hostname length, URL length and port validation are not
685
+ // about where the socket lands, so they still apply — otherwise opting in to
686
+ // a loopback fixture would also silently opt in to `file:` and `../`.
687
+ if (!options.allowPrivateNetwork) {
688
+ let host = parsed.hostname.toLowerCase();
689
+ const isV6Literal = host.startsWith("[") && host.endsWith("]");
690
+ if (isV6Literal)
691
+ host = host.slice(1, -1);
692
+ // Block loopback hostnames (including *.localhost subdomains)
693
+ if (host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0")
701
694
  return false;
695
+ // Block private/reserved IPs (SSRF prevention) — numeric comparison over
696
+ // expanded bytes so every IPv6 spelling and numeric IPv4 form is covered.
697
+ if (isV6Literal || host.includes(":")) {
698
+ const v6 = parseIPv6Host(host);
699
+ // Unparseable IPv6 literal → reject defensively
700
+ if (v6 === null || isBlockedIPv6(v6))
701
+ return false;
702
+ }
703
+ else {
704
+ const v4 = parseIPv4Host(host);
705
+ if (v4 !== null && isBlockedIPv4(v4))
706
+ return false;
707
+ }
702
708
  }
703
709
  // Check for suspicious patterns (path traversal)
704
710
  if (parsed.hostname.includes("..") || parsed.pathname.includes("..")) {
@@ -723,6 +729,35 @@ export function isSafeURL(url, allowedSchemes = ["http", "https"]) {
723
729
  return false;
724
730
  }
725
731
  }
732
+ /**
733
+ * True when a literal IPv4/IPv6 address falls in a blocked range.
734
+ *
735
+ * The same decision `isSafeURL` makes about a hostname, applied to an address
736
+ * that has already been resolved. Kept separate from `isSafeURL` because the two
737
+ * answer different questions: `isSafeURL` reads what the caller wrote, this
738
+ * reads what the resolver returned. Both are needed — see
739
+ * `createPinnedLookup` in `core.ts`, which is the only thing that stops a
740
+ * hostname that resolves to a private address from being dialled.
741
+ *
742
+ * IPv4 literals are accepted in every form a resolver can return (dotted-quad
743
+ * and numeric); anything unparseable is treated as blocked, so an address this
744
+ * cannot understand fails closed rather than open.
745
+ */
746
+ export function isBlockedAddress(address) {
747
+ const host = address.trim().toLowerCase();
748
+ if (host.startsWith("[") && host.endsWith("]"))
749
+ return true;
750
+ if (host === "" || host === "localhost" || host.endsWith(".localhost"))
751
+ return true;
752
+ if (host.includes(":")) {
753
+ const v6 = parseIPv6Host(host);
754
+ return v6 === null || isBlockedIPv6(v6);
755
+ }
756
+ // A resolver returns dotted-quad, but `parseIPv4Host` also accepts the
757
+ // numeric spellings, and a non-IP string must not be read as "safe".
758
+ const v4 = parseIPv4Host(host);
759
+ return v4 === null || isBlockedIPv4(v4);
760
+ }
726
761
  /**
727
762
  * Check if a URL is absolute (has a scheme).
728
763
  *
@@ -868,7 +903,7 @@ export function formatError(error) {
868
903
  * another, so the function whose entire job is to turn an error into a string
869
904
  * threw instead, taking the `catch` that was logging it down with it.
870
905
  */
871
- function safeStringify(value) {
906
+ export function safeStringify(value, circularMarker = "[Circular]") {
872
907
  const seen = new Set();
873
908
  try {
874
909
  return (JSON.stringify(value, (_key, v) => {
@@ -876,7 +911,7 @@ function safeStringify(value) {
876
911
  return v.toString();
877
912
  if (typeof v === "object" && v !== null) {
878
913
  if (seen.has(v))
879
- return "[Circular]";
914
+ return circularMarker;
880
915
  seen.add(v);
881
916
  }
882
917
  return v;
@@ -946,12 +981,12 @@ export function perfNow() {
946
981
  */
947
982
  export function sleep(ms, signal) {
948
983
  if (signal?.aborted) {
949
- return Promise.reject(_abortError());
984
+ return Promise.reject(abortError());
950
985
  }
951
986
  return new Promise((resolve, reject) => {
952
987
  const onAbort = () => {
953
988
  clearTimeout(timer);
954
- reject(_abortError());
989
+ reject(abortError());
955
990
  };
956
991
  const timer = setTimeout(() => {
957
992
  signal?.removeEventListener("abort", onAbort); // cleanup on normal completion
@@ -1017,6 +1052,50 @@ export function toUint8Array(data) {
1017
1052
  * @param bytes The buffer to encode.
1018
1053
  * @returns Base64-encoded string.
1019
1054
  */
1055
+ /**
1056
+ * SHA-256 of a string or byte array, as lowercase hex.
1057
+ *
1058
+ * The `ArrayBuffer` slice is not optional. `crypto.subtle.digest` takes an
1059
+ * `ArrayBuffer`, and a `Uint8Array`'s `.buffer` is the whole backing store —
1060
+ * pass it directly and a view that starts partway into a larger buffer gets
1061
+ * hashed from offset zero, hashing the wrong bytes. Three copies of this
1062
+ * function existed and one of them made exactly that mistake.
1063
+ */
1064
+ /**
1065
+ * Copy `headers`, replacing the value of any name in `sensitive` with `mask`.
1066
+ *
1067
+ * The comparison is case-insensitive because HTTP header names are, and the
1068
+ * caller's set is folded once here rather than at each call site — which is
1069
+ * what `noUncheckedIndexedAccess` makes expensive elsewhere, where every
1070
+ * consumer ends up writing `headers["x"] ?? headers["X"]` to find a header that
1071
+ * was only ever stored under one spelling.
1072
+ *
1073
+ * `mask` is a parameter rather than a constant because four call sites already
1074
+ * disagreed on it: the logger and the lifecycle hooks wrote `"***"`, the
1075
+ * logging interceptor wrote `"**REDACTED**"`, and the HAR recorder wrote
1076
+ * `"***REDACTED***"`. Those strings are observable in log output, so unifying
1077
+ * them is a behaviour change and is deliberately not made here — but they are
1078
+ * now in one function, so a future decision to converge is a one-line edit.
1079
+ */
1080
+ /** True when `name` is one of `sensitive`, compared case-insensitively. */
1081
+ export function isSensitiveHeader(name, sensitive) {
1082
+ return sensitive.has(name.toLowerCase());
1083
+ }
1084
+ export function redactHeaders(headers, sensitive, mask) {
1085
+ const out = {};
1086
+ for (const [k, v] of Object.entries(headers)) {
1087
+ out[k] = isSensitiveHeader(k, sensitive) ? mask : v;
1088
+ }
1089
+ return out;
1090
+ }
1091
+ export async function sha256Hex(data) {
1092
+ const raw = typeof data === "string" ? new TextEncoder().encode(data) : data;
1093
+ const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
1094
+ const hash = await crypto.subtle.digest("SHA-256", buf);
1095
+ return Array.from(new Uint8Array(hash))
1096
+ .map((b) => b.toString(16).padStart(2, "0"))
1097
+ .join("");
1098
+ }
1020
1099
  export function uint8ArrayToBase64(bytes) {
1021
1100
  // Process in 3-byte (24-bit) aligned chunks to avoid padding issues mid-stream.
1022
1101
  // 3 × 2¹³ = 24 576 bytes per chunk — keeps individual strings small.
@@ -1094,7 +1173,7 @@ export function mergeSignals(...signals) {
1094
1173
  const abort = () => {
1095
1174
  for (const s of validSignals)
1096
1175
  s.removeEventListener("abort", abort);
1097
- controller.abort(_abortError());
1176
+ controller.abort(abortError());
1098
1177
  };
1099
1178
  for (const s of validSignals)
1100
1179
  s.addEventListener("abort", abort, { once: true });
@@ -1149,11 +1228,20 @@ export function randomBytes(byteCount) {
1149
1228
  .join("");
1150
1229
  }
1151
1230
  // Cross-runtime error creation: DOMException may not exist in all runtimes.
1152
- function _abortError() {
1231
+ /**
1232
+ * A platform-shaped abort rejection: a `DOMException` named `AbortError` where
1233
+ * one exists, and a plain `Error` carrying the same name where it does not.
1234
+ *
1235
+ * `message` is a parameter because two callers need different text for the same
1236
+ * event, and they had each written this function. It is deliberately not
1237
+ * `AbortError` from `types.ts`: that is a `KinetexError` with code `EABORT`, a
1238
+ * different and equally deliberate contract used by the request pipeline.
1239
+ */
1240
+ export function abortError(message = "Aborted") {
1153
1241
  if (typeof DOMException !== "undefined") {
1154
- return new DOMException("Aborted", "AbortError");
1242
+ return new DOMException(message, "AbortError");
1155
1243
  }
1156
- const err = new Error("Aborted");
1244
+ const err = new Error(message);
1157
1245
  err.name = "AbortError";
1158
1246
  return err;
1159
1247
  }
package/dist/cjs/ws.js CHANGED
@@ -19,11 +19,40 @@
19
19
  */
20
20
  // FIX (H6): remote WS payloads are untrusted — parsed JSON dispatched to
21
21
  // listeners is stripped of prototype-pollution keys (see sanitizeParsedJSON).
22
- import { sanitizeParsedJSON } from "./utils.js";
22
+ import { sanitizeParsedJSON, isValidHeaderName, isValidHeaderValue } from "./utils.js";
23
23
  // ============================================================================
24
24
  // §2 ERRORS
25
25
  // ============================================================================
26
26
  /** WebSocket connection or protocol error. */
27
+ /**
28
+ * Reject a handshake header that cannot be represented as one.
29
+ *
30
+ * `client.ws()` builds an `authorization` header straight from the configured
31
+ * bearer token or API key and hands it to a `WSClient`, and `WSClient` applies
32
+ * it through the runtime's `setHeader` — with no validation anywhere on that
33
+ * path. The HTTP pipeline does validate: `mergeHeaders` runs
34
+ * `isValidHeaderName` / `isValidHeaderValue` and refuses control characters. A
35
+ * WebSocket configured through `client.ws()` therefore skipped the one check
36
+ * that stops a token containing `\r\n` from splitting into a second header on
37
+ * the handshake.
38
+ *
39
+ * Checked in the constructor rather than at connect time so the error names the
40
+ * configuration that is wrong, and so it cannot be reached by adding a header
41
+ * later — `_headers` is assigned once and never mutated.
42
+ */
43
+ function validateHandshakeHeaders(headers) {
44
+ if (!headers)
45
+ return;
46
+ for (const [name, value] of Object.entries(headers)) {
47
+ if (!isValidHeaderName(name)) {
48
+ throw new WSError(`Invalid WebSocket handshake header name: ${JSON.stringify(name)}`);
49
+ }
50
+ if (!isValidHeaderValue(value)) {
51
+ throw new WSError(`Invalid value for WebSocket handshake header ${JSON.stringify(name)}: ` +
52
+ "must be a string of printable characters with no control characters");
53
+ }
54
+ }
55
+ }
27
56
  export class WSError extends Error {
28
57
  /** Machine-readable error code for WebSocket errors. */
29
58
  code = "EWSCONNECT";
@@ -191,6 +220,7 @@ export class WSClient {
191
220
  this._url = cfg.url;
192
221
  this._protocols = cfg.protocols;
193
222
  this._headers = cfg.headers;
223
+ validateHandshakeHeaders(cfg.headers);
194
224
  this._maxReconnects = cfg.maxReconnects ?? 10;
195
225
  this._reconnectBaseMs = cfg.reconnectBaseMs ?? 1_000;
196
226
  this._reconnectMaxMs = cfg.reconnectMaxMs ?? 30_000;
@@ -990,7 +1020,18 @@ export class WSClient {
990
1020
  }
991
1021
  /** Buffer an outgoing message and track bytes for backpressure. */
992
1022
  _bufferMessage(data, byteLen) {
993
- if (this._bufferMessages && !this._aborted) {
1023
+ if (!this._bufferMessages) {
1024
+ // With buffering switched off, a send made while the socket is not OPEN
1025
+ // had nowhere to go: the buffer branch was skipped and nothing was sent.
1026
+ // `send()` returns void, so there was no return value to inspect either
1027
+ // — the message vanished with no error and no callback, which is data
1028
+ // loss the application cannot distinguish from a delivered message.
1029
+ if (!this._aborted) {
1030
+ this._cbError?.(new WSError(`WebSocket message dropped: the socket is not open and bufferMessages is disabled (${byteLen} bytes). Reconnect, or enable bufferMessages to queue sends made while reconnecting.`));
1031
+ }
1032
+ return;
1033
+ }
1034
+ if (!this._aborted) {
994
1035
  const evicted = this._buffer.length >= this._maxBufferSize ? this._buffer.shift() : undefined;
995
1036
  if (evicted !== undefined) {
996
1037
  this._bufferedBytes -=