kinetex 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +1164 -453
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3127 -715
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +137 -20
  6. package/dist/cjs/cache.js +101 -21
  7. package/dist/cjs/circuit-breaker.js +69 -7
  8. package/dist/cjs/client.js +838 -191
  9. package/dist/cjs/cookie-parser.js +110 -9
  10. package/dist/cjs/cookie-store.js +141 -36
  11. package/dist/cjs/core.js +501 -63
  12. package/dist/cjs/dedup.js +58 -18
  13. package/dist/cjs/digest.js +185 -23
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +362 -48
  16. package/dist/cjs/interceptors.js +285 -29
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +169 -16
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +261 -28
  21. package/dist/cjs/progress.js +282 -52
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +167 -36
  25. package/dist/cjs/sse.js +201 -34
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/worker.js +6 -6
  29. package/dist/cjs/ws.js +32 -16
  30. package/dist/esm/aws-sigv4.js +137 -20
  31. package/dist/esm/aws-sigv4.js.map +1 -1
  32. package/dist/esm/cache.js +101 -21
  33. package/dist/esm/cache.js.map +1 -1
  34. package/dist/esm/circuit-breaker.js +69 -7
  35. package/dist/esm/circuit-breaker.js.map +1 -1
  36. package/dist/esm/client.js +838 -191
  37. package/dist/esm/client.js.map +1 -1
  38. package/dist/esm/cookie-parser.js +110 -9
  39. package/dist/esm/cookie-parser.js.map +1 -1
  40. package/dist/esm/cookie-store.js +141 -36
  41. package/dist/esm/cookie-store.js.map +1 -1
  42. package/dist/esm/core.js +501 -63
  43. package/dist/esm/core.js.map +1 -1
  44. package/dist/esm/dedup.js +58 -18
  45. package/dist/esm/dedup.js.map +1 -1
  46. package/dist/esm/digest.js +185 -23
  47. package/dist/esm/digest.js.map +1 -1
  48. package/dist/esm/graphql.js +164 -24
  49. package/dist/esm/graphql.js.map +1 -1
  50. package/dist/esm/headers.js +362 -48
  51. package/dist/esm/headers.js.map +1 -1
  52. package/dist/esm/interceptors.js +285 -29
  53. package/dist/esm/interceptors.js.map +1 -1
  54. package/dist/esm/lifecycle.js +89 -40
  55. package/dist/esm/lifecycle.js.map +1 -1
  56. package/dist/esm/logging.js +169 -16
  57. package/dist/esm/logging.js.map +1 -1
  58. package/dist/esm/mod.js +3 -2
  59. package/dist/esm/mod.js.map +1 -1
  60. package/dist/esm/pagination.js +261 -28
  61. package/dist/esm/pagination.js.map +1 -1
  62. package/dist/esm/progress.js +282 -52
  63. package/dist/esm/progress.js.map +1 -1
  64. package/dist/esm/proxy.js +413 -0
  65. package/dist/esm/proxy.js.map +1 -0
  66. package/dist/esm/response.js +316 -47
  67. package/dist/esm/response.js.map +1 -1
  68. package/dist/esm/socks5.js +167 -36
  69. package/dist/esm/socks5.js.map +1 -1
  70. package/dist/esm/sse.js +201 -34
  71. package/dist/esm/sse.js.map +1 -1
  72. package/dist/esm/types.js.map +1 -1
  73. package/dist/esm/url.js +191 -45
  74. package/dist/esm/url.js.map +1 -1
  75. package/dist/esm/utils.js +222 -48
  76. package/dist/esm/utils.js.map +1 -1
  77. package/dist/esm/worker.js +6 -6
  78. package/dist/esm/worker.js.map +1 -1
  79. package/dist/esm/ws.js +32 -16
  80. package/dist/esm/ws.js.map +1 -1
  81. package/dist/types/aws-sigv4.d.ts.map +1 -1
  82. package/dist/types/cache.d.ts +27 -2
  83. package/dist/types/cache.d.ts.map +1 -1
  84. package/dist/types/circuit-breaker.d.ts +14 -1
  85. package/dist/types/circuit-breaker.d.ts.map +1 -1
  86. package/dist/types/client.d.ts +98 -23
  87. package/dist/types/client.d.ts.map +1 -1
  88. package/dist/types/cookie-parser.d.ts +0 -17
  89. package/dist/types/cookie-parser.d.ts.map +1 -1
  90. package/dist/types/cookie-store.d.ts.map +1 -1
  91. package/dist/types/core.d.ts +109 -25
  92. package/dist/types/core.d.ts.map +1 -1
  93. package/dist/types/dedup.d.ts +0 -7
  94. package/dist/types/dedup.d.ts.map +1 -1
  95. package/dist/types/digest.d.ts +31 -37
  96. package/dist/types/digest.d.ts.map +1 -1
  97. package/dist/types/graphql.d.ts.map +1 -1
  98. package/dist/types/headers.d.ts +62 -29
  99. package/dist/types/headers.d.ts.map +1 -1
  100. package/dist/types/interceptors.d.ts +102 -0
  101. package/dist/types/interceptors.d.ts.map +1 -1
  102. package/dist/types/lifecycle.d.ts +19 -2
  103. package/dist/types/lifecycle.d.ts.map +1 -1
  104. package/dist/types/logging.d.ts +23 -4
  105. package/dist/types/logging.d.ts.map +1 -1
  106. package/dist/types/mod.d.ts +5 -3
  107. package/dist/types/mod.d.ts.map +1 -1
  108. package/dist/types/pagination.d.ts +0 -25
  109. package/dist/types/pagination.d.ts.map +1 -1
  110. package/dist/types/progress.d.ts +1 -1
  111. package/dist/types/progress.d.ts.map +1 -1
  112. package/dist/types/proxy.d.ts +50 -0
  113. package/dist/types/proxy.d.ts.map +1 -0
  114. package/dist/types/response.d.ts +7 -1
  115. package/dist/types/response.d.ts.map +1 -1
  116. package/dist/types/socks5.d.ts.map +1 -1
  117. package/dist/types/sse.d.ts.map +1 -1
  118. package/dist/types/types.d.ts +139 -5
  119. package/dist/types/types.d.ts.map +1 -1
  120. package/dist/types/url.d.ts +0 -14
  121. package/dist/types/url.d.ts.map +1 -1
  122. package/dist/types/utils.d.ts.map +1 -1
  123. package/dist/types/worker.d.ts +6 -6
  124. package/dist/types/worker.d.ts.map +1 -1
  125. package/dist/types/ws.d.ts.map +1 -1
  126. package/package.json +2 -2
