kinetex 1.3.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 (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  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 +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  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 +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -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 +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  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 +14 -1
  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 +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
package/dist/cjs/core.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Node.js globals accessed via globalThis for cross-runtime compatibility
2
2
  const g = globalThis;
3
3
  import { KinetexError, TimeoutError, SizeLimitError } from "./types.js";
4
- import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse } from "./utils.js";
4
+ import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse, isSafeURL, randomBytes, } from "./utils.js";
5
5
  import { isValidHeaderName, isValidHeaderValue } from "./headers.js";
6
6
  // ============================================================================
7
7
  // §1 RUNTIME DETECTION
@@ -65,7 +65,26 @@ let _runtimeOverride = null;
65
65
  * setRuntime(null); // restore detection
66
66
  * ```
67
67
  */
68
+ /** Every value {@link Runtime} admits, for runtime validation. */
69
+ const KNOWN_RUNTIMES = [
70
+ "node",
71
+ "deno",
72
+ "bun",
73
+ "browser",
74
+ "cloudflare-workers",
75
+ "edge",
76
+ "unknown",
77
+ ];
68
78
  export function setRuntime(rt) {
79
+ // The parameter is typed, but nothing checks it at runtime, and the callers
80
+ // that matter pass a value read from configuration. A typo — "denno" — was
81
+ // stored verbatim and became the effective runtime, and every branch in the
82
+ // library gated on `RUNTIME === "..."` then missed: no fetch, no HTTP/2, no
83
+ // proxy, no Node-only path, with nothing thrown and nothing logged. An
84
+ // unrecognised value is a mistake worth reporting at the point it is made.
85
+ if (rt !== null && !KNOWN_RUNTIMES.includes(rt)) {
86
+ throw new TypeError(`setRuntime: unknown runtime ${JSON.stringify(rt)}. Expected one of: ${KNOWN_RUNTIMES.join(", ")}`);
87
+ }
69
88
  _runtimeOverride = rt;
70
89
  }
71
90
  /**
@@ -117,6 +136,7 @@ export class FetchTransport {
117
136
  fetchFn;
118
137
  strict;
119
138
  onDroppedHeader;
139
+ dispatcher;
120
140
  /**
121
141
  * @param fetchFnOrOptions - Custom fetch function or options object
122
142
  */
@@ -125,11 +145,13 @@ export class FetchTransport {
125
145
  this.fetchFn = fetchFnOrOptions;
126
146
  this.strict = false;
127
147
  this.onDroppedHeader = undefined;
148
+ this.dispatcher = undefined;
128
149
  }
129
150
  else {
130
151
  this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
131
152
  this.strict = fetchFnOrOptions.strict ?? false;
132
153
  this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
154
+ this.dispatcher = fetchFnOrOptions.dispatcher;
133
155
  }
134
156
  }
135
157
  /**
@@ -164,6 +186,20 @@ export class FetchTransport {
164
186
  }
165
187
  continue;
166
188
  }
189
+ // `__proto__` is a legal header name — it is made of token characters,
190
+ // so it passes the check above — and `sanitizedHeaders[name] = value` is a
191
+ // [[Set]], so it went to the inherited setter, which ignores a primitive.
192
+ // The header did not overwrite anything: it vanished, and the caller was
193
+ // never told. Written as a data property, which is what was meant.
194
+ if (name === "__proto__") {
195
+ Object.defineProperty(sanitizedHeaders, name, {
196
+ value,
197
+ writable: true,
198
+ enumerable: true,
199
+ configurable: true,
200
+ });
201
+ continue;
202
+ }
167
203
  sanitizedHeaders[name] = value;
168
204
  }
169
205
  // Strip the accept-encoding value INJECTED by client.ts so fetch() can add
@@ -195,6 +231,11 @@ export class FetchTransport {
195
231
  init.duplex = "half";
196
232
  }
197
233
  }
234
+ // Forwarded only when set: an explicit `dispatcher: undefined` would
235
+ // otherwise override a globally-installed dispatcher on the runtime.
236
+ if (this.dispatcher !== undefined) {
237
+ init.dispatcher = this.dispatcher;
238
+ }
198
239
  let response;
199
240
  try {
200
241
  response = await this.fetchFn(req.url, init);
@@ -264,6 +305,25 @@ export class NodeHTTP2Transport {
264
305
  _requestTimeoutMs;
265
306
  /** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
266
307
  _http1Fallback = null;
308
+ /**
309
+ * Dedicated keep-alive agent for the legacy `node:https` path.
310
+ *
311
+ * This path called `https.request(options)` with no `agent`, so behaviour was
312
+ * whatever the global agent happened to do — and that default is a moving
313
+ * target. Node 19 turned `keepAlive` on for `https.globalAgent`, so this path
314
+ * silently changed from a TCP + TLS handshake per request (Node 18 and
315
+ * earlier) to pooled connections, with no code change here to explain it.
316
+ *
317
+ * An explicit agent makes the behaviour identical on every supported Node
318
+ * version, bounds the idle-socket pool (the global agent keeps 256 free
319
+ * sockets, which is a lot to hold open for a legacy fallback), and gives
320
+ * callers a way to opt out.
321
+ */
322
+ _http1KeepAlive;
323
+ _http1MaxSockets;
324
+ _http1Agent = null;
325
+ /** HTTP(S) CONNECT proxy, or undefined for a direct connection. */
326
+ _proxy;
267
327
  /**
268
328
  * @param options - Session pool and transport configuration
269
329
  */
@@ -271,6 +331,9 @@ export class NodeHTTP2Transport {
271
331
  this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
272
332
  this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
273
333
  this.maxSessions = options.maxSessions ?? 100;
334
+ this._http1KeepAlive = options.http1KeepAlive ?? true;
335
+ this._http1MaxSockets = options.http1MaxSockets ?? 16;
336
+ this._proxy = options.proxy;
274
337
  this._strict = options.strict ?? false;
275
338
  this._onDroppedHeader = options.onDroppedHeader;
276
339
  this._ca = options.ca;
@@ -333,10 +396,29 @@ export class NodeHTTP2Transport {
333
396
  this._evictSession(lruOrigin, lruSession.session);
334
397
  }
335
398
  }
399
+ // With a proxy, the tunnel (and, for an https origin, the TLS handshake
400
+ // to the target) is established *before* http2.connect is called.
401
+ //
402
+ // `createConnection` must return a socket synchronously — node:http2 does
403
+ // not await it, and handing it a Promise yields a session built on a
404
+ // thenable, which fails deep inside the stream layer with
405
+ // "stream.pause is not a function". So the async work happens here and
406
+ // the already-open socket is passed back synchronously.
407
+ let proxiedSocket = null;
408
+ if (this._proxy !== undefined) {
409
+ proxiedSocket = await this._createProxiedSocket(origin, undefined, req);
410
+ }
336
411
  const session = await new Promise((resolve, reject) => {
337
412
  const s = http2.connect(origin, {
338
413
  rejectUnauthorized: true,
339
414
  ...(this._ca !== undefined ? { ca: this._ca } : {}),
415
+ // Already connected (and already TLS-wrapped for an https origin),
416
+ // so node:http2 must use it as-is rather than negotiating again.
417
+ ...(proxiedSocket !== null
418
+ ? {
419
+ createConnection: (() => proxiedSocket),
420
+ }
421
+ : {}),
340
422
  });
341
423
  // FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
342
424
  // FIX 9: unref() the timer so it does not prevent process exit
@@ -425,6 +507,29 @@ export class NodeHTTP2Transport {
425
507
  throw err;
426
508
  }
427
509
  }
510
+ /**
511
+ * Refuse one header on the HTTP/2 path. Strict mode raises `EVALIDATION`
512
+ * before anything is dialled; non-strict notifies the callback (if any) and
513
+ * warns, never dropping silently.
514
+ *
515
+ * Both the pseudo-header filter and the validation loop go through here so
516
+ * the two cannot drift apart, which is what let a caller-supplied `:path`
517
+ * reach the wire while `FetchTransport` dropped the identical header.
518
+ */
519
+ _rejectHeader(name, value, reason, request) {
520
+ if (this._strict) {
521
+ throw new KinetexError(`Strict mode: header "${name}" ${reason}`, "EVALIDATION", {
522
+ request,
523
+ });
524
+ }
525
+ if (this._onDroppedHeader) {
526
+ this._onDroppedHeader(name, value);
527
+ }
528
+ else if (typeof console !== "undefined") {
529
+ console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${name}" — ${reason}. ` +
530
+ `Pass strictHeaders: true to throw instead.`);
531
+ }
532
+ }
428
533
  /**
429
534
  * Send a request over HTTP/2 with iterative redirect following.
430
535
  * Each hop reuses or creates a session for the target origin.
@@ -457,14 +562,69 @@ export class NodeHTTP2Transport {
457
562
  session = existing.session;
458
563
  this.sessionUsage.set(origin, Date.now());
459
564
  }
460
- // Build headers for this hop
565
+ // A `FormData` body has to be encoded BEFORE the header block is built:
566
+ // the multipart boundary is generated during encoding, and a boundary
567
+ // that cannot reach `content-type` leaves a body the peer cannot parse.
568
+ // This transport bypasses fetch, so nothing else would have encoded it.
569
+ // The client's own path never reaches this — it encodes the form and sets
570
+ // the header together before dispatch — but a caller handing a `FormData`
571
+ // straight to a transport would otherwise send a body whose boundary no
572
+ // header ever named.
573
+ let bodyForHop = currentReq.body;
574
+ let encodedForHop;
575
+ if (currentReq.body instanceof FormData) {
576
+ encodedForHop = await serializeRawBody(currentReq.body);
577
+ bodyForHop = encodedForHop.bytes;
578
+ // Request headers are lowercased before they reach the transport, so
579
+ // a direct key read is the right test here.
580
+ if (encodedForHop.contentType !== undefined && !currentReq.headers["content-type"]) {
581
+ currentReq = {
582
+ ...currentReq,
583
+ headers: { ...currentReq.headers, "content-type": encodedForHop.contentType },
584
+ };
585
+ }
586
+ }
587
+ // Build headers for this hop. The transport owns the request line, so a
588
+ // caller-supplied pseudo-header (":path", ":authority", ":method",
589
+ // ":scheme") is refused rather than merged.
590
+ //
591
+ // They used to be spread over the transport's own values, which meant a
592
+ // caller could send a request to a path and a `:authority` that the URL
593
+ // argument never contained — the URL that `isSafeURL` screened is not
594
+ // the URL that got dialled. The validation loop below could not catch it
595
+ // either, because it skipped every name starting with ":", so even
596
+ // `strict: true` returned 200 for a hijacked `:path`, and
597
+ // `FetchTransport` dropped the very same header because ":" is not a
598
+ // token character. Same request, two transports, two answers.
461
599
  const h2ReqHeaders = {
462
600
  ":method": currentReq.method,
463
601
  ":path": currentUrl.pathname + currentUrl.search,
464
602
  ":scheme": "https",
465
603
  ":authority": currentUrl.host,
466
- ...currentReq.headers,
467
604
  };
605
+ for (const [hName, hValue] of Object.entries(currentReq.headers)) {
606
+ if (hName.startsWith(":")) {
607
+ this._rejectHeader(hName, Array.isArray(hValue) ? hValue.join(", ") : String(hValue), "is not a valid header name", currentReq);
608
+ continue;
609
+ }
610
+ // `__proto__` is a legal header name — it is made of token characters,
611
+ // so it passes `isValidHeaderName` — and `h2ReqHeaders[name] = value`
612
+ // is a [[Set]], so it hit the inherited setter, which ignores a
613
+ // primitive. The header did not overwrite anything: it vanished, and
614
+ // neither the callback nor the warning said so. Written as a data
615
+ // property, which is what `FetchTransport` already does — the two
616
+ // transports disagreed about whether it is sent at all.
617
+ if (hName === "__proto__") {
618
+ Object.defineProperty(h2ReqHeaders, hName, {
619
+ value: hValue,
620
+ writable: true,
621
+ enumerable: true,
622
+ configurable: true,
623
+ });
624
+ continue;
625
+ }
626
+ h2ReqHeaders[hName] = hValue;
627
+ }
468
628
  // Header validation (HTTP/2 control-character check). Runs in BOTH modes:
469
629
  // strict throws, non-strict drops with callback/warn — matching the
470
630
  // FetchTransport contract. (Previously the whole loop was gated on
@@ -475,35 +635,29 @@ export class NodeHTTP2Transport {
475
635
  if (hName.startsWith(":"))
476
636
  continue;
477
637
  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;
484
- }
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);
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
- }
638
+ // The same two checks FetchTransport runs, through the same helpers —
639
+ // not a hand-rolled control-character scan. The scan checked the value
640
+ // only, so a header *name* that was not a token ("X Bad", "X\u00e9")
641
+ // reached `session.request()` and came back as a raw
642
+ // ERR_INVALID_HTTP2_HEADER / ERR_INVALID_HEADER_VALUE instead of being
643
+ // dropped in non-strict mode or raising EVALIDATION in strict mode; and
644
+ // it had no upper bound, so a value above U+00FF — which FetchTransport
645
+ // refuses because no ByteString header value can carry it — was sent on
646
+ // this path and dropped on that one. Same request, two transports, two
647
+ // answers. The comment above this loop claimed they matched; they did
648
+ // not, and HTTP/2 is the default on Node.
649
+ const nameOk = isValidHeaderName(hName);
650
+ const valueOk = isValidHeaderValue(hStr);
651
+ if (!nameOk || !valueOk) {
652
+ this._rejectHeader(hName, hStr, nameOk ? "contains forbidden control characters" : "is not a valid header name", currentReq);
499
653
  delete h2ReqHeaders[hName];
500
654
  }
501
655
  }
502
- const endStream = !currentReq.body || currentReq.method === "GET" || currentReq.method === "HEAD";
656
+ const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
503
657
  const stream = session.request(h2ReqHeaders, { endStream });
504
658
  // FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
505
- if (currentReq.body && !endStream) {
506
- attachBodyToH2Stream(stream, currentReq.body).catch((err) => {
659
+ if (bodyForHop && !endStream) {
660
+ attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
507
661
  stream.destroy(err instanceof Error ? err : new Error(String(err)));
508
662
  });
509
663
  }
@@ -589,8 +743,9 @@ export class NodeHTTP2Transport {
589
743
  try {
590
744
  if (raw.body) {
591
745
  const drain = raw.body.getReader();
592
- // eslint-disable-next-line no-constant-condition
593
- while (true) {
746
+ // `for(;;)` rather than `while (true)`: same loop, no condition for a
747
+ // linter to have an opinion about.
748
+ for (;;) {
594
749
  const { done } = await drain.read();
595
750
  if (done)
596
751
  break;
@@ -603,14 +758,38 @@ export class NodeHTTP2Transport {
603
758
  }
604
759
  const location = raw.headers["location"];
605
760
  let nextHref;
761
+ let nextProtocol;
606
762
  try {
607
- nextHref = new URL(location, currentReq.url).href;
763
+ const nextUrl = new URL(location, currentReq.url);
764
+ nextHref = nextUrl.href;
765
+ nextProtocol = nextUrl.protocol.toLowerCase();
608
766
  }
609
767
  catch {
610
768
  throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
611
769
  request: req,
612
770
  });
613
771
  }
772
+ // The same two gates the client's manual redirect follower applies, for
773
+ // the same reasons. They were absent here, and this loop is the *only*
774
+ // follower on this path whenever the request arrives without
775
+ // `redirect: "manual"` — which is every direct use of this transport.
776
+ //
777
+ // - Protocol. This transport hardcodes `:scheme: "https"` and speaks
778
+ // HTTP/2, so a cleartext target cannot be dialled at all: the hop
779
+ // failed as `ERR_HTTP2_ERROR: Protocol error` with nothing to connect
780
+ // it to the target. A downgrade was therefore possible by accident
781
+ // rather than refused on purpose, and an `httpsOnly` client could not
782
+ // tell the difference.
783
+ // - SSRF. The hop origin is dialled directly by `http2.connect` below,
784
+ // with no `isSafeURL` screen — the client's follower screens every hop
785
+ // precisely because "a redirect target never went through that
786
+ // check". A 302 to `http://127.0.0.1:9/` opened the socket.
787
+ if (nextProtocol !== "https:") {
788
+ throw new KinetexError(`HTTP/2 redirect to a non-HTTPS target blocked: ${nextProtocol}//…`, "EVALIDATION", { request: req });
789
+ }
790
+ if (!isSafeURL(nextHref)) {
791
+ throw new KinetexError(`Unsafe redirect target blocked: ${nextHref.replace(/:\/\/[^/@]*@/, "://…@")}`, "EVALIDATION", { request: req });
792
+ }
614
793
  // RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
615
794
  const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
616
795
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
@@ -649,12 +828,33 @@ export class NodeHTTP2Transport {
649
828
  async _sendHTTP1Legacy(req) {
650
829
  const https = await import("node:https");
651
830
  const url = new URL(req.url);
831
+ // A `FormData` body is encoded before the request options are built, for
832
+ // the same reason as on the HTTP/2 path: the multipart boundary is
833
+ // generated during encoding, and this transport bypasses fetch, so
834
+ // nothing else would encode it or announce the boundary. A caller-set
835
+ // `content-type` wins — they may have encoded the form themselves.
836
+ let legacyReq = req;
837
+ if (req.body instanceof FormData) {
838
+ const encoded = await serializeRawBody(req.body);
839
+ legacyReq = { ...req, body: encoded.bytes };
840
+ if (encoded.contentType !== undefined && !legacyReq.headers["content-type"]) {
841
+ legacyReq = {
842
+ ...legacyReq,
843
+ headers: { ...legacyReq.headers, "content-type": encoded.contentType },
844
+ };
845
+ }
846
+ }
652
847
  const options = {
653
848
  hostname: url.hostname,
654
849
  port: url.port || "443",
655
850
  path: url.pathname + url.search,
656
- method: req.method,
657
- headers: req.headers,
851
+ method: legacyReq.method,
852
+ headers: legacyReq.headers,
853
+ ...(this._http1KeepAlive ? { agent: this._getHttp1Agent(https) } : {}),
854
+ // `ca` was accepted by the transport but never reached this path, so a
855
+ // private or self-signed peer could not be reached without disabling
856
+ // verification process-wide.
857
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
658
858
  };
659
859
  return new Promise((resolve, reject) => {
660
860
  const httpReq = https.request(options, (httpRes) => {
@@ -676,7 +876,13 @@ export class NodeHTTP2Transport {
676
876
  });
677
877
  });
678
878
  httpReq.once("error", (err) => {
679
- reject(new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
879
+ // An error raised while building the connection (a refused proxy
880
+ // tunnel, a TLS failure) is already a KinetexError carrying a
881
+ // meaningful code. Re-wrapping it as ENETWORK threw that away, so a
882
+ // 403 from the proxy and a DNS failure became indistinguishable.
883
+ reject(err instanceof KinetexError
884
+ ? err
885
+ : new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
680
886
  });
681
887
  // Remove the abort listener once the request settles so the httpReq
682
888
  // reference doesn't leak beyond the request lifetime.
@@ -689,13 +895,76 @@ export class NodeHTTP2Transport {
689
895
  httpReq.once("close", cleanup);
690
896
  httpReq.once("error", cleanup);
691
897
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
692
- pipeBodyToNodeReq(httpReq, req.body).catch(reject);
898
+ pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
693
899
  }
694
900
  else {
695
901
  httpReq.end();
696
902
  }
697
903
  });
698
904
  }
905
+ /**
906
+ * Lazily create the keep-alive agent used by the legacy HTTP/1.1 path.
907
+ *
908
+ * @param https - The already-imported `node:https` module.
909
+ * @returns The shared agent, reused across requests.
910
+ */
911
+ _getHttp1Agent(https) {
912
+ if (!this._http1Agent) {
913
+ this._http1Agent = new https.Agent({
914
+ keepAlive: true,
915
+ maxSockets: this._http1MaxSockets,
916
+ maxFreeSockets: this._http1MaxSockets,
917
+ // A private/self-signed peer must be trusted before the socket enters
918
+ // the pool, otherwise the agent only fails later on reuse.
919
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
920
+ });
921
+ if (this._proxy !== undefined) {
922
+ // Assigned to the INSTANCE, not passed in the agent options:
923
+ // `new Agent({ createConnection })` only copies it into
924
+ // `agent.options`, while `Agent.prototype.createSocket` calls
925
+ // `this.createConnection(...)` — the prototype method. Passing it in
926
+ // the options is silently ignored and the agent dials the origin
927
+ // directly, bypassing the proxy entirely.
928
+ //
929
+ // `createSocket` does:
930
+ // const s = this.createConnection(options, oncreate);
931
+ // if (s) oncreate(null, s);
932
+ // so returning a Promise would be truthy and the agent would adopt
933
+ // the thenable as a socket. The callback form is the supported way to
934
+ // connect asynchronously: this returns undefined and reports the
935
+ // tunneled socket through `oncreate`.
936
+ //
937
+ // The tunnel is already TLS-wrapped for an https origin, which is
938
+ // exactly what https.Agent expects createConnection to return.
939
+ this._http1Agent.createConnection = ((opts, oncreate) => {
940
+ void this._createProxiedSocket(`https://${String(opts.host ?? opts.servername ?? "localhost")}:${String(opts.port ?? 443)}`, undefined, undefined).then((socket) => oncreate(null, socket), (err) => oncreate(err));
941
+ return undefined;
942
+ });
943
+ }
944
+ }
945
+ return this._http1Agent;
946
+ }
947
+ /**
948
+ * Open a socket to `origin` through the configured proxy.
949
+ *
950
+ * The returned socket is fully established — tunneled, and TLS-wrapped when
951
+ * the origin is `https:` — so it can be handed to a transport that requires
952
+ * its connection synchronously.
953
+ *
954
+ * @param origin - Target origin, e.g. `https://api.example.com:443`.
955
+ * @param req - Originating request, attached to any thrown error.
956
+ * @returns A socket connected to the target through the proxy.
957
+ */
958
+ async _createProxiedSocket(origin, _tlsOpts, req) {
959
+ const { connectThroughProxy } = await import("./proxy.js");
960
+ const target = new URL(origin);
961
+ return await connectThroughProxy(this._proxy, target, {
962
+ ...(this._ca !== undefined ? { ca: this._ca } : {}),
963
+ connectTimeoutMs: this._connectTimeoutMs,
964
+ ...(req?.signal != null ? { signal: req.signal } : {}),
965
+ ...(req !== undefined ? { request: req } : {}),
966
+ });
967
+ }
699
968
  /** @internal Evict one session and its associated ping timer. */
