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
@@ -1513,7 +1513,12 @@ export function getPublicSuffix(hostname) {
1513
1513
  * For IP addresses, returns the IP itself.
1514
1514
  */
1515
1515
  export function getRegistrableDomain(hostname) {
1516
- const h = hostname.toLowerCase();
1516
+ // The trailing dot is stripped here for the same reason it is in
1517
+ // getPublicSuffix: without it the two disagreed about the same host.
1518
+ // `getPublicSuffix("1.2.3.4.")` correctly returned "4", but this function
1519
+ // split the *unstripped* string and handed back "4." — a fragment that is
1520
+ // not a domain at all, and one a caller could then use as a cookie domain.
1521
+ const h = hostname.toLowerCase().replace(/\.$/, "");
1517
1522
  if (isIPAddress(h))
1518
1523
  return h;
1519
1524
  const suffix = getPublicSuffix(h);
@@ -1696,6 +1701,43 @@ export function canonicalizeDomainFull(domain) {
1696
1701
  // ============================================================================
1697
1702
  const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
1698
1703
  const IPV6_RE = /^[\da-f:]+$/i;
1704
+ /**
1705
+ * Structural IPv6 check.
1706
+ *
1707
+ * The previous test was `/^[\da-f:]+$/` plus "contains a colon", which is both
1708
+ * too loose and too strict: it accepted `abc:123` and `cafe:babe` (a hostname
1709
+ * happens to be spelled entirely in hex characters) and rejected the
1710
+ * IPv4-mapped forms `::ffff:1.2.3.4` and `::1.2.3.4`, which are real
1711
+ * addresses. `getRegistrableDomain("::ffff:1.2.3.4")` therefore returned the
1712
+ * fragment "3.4". At most one `::` is allowed, a compressed form stands for
1713
+ * at least one omitted group, and an unadorned form must have all eight.
1714
+ */
1715
+ function isIPv6Literal(h) {
1716
+ let s = h;
1717
+ // An embedded dotted quad occupies the last two hextets.
1718
+ const lastColon = s.lastIndexOf(":");
1719
+ if (lastColon >= 0 && s.slice(lastColon + 1).includes(".")) {
1720
+ const v4 = s.slice(lastColon + 1);
1721
+ if (!IPV4_RE.test(v4))
1722
+ return false;
1723
+ const o = v4.split(".").map(Number);
1724
+ if (!o.every((p) => p >= 0 && p <= 255))
1725
+ return false;
1726
+ s =
1727
+ s.slice(0, lastColon + 1) +
1728
+ (((o[0] << 8) | o[1]).toString(16) + ":" + ((o[2] << 8) | o[3]).toString(16));
1729
+ }
1730
+ if (!IPV6_RE.test(s))
1731
+ return false;
1732
+ if (s.split("::").length > 2)
1733
+ return false; // at most one elision
1734
+ const groupCount = (part) => (part ?? "").split(":").filter(Boolean).length;
1735
+ if (s.includes("::")) {
1736
+ const [head, tail] = s.split("::");
1737
+ return groupCount(head) + groupCount(tail) <= 7;
1738
+ }
1739
+ return groupCount(s) === 8;
1740
+ }
1699
1741
  /**
1700
1742
  * Check whether a host string is an IPv4 or IPv6 address.
1701
1743
  *
@@ -1708,7 +1750,7 @@ export function isIPAddress(host) {
1708
1750
  const parts = h.split(".").map(Number);
1709
1751
  return parts.every((p) => p >= 0 && p <= 255);
1710
1752
  }
1711
- return IPV6_RE.test(h) && h.includes(":");
1753
+ return isIPv6Literal(h);
1712
1754
  }
1713
1755
  // ============================================================================
1714
1756
  // 6. RFC 6265 §5.1.3 — DOMAIN MATCHING
@@ -1722,7 +1764,13 @@ export function isIPAddress(host) {
1722
1764
  */
1723
1765
  export function domainMatch(requestHost, cookieDomain) {
1724
1766
  const rh = requestHost.toLowerCase();
1725
- const cd = cookieDomain.toLowerCase();
1767
+ // RFC 6265 §5.2.3: a leading %x2E (".") in the Domain attribute is ignored.
1768
+ // The parser already strips it, so this mattered only for a caller using
1769
+ // this exported helper directly — and this function's own JSDoc documents
1770
+ // `cookieDomain` as e.g. ".example.com", which it answered `false` for:
1771
+ // neither the exact host nor any subdomain of it ever matched. A cookie set
1772
+ // with the common `Domain=.example.com` spelling was simply not sent.
1773
+ const cd = cookieDomain.toLowerCase().replace(/^\./, "");
1726
1774
  if (rh === cd)
1727
1775
  return true;
1728
1776
  // IP addresses: exact match only
@@ -1735,10 +1783,13 @@ export function domainMatch(requestHost, cookieDomain) {
1735
1783
  if (isIPAddress(cd))
1736
1784
  return false;
1737
1785
  // RFC 6265 §5.3: cookie domain must not be a public suffix
1738
- // e.g., "com" or "co.uk" should not be allowed as cookie domain
1739
- const psl = getPublicSuffix(rh);
1740
- if (psl === rh)
1741
- return false; // requestHost is a public suffix
1786
+ // The COOKIE DOMAIN must not be a public suffix: "com" or "co.uk" must
1787
+ // never be accepted as a cookie domain. This check used to test the request
1788
+ // host instead, so it could never fire and a Domain=com cookie was reported
1789
+ // as matching every .com host. (HTTPCookieJar.setCookie guards it separately
1790
+ // with isPublicSuffix(cd); this exported helper must be correct on its own.)
1791
+ if (isPublicSuffix(cd))
1792
+ return false;
1742
1793
  return true;
1743
1794
  }
1744
1795
  // ============================================================================
@@ -1870,14 +1921,23 @@ export function parseSetCookieHeader(header) {
1870
1921
  if (eqIdx === -1) {
1871
1922
  // No equals sign at all — treat as value-only (empty name)
1872
1923
  name = "";
1873
- value = nameValueStr.trim();
1924
+ value = nameValueStr;
1874
1925
  }
1875
1926
  else {
1876
1927
  name = nameValueStr.slice(0, eqIdx).trim();
1877
- value = nameValueStr.slice(eqIdx + 1).trim();
1928
+ value = nameValueStr.slice(eqIdx + 1);
1878
1929
  }
1879
1930
  if (!isValidCookieName(name))
1880
1931
  return null;
1932
+ // Validate the raw value, BEFORE trimming. `trim()` runs first, so a control
1933
+ // character at either end was stripped along with the surrounding space and
1934
+ // the check could never see it: `a=\r\nX-Injected: 1` came back as the
1935
+ // cookie `a` with the value "X-Injected: 1" instead of being rejected. The
1936
+ // interior case was already refused, so the rejection applied only from the
1937
+ // second character onwards.
1938
+ if (!isValidCookieValue(value))
1939
+ return null;
1940
+ value = value.trim();
1881
1941
  // Strip surrounding double quotes from value (RFC 6265 §5.2 step 3)
1882
1942
  if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
1883
1943
  value = value.slice(1, -1);
@@ -2060,6 +2120,31 @@ export function formatSetCookieHeader(cookie) {
2060
2120
  * @param raw - Raw Set-Cookie header string (possibly containing multiple cookies)
2061
2121
  * @returns Array of individual Set-Cookie strings
2062
2122
  */
2123
+ /**
2124
+ * Decide whether a comma at `from` separates two cookies or is part of the
2125
+ * value currently being read.
2126
+ *
2127
+ * Looks ahead to the next `,` or `;` — the rest of the current value or
2128
+ * attribute — and reports whether that segment opens a `name=` pair. A
2129
+ * trailing comma with nothing after it also separates, so the empty tail is
2130
+ * dropped rather than kept as a value.
2131
+ */
2132
+ function startsNewCookie(raw, from) {
2133
+ let j = from;
2134
+ while (j < raw.length && (raw[j] === " " || raw[j] === "\t"))
2135
+ j++;
2136
+ // Nothing but whitespace after the comma: a trailing separator.
2137
+ if (j >= raw.length)
2138
+ return true;
2139
+ for (; j < raw.length; j++) {
2140
+ const c = raw[j];
2141
+ if (c === ";" || c === ",")
2142
+ return false;
2143
+ if (c === "=")
2144
+ return true;
2145
+ }
2146
+ return false;
2147
+ }
2063
2148
  export function splitSetCookieHeaders(raw) {
2064
2149
  const cookies = [];
2065
2150
  let buffer = "";
@@ -2111,6 +2196,22 @@ export function splitSetCookieHeaders(raw) {
2111
2196
  continue;
2112
2197
  }
2113
2198
  if (char === "," && !inQuotedValue && (afterSemicolon || valueComplete || nameComplete)) {
2199
+ // A comma only separates cookies when what follows looks like a new
2200
+ // `name=value` pair. It otherwise belongs to the value being read —
2201
+ // above all `Expires=Thu, 01 Jan 2099 00:00:00 GMT`, the RFC 6265 §5.2
2202
+ // date format, which is the reason this function exists at all.
2203
+ //
2204
+ // The test used to be "have we seen a value or an attribute yet", and
2205
+ // that is true the instant the first `=` is read, so it committed on
2206
+ // EVERY comma: a single cookie carrying an Expires was truncated to
2207
+ // `a=1; Expires=Thu`, silently losing its expiry, and a collapsed
2208
+ // two-cookie header was shredded into fragments such as
2209
+ // `01 Jan 2099 00:00:00 GMT; Path=/; HttpOnly`.
2210
+ if (!startsNewCookie(raw, i + 1)) {
2211
+ buffer += char;
2212
+ i++;
2213
+ continue;
2214
+ }
2114
2215
  commitCookie();
2115
2216
  while (i + 1 < raw.length && /\s/.test(raw[i + 1])) {
2116
2217
  i++;
@@ -79,6 +79,29 @@ function enforcePrefixRules(cookie, requestUrl) {
79
79
  * @param context - The request's SameSite context
80
80
  * @returns true if the cookie may be sent
81
81
  */
82
+ /**
83
+ * Canonicalise a `SameSite` read from outside the library.
84
+ *
85
+ * `SameSite` is a closed union, so anything arriving from JSON is untrusted
86
+ * by construction. An unrecognised value (including a missing one) becomes
87
+ * `"Unset"`, which `sameSiteAllows` treats permissively but still refuses on
88
+ * an explicit cross-site request.
89
+ */
90
+ function normalizeSameSite(value) {
91
+ if (typeof value !== "string")
92
+ return "Unset";
93
+ switch (value.trim().toLowerCase()) {
94
+ case "strict":
95
+ return "Strict";
96
+ case "lax":
97
+ return "Lax";
98
+ case "none":
99
+ return "None";
100
+ case "unset":
101
+ default:
102
+ return "Unset";
103
+ }
104
+ }
82
105
  function sameSiteAllows(sameSite, context) {
83
106
  switch (sameSite) {
84
107
  case "Strict":
@@ -180,7 +203,11 @@ export class CookieJar {
180
203
  let cookieDomain;
181
204
  let hostOnly;
182
205
  if (parsed.domain !== null && parsed.domain !== "") {
183
- const cd = parsed.domain;
206
+ // Normalize the case: the Domain attribute is case-insensitive, but the
207
+ // value was stored verbatim while every lookup compares against a
208
+ // lower-cased request host. `Domain=Example.COM` therefore stored a cookie
209
+ // that could never be matched again — silently lost for the jar's lifetime.
210
+ const cd = parsed.domain.toLowerCase();
184
211
  // Must domain-match the request host (use custom matcher if provided)
185
212
  const matcher = this.domainMatcherFn ?? domainMatch;
186
213
  if (!matcher(reqHost, cd))
@@ -269,10 +296,9 @@ export class CookieJar {
269
296
  * @returns Array of matching Cookie objects (direct references into storage)
270
297
  */
271
298
  getCookies(options) {
272
- // Lazy cleanup: 1% chance to clear expired cookies on each access
273
- if (Math.random() < 0.01) {
274
- this.clearExpired();
275
- }
299
+ // Expired cookies are removed by the periodic cleanup timer (and by
300
+ // clearExpired()); the old 1%-per-access coin flip walked the whole store
301
+ // at random and made eviction timing non-deterministic.
276
302
  const url = safeParseUrl(options.url);
277
303
  if (!url)
278
304
  return [];
@@ -321,6 +347,16 @@ export class CookieJar {
321
347
  if (cookie.name.startsWith("__Host-") &&
322
348
  !(cookie.secure && isSecure && cookie.path === "/" && cookie.hostOnly))
323
349
  continue;
350
+ // The same argument applies to RFC 6265 §5.3's "the cookie domain must
351
+ // not be a public suffix", which setCookie() enforces but the storage
352
+ // map does not: fromJSON()/loadCookieJar() write straight into the map
353
+ // and skip setCookie() entirely. A persisted cookie with
354
+ // `domain: "com"` — which setCookie refuses — was therefore sent to
355
+ // every .com host, so anyone able to write the serialized jar (an XSS
356
+ // bug plus localStorage) got a cross-site cookie-tossing primitive.
357
+ // A cookie whose domain is a public suffix is never emitted.
358
+ if (isPublicSuffix(cookie.domain))
359
+ continue;
324
360
  result.push(cookie);
325
361
  }
326
362
  }
@@ -333,13 +369,23 @@ export class CookieJar {
333
369
  return a.createdAt - b.createdAt;
334
370
  });
335
371
  // §5.4 step 3: update last-access time
372
+ //
373
+ // The timestamp is stamped on the STORED cookie, because that is the value
374
+ // LRU eviction orders by, and the caller receives copies. It used to return
375
+ // the stored objects themselves, so
376
+ // `jar.getCookies(opts)[0].value = "x"` rewrote the jar's contents — while
377
+ // getAll() and getForDomain() both return copies, leaving the two halves of
378
+ // the same API disagreeing about whether the result was yours to modify.
336
379
  const ts = Date.now();
337
- for (const c of result) {
338
- // Note: getCookies returns direct references to stored cookies, not copies.
339
- // Modifying lastAccessed here directly updates the stored cookie.
380
+ // The snapshot is taken before the stamp, so a caller sees the cookie as it
381
+ // was stored at the moment of the call while the jar records the access.
382
+ // Copying first and stamping after also means a mutation of the returned
383
+ // object cannot write a lastAccessed back into the store.
384
+ return result.map((c) => {
385
+ const snapshot = { ...c };
340
386
  c.lastAccessed = ts;
341
- }
342
- return result;
387
+ return snapshot;
388
+ });
343
389
  }
344
390
  // --------------------------------------------------------------------------
345
391
  // 7.3 getCookieHeader
@@ -371,15 +417,38 @@ export class CookieJar {
371
417
  getCookiesForDomain(domain) {
372
418
  const d = canonicalizeDomainFull(domain);
373
419
  const result = [];
420
+ const now = Date.now();
374
421
  for (const [stored, pm] of this.dm) {
375
- // Match domain or its subdomains
376
- if (stored === d || stored.endsWith("." + d)) {
422
+ // A domain is in scope if it is `d` itself, a subdomain of it, or an
423
+ // ancestor of it. The ancestor case was missing, so the result
424
+ // disagreed with getCookieHeader() for every subdomain query:
425
+ // getCookiesForDomain("sub.example.com") listed only the host-only
426
+ // cookie while the request actually sent "hostonly=1; domainwide=1",
427
+ // and "deep.sub.example.com" returned nothing at all. A parent-domain
428
+ // cookie applies to every host beneath it (RFC 6265 §5.3 step 5), and
429
+ // the host-only check below already keeps an ancestor's host-only
430
+ // cookie out of the result.
431
+ if (stored === d || stored.endsWith("." + d) || d.endsWith("." + stored)) {
377
432
  for (const [, nm] of pm) {
378
433
  for (const [, cookie] of nm) {
379
434
  // Skip expired cookies
380
- if (cookie.expires !== Infinity && cookie.expires < Date.now())
435
+ if (cookie.expires !== Infinity && cookie.expires < now)
436
+ continue;
437
+ // A host-only cookie is bound to its own host and is never sent to
438
+ // a sibling or parent domain. The method documents itself as
439
+ // returning "cookies that would be sent to this domain", and it was
440
+ // returning them anyway: asking for "example.com" listed the
441
+ // host-only cookie belonging to "sub.example.com", which never
442
+ // reaches example.com. Only exact-domain host-only cookies apply.
443
+ if (cookie.hostOnly && cookie.domain !== d)
444
+ continue;
445
+ // Same public-suffix rule as getCookies().
446
+ if (isPublicSuffix(cookie.domain))
381
447
  continue;
382
- result.push(cookie);
448
+ // A copy, like every other accessor. Returning the stored object
449
+ // let `jar.getCookiesForDomain(d)[0].value = "x"` rewrite the jar,
450
+ // while the method sitting directly above it returned copies.
451
+ result.push({ ...cookie });
383
452
  }
384
453
  }
385
454
  }
@@ -590,7 +659,16 @@ export class CookieJar {
590
659
  maxAge: item.maxAge,
591
660
  secure: item.secure,
592
661
  httpOnly: item.httpOnly,
593
- sameSite: item.sameSite,
662
+ // Normalized, because this is the one public ingestion path that takes
663
+ // externally authored JSON verbatim. `sameSiteAllows` switches on the
664
+ // canonical `"Strict" | "Lax" | "Unset" | "None"` and returns
665
+ // `undefined` for anything else, and `getCookies` filters on that
666
+ // result — so a persisted `"lax"`, or a store that simply omitted the
667
+ // field, produced a jar whose `count` was 1 and whose cookie was never
668
+ // sent to anything, with no error anywhere. `Unset` is the documented
669
+ // "cannot tell" bucket: everything except an explicit cross-site
670
+ // non-navigation request.
671
+ sameSite: normalizeSameSite(item.sameSite),
594
672
  createdAt: item.createdAt,
595
673
  lastAccessed: item.lastAccessed,
596
674
  hostOnly: item.hostOnly,
@@ -651,31 +729,53 @@ export class CookieJar {
651
729
  */
652
730
  putCookie(cookie) {
653
731
  const { domain, path, name } = cookie;
654
- if (!this.dm.has(domain))
655
- this.dm.set(domain, new Map());
656
- const pm = this.dm.get(domain);
657
- if (!pm.has(path))
658
- pm.set(path, new Map());
659
- const nm = pm.get(path);
660
- if (nm.has(name)) {
661
- // Update: preserve original createdAt (RFC 6265 §5.3 step 11)
662
- const old = nm.get(name);
663
- nm.set(name, { ...cookie, createdAt: old.createdAt });
664
- // total unchanged — same slot
732
+ // Update in place: same domain/path/name replaces the value and keeps the
733
+ // original createdAt (RFC 6265 §5.3 step 11). `total` is unchanged — it is
734
+ // the same slot.
735
+ const existing = this.dm.get(domain)?.get(path)?.get(name);
736
+ if (existing) {
737
+ this.dm
738
+ .get(domain)
739
+ .get(path)
740
+ .set(name, { ...cookie, createdAt: existing.createdAt });
741
+ return;
665
742
  }
666
- else {
667
- // New cookie: enforce caps first
668
- this.evictForDomain(domain, pm);
669
- this.evictGlobal();
670
- nm.set(name, cookie);
671
- this.total++;
743
+ // Evict BEFORE this cookie's path map exists.
744
+ //
745
+ // Both eviction helpers prune empty path maps as they go, and evictGlobal
746
+ // can drop a whole domain. Creating the path map up front meant the prune
747
+ // deleted the map that was about to be written into: the new cookie was
748
+ // stored into an orphaned map that nothing could reach, so `setCookie`
749
+ // returned true, the cookie was never sent, `getAll()`/`toJSON()` could
750
+ // not see it, and `this.total` was incremented regardless. The counter
751
+ // then drifted above the number of cookies actually held — measured at
752
+ // `count === 5` with 2 cookies stored — and since evictGlobal() reads
753
+ // `this.total`, the global cap stopped bounding anything either.
754
+ this.evictForDomain(domain);
755
+ this.evictGlobal();
756
+ // Re-fetch: eviction may have removed this domain, or emptied it, while
757
+ // the local reference went stale.
758
+ let pm = this.dm.get(domain);
759
+ if (!pm) {
760
+ pm = new Map();
761
+ this.dm.set(domain, pm);
762
+ }
763
+ let nm = pm.get(path);
764
+ if (!nm) {
765
+ nm = new Map();
766
+ pm.set(path, nm);
672
767
  }
768
+ nm.set(name, cookie);
769
+ this.total++;
673
770
  }
674
771
  /**
675
772
  * Evict the least-recently-accessed cookie from a domain when
676
773
  * the per-domain cap is exceeded. Evicts down to maxPerDomain - 1.
677
774
  */
678
- evictForDomain(_domain, pm) {
775
+ evictForDomain(domain) {
776
+ const pm = this.dm.get(domain);
777
+ if (!pm)
778
+ return;
679
779
  let count = 0;
680
780
  for (const nm of pm.values())
681
781
  count += nm.size;
@@ -747,11 +847,16 @@ export class CookieJar {
747
847
  // For domain cookies: domain-match (subdomains allowed).
748
848
  // We can't know per-domain whether it's host-only without checking cookies,
749
849
  // so we do the broader domain-match here and filter host-only per cookie.
750
- if (reqHost === cookieDomain)
850
+ // Lower-case both sides: cookie domains are stored from the (already
851
+ // normalized) Domain attribute, but a caller can also insert cookies
852
+ // directly via putCookie() with mixed-case input.
853
+ const host = reqHost.toLowerCase();
854
+ const domain = cookieDomain.toLowerCase();
855
+ if (host === domain)
751
856
  return true;
752
- if (isIPAddress(reqHost))
857
+ if (isIPAddress(host))
753
858
  return false;
754
- return reqHost.endsWith("." + cookieDomain);
859
+ return host.endsWith("." + domain);
755
860
  }
756
861
  }
757
862
  // ============================================================================