kinetex 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +1164 -453
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3127 -715
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +137 -20
  6. package/dist/cjs/cache.js +101 -21
  7. package/dist/cjs/circuit-breaker.js +69 -7
  8. package/dist/cjs/client.js +838 -191
  9. package/dist/cjs/cookie-parser.js +110 -9
  10. package/dist/cjs/cookie-store.js +141 -36
  11. package/dist/cjs/core.js +501 -63
  12. package/dist/cjs/dedup.js +58 -18
  13. package/dist/cjs/digest.js +185 -23
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +362 -48
  16. package/dist/cjs/interceptors.js +285 -29
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +169 -16
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +261 -28
  21. package/dist/cjs/progress.js +282 -52
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +167 -36
  25. package/dist/cjs/sse.js +201 -34
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/worker.js +6 -6
  29. package/dist/cjs/ws.js +32 -16
  30. package/dist/esm/aws-sigv4.js +137 -20
  31. package/dist/esm/aws-sigv4.js.map +1 -1
  32. package/dist/esm/cache.js +101 -21
  33. package/dist/esm/cache.js.map +1 -1
  34. package/dist/esm/circuit-breaker.js +69 -7
  35. package/dist/esm/circuit-breaker.js.map +1 -1
  36. package/dist/esm/client.js +838 -191
  37. package/dist/esm/client.js.map +1 -1
  38. package/dist/esm/cookie-parser.js +110 -9
  39. package/dist/esm/cookie-parser.js.map +1 -1
  40. package/dist/esm/cookie-store.js +141 -36
  41. package/dist/esm/cookie-store.js.map +1 -1
  42. package/dist/esm/core.js +501 -63
  43. package/dist/esm/core.js.map +1 -1
  44. package/dist/esm/dedup.js +58 -18
  45. package/dist/esm/dedup.js.map +1 -1
  46. package/dist/esm/digest.js +185 -23
  47. package/dist/esm/digest.js.map +1 -1
  48. package/dist/esm/graphql.js +164 -24
  49. package/dist/esm/graphql.js.map +1 -1
  50. package/dist/esm/headers.js +362 -48
  51. package/dist/esm/headers.js.map +1 -1
  52. package/dist/esm/interceptors.js +285 -29
  53. package/dist/esm/interceptors.js.map +1 -1
  54. package/dist/esm/lifecycle.js +89 -40
  55. package/dist/esm/lifecycle.js.map +1 -1
  56. package/dist/esm/logging.js +169 -16
  57. package/dist/esm/logging.js.map +1 -1
  58. package/dist/esm/mod.js +3 -2
  59. package/dist/esm/mod.js.map +1 -1
  60. package/dist/esm/pagination.js +261 -28
  61. package/dist/esm/pagination.js.map +1 -1
  62. package/dist/esm/progress.js +282 -52
  63. package/dist/esm/progress.js.map +1 -1
  64. package/dist/esm/proxy.js +413 -0
  65. package/dist/esm/proxy.js.map +1 -0
  66. package/dist/esm/response.js +316 -47
  67. package/dist/esm/response.js.map +1 -1
  68. package/dist/esm/socks5.js +167 -36
  69. package/dist/esm/socks5.js.map +1 -1
  70. package/dist/esm/sse.js +201 -34
  71. package/dist/esm/sse.js.map +1 -1
  72. package/dist/esm/types.js.map +1 -1
  73. package/dist/esm/url.js +191 -45
  74. package/dist/esm/url.js.map +1 -1
  75. package/dist/esm/utils.js +222 -48
  76. package/dist/esm/utils.js.map +1 -1
  77. package/dist/esm/worker.js +6 -6
  78. package/dist/esm/worker.js.map +1 -1
  79. package/dist/esm/ws.js +32 -16
  80. package/dist/esm/ws.js.map +1 -1
  81. package/dist/types/aws-sigv4.d.ts.map +1 -1
  82. package/dist/types/cache.d.ts +27 -2
  83. package/dist/types/cache.d.ts.map +1 -1
  84. package/dist/types/circuit-breaker.d.ts +14 -1
  85. package/dist/types/circuit-breaker.d.ts.map +1 -1
  86. package/dist/types/client.d.ts +98 -23
  87. package/dist/types/client.d.ts.map +1 -1
  88. package/dist/types/cookie-parser.d.ts +0 -17
  89. package/dist/types/cookie-parser.d.ts.map +1 -1
  90. package/dist/types/cookie-store.d.ts.map +1 -1
  91. package/dist/types/core.d.ts +109 -25
  92. package/dist/types/core.d.ts.map +1 -1
  93. package/dist/types/dedup.d.ts +0 -7
  94. package/dist/types/dedup.d.ts.map +1 -1
  95. package/dist/types/digest.d.ts +31 -37
  96. package/dist/types/digest.d.ts.map +1 -1
  97. package/dist/types/graphql.d.ts.map +1 -1
  98. package/dist/types/headers.d.ts +62 -29
  99. package/dist/types/headers.d.ts.map +1 -1
  100. package/dist/types/interceptors.d.ts +102 -0
  101. package/dist/types/interceptors.d.ts.map +1 -1
  102. package/dist/types/lifecycle.d.ts +19 -2
  103. package/dist/types/lifecycle.d.ts.map +1 -1
  104. package/dist/types/logging.d.ts +23 -4
  105. package/dist/types/logging.d.ts.map +1 -1
  106. package/dist/types/mod.d.ts +5 -3
  107. package/dist/types/mod.d.ts.map +1 -1
  108. package/dist/types/pagination.d.ts +0 -25
  109. package/dist/types/pagination.d.ts.map +1 -1
  110. package/dist/types/progress.d.ts +1 -1
  111. package/dist/types/progress.d.ts.map +1 -1
  112. package/dist/types/proxy.d.ts +50 -0
  113. package/dist/types/proxy.d.ts.map +1 -0
  114. package/dist/types/response.d.ts +7 -1
  115. package/dist/types/response.d.ts.map +1 -1
  116. package/dist/types/socks5.d.ts.map +1 -1
  117. package/dist/types/sse.d.ts.map +1 -1
  118. package/dist/types/types.d.ts +139 -5
  119. package/dist/types/types.d.ts.map +1 -1
  120. package/dist/types/url.d.ts +0 -14
  121. package/dist/types/url.d.ts.map +1 -1
  122. package/dist/types/utils.d.ts.map +1 -1
  123. package/dist/types/worker.d.ts +6 -6
  124. package/dist/types/worker.d.ts.map +1 -1
  125. package/dist/types/ws.d.ts.map +1 -1
  126. package/package.json +2 -2
