kinetex 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +919 -445
  2. package/dist/browser/kinetex.esm.js +18 -18
  3. package/dist/browser/kinetex.js +749 -295
  4. package/dist/browser/kinetex.min.js +18 -18
  5. package/dist/cjs/aws-sigv4.js +4 -1
  6. package/dist/cjs/cache.js +52 -14
  7. package/dist/cjs/circuit-breaker.js +24 -4
  8. package/dist/cjs/client.js +495 -131
  9. package/dist/cjs/cookie-parser.js +7 -4
  10. package/dist/cjs/cookie-store.js +16 -8
  11. package/dist/cjs/core.js +83 -35
  12. package/dist/cjs/dedup.js +9 -7
  13. package/dist/cjs/digest.js +26 -0
  14. package/dist/cjs/graphql.js +19 -4
  15. package/dist/cjs/headers.js +64 -8
  16. package/dist/cjs/interceptors.js +86 -33
  17. package/dist/cjs/logging.js +1 -1
  18. package/dist/cjs/pagination.js +14 -6
  19. package/dist/cjs/progress.js +129 -42
  20. package/dist/cjs/socks5.js +36 -21
  21. package/dist/cjs/sse.js +48 -11
  22. package/dist/cjs/utils.js +24 -12
  23. package/dist/cjs/worker.js +6 -6
  24. package/dist/cjs/ws.js +23 -7
  25. package/dist/esm/aws-sigv4.js +4 -1
  26. package/dist/esm/aws-sigv4.js.map +1 -1
  27. package/dist/esm/cache.js +52 -14
  28. package/dist/esm/cache.js.map +1 -1
  29. package/dist/esm/circuit-breaker.js +24 -4
  30. package/dist/esm/circuit-breaker.js.map +1 -1
  31. package/dist/esm/client.js +495 -131
  32. package/dist/esm/client.js.map +1 -1
  33. package/dist/esm/cookie-parser.js +7 -4
  34. package/dist/esm/cookie-parser.js.map +1 -1
  35. package/dist/esm/cookie-store.js +16 -8
  36. package/dist/esm/cookie-store.js.map +1 -1
  37. package/dist/esm/core.js +83 -35
  38. package/dist/esm/core.js.map +1 -1
  39. package/dist/esm/dedup.js +9 -7
  40. package/dist/esm/dedup.js.map +1 -1
  41. package/dist/esm/digest.js +26 -0
  42. package/dist/esm/digest.js.map +1 -1
  43. package/dist/esm/graphql.js +19 -4
  44. package/dist/esm/graphql.js.map +1 -1
  45. package/dist/esm/headers.js +64 -8
  46. package/dist/esm/headers.js.map +1 -1
  47. package/dist/esm/interceptors.js +86 -33
  48. package/dist/esm/interceptors.js.map +1 -1
  49. package/dist/esm/logging.js +1 -1
  50. package/dist/esm/pagination.js +14 -6
  51. package/dist/esm/pagination.js.map +1 -1
  52. package/dist/esm/progress.js +129 -42
  53. package/dist/esm/progress.js.map +1 -1
  54. package/dist/esm/socks5.js +36 -21
  55. package/dist/esm/socks5.js.map +1 -1
  56. package/dist/esm/sse.js +48 -11
  57. package/dist/esm/sse.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/utils.js +24 -12
  60. package/dist/esm/utils.js.map +1 -1
  61. package/dist/esm/worker.js +6 -6
  62. package/dist/esm/worker.js.map +1 -1
  63. package/dist/esm/ws.js +23 -7
  64. package/dist/esm/ws.js.map +1 -1
  65. package/dist/types/aws-sigv4.d.ts.map +1 -1
  66. package/dist/types/cache.d.ts +8 -1
  67. package/dist/types/cache.d.ts.map +1 -1
  68. package/dist/types/circuit-breaker.d.ts.map +1 -1
  69. package/dist/types/client.d.ts +29 -12
  70. package/dist/types/client.d.ts.map +1 -1
  71. package/dist/types/cookie-parser.d.ts.map +1 -1
  72. package/dist/types/cookie-store.d.ts.map +1 -1
  73. package/dist/types/core.d.ts +10 -0
  74. package/dist/types/core.d.ts.map +1 -1
  75. package/dist/types/dedup.d.ts +0 -7
  76. package/dist/types/dedup.d.ts.map +1 -1
  77. package/dist/types/digest.d.ts +14 -0
  78. package/dist/types/digest.d.ts.map +1 -1
  79. package/dist/types/graphql.d.ts.map +1 -1
  80. package/dist/types/headers.d.ts +25 -10
  81. package/dist/types/headers.d.ts.map +1 -1
  82. package/dist/types/interceptors.d.ts.map +1 -1
  83. package/dist/types/logging.d.ts +1 -1
  84. package/dist/types/pagination.d.ts.map +1 -1
  85. package/dist/types/progress.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/sse.d.ts.map +1 -1
  88. package/dist/types/types.d.ts +25 -2
  89. package/dist/types/types.d.ts.map +1 -1
  90. package/dist/types/utils.d.ts.map +1 -1
  91. package/dist/types/worker.d.ts +6 -6
  92. package/dist/types/worker.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +4 -0
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +4 -4
@@ -1735,10 +1735,13 @@ export function domainMatch(requestHost, cookieDomain) {
1735
1735
  if (isIPAddress(cd))
1736
1736
  return false;
1737
1737
  // 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
1738
+ // The COOKIE DOMAIN must not be a public suffix: "com" or "co.uk" must
1739
+ // never be accepted as a cookie domain. This check used to test the request
1740
+ // host instead, so it could never fire and a Domain=com cookie was reported
1741
+ // as matching every .com host. (HTTPCookieJar.setCookie guards it separately
1742
+ // with isPublicSuffix(cd); this exported helper must be correct on its own.)
1743
+ if (isPublicSuffix(cd))
1744
+ return false;
1742
1745
  return true;
1743
1746
  }