700
969
  _evictSession(origin, session) {
701
970
  const timer = this.pingTimers.get(origin);
@@ -721,6 +990,10 @@ export class NodeHTTP2Transport {
721
990
  this._evictSession(origin, session);
722
991
  }
723
992
  this.sessions.clear();
993
+ // Drain the legacy HTTP/1.1 keep-alive pool too, so a destroyed transport
994
+ // leaves no idle sockets behind holding the event loop open.
995
+ this._http1Agent?.destroy();
996
+ this._http1Agent = null;
724
997
  }
725
998
  }
726
999
  // ============================================================================
@@ -743,14 +1016,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
743
1016
  // "Custom fetch implementation" behavior holds on every runtime.
744
1017
  // Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
745
1018
  // fetch is given. Falls back to FetchTransport otherwise.
746
- if (IS_NODE && preferHTTP2 && fetchFn) {
1019
+ // A dispatcher belongs to the fetch implementation, exactly like a custom
1020
+ // fetch does — NodeHTTP2Transport speaks `node:http2` and has no notion of
1021
+ // one. So it forces the same fallback rather than being silently dropped.
1022
+ const needsFetchTransport = fetchFn !== undefined || transportOptions?.dispatcher !== undefined;
1023
+ if (IS_NODE && preferHTTP2 && needsFetchTransport) {
747
1024
  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.");
1025
+ console.warn(fetchFn !== undefined
1026
+ ? '[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
1027
+ "NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
1028
+ "FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2."
1029
+ : '[kinetex] httpVersion: "HTTP/2" is ignored when a `dispatcher` is configured — ' +
1030
+ "NodeHTTP2Transport talks to node:http2 directly and cannot use a fetch " +
1031
+ "dispatcher, so the request goes through FetchTransport (HTTP/1.1 semantics). " +
1032
+ "Drop the `dispatcher` option to use HTTP/2.");
751
1033
  }
752
1034
  }