@@ -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
  }
@@ -880,9 +955,9 @@ export function parseContentDisposition(value) {
880
955
  export function formatContentDisposition(cd) {
881
956
  let out = cd.type;
882
957
  if (cd.name)
883
- out += `; name="${cd.name}"`;
958
+ out += `; name=${quoteParam(cd.name)}`;
884
959
  if (cd.filename) {
885
- out += `; filename="${cd.filename}"`;
960
+ out += `; filename=${quoteParam(cd.filename)}`;
886
961
  // Also emit RFC 5987 encoded form
887
962
  const encoded = encodeURIComponent(cd.filename);
888
963
  if (encoded !== cd.filename) {
@@ -897,6 +972,31 @@ export function formatContentDisposition(cd) {
897
972
  * @param value - Raw `Cache-Control` header value
898
973
  * @returns Structured directives object with boolean flags and numeric values
899
974
  */
975
+ /**
976
+ * Split a `Cache-Control` value on commas, ignoring commas inside a quoted
977
+ * string. RFC 7234 §5.2 allows quoted-string values that contain commas, e.g.
978
+ * `private="field1, field2"`, so a plain `split(",")` corrupts them.
979
+ *
980
+ * @param value - Raw `Cache-Control` header value
981
+ * @returns Each directive as its own (untrimmed) string
982
+ */
983
+ function splitCacheControlDirectives(value) {
984
+ const parts = [];
985
+ let current = "";
986
+ let inQuotes = false;
987
+ for (const ch of value) {
988
+ if (ch === '"')
989
+ inQuotes = !inQuotes;
990
+ if (ch === "," && !inQuotes) {
991
+ parts.push(current);
992
+ current = "";
993
+ continue;
994
+ }
995
+ current += ch;
996
+ }
997
+ parts.push(current);
998
+ return parts;
999
+ }
900
1000
  export function parseCacheControl(value) {
901
1001
  const d = {
902
1002
  noCache: false,
@@ -917,7 +1017,7 @@ export function parseCacheControl(value) {
917
1017
  staleWhileRevalidate: null,
918
1018
  unknown: new Map(),
919
1019
  };
920
- for (const part of value.split(",")) {
1020
+ for (const part of splitCacheControlDirectives(value)) {
921
1021
  const t = part.trim();
922
1022
  const eq = t.indexOf("=");
923
1023
  const k = (eq === -1 ? t : t.slice(0, eq)).trim().toLowerCase();
@@ -1205,20 +1305,54 @@ export function parseContentLanguage(value) {
1205
1305
  * @returns Best matching content type, or `null` if none match
1206
1306
  */
1207
1307
  export function negotiateContentType(acceptHeader, available) {
1208
- const accepted = parseAccept(acceptHeader);
1209
- for (const { value } of accepted) {
1210
- if (value === "*/*")
1211
- return available[0] ?? null;
1212
- const [type] = value.split("/");
1213
- if (value.endsWith("/*")) {
1214
- const match = available.find((a) => a.startsWith(type + "/"));
1215
- if (match)
1216
- return match;
1217
- }
1218
- if (available.includes(value))
1219
- return value;
1220
- }
1221
- return null;
1308
+ if (available.length === 0)
1309
+ return null;
1310
+ // RFC 9110 §12.5.1: "A request without any Accept header field implies that
1311
+ // the user agent will accept any media type in response." An absent or empty
1312
+ // Accept therefore means *everything*, not nothing — the old loop over an
1313
+ // empty list returned null, so a client that simply did not care got no
1314
+ // match at all.
1315
+ if (!acceptHeader.trim())
1316
+ return available[0];
1317
+ /** The `type/subtype` of an entry, lowercased, with parameters dropped. */
1318
+ const essence = (v) => v.split(";")[0].trim().toLowerCase();
1319
+ const ranges = parseAccept(acceptHeader)
1320
+ .map((qv) => ({ essence: essence(qv.value), quality: qv.quality }))
1321
+ .filter((r) => r.essence !== "");
1322
+ // 0 = no match, 1 = type/*, 2 = exact type/subtype. RFC 9110 §12.5.1 gives
1323
+ // precedence to the *most specific* matching range, so a type named with
1324
+ // `q=0` stays refused even when a wildcard would otherwise admit it.
1325
+ const specificity = (range, want) => {
1326
+ if (range === want)
1327
+ return 2;
1328
+ if (range.endsWith("/*") && want.startsWith(range.slice(0, range.length - 1)))
1329
+ return 1;
1330
+ if (range === "*/*")
1331
+ return 0;
1332
+ return -1;
1333
+ };
1334
+ let best = null;
1335
+ for (let i = 0; i < available.length; i++) {
1336
+ const want = essence(available[i]);
1337
+ let quality = null;
1338
+ let spec = -1;
1339
+ for (const range of ranges) {
1340
+ const m = specificity(range.essence, want);
1341
+ if (m < 0)
1342
+ continue;
1343
+ if (m > spec) {
1344
+ spec = m;
1345
+ quality = range.quality;
1346
+ }
1347
+ }
1348
+ // Unmatched, or matched at q=0 — the client will not take it.
1349
+ if (quality === null || quality <= 0)
1350
+ continue;
1351
+ if (best === null || quality > best.quality || (quality === best.quality && spec > best.spec)) {
1352
+ best = { index: i, quality, spec };
1353
+ }
1354
+ }
1355
+ return best === null ? null : available[best.index];
1222
1356
  }
1223
1357
  /**
1224
1358
  * Parse a Range header value (RFC 7233 §3.1).
@@ -1239,14 +1373,33 @@ export function parseRange(value) {
1239
1373
  const dash = t.indexOf("-");
1240
1374
  if (dash === -1)
1241
1375
  return null;
1242
- const start = t.slice(0, dash).trim();
1243
- const end = t.slice(dash + 1).trim();
1244
- return {
1245
- start: start === "" ? null : parseInt(start, 10),
1246
- end: end === "" ? null : parseInt(end, 10),
1247
- };
1376
+ const startStr = t.slice(0, dash).trim();
1377
+ const endStr = t.slice(dash + 1).trim();
1378
+ const start = startStr === "" ? null : parseInt(startStr, 10);
1379
+ const end = endStr === "" ? null : parseInt(endStr, 10);
1380
+ // `bytes=abc-def` used to yield `{ start: NaN, end: NaN }` — `parseInt`
1381
+ // returns NaN and nothing filtered it, so a caller arithmetic-ing on the
1382
+ // bounds got NaN rather than a rejection.
1383
+ if (start !== null && !Number.isFinite(start))
1384
+ return null;
1385
+ if (end !== null && !Number.isFinite(end))
1386
+ return null;
1387
+ // RFC 9110 §14.1.1: byte-range-spec is `first-pos "-" [last-pos]` or
1388
+ // `"-" suffix-length`, with first-pos <= last-pos and both non-negative.
1389
+ if (start === null && end === null)
1390
+ return null; // neither form given
1391
+ if (start !== null && start < 0)
1392
+ return null;
1393
+ if (end !== null && end < 0)
1394
+ return null;
1395
+ if (start !== null && end !== null && start > end)
1396
+ return null;
1397
+ return { start, end };
1248
1398
  })
1249
1399
  .filter((r) => r !== null);
1400
+ // `Range: bytes=` and a list of nothing but invalid specs are not ranges.
1401
+ if (ranges.length === 0)
1402
+ return null;
1250
1403
  return { unit, ranges };
1251
1404
  }
1252
1405
  /**
@@ -1315,20 +1468,20 @@ export function formatLinkHeader(links) {
1315
1468
  .map((l) => {
1316
1469
  let s = `<${l.uri}>`;
1317
1470
  if (l.rel)
1318
- s += `; rel="${l.rel}"`;
1471
+ s += `; rel=${quoteParam(l.rel)}`;
1319
1472
  if (l.type)
1320
- s += `; type="${l.type}"`;
1473
+ s += `; type=${quoteParam(l.type)}`;
1321
1474
  if (l.hreflang)
1322
- s += `; hreflang="${l.hreflang}"`;
1475
+ s += `; hreflang=${quoteParam(l.hreflang)}`;
1323
1476
  if (l.title)
1324
- s += `; title="${l.title}"`;
1477
+ s += `; title=${quoteParam(l.title)}`;
1325
1478
  if (l.media)
1326
- s += `; media="${l.media}"`;
1479
+ s += `; media=${quoteParam(l.media)}`;
1327
1480
  const paramEntries = l.params instanceof Map ? [...l.params.entries()] : Object.entries(l.params ?? {});
1328
1481
  for (const [k, v] of paramEntries) {
1329
1482
  if (["rel", "type", "hreflang", "title", "media"].includes(k))
1330
1483
  continue;
1331
- s += `; ${k}="${v}"`;
1484
+ s += `; ${k}=${quoteParam(String(v))}`;
1332
1485
  }
1333
1486
  return s;
1334
1487
  })
@@ -1379,17 +1532,115 @@ export function normalizeForwardedHeaders(headers) {
1379
1532
  };
1380
1533
  }
1381
1534
  /**
1382
- * Extract the real client IP from Forwarded, X-Forwarded-For, or X-Real-IP
1383
- * headers (in priority order).
1535
+ * Extract the client IP from Forwarded, X-Forwarded-For, or X-Real-IP.
1536
+ *
1537
+ * ⚠️ SECURITY: the value is derived from client-supplied headers and must NOT be
1538
+ * used for access control, rate limiting or audit trails unless `trustedHops` is
1539
+ * set to the real number of proxies in front of the server. The returned string
1540
+ * is also not validated as an IP address.
1384
1541
  *
1385
1542
  * @param headers - Source headers object
1386
- * @returns First client IP found, or `null` if none present
1543
+ * @param options - Trust configuration
1544
+ * @returns The selected client IP, or `null` if none present
1545
+ */
1546
+ /** A dotted-quad IPv4 literal, or a syntactically plausible IPv6 literal. */
1547
+ function isIPLiteral(v) {
1548
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v)) {
1549
+ return v.split(".").every((o) => Number(o) <= 255 && (o === "0" || !o.startsWith("0")));
1550
+ }
1551
+ if (!v.includes(":"))
1552
+ return false;
1553
+ // An IPv6 literal is hex groups, colons, and at most one `::`. At most one
1554
+ // `::` is what rejects prose like "not:an:ip:address:here" while accepting
1555
+ // the compressed and IPv4-mapped forms real proxies emit.
1556
+ if (!/^[0-9a-f:.]+$/i.test(v))
1557
+ return false;
1558
+ return (v.match(/::/g) ?? []).length <= 1;
1559
+ }
1560
+ /**
1561
+ * RFC 7239 permits an obfuscated identifier in place of an address, and
1562
+ * `unknown` is the de-facto placeholder that nginx, HAProxy and several CDNs
1563
+ * emit when they cannot determine the client. Neither is an IP address.
1387
1564
  */
1388
- export function getClientIP(headers) {
1565
+ function usableClientAddress(raw) {
1566
+ if (!raw)
1567
+ return null;
1568
+ const v = stripIPPortAndBrackets(raw);
1569
+ if (!v)
1570
+ return null;
1571
+ if (/^_/.test(v))
1572
+ return v; // RFC 7239 obfuscated identifier
1573
+ return isIPLiteral(v) ? v : null;
1574
+ }
1575
+ export function getClientIP(headers, options = {}) {
1576
+ const trustedHops = Math.max(0, Math.trunc(options.trustedHops ?? 0));
1389
1577
  const fwd = normalizeForwardedHeaders(headers);
1390
- if (fwd.for.length > 0)
1391
- return fwd.for[0];
1392
- return headers.get(HeaderName.XRealIP);
1578
+ if (fwd.for.length > 0) {
1579
+ // Right-most entry is the closest hop. trustedHops = 1 → the entry the
1580
+ // nearest trusted proxy appended; index = length - trustedHops.
1581
+ const index = trustedHops === 0 ? 0 : fwd.for.length - trustedHops;
1582
+ // Walk outwards from the chosen hop, skipping placeholders. Returning
1583
+ // `unknown` as a client address, or any non-address the header happened
1584
+ // to contain, meant a caller rate-limiting, logging or geo-locating on
1585
+ // the literal string "unknown" or "garbage".
1586
+ for (let i = index; i >= 0; i--) {
1587
+ const found = usableClientAddress(fwd.for[i]);
1588
+ if (found)
1589
+ return found;
1590
+ }
1591
+ for (const entry of fwd.for) {
1592
+ const found = usableClientAddress(entry);
1593
+ if (found)
1594
+ return found;
1595
+ }
1596
+ return null;
1597
+ }
1598
+ // The old fallback returned `stripIPPortAndBrackets("")` — the empty string —
1599
+ // for an empty `x-real-ip`, breaking the documented `string | null` contract.
1600
+ return usableClientAddress(headers.get(HeaderName.XRealIP));
1601
+ }
1602
+ /**
1603
+ * Normalize a Forwarded/XFF entry to a bare host: strip `for="…"` quoting,
1604
+ * `[ipv6]:port`, and a bare `:port` suffix.
1605
+ */
1606
+ function stripIPPortAndBrackets(value) {
1607
+ let v = value.trim();
1608
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
1609
+ v = v.slice(1, -1);
1610
+ if (v.startsWith("[")) {
1611
+ const end = v.indexOf("]");
1612
+ if (end !== -1)
1613
+ return v.slice(1, end);
1614
+ }
1615
+ // Only strip a trailing :digits (never the colons inside an IPv6 literal).
1616
+ const m = /^(.*):\d+$/.exec(v);
1617
+ if (m && (m[1] ?? "").includes("."))
1618
+ return m[1];
1619
+ return v;
1620
+ }
1621
+ /** RFC 7231 §7.1.1.1 IMF-fixdate, e.g. `Sun, 06 Nov 1994 08:49:37 GMT`. */
1622
+ 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$/;
1623
+ /** RFC 7231 §7.1.1.1 obsolete RFC 850 date, e.g. `Sunday, 06-Nov-94 08:49:37 GMT`. */
1624
+ 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$/;
1625
+ /** RFC 7231 §7.1.1.1 obsolete asctime date, e.g. `Sun Nov 6 08:49:37 1994`. */
1626
+ 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}$/;
1627
+ /**
1628
+ * Parse a string that must be an HTTP-date in one of the three formats
1629
+ * RFC 7231 §7.1.1.1 allows a recipient to accept.
1630
+ *
1631
+ * Unlike `Date.parse`, this never invents a date out of a bare number, a
1632
+ * signed value or a floating-point one.
1633
+ *
1634
+ * @param value - Candidate HTTP-date (already trimmed)
1635
+ * @returns Epoch milliseconds, or `null` when the value is not an HTTP-date
1636
+ */
1637
+ export function parseHTTPDate(value) {
1638
+ const t = value.trim();
1639
+ if (!IMF_FIXDATE_RE.test(t) && !RFC850_DATE_RE.test(t) && !ASCTIME_DATE_RE.test(t)) {
1640
+ return null;
1641
+ }
1642
+ const ms = Date.parse(t);
1643
+ return isNaN(ms) ? null : ms;
1393
1644
  }
1394
1645
  /**
1395
1646
  * Parse a Retry-After header which may be either delta-seconds or an
@@ -1403,9 +1654,12 @@ export function parseRetryAfter(value) {
1403
1654
  // Delta-seconds: pure integer
1404
1655
  if (/^\d+$/.test(t))
1405
1656
  return { date: null, delay: parseInt(t, 10) };
1406
- // HTTP-date
1407
- const ms = Date.parse(t);
1408
- if (!isNaN(ms))
1657
+ // HTTP-date. Date.parse() is far more permissive than RFC 7231 §7.1.1.1 and
1658
+ // happily reads "-5" as 2001-05-01, "1.5" as 2001-01-05 and "+5" as
1659
+ // 2001-05-01, so a malformed header was silently turned into a *delay*.
1660
+ // Gate on the three legal HTTP-date shapes before parsing.
1661
+ const ms = parseHTTPDate(t);
1662
+ if (ms !== null)
1409
1663
  return { date: new Date(ms), delay: null };
1410
1664
  return { date: null, delay: null };
1411
1665
  }
@@ -1571,7 +1825,7 @@ export function formatServerTiming(entries) {
1571
1825
  if (e.duration !== null)
1572
1826
  s += `;dur=${e.duration}`;
1573
1827
  if (e.description !== null)
1574
- s += `;desc="${e.description}"`;
1828
+ s += `;desc=${quoteParam(e.description)}`;
1575
1829
  return s;
1576
1830
  })
1577
1831
  .join(", ");
@@ -2244,6 +2498,66 @@ export function createHeaders(init) {
2244
2498
  export function createRequestHeaders(init) {
2245
2499
  return new RichHeaders(init, "request");
2246
2500
  }
2501
+ /**
2502
+ * Generate a random Idempotency-Key value.
2503
+ *
2504
+ * Uses `crypto.getRandomValues`, available in every runtime kinetex targets
2505
+ * (Node 18+, Deno, Bun, browsers, Workers) — no dependency, and not
2506
+ * `Math.random`.
2507
+ *
2508
+ * Format is a RFC 9562 v4 UUID: 122 random bits, hyphenated, with the
2509
+ * version and variant bits set. Servers that require an opaque string are
2510
+ * equally satisfied; UUID is the form Stripe and most others document.
2511
+ *
2512
+ * @returns A fresh key, e.g. `"3f2a9c1e-7b4d-4a8f-9c2e-1d5b6f7a8c90"`.
2513
+ * @throws {Error} If the runtime exposes no CSPRNG.
2514
+ *
2515
+ * @example
2516
+ * ```ts
2517
+ * client.post("/charges", body, { headers: { "idempotency-key": generateIdempotencyKey() } });
2518
+ * ```
2519
+ */
2520
+ export function generateIdempotencyKey() {
2521
+ if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
2522
+ throw new Error("generateIdempotencyKey: no CSPRNG available in this runtime — crypto.getRandomValues is required");
2523
+ }
2524
+ const bytes = new Uint8Array(16);
2525
+ crypto.getRandomValues(bytes);
2526
+ // RFC 9562 §5.4: set the version (4) and variant (10xx) bits.
2527
+ // `noUncheckedIndexedAccess` makes these reads `number | undefined`; the
2528
+ // buffer is a fixed 16 bytes so they are always present.
2529
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
2530
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
2531
+ const hex = [];
2532
+ for (const b of bytes)
2533
+ hex.push(b.toString(16).padStart(2, "0"));
2534
+ return [
2535
+ hex.slice(0, 4).join(""),
2536
+ hex.slice(4, 6).join(""),
2537
+ hex.slice(6, 8).join(""),
2538
+ hex.slice(8, 10).join(""),
2539
+ hex.slice(10, 16).join(""),
2540
+ ].join("-");
2541
+ }
2542
+ /**
2543
+ * Validate an Idempotency-Key value.
2544
+ *
2545
+ * The header is opaque to the client, but it travels in a request header, so
2546
+ * it must not be able to smuggle CR/LF into the request line or headers.
2547
+ * Servers commonly cap the length (Stripe: 255 chars).
2548
+ *
2549
+ * @param key - Candidate value.
2550
+ * @param maxLength - Maximum accepted length. Default: 255.
2551
+ * @returns True when the value is safe to send.
2552
+ */
2553
+ export function isValidIdempotencyKey(key, maxLength = 255) {
2554
+ if (typeof key !== "string")
2555
+ return false;
2556
+ if (key.length === 0 || key.length > maxLength)
2557
+ return false;
2558
+ // Visible ASCII only: rejects CR, LF, NUL, and any non-ASCII byte.
2559
+ return /^[\x21-\x7e]+$/.test(key);
2560
+ }
2247
2561
  /**
2248
2562
  * Create a new RichHeaders instance with "response" guard.
2249
2563
  * Response guard forbids Set-Cookie headers.