1744
1747
  // ============================================================================
@@ -180,7 +180,11 @@ export class CookieJar {
180
180
  let cookieDomain;
181
181
  let hostOnly;
182
182
  if (parsed.domain !== null && parsed.domain !== "") {
183
- const cd = parsed.domain;
183
+ // Normalize the case: the Domain attribute is case-insensitive, but the
184
+ // value was stored verbatim while every lookup compares against a
185
+ // lower-cased request host. `Domain=Example.COM` therefore stored a cookie
186
+ // that could never be matched again — silently lost for the jar's lifetime.
187
+ const cd = parsed.domain.toLowerCase();
184
188
  // Must domain-match the request host (use custom matcher if provided)
185
189
  const matcher = this.domainMatcherFn ?? domainMatch;
186
190
  if (!matcher(reqHost, cd))
@@ -269,10 +273,9 @@ export class CookieJar {
269
273
  * @returns Array of matching Cookie objects (direct references into storage)
270
274
  */
271
275
  getCookies(options) {
272
- // Lazy cleanup: 1% chance to clear expired cookies on each access
273
- if (Math.random() < 0.01) {
274
- this.clearExpired();
275
- }
276
+ // Expired cookies are removed by the periodic cleanup timer (and by
277
+ // clearExpired()); the old 1%-per-access coin flip walked the whole store
278
+ // at random and made eviction timing non-deterministic.
276
279
  const url = safeParseUrl(options.url);
277
280
  if (!url)
278
281
  return [];
@@ -747,11 +750,16 @@ export class CookieJar {
747
750
  // For domain cookies: domain-match (subdomains allowed).
748
751
  // We can't know per-domain whether it's host-only without checking cookies,
749
752
  // so we do the broader domain-match here and filter host-only per cookie.
750
- if (reqHost === cookieDomain)
753
+ // Lower-case both sides: cookie domains are stored from the (already
754
+ // normalized) Domain attribute, but a caller can also insert cookies
755
+ // directly via putCookie() with mixed-case input.
756
+ const host = reqHost.toLowerCase();
757
+ const domain = cookieDomain.toLowerCase();
758
+ if (host === domain)
751
759
  return true;
752
- if (isIPAddress(reqHost))
760
+ if (isIPAddress(host))
753
761
  return false;
754
- return reqHost.endsWith("." + cookieDomain);
762
+ return host.endsWith("." + domain);
755
763
  }
756
764
  }
757
765
  // ============================================================================
package/dist/cjs/core.js CHANGED
@@ -103,6 +103,12 @@ function isProductionEnvironment() {
103
103
  return false;
104
104
  }
105
105
  }
106
+ /**
107
+ * The accept-encoding value client.ts injects when the caller did not set
108
+ * one. FetchTransport removes exactly this string so fetch() negotiates its
109
+ * own encodings; any other value is treated as caller intent.
110
+ */
111
+ export const DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
106
112
  /**
107
113
  * Universal fetch-based transport.
108
114
  * Suitable for all runtimes where `fetch` is available.
@@ -160,10 +166,12 @@ export class FetchTransport {
160
166
  }
161
167
  sanitizedHeaders[name] = value;
162
168
  }
163
- // Strip default accept-encoding injected by client.ts so fetch()
164
- // can add its own. Preserve caller-explicit values (e.g. "identity").
169
+ // Strip the accept-encoding value INJECTED by client.ts so fetch() can add
170
+ // its own. The old check removed ANY value containing gzip+deflate+br,
171
+ // including one the caller set deliberately; matching the exact injected
172
+ // default keeps caller intent intact.
165
173
  const ae = sanitizedHeaders["accept-encoding"];
166
- if (ae && ae.toLowerCase().includes("gzip") && ae.toLowerCase().includes("deflate") && ae.toLowerCase().includes("br")) {
174
+ if (ae && ae.toLowerCase().replace(/\s+/g, " ") === DEFAULT_ACCEPT_ENCODING) {
167
175
  delete sanitizedHeaders["accept-encoding"];
168
176
  }
169
177
  // Build fetch init
@@ -248,6 +256,8 @@ export class NodeHTTP2Transport {
248
256
  sessionUsage = new Map();
249
257
  _strict;
250
258
  _onDroppedHeader;
259
+ /** Optional CA bundle for origins with self-signed / private-PKI certificates. */
260
+ _ca;
251
261
  /** FIX 11: Configurable connect timeout (replaces hardcoded 30 000 ms) */
252
262
  _connectTimeoutMs;
253
263
  /** FIX 11: Configurable per-request stream timeout (replaces hardcoded 30 000 ms) */
@@ -263,6 +273,7 @@ export class NodeHTTP2Transport {
263
273
  this.maxSessions = options.maxSessions ?? 100;
264
274
  this._strict = options.strict ?? false;
265
275
  this._onDroppedHeader = options.onDroppedHeader;
276
+ this._ca = options.ca;
266
277
  this._connectTimeoutMs = options.connectTimeoutMs ?? 30_000;
267
278
  this._requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
268
279
  }
@@ -323,7 +334,10 @@ export class NodeHTTP2Transport {
323
334
  }
324
335
  }
325
336
  const session = await new Promise((resolve, reject) => {
326
- const s = http2.connect(origin, { rejectUnauthorized: true });
337
+ const s = http2.connect(origin, {
338
+ rejectUnauthorized: true,
339
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
340
+ });
327
341
  // FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
328
342
  // FIX 9: unref() the timer so it does not prevent process exit
329
343
  const connectTimeout = setTimeout(() => {
@@ -451,35 +465,38 @@ export class NodeHTTP2Transport {
451
465
  ":authority": currentUrl.host,
452
466
  ...currentReq.headers,
453
467
  };
454
- // Strict-mode header validation (HTTP/2 control-character check)
455
- if (this._strict) {
456
- for (const [hName, hValue] of Object.entries(h2ReqHeaders)) {
457
- if (hName.startsWith(":"))
458
- continue;
459
- const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
460
- let hasForbidden = false;
461
- for (let ci = 0; ci < hStr.length; ci++) {
462
- const code = hStr.charCodeAt(ci);
463
- if ((code >= 0x00 && code <= 0x08) || (code >= 0x0a && code <= 0x1f) || code === 0x7f) {
464
- hasForbidden = true;
465
- break;
466
- }
468
+ // Header validation (HTTP/2 control-character check). Runs in BOTH modes:
469
+ // strict throws, non-strict drops with callback/warn — matching the
470
+ // FetchTransport contract. (Previously the whole loop was gated on
471
+ // strict mode, so non-strict requests never validated and a header with
472
+ // forbidden control characters crashed session.request() with a raw
473
+ // ERR_INVALID_HEADER_VALUE instead of being dropped.)
474
+ for (const [hName, hValue] of Object.entries(h2ReqHeaders)) {
475
+ if (hName.startsWith(":"))
476
+ continue;
477
+ const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
478
+ let hasForbidden = false;
479
+ for (let ci = 0; ci < hStr.length; ci++) {
480
+ const code = hStr.charCodeAt(ci);
481
+ if ((code >= 0x00 && code <= 0x08) || (code >= 0x0a && code <= 0x1f) || code === 0x7f) {
482
+ hasForbidden = true;
483
+ break;
467
484
  }
468
- if (hasForbidden) {
469
- if (this._strict) {
470
- throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
471
- }
472
- // FIX (H3): non-strict mode must match FetchTransport behavior —
473
- // notify the callback (if any) and warn, never drop silently.
474
- if (this._onDroppedHeader) {
475
- this._onDroppedHeader(hName, hStr);
476
- }
477
- else if (typeof console !== "undefined") {
478
- console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
479
- `Pass strictHeaders: true to throw instead.`);
480
- }
481
- delete h2ReqHeaders[hName];
485
+ }
486
+ if (hasForbidden) {
487
+ if (this._strict) {
488
+ throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
489
+ }
490
+ // FIX (H3): non-strict mode must match FetchTransport behavior —
491
+ // notify the callback (if any) and warn, never drop silently.
492
+ if (this._onDroppedHeader) {
493
+ this._onDroppedHeader(hName, hStr);
482
494
  }
495
+ else if (typeof console !== "undefined") {
496
+ console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
497
+ `Pass strictHeaders: true to throw instead.`);
498
+ }
499
+ delete h2ReqHeaders[hName];
483
500
  }
484
501
  }
485
502
  const endStream = !currentReq.body || currentReq.method === "GET" || currentReq.method === "HEAD";
@@ -726,6 +743,13 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
726
743
  // "Custom fetch implementation" behavior holds on every runtime.
727
744
  // Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
728
745
  // fetch is given. Falls back to FetchTransport otherwise.
746
+ if (IS_NODE && preferHTTP2 && fetchFn) {
747
+ if (!isProductionEnvironment()) {
748
+ console.warn('[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
749
+ "NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
750
+ "FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2.");
751
+ }
752
+ }
729
753
  if (IS_NODE && preferHTTP2 && !fetchFn) {
730
754
  return new NodeHTTP2Transport({
731
755
  ...(sessionOptions?.sessionTTLMs !== undefined
@@ -1102,7 +1126,7 @@ async function writeChunkWithBackpressure(stream, chunk) {
1102
1126
  }
1103
1127
  /**
1104
1128
  * Write a request body to an HTTP/2 stream, respecting backpressure.
1105
- * Handles ReadableStream, Uint8Array, ArrayBuffer, and string body types.
1129
+ * Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
1106
1130
  *
1107
1131
  * @param stream - HTTP/2 stream to write to
1108
1132
  * @param body - Request body
@@ -1127,12 +1151,16 @@ async function attachBodyToH2Stream(stream, body) {
1127
1151
  stream.end(body);
1128
1152
  }
1129
1153
  else {
1130
- stream.end();
1154
+ // The raw Node transports bypass fetch, so bodies fetch would normally
1155
+ // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1156
+ // silently sent an empty body to the server.
1157
+ const bytes = await serializeRawBody(body);
1158
+ stream.end(bytes);
1131
1159
  }
1132
1160
  }
1133
1161
  /**
1134
1162
  * Write a request body to a Node.js http.ClientRequest, respecting backpressure.
1135
- * Handles ReadableStream, Uint8Array, ArrayBuffer, and string body types.
1163
+ * Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
1136
1164
  *
1137
1165
  * @param req - Node.js ClientRequest
1138
1166
  * @param body - Request body
@@ -1157,8 +1185,28 @@ async function pipeBodyToNodeReq(req, body) {
1157
1185
  req.end(body);
1158
1186
  }
1159
1187
  else {
1160
- req.end();
1188
+ // The raw Node transports bypass fetch, so bodies fetch would normally
1189
+ // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1190
+ // silently sent an empty body to the server.
1191
+ const bytes = await serializeRawBody(body);
1192
+ req.end(bytes);
1193
+ }
1194
+ }
1195
+ /**
1196
+ * Serialize body types that `fetch` would normally encode for us, so the raw
1197
+ * Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
1198
+ *
1199
+ * @param body - Request body that is not a stream, byte array, or string
1200
+ * @returns The encoded bytes to write (empty for unsupported types)
1201
+ */
1202
+ async function serializeRawBody(body) {
1203
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
1204
+ return new TextEncoder().encode(body.toString());
1205
+ }
1206
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
1207
+ return new Uint8Array(await body.arrayBuffer());
1161
1208
  }
1209
+ return new Uint8Array(0);
1162
1210
  }
1163
1211
  // ============================================================================
1164
1212
  // §10 DECOMPRESSION
package/dist/cjs/dedup.js CHANGED
@@ -136,6 +136,15 @@ export class DedupMap {
136
136
  this.inflight.delete(key);
137
137
  }
138
138
  else {
139
+ // Clear any previous timer for this key first. Registering a new
140
+ // window without clearing the old one let the stale timer fire later
141
+ // and `inflight.delete(key)` — wiping the *new*, still-in-flight entry
142
+ // out from under callers that were sharing it.
143
+ const stale = this.timeouts.get(key);
144
+ if (stale !== undefined) {
145
+ clearTimeout(stale);
146
+ this.timeouts.delete(key);
147
+ }
139
148
  const timeoutId = setTimeout(() => {
140
149
  this.inflight.delete(key);
141
150
  this.timeouts.delete(key);
@@ -177,13 +186,6 @@ export class DedupMap {
177
186
  get keys() {
178
187
  return [...this.inflight.keys()];
179
188
  }
180
- /**
181
- * Get comprehensive deduplication statistics.
182
- * NOTE: inFlightCount and trackedKeys reflect snapshot time; entries may
183
- * resolve asynchronously between sampling and return (non-blocking, acceptable).
184
- *
185
- * @returns Snapshot of hits, misses, hit rate, and tracked entries
186
- */
187
189
  /**
188
190
  * Get comprehensive deduplication statistics.
189
191
  *
@@ -550,3 +550,29 @@ export async function createDigestAuthorization(wwwAuth, username, password, met
550
550
  const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
551
551
  return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
552
552
  }
553
+ /**
554
+ * Stateful Digest-auth authorizer.
555
+ *
556
+ * Keeps a per-nonce request counter and increments it on every call, as
557
+ * RFC 7616 §3.4.1 requires: `nc` is the hex request count for the current
558
+ * nonce and MUST increase for each request. The stateless
559
+ * {@link createDigestAuthorization} always sends 00000001, which replay-
560
+ * protecting servers (nginx, Apache with `AuthDigestNonceLifetime`) reject on
561
+ * the second request. A new nonce (challenge change) resets the counter to 1.
562
+ *
563
+ * Not safe to share across clients that authenticate as different users —
564
+ * create one per client.
565
+ */
566
+ export function createDigestAuthorizer() {
567
+ const counters = new Map();
568
+ return async (wwwAuth, username, password, method, uri) => {
569
+ const challenge = parseDigestChallenge(wwwAuth);
570
+ const cnonce = randomBytes(5);
571
+ const scope = `${challenge.realm}\u0000${challenge.nonce}`;
572
+ const next = (counters.get(scope) ?? 0) + 1;
573
+ counters.set(scope, next);
574
+ const nc = next.toString(16).padStart(8, "0");
575
+ const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
576
+ return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
577
+ };
578
+ }
@@ -383,8 +383,10 @@ async function executeHTTP(req, config, signal, apqMode = "none", getAPQHashFn)
383
383
  fetchHeaders["content-type"] = "application/json";
384
384
  const controller = new AbortController();
385
385
  const timer = config.timeoutMs > 0 ? setTimeout(() => controller.abort(), config.timeoutMs) : null;
386
- // Merge external signal
387
- signal?.addEventListener("abort", () => controller.abort(), { once: true });
386
+ // Merge external signal. The listener is removed once the fetch settles so
387
+ // it does not accumulate on long-lived caller-provided signals (leak fix).
388
+ const onExternalAbort = () => controller.abort();
389
+ signal?.addEventListener("abort", onExternalAbort, { once: true });
388
390
  let response;
389
391
  try {
390
392
  response = await config.fetch(fetchUrl, {
@@ -405,6 +407,7 @@ async function executeHTTP(req, config, signal, apqMode = "none", getAPQHashFn)
405
407
  finally {
406
408
  if (timer)
407
409
  clearTimeout(timer);
410
+ signal?.removeEventListener("abort", onExternalAbort);
408
411
  }
409
412
  return parseGraphQLResponse(response, req);
410
413
  }
@@ -634,7 +637,10 @@ export class GraphQLClient {
634
637
  delete headers["content-type"]; // FormData sets it with boundary
635
638
  const form = buildMultipartBody(req, uploads);
636
639
  const controller = new AbortController();
637
- options.signal?.addEventListener("abort", () => controller.abort(), { once: true });
640
+ // Listener removed when the fetch settles — avoids accumulating on the
641
+ // caller's signal across many uploads (leak fix).
642
+ const onExternalAbort = () => controller.abort();
643
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
638
644
  this.config.onRequest(req);
639
645
  let response;
640
646
  try {
@@ -650,6 +656,9 @@ export class GraphQLClient {
650
656
  this.config.onError(clientErr, req);
651
657
  throw clientErr;
652
658
  }
659
+ finally {
660
+ options.signal?.removeEventListener("abort", onExternalAbort);
661
+ }
653
662
  const gqlRes = await parseGraphQLResponse(response, req);
654
663
  this.config.onResponse(gqlRes, req);
655
664
  if (gqlRes.errors?.length) {
@@ -679,7 +688,10 @@ export class GraphQLClient {
679
688
  async batch(requests, options = {}) {
680
689
  const headers = await buildHeaders(this.config);
681
690
  const controller = new AbortController();
682
- options.signal?.addEventListener("abort", () => controller.abort(), { once: true });
691
+ // Listener removed when the fetch settles — avoids accumulating on the
692
+ // caller's signal across many batches (leak fix).
693
+ const onExternalAbort = () => controller.abort();
694
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
683
695
  let response;
684
696
  try {
685
697
  response = await this.config.fetch(this.config.url, {
@@ -692,6 +704,9 @@ export class GraphQLClient {
692
704
  catch (err) {
693
705
  throw new GraphQLClientError(err instanceof Error ? err.message : "Batch network error", "ENETWORK", undefined, requests[0], undefined, err);
694
706
  }
707
+ finally {
708
+ options.signal?.removeEventListener("abort", onExternalAbort);
709
+ }
695
710
  // FIX (H6): sanitize untrusted batch response JSON before validation.
696
711
  const rawResults = sanitizeParsedJSON(await response.json());
697
712
  // Validate response is array (B-5 fix)
@@ -897,6 +897,31 @@ export function formatContentDisposition(cd) {
897
897
  * @param value - Raw `Cache-Control` header value
898
898
  * @returns Structured directives object with boolean flags and numeric values
899
899
  */
900
+ /**
901
+ * Split a `Cache-Control` value on commas, ignoring commas inside a quoted
902
+ * string. RFC 7234 §5.2 allows quoted-string values that contain commas, e.g.
903
+ * `private="field1, field2"`, so a plain `split(",")` corrupts them.
904
+ *
905
+ * @param value - Raw `Cache-Control` header value
906
+ * @returns Each directive as its own (untrimmed) string
907
+ */
908
+ function splitCacheControlDirectives(value) {
909
+ const parts = [];
910
+ let current = "";
911
+ let inQuotes = false;
912
+ for (const ch of value) {
913
+ if (ch === '"')
914
+ inQuotes = !inQuotes;
915
+ if (ch === "," && !inQuotes) {
916
+ parts.push(current);
917
+ current = "";
918
+ continue;
919
+ }
920
+ current += ch;
921
+ }
922
+ parts.push(current);
923
+ return parts;
924
+ }
900
925
  export function parseCacheControl(value) {
901
926
  const d = {
902
927
  noCache: false,
@@ -917,7 +942,7 @@ export function parseCacheControl(value) {
917
942
  staleWhileRevalidate: null,
918
943
  unknown: new Map(),
919
944
  };
920
- for (const part of value.split(",")) {
945
+ for (const part of splitCacheControlDirectives(value)) {
921
946
  const t = part.trim();
922
947
  const eq = t.indexOf("=");
923
948
  const k = (eq === -1 ? t : t.slice(0, eq)).trim().toLowerCase();
@@ -1379,17 +1404,48 @@ export function normalizeForwardedHeaders(headers) {
1379
1404
  };
1380
1405
  }
1381
1406
  /**
1382
- * Extract the real client IP from Forwarded, X-Forwarded-For, or X-Real-IP
1383
- * headers (in priority order).
1407
+ * Extract the client IP from Forwarded, X-Forwarded-For, or X-Real-IP.
1408
+ *
1409
+ * ⚠️ SECURITY: the value is derived from client-supplied headers and must NOT be
1410
+ * used for access control, rate limiting or audit trails unless `trustedHops` is
1411
+ * set to the real number of proxies in front of the server. The returned string
1412
+ * is also not validated as an IP address.
1384
1413
  *
1385
1414
  * @param headers - Source headers object
1386
- * @returns First client IP found, or `null` if none present
1415
+ * @param options - Trust configuration
1416
+ * @returns The selected client IP, or `null` if none present
1387
1417
  */
1388
- export function getClientIP(headers) {
1418
+ export function getClientIP(headers, options = {}) {
1419
+ const trustedHops = Math.max(0, Math.trunc(options.trustedHops ?? 0));
1389
1420
  const fwd = normalizeForwardedHeaders(headers);
1390
- if (fwd.for.length > 0)
1391
- return fwd.for[0];
1392
- return headers.get(HeaderName.XRealIP);
1421
+ if (fwd.for.length > 0) {
1422
+ const list = fwd.for;
1423
+ // Right-most entry is the closest hop. trustedHops = 1 → the entry the
1424
+ // 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);
1428
+ }
1429
+ return stripIPPortAndBrackets(headers.get(HeaderName.XRealIP) ?? "") || null;
1430
+ }
1431
+ /**
1432
+ * Normalize a Forwarded/XFF entry to a bare host: strip `for="…"` quoting,
1433
+ * `[ipv6]:port`, and a bare `:port` suffix.
1434
+ */
1435
+ function stripIPPortAndBrackets(value) {
1436
+ let v = value.trim();
1437
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
1438
+ v = v.slice(1, -1);
1439
+ if (v.startsWith("[")) {
1440
+ const end = v.indexOf("]");
1441
+ if (end !== -1)
1442
+ return v.slice(1, end);
1443
+ }
1444
+ // Only strip a trailing :digits (never the colons inside an IPv6 literal).
1445
+ const m = /^(.*):\d+$/.exec(v);
1446
+ if (m && (m[1] ?? "").includes("."))
1447
+ return m[1];
1448
+ return v;
1393
1449
  }
1394
1450
  /**
1395
1451
  * Parse a Retry-After header which may be either delta-seconds or an