753
- if (IS_NODE && preferHTTP2 && !fetchFn) {
1035
+ if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
754
1036
  return new NodeHTTP2Transport({
755
1037
  ...(sessionOptions?.sessionTTLMs !== undefined
756
1038
  ? { sessionTTLMs: sessionOptions.sessionTTLMs }
@@ -758,6 +1040,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
758
1040
  ...(sessionOptions?.pingIntervalMs !== undefined
759
1041
  ? { pingIntervalMs: sessionOptions.pingIntervalMs }
760
1042
  : {}),
1043
+ // maxSessions was a documented transport option but was never forwarded
1044
+ // here, so the LRU cap was unreachable and the pool grew unbounded.
1045
+ ...(sessionOptions?.maxSessions !== undefined
1046
+ ? { maxSessions: sessionOptions.maxSessions }
1047
+ : {}),
1048
+ ...(sessionOptions?.http1KeepAlive !== undefined
1049
+ ? { http1KeepAlive: sessionOptions.http1KeepAlive }
1050
+ : {}),
1051
+ ...(sessionOptions?.http1MaxSockets !== undefined
1052
+ ? { http1MaxSockets: sessionOptions.http1MaxSockets }
1053
+ : {}),
761
1054
  ...(sessionOptions?.connectTimeoutMs !== undefined
762
1055
  ? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
763
1056
  : {}),
@@ -768,6 +1061,8 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
768
1061
  ...(transportOptions?.onDroppedHeader !== undefined
769
1062
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
770
1063
  : {}),
1064
+ ...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
1065
+ ...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
771
1066
  });
772
1067
  }
773
1068
  return new FetchTransport({
@@ -776,6 +1071,9 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
776
1071
  ...(transportOptions?.onDroppedHeader !== undefined
777
1072
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
778
1073
  : {}),
1074
+ ...(transportOptions?.dispatcher !== undefined
1075
+ ? { dispatcher: transportOptions.dispatcher }
1076
+ : {}),
779
1077
  });
780
1078
  }