package/dist/esm/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
  /**
@@ -103,6 +122,12 @@ function isProductionEnvironment() {
103
122
  return false;
104
123
  }
105
124
  }
125
+ /**
126
+ * The accept-encoding value client.ts injects when the caller did not set
127
+ * one. FetchTransport removes exactly this string so fetch() negotiates its
128
+ * own encodings; any other value is treated as caller intent.
129
+ */
130
+ export const DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
106
131
  /**
107
132
  * Universal fetch-based transport.
108
133
  * Suitable for all runtimes where `fetch` is available.
@@ -111,6 +136,7 @@ export class FetchTransport {
111
136
  fetchFn;
112
137
  strict;
113
138
  onDroppedHeader;
139
+ dispatcher;
114
140
  /**
115
141
  * @param fetchFnOrOptions - Custom fetch function or options object
116
142
  */
@@ -119,11 +145,13 @@ export class FetchTransport {
119
145
  this.fetchFn = fetchFnOrOptions;
120
146
  this.strict = false;
121
147
  this.onDroppedHeader = undefined;
148
+ this.dispatcher = undefined;
122
149
  }
123
150
  else {
124
151
  this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
125
152
  this.strict = fetchFnOrOptions.strict ?? false;
126
153
  this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
154
+ this.dispatcher = fetchFnOrOptions.dispatcher;
127
155
  }
128
156
  }
129
157
  /**
@@ -158,12 +186,28 @@ export class FetchTransport {
158
186
  }
159
187
  continue;
160
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
+ }
161
203
  sanitizedHeaders[name] = value;
162
204
  }
163
- // Strip default accept-encoding injected by client.ts so fetch()
164
- // can add its own. Preserve caller-explicit values (e.g. "identity").
205
+ // Strip the accept-encoding value INJECTED by client.ts so fetch() can add
206
+ // its own. The old check removed ANY value containing gzip+deflate+br,
207
+ // including one the caller set deliberately; matching the exact injected
208
+ // default keeps caller intent intact.
165
209
  const ae = sanitizedHeaders["accept-encoding"];
166
- if (ae && ae.toLowerCase().includes("gzip") && ae.toLowerCase().includes("deflate") && ae.toLowerCase().includes("br")) {
210
+ if (ae && ae.toLowerCase().replace(/\s+/g, " ") === DEFAULT_ACCEPT_ENCODING) {
167
211
  delete sanitizedHeaders["accept-encoding"];
168
212
  }
169
213
  // Build fetch init
@@ -187,6 +231,11 @@ export class FetchTransport {
187
231
  init.duplex = "half";
188
232
  }
189
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
+ }
190
239
  let response;
191
240
  try {
192
241
  response = await this.fetchFn(req.url, init);
@@ -256,6 +305,25 @@ export class NodeHTTP2Transport {
256
305
  _requestTimeoutMs;
257
306
  /** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
258
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;
259
327
  /**
260
328
  * @param options - Session pool and transport configuration
261
329
  */
@@ -263,6 +331,9 @@ export class NodeHTTP2Transport {
263
331
  this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
264
332
  this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
265
333
  this.maxSessions = options.maxSessions ?? 100;
334
+ this._http1KeepAlive = options.http1KeepAlive ?? true;
335
+ this._http1MaxSockets = options.http1MaxSockets ?? 16;
336
+ this._proxy = options.proxy;
266
337
  this._strict = options.strict ?? false;
267
338
  this._onDroppedHeader = options.onDroppedHeader;
268
339
  this._ca = options.ca;
@@ -325,10 +396,29 @@ export class NodeHTTP2Transport {
325
396
  this._evictSession(lruOrigin, lruSession.session);
326
397
  }
327
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
+ }
328
411
  const session = await new Promise((resolve, reject) => {
329
412
  const s = http2.connect(origin, {
330
413
  rejectUnauthorized: true,
331
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
+ : {}),
332
422
  });
333
423
  // FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
334
424
  // FIX 9: unref() the timer so it does not prevent process exit
@@ -417,6 +507,29 @@ export class NodeHTTP2Transport {
417
507
  throw err;
418
508
  }
419
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
+ }
420
533
  /**
421
534
  * Send a request over HTTP/2 with iterative redirect following.
422
535
  * Each hop reuses or creates a session for the target origin.
@@ -449,14 +562,69 @@ export class NodeHTTP2Transport {
449
562
  session = existing.session;
450
563
  this.sessionUsage.set(origin, Date.now());
451
564
  }
452
- // 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.
453
599
  const h2ReqHeaders = {
454
600
  ":method": currentReq.method,
455
601
  ":path": currentUrl.pathname + currentUrl.search,
456
602
  ":scheme": "https",
457
603
  ":authority": currentUrl.host,
458
- ...currentReq.headers,
459
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
+ }
460
628
  // Header validation (HTTP/2 control-character check). Runs in BOTH modes:
461
629
  // strict throws, non-strict drops with callback/warn — matching the
462
630
  // FetchTransport contract. (Previously the whole loop was gated on
@@ -467,35 +635,29 @@ export class NodeHTTP2Transport {
467
635
  if (hName.startsWith(":"))
468
636
  continue;
469
637
  const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
470
- let hasForbidden = false;
471
- for (let ci = 0; ci < hStr.length; ci++) {
472
- const code = hStr.charCodeAt(ci);
473
- if ((code >= 0x00 && code <= 0x08) || (code >= 0x0a && code <= 0x1f) || code === 0x7f) {
474
- hasForbidden = true;
475
- break;
476
- }
477
- }
478
- if (hasForbidden) {
479
- if (this._strict) {
480
- throw new KinetexError(`Strict mode: header "${hName}" contains forbidden control characters`, "EVALIDATION", { request: currentReq });
481
- }
482
- // FIX (H3): non-strict mode must match FetchTransport behavior —
483
- // notify the callback (if any) and warn, never drop silently.
484
- if (this._onDroppedHeader) {
485
- this._onDroppedHeader(hName, hStr);
486
- }
487
- else if (typeof console !== "undefined") {
488
- console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
489
- `Pass strictHeaders: true to throw instead.`);
490
- }
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);
491
653
  delete h2ReqHeaders[hName];
492
654
  }
493
655
  }
494
- const endStream = !currentReq.body || currentReq.method === "GET" || currentReq.method === "HEAD";
656
+ const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
495
657
  const stream = session.request(h2ReqHeaders, { endStream });
496
658
  // FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
497
- if (currentReq.body && !endStream) {
498
- attachBodyToH2Stream(stream, currentReq.body).catch((err) => {
659
+ if (bodyForHop && !endStream) {
660
+ attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
499
661
  stream.destroy(err instanceof Error ? err : new Error(String(err)));
500
662
  });
501
663
  }
@@ -581,8 +743,9 @@ export class NodeHTTP2Transport {
581
743
  try {
582
744
  if (raw.body) {
583
745
  const drain = raw.body.getReader();
584
- // eslint-disable-next-line no-constant-condition
585
- while (true) {
746
+ // `for(;;)` rather than `while (true)`: same loop, no condition for a
747
+ // linter to have an opinion about.
748
+ for (;;) {
586
749
  const { done } = await drain.read();
587
750
  if (done)
588
751
  break;
@@ -595,14 +758,38 @@ export class NodeHTTP2Transport {
595
758
  }
596
759
  const location = raw.headers["location"];
597
760
  let nextHref;
761
+ let nextProtocol;
598
762
  try {
599
- nextHref = new URL(location, currentReq.url).href;
763
+ const nextUrl = new URL(location, currentReq.url);
764
+ nextHref = nextUrl.href;
765
+ nextProtocol = nextUrl.protocol.toLowerCase();
600
766
  }
601
767
  catch {
602
768
  throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
603
769
  request: req,
604
770
  });
605
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
+ }
606
793
  // RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
607
794
  const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
608
795
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
@@ -641,12 +828,33 @@ export class NodeHTTP2Transport {
641
828
  async _sendHTTP1Legacy(req) {
642
829
  const https = await import("node:https");
643
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
+ }
644
847
  const options = {
645
848
  hostname: url.hostname,
646
849
  port: url.port || "443",
647
850
  path: url.pathname + url.search,
648
- method: req.method,
649
- 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 } : {}),
650
858
  };
651
859
  return new Promise((resolve, reject) => {
652
860
  const httpReq = https.request(options, (httpRes) => {
@@ -668,7 +876,13 @@ export class NodeHTTP2Transport {
668
876
  });
669
877
  });
670
878
  httpReq.once("error", (err) => {
671
- 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 }));
672
886
  });
673
887
  // Remove the abort listener once the request settles so the httpReq
674
888
  // reference doesn't leak beyond the request lifetime.
@@ -681,13 +895,76 @@ export class NodeHTTP2Transport {
681
895
  httpReq.once("close", cleanup);
682
896
  httpReq.once("error", cleanup);
683
897
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
684
- pipeBodyToNodeReq(httpReq, req.body).catch(reject);
898
+ pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
685
899
  }
686
900
  else {
687
901
  httpReq.end();
688
902
  }
689
903
  });
690
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
+ }
691
968
  /** @internal Evict one session and its associated ping timer. */
692
969
  _evictSession(origin, session) {
693
970
  const timer = this.pingTimers.get(origin);
@@ -713,6 +990,10 @@ export class NodeHTTP2Transport {
713
990
  this._evictSession(origin, session);
714
991
  }
715
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;
716
997
  }
717
998
  }
718
999
  // ============================================================================
@@ -735,7 +1016,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
735
1016
  // "Custom fetch implementation" behavior holds on every runtime.
736
1017
  // Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
737
1018
  // fetch is given. Falls back to FetchTransport otherwise.
738
- 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) {
1024
+ if (!isProductionEnvironment()) {
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.");
1033
+ }
1034
+ }
1035
+ if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
739
1036
  return new NodeHTTP2Transport({
740
1037
  ...(sessionOptions?.sessionTTLMs !== undefined
741
1038
  ? { sessionTTLMs: sessionOptions.sessionTTLMs }
@@ -743,6 +1040,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
743
1040
  ...(sessionOptions?.pingIntervalMs !== undefined
744
1041
  ? { pingIntervalMs: sessionOptions.pingIntervalMs }
745
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
+ : {}),
746
1054
  ...(sessionOptions?.connectTimeoutMs !== undefined
747
1055
  ? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
748
1056
  : {}),
@@ -753,6 +1061,8 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
753
1061
  ...(transportOptions?.onDroppedHeader !== undefined
754
1062
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
755
1063
  : {}),
1064
+ ...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
1065
+ ...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
756
1066
  });
757
1067
  }
758
1068
  return new FetchTransport({
@@ -761,6 +1071,9 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
761
1071
  ...(transportOptions?.onDroppedHeader !== undefined
762
1072
  ? { onDroppedHeader: transportOptions.onDroppedHeader }
763
1073
  : {}),
1074
+ ...(transportOptions?.dispatcher !== undefined
1075
+ ? { dispatcher: transportOptions.dispatcher }
1076
+ : {}),
764
1077
  });
765
1078
  }
766
1079
  // ============================================================================
@@ -922,6 +1235,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
922
1235
  if (result.success && result.value !== undefined) {
923
1236
  parseResult = result.value;
924
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
+ }
925
1251
  }
926
1252
  catch (e) {
927
1253
  parseError = e instanceof Error ? e : new Error(String(e));
@@ -969,29 +1295,52 @@ export function normalizeHeaders(headers) {
969
1295
  * @param _headers - Parsed response headers (reserved)
970
1296
  * @returns Detected HTTP version
971
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
+ }
972
1316
  function detectHTTPVersion(response, _headers) {
973
- // Deno exposes response.type or we can infer from headers
974
- // 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.
975
1327
  const denoResponse = response;
976
- if (denoResponse.httpVersion === "2.0" || denoResponse.httpVersion === "2") {
977
- return "HTTP/2";
1328
+ const runtimeVersion = denoResponse.httpVersion;
1329
+ if (typeof runtimeVersion === "string") {
1330
+ const normalized = normalizeHTTPVersion(runtimeVersion);
1331
+ if (normalized)
1332
+ return normalized;
978
1333
  }
979
- // Bun exposes httpVersion as a property
980
- const bunResponse = response;
981
- if (bunResponse.httpVersion) {
982
- return bunResponse.httpVersion;
983
- }
984
- // HTTP/3 (QUIC) detection via Alt-Svc header.
1334
+ // Server capability advertisement.
985
1335
  // Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
986
- // 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.
987
1343
  const altSvc = response.headers.get("alt-svc");
988
- // Check for active HTTP/3 negotiation (runtime-specific property)
989
- const h3Response = response;
990
- if (h3Response.httpVersion === "3" ||
991
- h3Response.httpVersion === "3.0" ||
992
- h3Response.protocol === "h3") {
993
- return "HTTP/3";
994
- }
995
1344
  // Alt-Svc advertisement: infer HTTP version from the advertised protocols.
996
1345
  // - h3 (QUIC) means the server supports HTTP/3
997
1346
  // - h2 means the server supports HTTP/2
@@ -1111,7 +1460,7 @@ async function writeChunkWithBackpressure(stream, chunk) {
1111
1460
  }
1112
1461
  /**
1113
1462
  * Write a request body to an HTTP/2 stream, respecting backpressure.
1114
- * Handles ReadableStream, Uint8Array, ArrayBuffer, and string body types.
1463
+ * Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
1115
1464
  *
1116
1465
  * @param stream - HTTP/2 stream to write to
1117
1466
  * @param body - Request body
@@ -1136,12 +1485,16 @@ async function attachBodyToH2Stream(stream, body) {
1136
1485
  stream.end(body);
1137
1486
  }
1138
1487
  else {
1139
- stream.end();
1488
+ // The raw Node transports bypass fetch, so bodies fetch would normally
1489
+ // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1490
+ // silently sent an empty body to the server.
1491
+ const { bytes } = await serializeRawBody(body);
1492
+ stream.end(bytes);
1140
1493
  }
1141
1494
  }
1142
1495
  /**
1143
1496
  * Write a request body to a Node.js http.ClientRequest, respecting backpressure.
1144
- * Handles ReadableStream, Uint8Array, ArrayBuffer, and string body types.
1497
+ * Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
1145
1498
  *
1146
1499
  * @param req - Node.js ClientRequest
1147
1500
  * @param body - Request body
@@ -1166,8 +1519,92 @@ async function pipeBodyToNodeReq(req, body) {
1166
1519
  req.end(body);
1167
1520
  }
1168
1521
  else {
1169
- req.end();
1522
+ // The raw Node transports bypass fetch, so bodies fetch would normally
1523
+ // serialize (URLSearchParams, Blob) must be encoded here. Skipping them
1524
+ // silently sent an empty body to the server.
1525
+ const { bytes } = await serializeRawBody(body);
1526
+ req.end(bytes);
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Serialize body types that `fetch` would normally encode for us, so the raw
1531
+ * Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
1532
+ *
1533
+ * @param body - Request body that is not a stream, byte array, or string
1534
+ * @returns The encoded bytes to write, plus a content type to announce when the
1535
+ * encoding generated one (empty bytes for unsupported types)
1536
+ */
1537
+ async function serializeRawBody(body) {
1538
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
1539
+ return { bytes: new TextEncoder().encode(body.toString()) };
1540
+ }
1541
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
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");
1170
1605
  }
1606
+ push(`--${bnd}--\r\n`);
1607
+ return { bytes: concatUint8Arrays(chunks), boundary: bnd };
1171
1608
  }
1172
1609
  // ============================================================================
1173
1610
  // §10 DECOMPRESSION
@@ -1177,8 +1614,9 @@ async function pipeBodyToNodeReq(req, body) {
1177
1614
  * Dynamically imports response.ts so that environments that don't use
1178
1615
  * decompression don't pay the code cost. The import is cached by the runtime.
1179
1616
  *
1180
- * Supported encodings: gzip, deflate, br (brotli)
1181
- * 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.
1182
1620
  *
1183
1621
  * @param body - Raw body stream (or null)
1184
1622
  * @param headers - Response headers (content-encoding is read and stripped on success)
@@ -1197,7 +1635,7 @@ export async function decompressBodyStream(body, headers) {
1197
1635
  .map((e) => e.trim())
1198
1636
  .filter(Boolean);
1199
1637
  // Check for unsupported encodings
1200
- const supportedEncodings = ["gzip", "deflate", "br", "identity"];
1638
+ const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
1201
1639
  for (const enc of encodings) {
1202
1640
  if (!supportedEncodings.includes(enc)) {
1203
1641
  console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +