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
@@ -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);
@@ -1562,10 +1567,21 @@ function decodePunycode(input) {
1562
1567
  const initialN = 0x80;
1563
1568
  const delim = input.lastIndexOf("-");
1564
1569
  const output = [];
1570
+ // RFC 3492 §6.2: the basic part is everything before the *last* delimiter,
1571
+ // so it may contain any basic code point — "-" included. Testing it for
1572
+ // alphanumerics only rejected legal input: "xn--abc-def-" and "xn--a--"
1573
+ // both carry a hyphen inside their basic part, and were returned
1574
+ // undecoded. That left the A-label and its U-label looking like two
1575
+ // different cookie domains, so one host could be registered twice.
1576
+ //
1577
+ // The decoder only ever inserts code points >= initialN (0x80), so the
1578
+ // result can never contain a "." and cannot re-split the domain. Control
1579
+ // characters and non-ASCII are still refused, since neither is a basic
1580
+ // code point and both would be unsafe in a canonicalized cookie domain.
1565
1581
  const basic = delim >= 0 ? input.slice(0, delim) : "";
1566
1582
  for (let j = 0; j < basic.length; j++) {
1567
1583
  const cp = basic.charCodeAt(j);
1568
- if (cp > 0x7a || (cp > 0x5a && cp < 0x61) || (cp > 0x39 && cp < 0x41) || cp < 0x30)
1584
+ if (cp >= 0x80 || cp < 0x20 || cp === 0x7f)
1569
1585
  return input;
1570
1586
  output.push(cp);
1571
1587
  }
@@ -1642,6 +1658,22 @@ let _domainToUnicode;
1642
1658
  * @returns Decoded label (e.g., "中国") or original if not punycode
1643
1659
  */
1644
1660
  export function decodeIDNLabel(label) {
1661
+ // A dotted input is a domain, not a single label. Split it and decode each
1662
+ // label, so a full domain answers the same however many labels it has and
1663
+ // whichever decoder ends up handling it.
1664
+ //
1665
+ // This has to happen before the `xn--` test below. Previously a dotted input
1666
+ // that did not *begin* with `xn--` — "sub.xn--bcdf-zna1d.co.uk" — returned
1667
+ // untouched, and one that did was handed whole to the single-label decoder,
1668
+ // which failed on the dot. Whether the lazily-imported `node:url` helper had
1669
+ // resolved decided which of those happened, so the same input could decode or
1670
+ // not depending on module load timing.
1671
+ if (label.includes(".")) {
1672
+ return label
1673
+ .split(".")
1674
+ .map((part) => decodeIDNLabel(part))
1675
+ .join(".");
1676
+ }
1645
1677
  if (!label.toLowerCase().startsWith("xn--")) {
1646
1678
  return label;
1647
1679
  }
@@ -1696,6 +1728,43 @@ export function canonicalizeDomainFull(domain) {
1696
1728
  // ============================================================================
1697
1729
  const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
1698
1730
  const IPV6_RE = /^[\da-f:]+$/i;
1731
+ /**
1732
+ * Structural IPv6 check.
1733
+ *
1734
+ * The previous test was `/^[\da-f:]+$/` plus "contains a colon", which is both
1735
+ * too loose and too strict: it accepted `abc:123` and `cafe:babe` (a hostname
1736
+ * happens to be spelled entirely in hex characters) and rejected the
1737
+ * IPv4-mapped forms `::ffff:1.2.3.4` and `::1.2.3.4`, which are real
1738
+ * addresses. `getRegistrableDomain("::ffff:1.2.3.4")` therefore returned the
1739
+ * fragment "3.4". At most one `::` is allowed, a compressed form stands for
1740
+ * at least one omitted group, and an unadorned form must have all eight.
1741
+ */
1742
+ function isIPv6Literal(h) {
1743
+ let s = h;
1744
+ // An embedded dotted quad occupies the last two hextets.
1745
+ const lastColon = s.lastIndexOf(":");
1746
+ if (lastColon >= 0 && s.slice(lastColon + 1).includes(".")) {
1747
+ const v4 = s.slice(lastColon + 1);
1748
+ if (!IPV4_RE.test(v4))
1749
+ return false;
1750
+ const o = v4.split(".").map(Number);
1751
+ if (!o.every((p) => p >= 0 && p <= 255))
1752
+ return false;
1753
+ s =
1754
+ s.slice(0, lastColon + 1) +
1755
+ (((o[0] << 8) | o[1]).toString(16) + ":" + ((o[2] << 8) | o[3]).toString(16));
1756
+ }
1757
+ if (!IPV6_RE.test(s))
1758
+ return false;
1759
+ if (s.split("::").length > 2)
1760
+ return false; // at most one elision
1761
+ const groupCount = (part) => (part ?? "").split(":").filter(Boolean).length;
1762
+ if (s.includes("::")) {
1763
+ const [head, tail] = s.split("::");
1764
+ return groupCount(head) + groupCount(tail) <= 7;
1765
+ }
1766
+ return groupCount(s) === 8;
1767
+ }
1699
1768
  /**
1700
1769
  * Check whether a host string is an IPv4 or IPv6 address.
1701
1770
  *
@@ -1708,7 +1777,7 @@ export function isIPAddress(host) {
1708
1777
  const parts = h.split(".").map(Number);
1709
1778
  return parts.every((p) => p >= 0 && p <= 255);
1710
1779
  }
1711
- return IPV6_RE.test(h) && h.includes(":");
1780
+ return isIPv6Literal(h);
1712
1781
  }
1713
1782
  // ============================================================================
1714
1783
  // 6. RFC 6265 §5.1.3 — DOMAIN MATCHING
@@ -1722,7 +1791,13 @@ export function isIPAddress(host) {
1722
1791
  */
1723
1792
  export function domainMatch(requestHost, cookieDomain) {
1724
1793
  const rh = requestHost.toLowerCase();
1725
- const cd = cookieDomain.toLowerCase();
1794
+ // RFC 6265 §5.2.3: a leading %x2E (".") in the Domain attribute is ignored.
1795
+ // The parser already strips it, so this mattered only for a caller using
1796
+ // this exported helper directly — and this function's own JSDoc documents
1797
+ // `cookieDomain` as e.g. ".example.com", which it answered `false` for:
1798
+ // neither the exact host nor any subdomain of it ever matched. A cookie set
1799
+ // with the common `Domain=.example.com` spelling was simply not sent.
1800
+ const cd = cookieDomain.toLowerCase().replace(/^\./, "");
1726
1801
  if (rh === cd)
1727
1802
  return true;
1728
1803
  // IP addresses: exact match only
@@ -1873,14 +1948,23 @@ export function parseSetCookieHeader(header) {
1873
1948
  if (eqIdx === -1) {
1874
1949
  // No equals sign at all — treat as value-only (empty name)
1875
1950
  name = "";
1876
- value = nameValueStr.trim();
1951
+ value = nameValueStr;
1877
1952
  }
1878
1953
  else {
1879
1954
  name = nameValueStr.slice(0, eqIdx).trim();
1880
- value = nameValueStr.slice(eqIdx + 1).trim();
1955
+ value = nameValueStr.slice(eqIdx + 1);
1881
1956
  }
1882
1957
  if (!isValidCookieName(name))
1883
1958
  return null;
1959
+ // Validate the raw value, BEFORE trimming. `trim()` runs first, so a control
1960
+ // character at either end was stripped along with the surrounding space and
1961
+ // the check could never see it: `a=\r\nX-Injected: 1` came back as the
1962
+ // cookie `a` with the value "X-Injected: 1" instead of being rejected. The
1963
+ // interior case was already refused, so the rejection applied only from the
1964
+ // second character onwards.
1965
+ if (!isValidCookieValue(value))
1966
+ return null;
1967
+ value = value.trim();
1884
1968
  // Strip surrounding double quotes from value (RFC 6265 §5.2 step 3)
1885
1969
  if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
1886
1970
  value = value.slice(1, -1);
@@ -2063,6 +2147,31 @@ export function formatSetCookieHeader(cookie) {
2063
2147
  * @param raw - Raw Set-Cookie header string (possibly containing multiple cookies)
2064
2148
  * @returns Array of individual Set-Cookie strings
2065
2149
  */
2150
+ /**
2151
+ * Decide whether a comma at `from` separates two cookies or is part of the
2152
+ * value currently being read.
2153
+ *
2154
+ * Looks ahead to the next `,` or `;` — the rest of the current value or
2155
+ * attribute — and reports whether that segment opens a `name=` pair. A
2156
+ * trailing comma with nothing after it also separates, so the empty tail is
2157
+ * dropped rather than kept as a value.
2158
+ */
2159
+ function startsNewCookie(raw, from) {
2160
+ let j = from;
2161
+ while (j < raw.length && (raw[j] === " " || raw[j] === "\t"))
2162
+ j++;
2163
+ // Nothing but whitespace after the comma: a trailing separator.
2164
+ if (j >= raw.length)
2165
+ return true;
2166
+ for (; j < raw.length; j++) {
2167
+ const c = raw[j];
2168
+ if (c === ";" || c === ",")
2169
+ return false;
2170
+ if (c === "=")
2171
+ return true;
2172
+ }
2173
+ return false;
2174
+ }
2066
2175
  export function splitSetCookieHeaders(raw) {
2067
2176
  const cookies = [];
2068
2177
  let buffer = "";
@@ -2114,6 +2223,22 @@ export function splitSetCookieHeaders(raw) {
2114
2223
  continue;
2115
2224
  }
2116
2225
  if (char === "," && !inQuotedValue && (afterSemicolon || valueComplete || nameComplete)) {
2226
+ // A comma only separates cookies when what follows looks like a new
2227
+ // `name=value` pair. It otherwise belongs to the value being read —
2228
+ // above all `Expires=Thu, 01 Jan 2099 00:00:00 GMT`, the RFC 6265 §5.2
2229
+ // date format, which is the reason this function exists at all.
2230
+ //
2231
+ // The test used to be "have we seen a value or an attribute yet", and
2232
+ // that is true the instant the first `=` is read, so it committed on
2233
+ // EVERY comma: a single cookie carrying an Expires was truncated to
2234
+ // `a=1; Expires=Thu`, silently losing its expiry, and a collapsed
2235
+ // two-cookie header was shredded into fragments such as
2236
+ // `01 Jan 2099 00:00:00 GMT; Path=/; HttpOnly`.
2237
+ if (!startsNewCookie(raw, i + 1)) {
2238
+ buffer += char;
2239
+ i++;
2240
+ continue;
2241
+ }
2117
2242
  commitCookie();
2118
2243
  while (i + 1 < raw.length && /\s/.test(raw[i + 1])) {
2119
2244
  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":
@@ -324,6 +347,16 @@ export class CookieJar {
324
347
  if (cookie.name.startsWith("__Host-") &&
325
348
  !(cookie.secure && isSecure && cookie.path === "/" && cookie.hostOnly))
326
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;
327
360
  result.push(cookie);
328
361
  }
329
362
  }
@@ -336,13 +369,23 @@ export class CookieJar {
336
369
  return a.createdAt - b.createdAt;
337
370
  });
338
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.
339
379
  const ts = Date.now();
340
- for (const c of result) {
341
- // Note: getCookies returns direct references to stored cookies, not copies.
342
- // 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 };
343
386
  c.lastAccessed = ts;
344
- }
345
- return result;
387
+ return snapshot;
388
+ });
346
389
  }
347
390
  // --------------------------------------------------------------------------
348
391
  // 7.3 getCookieHeader
@@ -374,15 +417,38 @@ export class CookieJar {
374
417
  getCookiesForDomain(domain) {
375
418
  const d = canonicalizeDomainFull(domain);
376
419
  const result = [];
420
+ const now = Date.now();
377
421
  for (const [stored, pm] of this.dm) {
378
- // Match domain or its subdomains
379
- 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)) {
380
432
  for (const [, nm] of pm) {
381
433
  for (const [, cookie] of nm) {
382
434
  // Skip expired cookies
383
- if (cookie.expires !== Infinity && cookie.expires < Date.now())
435
+ if (cookie.expires !== Infinity && cookie.expires < now)
384
436
  continue;
385
- result.push(cookie);
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))
447
+ continue;
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 });
386
452
  }
387
453
  }
388
454
  }
@@ -593,7 +659,16 @@ export class CookieJar {
593
659
  maxAge: item.maxAge,
594
660
  secure: item.secure,
595
661
  httpOnly: item.httpOnly,
596
- 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),
597
672
  createdAt: item.createdAt,
598
673
  lastAccessed: item.lastAccessed,
599
674
  hostOnly: item.hostOnly,
@@ -654,31 +729,53 @@ export class CookieJar {
654
729
  */
655
730
  putCookie(cookie) {
656
731
  const { domain, path, name } = cookie;
657
- if (!this.dm.has(domain))
658
- this.dm.set(domain, new Map());
659
- const pm = this.dm.get(domain);
660
- if (!pm.has(path))
661
- pm.set(path, new Map());
662
- const nm = pm.get(path);
663
- if (nm.has(name)) {
664
- // Update: preserve original createdAt (RFC 6265 §5.3 step 11)
665
- const old = nm.get(name);
666
- nm.set(name, { ...cookie, createdAt: old.createdAt });
667
- // 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;
668
742
  }
669
- else {
670
- // New cookie: enforce caps first
671
- this.evictForDomain(domain, pm);
672
- this.evictGlobal();
673
- nm.set(name, cookie);
674
- 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);
675
767
  }
768
+ nm.set(name, cookie);
769
+ this.total++;
676
770
  }
677
771
  /**
678
772
  * Evict the least-recently-accessed cookie from a domain when
679
773
  * the per-domain cap is exceeded. Evicts down to maxPerDomain - 1.
680
774
  */
681
- evictForDomain(_domain, pm) {
775
+ evictForDomain(domain) {
776
+ const pm = this.dm.get(domain);
777
+ if (!pm)
778
+ return;
682
779
  let count = 0;
683
780
  for (const nm of pm.values())
684
781
  count += nm.size;