781
1079
  // ============================================================================
@@ -937,6 +1235,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
937
1235
  if (result.success && result.value !== undefined) {
938
1236
  parseResult = result.value;
939
1237
  }
1238
+ else if (!result.success) {
1239
+ // `safeJSONParse` refuses a payload for a reason it can name — a
1240
+ // depth, a length, a key count, a prototype-pollution key — and that
1241
+ // reason was dropped on the floor, so the caller was told "JSON parse
1242
+ // failed" for a body that is perfectly valid JSON and merely larger
1243
+ // than the limits this function chose. A response that quietly changes
1244
+ // from parsed to raw text is worth one specific sentence about which
1245
+ // limit it crossed, and the code is on the error so a handler can
1246
+ // branch on it.
1247
+ const failure = new Error(`JSON body rejected: ${result.message ?? "parse failed"} — falling back to raw text`);
1248
+ Object.assign(failure, { code: result.error ?? "EPARSE" });
1249
+ parseError = failure;
1250
+ }
940
1251
  }
941
1252
  catch (e) {
942
1253
  parseError = e instanceof Error ? e : new Error(String(e));
@@ -984,29 +1295,52 @@ export function normalizeHeaders(headers) {
984
1295
  * @param _headers - Parsed response headers (reserved)
985
1296
  * @returns Detected HTTP version
986
1297
  */
1298
+ /**
1299
+ * Translate a runtime-reported protocol string into the {@link HTTPVersion}
1300
+ * union, or return null when it says nothing this library can act on.
1301
+ *
1302
+ * Accepts the spellings Deno and Bun actually use ("2", "2.0", "1", "1.0",
1303
+ * "1.1") plus the union's own members, case- and whitespace-insensitively, so a
1304
+ * peer cannot widen the field to a string by adding a prefix.
1305
+ */
1306
+ function normalizeHTTPVersion(raw) {
1307
+ const v = raw.trim().toLowerCase();
1308
+ if (v === "2" || v === "2.0" || v === "h2" || v === "http/2" || v === "http/2.0")
1309
+ return "HTTP/2";
1310
+ if (v === "1" || v === "1.0" || v === "http/1" || v === "http/1.0")
1311
+ return "HTTP/1.0";
1312
+ if (v === "1.1" || v === "http/1.1")
1313
+ return "HTTP/1.1";
1314
+ return null;
1315
+ }
987
1316
  function detectHTTPVersion(response, _headers) {
988
- // Deno exposes response.type or we can infer from headers
989
- // Runtime-specific property access requires type assertion
1317
+ // Deno and Bun both expose `httpVersion` on the Response, and both spell it
1318
+ // their own way: Deno answers "2.0", Bun answers "1.1" and "2". The Deno arm
1319
+ // translated its values; the Bun arm returned whatever it was given, so a
1320
+ // plain HTTP/1.1 response on Bun reported the string "1.1" — a value outside
1321
+ // the `HTTPVersion` union, reaching every caller of `res.httpVersion` through
1322
+ // a `[[typed]]` lie. A consumer switching on "HTTP/1.1" silently fell through,
1323
+ // and on a runtime the library does not run in CI the type checker is the only
1324
+ // thing that would have said so. Both arms now go through one translation, and
1325
+ // anything unrecognised falls through to the evidence below rather than being
1326
+ // reported as a protocol.
990
1327
  const denoResponse = response;
991
- if (denoResponse.httpVersion === "2.0" || denoResponse.httpVersion === "2") {
992
- return "HTTP/2";
993
- }
994
- // Bun exposes httpVersion as a property
995
- const bunResponse = response;
996
- if (bunResponse.httpVersion) {
997
- return bunResponse.httpVersion;
1328
+ const runtimeVersion = denoResponse.httpVersion;
1329
+ if (typeof runtimeVersion === "string") {
1330
+ const normalized = normalizeHTTPVersion(runtimeVersion);
1331
+ if (normalized)
1332
+ return normalized;
998
1333
  }
999
- // HTTP/3 (QUIC) detection via Alt-Svc header.
1334
+ // Server capability advertisement.
1000
1335
  // Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
1001
- // We detect the advertisement here and update accordingly.
1336
+ //
1337
+ // kinetex does not speak HTTP/3, and no runtime it targets has a stable
1338
+ // HTTP/3 client, so an h3 advertisement is deliberately NOT reported as
1339
+ // HTTP/3: the response in hand was served over HTTP/2, and saying otherwise
1340
+ // would misreport the protocol actually used. A runtime that ever does
1341
+ // negotiate h3 itself reaches the same answer below rather than claiming a
1342
+ // version the transport cannot produce.
1002
1343
  const altSvc = response.headers.get("alt-svc");
1003
- // Check for active HTTP/3 negotiation (runtime-specific property)
1004
- const h3Response = response;
1005
- if (h3Response.httpVersion === "3" ||
1006
- h3Response.httpVersion === "3.0" ||
1007
- h3Response.protocol === "h3") {
1008
- return "HTTP/3";
1009
- }
1010
1344
  // Alt-Svc advertisement: infer HTTP version from the advertised protocols.
1011
1345
  // - h3 (QUIC) means the server supports HTTP/3
1012
1346
  // - h2 means the server supports HTTP/2
@@ -1154,7 +1488,7 @@ async function attachBodyToH2Stream(stream, body) {
1154
1488
  // The raw Node transports bypass fetch, so bodies fetch would normally
1155
1489
  // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1156
1490
  // silently sent an empty body to the server.
1157
- const bytes = await serializeRawBody(body);
1491
+ const { bytes } = await serializeRawBody(body);
1158
1492
  stream.end(bytes);
1159
1493
  }
1160
1494
  }
@@ -1188,7 +1522,7 @@ async function pipeBodyToNodeReq(req, body) {
1188
1522
  // The raw Node transports bypass fetch, so bodies fetch would normally
1189
1523
  // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1190
1524
  // silently sent an empty body to the server.
1191
- const bytes = await serializeRawBody(body);
1525
+ const { bytes } = await serializeRawBody(body);
1192
1526
  req.end(bytes);
1193
1527
  }
1194
1528
  }
@@ -1197,16 +1531,80 @@ async function pipeBodyToNodeReq(req, body) {
1197
1531
  * Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
1198
1532
  *
1199
1533
  * @param body - Request body that is not a stream, byte array, or string
1200
- * @returns The encoded bytes to write (empty for unsupported types)
1534
+ * @returns The encoded bytes to write, plus a content type to announce when the
1535
+ * encoding generated one (empty bytes for unsupported types)
1201
1536
  */
1202
1537
  async function serializeRawBody(body) {
1203
1538
  if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
1204
- return new TextEncoder().encode(body.toString());
1539
+ return { bytes: new TextEncoder().encode(body.toString()) };
1205
1540
  }
1206
1541
  if (typeof Blob !== "undefined" && body instanceof Blob) {
1207
- return new Uint8Array(await body.arrayBuffer());
1542
+ return { bytes: new Uint8Array(await body.arrayBuffer()) };
1543
+ }
1544
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
1545
+ const { bytes, boundary } = await encodeMultipart(body);
1546
+ return { bytes, contentType: `multipart/form-data; boundary=${boundary}` };
1547
+ }
1548
+ return { bytes: new Uint8Array(0) };
1549
+ }
1550
+ /**
1551
+ * Escape a multipart field name or filename for a `Content-Disposition`
1552
+ * parameter. RFC 7578 §5.1 percent-encodes CR, LF and a double quote; a bare
1553
+ * backslash is escaped too so a name cannot terminate the quoted string early.
1554
+ */
1555
+ function escapeFieldName(name) {
1556
+ return name.replace(/[\r\n"\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
1557
+ }
1558
+ /**
1559
+ * Encode a `FormData` as `multipart/form-data` (RFC 7578).
1560
+ *
1561
+ * The function this lives in was written to stop the raw Node transports from
1562
+ * sending an *empty* body for the types fetch would have serialized, and it
1563
+ * covered `URLSearchParams` and `Blob`. `FormData` was missed, so on the
1564
+ * default transport on Node — `NodeHTTP2Transport`, which bypasses fetch
1565
+ * entirely — a form upload went out as a request with no body at all and no
1566
+ * `Content-Type`, and the server recorded an empty form. The response was an
1567
+ * ordinary 200, so nothing looked wrong.
1568
+ *
1569
+ * The boundary is generated per call and written into the body. The transports
1570
+ * that call this only ever see bytes, so the header is set by the client from
1571
+ * `options.headers`; a body encoded here without a matching header is
1572
+ * unparseable, which is why the boundary is returned alongside the bytes for a
1573
+ * caller that has to announce it. Passing one in makes the output
1574
+ * deterministic, which is what the test suite pins.
1575
+ */
1576
+ export async function encodeMultipart(form, boundary) {
1577
+ // 24 random bytes as hex: 192 bits is far past any collision concern, and hex
1578
+ // is all legal in a boundary.
1579
+ const bnd = boundary ?? `----kinetexFormBoundary${randomBytes(24)}`;
1580
+ const encoder = new TextEncoder();
1581
+ const chunks = [];
1582
+ const push = (text) => {
1583
+ chunks.push(encoder.encode(text));
1584
+ };
1585
+ for (const [name, value] of form.entries()) {
1586
+ // A field name is application- and sometimes user-controlled, and CR/LF in
1587
+ // one would forge a part header. Such a name is refused rather than sent
1588
+ // or silently dropped.
1589
+ if (/[\r\n"]/.test(name)) {
1590
+ throw new KinetexError(`Cannot send a multipart field whose name contains CR, LF or a quote: ${JSON.stringify(name)}`, "EVALIDATION");
1591
+ }
1592
+ push(`--${bnd}\r\n`);
1593
+ if (typeof value === "string") {
1594
+ push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"\r\n\r\n`);
1595
+ push(value);
1596
+ push("\r\n");
1597
+ continue;
1598
+ }
1599
+ // A File/Blob part: RFC 7578 §4.2 wants its own type and filename.
1600
+ push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"; ` +
1601
+ `filename="${escapeFieldName(value.name || "blob")}"\r\n` +
1602
+ `Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`);
1603
+ chunks.push(new Uint8Array(await value.arrayBuffer()));
1604
+ push("\r\n");
1208
1605
  }
1209
- return new Uint8Array(0);
1606
+ push(`--${bnd}--\r\n`);
1607
+ return { bytes: concatUint8Arrays(chunks), boundary: bnd };
1210
1608
  }
1211
1609
  // ============================================================================
1212
1610
  // §10 DECOMPRESSION
@@ -1216,8 +1614,9 @@ async function serializeRawBody(body) {
1216
1614
  * Dynamically imports response.ts so that environments that don't use
1217
1615
  * decompression don't pay the code cost. The import is cached by the runtime.
1218
1616
  *
1219
- * Supported encodings: gzip, deflate, br (brotli)
1220
- * Unsupported encodings (zstd, etc.) are passed through compressed; caller must handle or error.
1617
+ * Supported encodings: gzip, deflate, br (brotli), zstd.
1618
+ * Unsupported encodings are passed through compressed; the caller must handle
1619
+ * them.
1221
1620
  *
1222
1621
  * @param body - Raw body stream (or null)
1223
1622
  * @param headers - Response headers (content-encoding is read and stripped on success)
@@ -1236,7 +1635,7 @@ export async function decompressBodyStream(body, headers) {
1236
1635
  .map((e) => e.trim())
1237
1636
  .filter(Boolean);
1238
1637
  // Check for unsupported encodings
1239
- const supportedEncodings = ["gzip", "deflate", "br", "identity"];
1638
+ const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
1240
1639
  for (const enc of encodings) {
1241
1640
  if (!supportedEncodings.includes(enc)) {
1242
1641
  console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +