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
@@ -95,6 +95,8 @@ export const HeaderName = {
95
95
  TE: "te",
96
96
  /** HTTP header name for Expect */
97
97
  Expect: "expect",
98
+ /** HTTP header name for Idempotency-Key */
99
+ IdempotencyKey: "idempotency-key",
98
100
  /** HTTP header name for Max-Forwards */
99
101
  MaxForwards: "max-forwards",
100
102
  // ── Negotiation ───────────────────────────────────────────────────────────
@@ -726,9 +728,77 @@ _a = HttpHeaders;
726
728
  * // Map(2) { "charset" => "utf-8", "boundary" => "something" }
727
729
  * ```
728
730
  */
731
+ /**
732
+ * Split a parameter list on `;`, ignoring separators inside a quoted-string
733
+ * and honouring `quoted-pair` (RFC 7230 §3.2.6).
734
+ *
735
+ * `String.prototype.split(";")` treated every semicolon as a separator, so a
736
+ * value containing one was shredded into extra parameters:
737
+ *
738
+ * `boundary="a;b"` → boundary = `"a`, then a stray `b"`
739
+ * `filename="report; final.pdf"` → filename = `"report`, then `final.pdf"`
740
+ *
741
+ * The second is a `Content-Disposition` with a perfectly ordinary filename —
742
+ * semicolons are legal in filenames, and the multipart spec's own example
743
+ * uses one. The first appears in every `multipart/form-data` boundary. Both
744
+ * were parsed by `parseParams`, which is also what `parseContentType`,
745
+ * `parseContentDisposition`, `parseCacheControl` and `parseServerTiming` read
746
+ * their parameters through.
747
+ */
748
+ function splitParamList(paramStr) {
749
+ const out = [];
750
+ let cur = "";
751
+ let inQuotes = false;
752
+ for (let i = 0; i < paramStr.length; i++) {
753
+ const c = paramStr[i];
754
+ if (inQuotes) {
755
+ // A backslash escapes the next character, including a closing quote.
756
+ if (c === "\\" && i + 1 < paramStr.length) {
757
+ cur += c + paramStr[++i];
758
+ continue;
759
+ }
760
+ if (c === '"')
761
+ inQuotes = false;
762
+ cur += c;
763
+ continue;
764
+ }
765
+ if (c === '"') {
766
+ inQuotes = true;
767
+ cur += c;
768
+ continue;
769
+ }
770
+ if (c === ";") {
771
+ out.push(cur);
772
+ cur = "";
773
+ continue;
774
+ }
775
+ cur += c;
776
+ }
777
+ out.push(cur);
778
+ return out;
779
+ }
780
+ /** Remove surrounding quotes from a parameter value and decode quoted-pairs. */
781
+ function unquoteParam(v) {
782
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
783
+ return v.slice(1, -1).replace(/\\(.)/g, "$1");
784
+ }
785
+ return v;
786
+ }
787
+ /**
788
+ * Wrap a parameter value in a `quoted-string`, escaping `\` and `"`.
789
+ *
790
+ * Every formatter in this module interpolated parameter values raw, so a
791
+ * value containing a double quote terminated its own string and the rest was
792
+ * read as further parameters: `formatLinkHeader` with `title: 'a "q" b'`
793
+ * emitted `title="a "q" b"`, and an unknown param `x` with value `v"w"`
794
+ * emitted `x="v"w""`. The same class of bug as an unescaped digest auth-param.
795
+ */
796
+ function quoteParam(v) {
797
+ return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
798
+ }
729
799
  export function parseParams(paramStr) {
730
800
  const map = new Map();
731
- for (const part of paramStr.split(";")) {
801
+ for (const part of splitParamList(paramStr)) {
732
802
  const t = part.trim();
733
803
  if (!t)
734
804
  continue;
@@ -738,11 +808,7 @@ export function parseParams(paramStr) {
738
808
  }
739
809
  else {
740
810
  const k = t.slice(0, eq).trim().toLowerCase();
741
- let v = t.slice(eq + 1).trim();
742
- // Strip optional surrounding quotes
743
- if (v.startsWith('"') && v.endsWith('"'))
744
- v = v.slice(1, -1);
745
- map.set(k, v);
811
+ map.set(k, unquoteParam(t.slice(eq + 1).trim()));
746
812
  }
747
813
  }
748
814
  return map;
@@ -762,8 +828,17 @@ function parseQualityList(header) {
762
828
  continue;
763
829
  const k = seg.slice(0, eq).trim().toLowerCase();
764
830
  const v = seg.slice(eq + 1).trim();
765
- if (k === "q")
766
- quality = parseFloat(v) || 0;
831
+ if (k === "q") {
832
+ // RFC 9110 §12.4.2 bounds a qvalue to 0..1 with at most 3 decimals.
833
+ // `parseFloat(v) || 0` made a *malformed* q read as 0 — "not
834
+ // acceptable" — so `Accept: text/html;q=abc` silently stopped
835
+ // matching text/html, and `q=1.5` or `q=-1` flowed out of here as
836
+ // out-of-range weights. A q that is not a number at all is treated
837
+ // as absent, which is what RFC 9110 §12.4.2 says it means.
838
+ const n = parseFloat(v);
839
+ if (Number.isFinite(n))
840
+ quality = Math.min(1, Math.max(0, n));
841
+ }
767
842
  else
768
843
  params.set(k, v);
769
844
  }
@@ -877,12 +952,22 @@ export function parseContentDisposition(value) {
877
952
  * @param cd - Parsed disposition value
878
953
  * @returns Formatted `Content-Disposition` header value
879
954
  */
955
+ /**
956
+ * Render a `Content-Disposition` header value.
957
+ *
958
+ * Takes the fields it actually reads rather than a whole
959
+ * `ContentDispositionValue`: `params` is never consulted, and `name` /
960
+ * `filename` are already optional in the sense that matters — a null or absent
961
+ * one is simply not emitted. Requiring callers to write `name: null,
962
+ * params: new Map()` to format `{ type: "inline" }` made the signature
963
+ * describe a parse result rather than what formatting needs.
964
+ */
880
965
  export function formatContentDisposition(cd) {
881
966
  let out = cd.type;
882
967
  if (cd.name)
883
- out += `; name="${cd.name}"`;
968
+ out += `; name=${quoteParam(cd.name)}`;
884
969
  if (cd.filename) {
885
- out += `; filename="${cd.filename}"`;
970
+ out += `; filename=${quoteParam(cd.filename)}`;
886
971
  // Also emit RFC 5987 encoded form
887
972
  const encoded = encodeURIComponent(cd.filename);
888
973
  if (encoded !== cd.filename) {
@@ -1105,7 +1190,11 @@ export function parseAuthorization(value) {
1105
1190
  continue;
1106
1191
  const k = t.slice(0, eq).trim().toLowerCase();
1107
1192
  let v = t.slice(eq + 1).trim();
1108
- if (v.startsWith('"') && v.endsWith('"'))
1193
+ // `length >= 2` so a lone `"` is left alone instead of becoming the
1194
+ // empty string: `'"'.slice(1, -1)` is `""`, so without the guard a
1195
+ // one-character value silently lost its only character. The identical
1196
+ // strip further down (stripIPPortAndBrackets) already had this guard.
1197
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
1109
1198
  v = v.slice(1, -1);
1110
1199
  params.set(k, v);
1111
1200
  }
@@ -1230,20 +1319,54 @@ export function parseContentLanguage(value) {
1230
1319
  * @returns Best matching content type, or `null` if none match
1231
1320
  */
1232
1321
  export function negotiateContentType(acceptHeader, available) {
1233
- const accepted = parseAccept(acceptHeader);
1234
- for (const { value } of accepted) {
1235
- if (value === "*/*")
1236
- return available[0] ?? null;
1237
- const [type] = value.split("/");
1238
- if (value.endsWith("/*")) {
1239
- const match = available.find((a) => a.startsWith(type + "/"));
1240
- if (match)
1241
- return match;
1242
- }
1243
- if (available.includes(value))
1244
- return value;
1245
- }
1246
- return null;
1322
+ if (available.length === 0)
1323
+ return null;
1324
+ // RFC 9110 §12.5.1: "A request without any Accept header field implies that
1325
+ // the user agent will accept any media type in response." An absent or empty
1326
+ // Accept therefore means *everything*, not nothing — the old loop over an
1327
+ // empty list returned null, so a client that simply did not care got no
1328
+ // match at all.
1329
+ if (!acceptHeader.trim())
1330
+ return available[0];
1331
+ /** The `type/subtype` of an entry, lowercased, with parameters dropped. */
1332
+ const essence = (v) => v.split(";")[0].trim().toLowerCase();
1333
+ const ranges = parseAccept(acceptHeader)
1334
+ .map((qv) => ({ essence: essence(qv.value), quality: qv.quality }))
1335
+ .filter((r) => r.essence !== "");
1336
+ // 0 = no match, 1 = type/*, 2 = exact type/subtype. RFC 9110 §12.5.1 gives
1337
+ // precedence to the *most specific* matching range, so a type named with
1338
+ // `q=0` stays refused even when a wildcard would otherwise admit it.
1339
+ const specificity = (range, want) => {
1340
+ if (range === want)
1341
+ return 2;
1342
+ if (range.endsWith("/*") && want.startsWith(range.slice(0, range.length - 1)))
1343
+ return 1;
1344
+ if (range === "*/*")
1345
+ return 0;
1346
+ return -1;
1347
+ };
1348
+ let best = null;
1349
+ for (let i = 0; i < available.length; i++) {
1350
+ const want = essence(available[i]);
1351
+ let quality = null;
1352
+ let spec = -1;
1353
+ for (const range of ranges) {
1354
+ const m = specificity(range.essence, want);
1355
+ if (m < 0)
1356
+ continue;
1357
+ if (m > spec) {
1358
+ spec = m;
1359
+ quality = range.quality;
1360
+ }
1361
+ }
1362
+ // Unmatched, or matched at q=0 — the client will not take it.
1363
+ if (quality === null || quality <= 0)
1364
+ continue;
1365
+ if (best === null || quality > best.quality || (quality === best.quality && spec > best.spec)) {
1366
+ best = { index: i, quality, spec };
1367
+ }
1368
+ }
1369
+ return best === null ? null : available[best.index];
1247
1370
  }
1248
1371
  /**
1249
1372
  * Parse a Range header value (RFC 7233 §3.1).
@@ -1264,14 +1387,33 @@ export function parseRange(value) {
1264
1387
  const dash = t.indexOf("-");
1265
1388
  if (dash === -1)
1266
1389
  return null;
1267
- const start = t.slice(0, dash).trim();
1268
- const end = t.slice(dash + 1).trim();
1269
- return {
1270
- start: start === "" ? null : parseInt(start, 10),
1271
- end: end === "" ? null : parseInt(end, 10),
1272
- };
1390
+ const startStr = t.slice(0, dash).trim();
1391
+ const endStr = t.slice(dash + 1).trim();
1392
+ const start = startStr === "" ? null : parseInt(startStr, 10);
1393
+ const end = endStr === "" ? null : parseInt(endStr, 10);
1394
+ // `bytes=abc-def` used to yield `{ start: NaN, end: NaN }` — `parseInt`
1395
+ // returns NaN and nothing filtered it, so a caller arithmetic-ing on the
1396
+ // bounds got NaN rather than a rejection.
1397
+ if (start !== null && !Number.isFinite(start))
1398
+ return null;
1399
+ if (end !== null && !Number.isFinite(end))
1400
+ return null;
1401
+ // RFC 9110 §14.1.1: byte-range-spec is `first-pos "-" [last-pos]` or
1402
+ // `"-" suffix-length`, with first-pos <= last-pos and both non-negative.
1403
+ if (start === null && end === null)
1404
+ return null; // neither form given
1405
+ if (start !== null && start < 0)
1406
+ return null;
1407
+ if (end !== null && end < 0)
1408
+ return null;
1409
+ if (start !== null && end !== null && start > end)
1410
+ return null;
1411
+ return { start, end };
1273
1412
  })
1274
1413
  .filter((r) => r !== null);
1414
+ // `Range: bytes=` and a list of nothing but invalid specs are not ranges.
1415
+ if (ranges.length === 0)
1416
+ return null;
1275
1417
  return { unit, ranges };
1276
1418
  }
1277
1419
  /**
@@ -1335,25 +1477,34 @@ export function parseLinkHeader(value) {
1335
1477
  * @param links - Parsed Link entries
1336
1478
  * @returns Formatted `Link` header value
1337
1479
  */
1480
+ /**
1481
+ * Render an array of links as a `Link` header value.
1482
+ *
1483
+ * Only `uri` is required. `LinkValue` is the shape `parseLinkHeader` *returns*
1484
+ * — every parameter present, null when absent — and a caller building a header
1485
+ * by hand should not have to write `rel: null, type: null, hreflang: null,
1486
+ * title: null, media: null` to say `{ uri, rel }`. The body already guards
1487
+ * every one of them, so the parameter type now says what formatting needs.
1488
+ */
1338
1489
  export function formatLinkHeader(links) {
1339
1490
  return links
1340
1491
  .map((l) => {
1341
1492
  let s = `<${l.uri}>`;
1342
1493
  if (l.rel)
1343
- s += `; rel="${l.rel}"`;
1494
+ s += `; rel=${quoteParam(l.rel)}`;
1344
1495
  if (l.type)
1345
- s += `; type="${l.type}"`;
1496
+ s += `; type=${quoteParam(l.type)}`;
1346
1497
  if (l.hreflang)
1347
- s += `; hreflang="${l.hreflang}"`;
1498
+ s += `; hreflang=${quoteParam(l.hreflang)}`;
1348
1499
  if (l.title)
1349
- s += `; title="${l.title}"`;
1500
+ s += `; title=${quoteParam(l.title)}`;
1350
1501
  if (l.media)
1351
- s += `; media="${l.media}"`;
1502
+ s += `; media=${quoteParam(l.media)}`;
1352
1503
  const paramEntries = l.params instanceof Map ? [...l.params.entries()] : Object.entries(l.params ?? {});
1353
1504
  for (const [k, v] of paramEntries) {
1354
1505
  if (["rel", "type", "hreflang", "title", "media"].includes(k))
1355
1506
  continue;
1356
- s += `; ${k}="${v}"`;
1507
+ s += `; ${k}=${quoteParam(String(v))}`;
1357
1508
  }
1358
1509
  return s;
1359
1510
  })
@@ -1415,18 +1566,61 @@ export function normalizeForwardedHeaders(headers) {
1415
1566
  * @param options - Trust configuration
1416
1567
  * @returns The selected client IP, or `null` if none present
1417
1568
  */
1569
+ /** A dotted-quad IPv4 literal, or a syntactically plausible IPv6 literal. */
1570
+ function isIPLiteral(v) {
1571
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v)) {
1572
+ return v.split(".").every((o) => Number(o) <= 255 && (o === "0" || !o.startsWith("0")));
1573
+ }
1574
+ if (!v.includes(":"))
1575
+ return false;
1576
+ // An IPv6 literal is hex groups, colons, and at most one `::`. At most one
1577
+ // `::` is what rejects prose like "not:an:ip:address:here" while accepting
1578
+ // the compressed and IPv4-mapped forms real proxies emit.
1579
+ if (!/^[0-9a-f:.]+$/i.test(v))
1580
+ return false;
1581
+ return (v.match(/::/g) ?? []).length <= 1;
1582
+ }
1583
+ /**
1584
+ * RFC 7239 permits an obfuscated identifier in place of an address, and
1585
+ * `unknown` is the de-facto placeholder that nginx, HAProxy and several CDNs
1586
+ * emit when they cannot determine the client. Neither is an IP address.
1587
+ */
1588
+ function usableClientAddress(raw) {
1589
+ if (!raw)
1590
+ return null;
1591
+ const v = stripIPPortAndBrackets(raw);
1592
+ if (!v)
1593
+ return null;
1594
+ if (/^_/.test(v))
1595
+ return v; // RFC 7239 obfuscated identifier
1596
+ return isIPLiteral(v) ? v : null;
1597
+ }
1418
1598
  export function getClientIP(headers, options = {}) {
1419
1599
  const trustedHops = Math.max(0, Math.trunc(options.trustedHops ?? 0));
1420
1600
  const fwd = normalizeForwardedHeaders(headers);
1421
1601
  if (fwd.for.length > 0) {
1422
- const list = fwd.for;
1423
1602
  // Right-most entry is the closest hop. trustedHops = 1 → the entry the
1424
1603
  // nearest trusted proxy appended; index = length - trustedHops.
1425
- const index = trustedHops === 0 ? 0 : list.length - trustedHops;
1426
- const value = list[index] ?? list[0];
1427
- return stripIPPortAndBrackets(value);
1604
+ const index = trustedHops === 0 ? 0 : fwd.for.length - trustedHops;
1605
+ // Walk outwards from the chosen hop, skipping placeholders. Returning
1606
+ // `unknown` as a client address, or any non-address the header happened
1607
+ // to contain, meant a caller rate-limiting, logging or geo-locating on
1608
+ // the literal string "unknown" or "garbage".
1609
+ for (let i = index; i >= 0; i--) {
1610
+ const found = usableClientAddress(fwd.for[i]);
1611
+ if (found)
1612
+ return found;
1613
+ }
1614
+ for (const entry of fwd.for) {
1615
+ const found = usableClientAddress(entry);
1616
+ if (found)
1617
+ return found;
1618
+ }
1619
+ return null;
1428
1620
  }
1429
- return stripIPPortAndBrackets(headers.get(HeaderName.XRealIP) ?? "") || null;
1621
+ // The old fallback returned `stripIPPortAndBrackets("")` — the empty string —
1622
+ // for an empty `x-real-ip`, breaking the documented `string | null` contract.
1623
+ return usableClientAddress(headers.get(HeaderName.XRealIP));
1430
1624
  }
1431
1625
  /**
1432
1626
  * Normalize a Forwarded/XFF entry to a bare host: strip `for="…"` quoting,
@@ -1447,6 +1641,30 @@ function stripIPPortAndBrackets(value) {
1447
1641
  return m[1];
1448
1642
  return v;
1449
1643
  }
1644
+ /** RFC 7231 §7.1.1.1 IMF-fixdate, e.g. `Sun, 06 Nov 1994 08:49:37 GMT`. */
1645
+ const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
1646
+ /** RFC 7231 §7.1.1.1 obsolete RFC 850 date, e.g. `Sunday, 06-Nov-94 08:49:37 GMT`. */
1647
+ const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2,4} \d{2}:\d{2}:\d{2} GMT$/;
1648
+ /** RFC 7231 §7.1.1.1 obsolete asctime date, e.g. `Sun Nov 6 08:49:37 1994`. */
1649
+ const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4}$/;
1650
+ /**
1651
+ * Parse a string that must be an HTTP-date in one of the three formats
1652
+ * RFC 7231 §7.1.1.1 allows a recipient to accept.
1653
+ *
1654
+ * Unlike `Date.parse`, this never invents a date out of a bare number, a
1655
+ * signed value or a floating-point one.
1656
+ *
1657
+ * @param value - Candidate HTTP-date (already trimmed)
1658
+ * @returns Epoch milliseconds, or `null` when the value is not an HTTP-date
1659
+ */
1660
+ export function parseHTTPDate(value) {
1661
+ const t = value.trim();
1662
+ if (!IMF_FIXDATE_RE.test(t) && !RFC850_DATE_RE.test(t) && !ASCTIME_DATE_RE.test(t)) {
1663
+ return null;
1664
+ }
1665
+ const ms = Date.parse(t);
1666
+ return isNaN(ms) ? null : ms;
1667
+ }
1450
1668
  /**
1451
1669
  * Parse a Retry-After header which may be either delta-seconds or an
1452
1670
  * HTTP-date.
@@ -1459,9 +1677,12 @@ export function parseRetryAfter(value) {
1459
1677
  // Delta-seconds: pure integer
1460
1678
  if (/^\d+$/.test(t))
1461
1679
  return { date: null, delay: parseInt(t, 10) };
1462
- // HTTP-date
1463
- const ms = Date.parse(t);
1464
- if (!isNaN(ms))
1680
+ // HTTP-date. Date.parse() is far more permissive than RFC 7231 §7.1.1.1 and
1681
+ // happily reads "-5" as 2001-05-01, "1.5" as 2001-01-05 and "+5" as
1682
+ // 2001-05-01, so a malformed header was silently turned into a *delay*.
1683
+ // Gate on the three legal HTTP-date shapes before parsing.
1684
+ const ms = parseHTTPDate(t);
1685
+ if (ms !== null)
1465
1686
  return { date: new Date(ms), delay: null };
1466
1687
  return { date: null, delay: null };
1467
1688
  }
@@ -1627,7 +1848,7 @@ export function formatServerTiming(entries) {
1627
1848
  if (e.duration !== null)
1628
1849
  s += `;dur=${e.duration}`;
1629
1850
  if (e.description !== null)
1630
- s += `;desc="${e.description}"`;
1851
+ s += `;desc=${quoteParam(e.description)}`;
1631
1852
  return s;
1632
1853
  })
1633
1854
  .join(", ");
@@ -2300,6 +2521,66 @@ export function createHeaders(init) {
2300
2521
  export function createRequestHeaders(init) {
2301
2522
  return new RichHeaders(init, "request");
2302
2523
  }
2524
+ /**
2525
+ * Generate a random Idempotency-Key value.
2526
+ *
2527
+ * Uses `crypto.getRandomValues`, available in every runtime kinetex targets
2528
+ * (Node 18+, Deno, Bun, browsers, Workers) — no dependency, and not
2529
+ * `Math.random`.
2530
+ *
2531
+ * Format is a RFC 9562 v4 UUID: 122 random bits, hyphenated, with the
2532
+ * version and variant bits set. Servers that require an opaque string are
2533
+ * equally satisfied; UUID is the form Stripe and most others document.
2534
+ *
2535
+ * @returns A fresh key, e.g. `"3f2a9c1e-7b4d-4a8f-9c2e-1d5b6f7a8c90"`.
2536
+ * @throws {Error} If the runtime exposes no CSPRNG.
2537
+ *
2538
+ * @example
2539
+ * ```ts
2540
+ * client.post("/charges", body, { headers: { "idempotency-key": generateIdempotencyKey() } });
2541
+ * ```
2542
+ */
2543
+ export function generateIdempotencyKey() {
2544
+ if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
2545
+ throw new Error("generateIdempotencyKey: no CSPRNG available in this runtime — crypto.getRandomValues is required");
2546
+ }
2547
+ const bytes = new Uint8Array(16);
2548
+ crypto.getRandomValues(bytes);
2549
+ // RFC 9562 §5.4: set the version (4) and variant (10xx) bits.
2550
+ // `noUncheckedIndexedAccess` makes these reads `number | undefined`; the
2551
+ // buffer is a fixed 16 bytes so they are always present.
2552
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
2553
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
2554
+ const hex = [];
2555
+ for (const b of bytes)
2556
+ hex.push(b.toString(16).padStart(2, "0"));
2557
+ return [
2558
+ hex.slice(0, 4).join(""),
2559
+ hex.slice(4, 6).join(""),
2560
+ hex.slice(6, 8).join(""),
2561
+ hex.slice(8, 10).join(""),
2562
+ hex.slice(10, 16).join(""),
2563
+ ].join("-");
2564
+ }
2565
+ /**
2566
+ * Validate an Idempotency-Key value.
2567
+ *
2568
+ * The header is opaque to the client, but it travels in a request header, so
2569
+ * it must not be able to smuggle CR/LF into the request line or headers.
2570
+ * Servers commonly cap the length (Stripe: 255 chars).
2571
+ *
2572
+ * @param key - Candidate value.
2573
+ * @param maxLength - Maximum accepted length. Default: 255.
2574
+ * @returns True when the value is safe to send.
2575
+ */
2576
+ export function isValidIdempotencyKey(key, maxLength = 255) {
2577
+ if (typeof key !== "string")
2578
+ return false;
2579
+ if (key.length === 0 || key.length > maxLength)
2580
+ return false;
2581
+ // Visible ASCII only: rejects CR, LF, NUL, and any non-ASCII byte.
2582
+ return /^[\x21-\x7e]+$/.test(key);
2583
+ }
2303
2584
  /**
2304
2585
  * Create a new RichHeaders instance with "response" guard.
2305
2586
  * Response guard forbids Set-Cookie headers.