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/README.md CHANGED
@@ -39,6 +39,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
39
39
  - [Authentication](#authentication)
40
40
  - [Retry](#retry)
41
41
  - [Rate Limiting](#rate-limiting)
42
+ - [Concurrency Limiting](#concurrency-limiting)
42
43
  - [Timeout](#timeout)
43
44
  - [Interceptors](#interceptors)
44
45
  - [Lifecycle Hooks](#lifecycle-hooks)
@@ -150,6 +151,9 @@ const client = kinetex({
150
151
  throwOnError: true, // Throw on 4xx/5xx (default: true)
151
152
  followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
152
153
  maxRedirects: 20, // Max redirect hops (default: 20; 0 disables following)
154
+ // kinetex follows every hop itself, so each `Location` is screened
155
+ // (see "Redirects" under Transport Layer) — the transport is never
156
+ // asked to follow, whatever this is set to
153
157
  httpsOnly: false, // Reject non-HTTPS URLs
154
158
  maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
155
159
  maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
@@ -171,10 +175,15 @@ const client = kinetex({
171
175
  // ── Rate Limit ──
172
176
  rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
173
177
 
178
+ // ── Concurrency Limit ──
179
+ // Bounds requests *in flight*, which rateLimit cannot: a token bucket
180
+ // releases at dispatch, so 100/min still permits 100 simultaneous sockets.
181
+ concurrencyLimit: { maxConcurrent: 10, queue: true, maxQueue: 100 },
182
+
174
183
  // ── Proxy ──
175
- // NOTE: `proxy` fails fast — kinetex's built-in transports cannot route
176
- // through it. Use the `fetch` option with a proxy-capable agent for
177
- // HTTP(S) proxies, or createSocks5Tunnel() from "kinetex/socks5" for SOCKS5.
184
+ // HTTP(S) CONNECT proxies work on Node, on both built-in transports.
185
+ // SOCKS5 URLs still throw, pointing at kinetex/socks5.
186
+ // proxy: { url: "http://127.0.0.1:8080" },
178
187
  // proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
179
188
 
180
189
  // ── Cache ──
@@ -516,6 +525,76 @@ kinetex({
516
525
 
517
526
  ---
518
527
 
528
+ ## Concurrency Limiting
529
+
530
+ A counting semaphore bounding how many requests may be **in flight** at once.
531
+ `rateLimit` cannot do this: a token bucket releases at _dispatch_, so
532
+ `rateLimit: { limit: 100 }` per minute still permits 100 simultaneous sockets.
533
+
534
+ ```ts
535
+ kinetex({
536
+ concurrencyLimit: {
537
+ maxConcurrent: 10, // Permits held at once (default: 10; must be a finite number >= 1)
538
+ queue: true, // Queue the excess vs reject (default: true)
539
+ maxQueue: 100, // Queue depth before rejecting (default: 100)
540
+ },
541
+ });
542
+ ```
543
+
544
+ A permit is held for the **whole logical request, retries included**, and
545
+ returned in a `finally` — so a throw, an exhausted retry budget or a `destroy()`
546
+ can never leak one and permanently shrink the pool. On release the permit is
547
+ handed straight to the longest-waiting caller rather than freed and re-taken, so
548
+ `inFlight` never transiently exceeds `maxConcurrent`.
549
+
550
+ When the queue is full (or `queue: false`), the request is rejected with
551
+ `ConcurrencyLimitError`, code `ECONCURRENCY`:
552
+
553
+ ```ts
554
+ import { ConcurrencyLimitError } from "kinetex";
555
+
556
+ try {
557
+ await client.get("/slow");
558
+ } catch (err) {
559
+ if (err instanceof ConcurrencyLimitError) {
560
+ console.log(err.code); // "ECONCURRENCY"
561
+ }
562
+ }
563
+ ```
564
+
565
+ A request cancelled while queued is a **different** failure and is reported as
566
+ such — `code: "EABORT"` with `isAbort`, the same contract every other abort path
567
+ in the library offers. That holds whichever path the acquire took: an idle pool,
568
+ a saturated pool with queueing on, queueing off, and a full queue all answer
569
+ `EABORT` rather than reporting a cancellation as a capacity failure. An
570
+ already-aborted signal is refused before a permit is taken, so none can leak.
571
+
572
+ `maxQueue` must be a non-negative integer, or `Infinity` for an unbounded queue;
573
+ anything else — including `NaN` — throws a `RangeError` at construction rather
574
+ than leaving the cap unbound. `maxConcurrent` is validated the same way.
575
+
576
+ ### Standalone use
577
+
578
+ The limiter is exported on its own, for gating work that is not a request:
579
+
580
+ ```ts
581
+ import { ConcurrencyLimiter, CONCURRENCY_DEFAULTS } from "kinetex";
582
+
583
+ const limiter = new ConcurrencyLimiter({ maxConcurrent: 4 });
584
+ console.log(limiter.inFlight, limiter.waiting, limiter.highWaterMark);
585
+
586
+ await limiter.acquire(signal);
587
+ try {
588
+ await doWork();
589
+ } finally {
590
+ limiter.release();
591
+ }
592
+
593
+ limiter.drain(); // reject everyone still queued (what Kinetex.destroy does)
594
+ ```
595
+
596
+ ---
597
+
519
598
  ## Timeout
520
599
 
521
600
  Default timeout is 30 seconds. Set to 0 for no timeout:
@@ -540,6 +619,15 @@ try {
540
619
 
541
620
  Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
542
621
 
622
+ **A merged signal keeps the reason.** `mergeSignals(a, b)` re-aborts with the
623
+ `reason` of whichever source actually aborted, so
624
+ `signal.reason` survives the combination instead of collapsing to a generic
625
+ `AbortError: This operation was aborted` — including when a source was _already_
626
+ aborted before the call, which is the case a shortcut in the "no live signals
627
+ left" branch used to get wrong. Interceptors rely on this: retry and
628
+ deduplication logic reads `existing.reason` off the combined signal to decide
629
+ what actually went wrong.
630
+
543
631
  ---
544
632
 
545
633
  ## Interceptors
@@ -655,7 +743,14 @@ const suite = createInterceptorSuite({
655
743
 
656
744
  ## Lifecycle Hooks
657
745
 
658
- Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation:
746
+ Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation.
747
+
748
+ `onAfterRequest` fires in exactly the window its name describes: after the
749
+ transport has answered and before the response is built, once per attempt. It
750
+ is the only place you can observe that the round trip is over without also
751
+ having to see the parsed response — and it deliberately does **not** fire on a
752
+ request that never left, so a hook that counts what was sent does not count a
753
+ connection that died first.
659
754
 
660
755
  ```ts
661
756
  kinetex({
@@ -668,7 +763,7 @@ kinetex({
668
763
  ],
669
764
  onAfterRequest: [
670
765
  (req, ctx) => {
671
- /* request was sent */
766
+ /* the wire round trip is over; the response is not built yet */
672
767
  },
673
768
  ],
674
769
  onBeforeResponse: [
@@ -1009,6 +1104,11 @@ client.GET("/users").cache({ ttlMs: 5000 }).json();
1009
1104
  client.GET("/users").noCache().json(); // forceRefresh: true
1010
1105
  ```
1011
1106
 
1107
+ `forceRefresh` skips the cache **read** for that one request and nothing else:
1108
+ the fresh response is still written, so a later plain request is served from
1109
+ the cache again. It is a bypass, not a purge — use `cache.clear()` or
1110
+ `invalidateTags()` to drop entries.
1111
+
1012
1112
  **Stale-while-revalidate needs no config flag.** There is no `swr` or `swrTtlMs` option: the SWR window is taken from the response's `Cache-Control: stale-while-revalidate=N`, and within that window kinetex serves the stale copy immediately and revalidates in the background. Two consequences worth knowing:
1013
1113
 
1014
1114
  - A per-request `cache.ttlMs` override pins the SWR window to 0 for that request. If you want SWR, let the server's `Cache-Control` decide the lifetime.
@@ -1113,7 +1213,15 @@ await cache.clear();
1113
1213
 
1114
1214
  ## Cookie Jar
1115
1215
 
1116
- Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching:
1216
+ Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching.
1217
+
1218
+ `CookieJar.fromJSON` / `loadCookieJar` are the one public path that ingests
1219
+ externally authored JSON, so they canonicalise `sameSite` on the way in:
1220
+ `"lax"`, `"LAX"` and `"Lax"` all mean `Lax`, and an unrecognised or missing
1221
+ value becomes `Unset`. Without that, a persisted jar could report `count === 1`
1222
+ and send nothing at all, because an unrecognised `SameSite` matches no branch
1223
+ of the retrieval filter. `Unset` still refuses an explicit cross-site request,
1224
+ so a value that cannot be interpreted is never treated as "send everywhere".
1117
1225
 
1118
1226
  ```ts
1119
1227
  // Auto-managed through the client
@@ -2004,6 +2112,17 @@ const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
2004
2112
  isClockSkewError(err); // → boolean
2005
2113
  ```
2006
2114
 
2115
+ **`imdsCredentials` reports failure as a `NetworkError`, never a raw
2116
+ `SyntaxError`.** Every way the EC2 metadata service can disappoint you — an
2117
+ unreachable endpoint, a non-200, a body that is not JSON, or a 200 whose JSON
2118
+ is missing `AccessKeyId` / `SecretAccessKey` / `Token` — surfaces as the same
2119
+ error type, so a `chainCredentials` fallback can catch one thing. A body is
2120
+ accepted only if every required field is a non-empty string; `{}` and
2121
+ `{"AccessKeyId": null}` are refused, and the error names _all_ the fields that
2122
+ were absent rather than making you discover them one round trip at a time.
2123
+ Signing with `undefined` keys would otherwise produce a `SignatureDoesNotMatch`
2124
+ from AWS that points at the caller instead of at the metadata service.
2125
+
2007
2126
  ### Client-Level SigV4
2008
2127
 
2009
2128
  ```ts
@@ -2155,6 +2274,14 @@ Create one per client (do not share it across users). `Kinetex`'s built-in `auth
2155
2274
 
2156
2275
  ## Structured Logging
2157
2276
 
2277
+ Body-field redaction applies to every JSON media type, not just
2278
+ `application/json`: `+json` structured suffixes (`application/vnd.api+json`,
2279
+ `application/hal+json`, `application/problem+json`, …), `text/json`, any
2280
+ casing, and any parameters. `allowedBodyTypes` decides whether a body is logged
2281
+ at all; a body that clears that gate has its `bodyFields` redacted whatever
2282
+ its exact media type, so widening `allowedBodyTypes` cannot quietly start
2283
+ leaking `password` fields.
2284
+
2158
2285
  ```ts
2159
2286
  import {
2160
2287
  HTTPLogger,
@@ -2238,7 +2365,7 @@ const har = client.getHAR();
2238
2365
  // HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
2239
2366
 
2240
2367
  // Each HAREntry contains:
2241
- // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
2368
+ // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize, postData?),
2242
2369
  // response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
2243
2370
  // timings (send, wait, receive), cache
2244
2371
 
@@ -2254,6 +2381,7 @@ HAR logs are routinely exported and shared, so entries are redacted before they
2254
2381
  - **URLs** — sensitive query parameters (`api_key`, `access_token`, `signature`, `password`, `code`, `sas`, …) and the fragment are masked, in both `request.url` and `request.queryString[]`. Non-sensitive parameters and the rest of the URL are preserved so the log stays useful.
2255
2382
  - **`Location`** — the redirect target is passed through the same URL redaction.
2256
2383
  - **Bodies** — response text is recorded only for `json`/`xml`/`text/plain`/`javascript` content types and truncated to 8 KiB; HTML and binary bodies are never recorded.
2384
+ - **Request bodies** — recorded as `request.postData` (`{ mimeType, text }`) under the same policy and the same limit, so a HAR viewer shows what was actually sent. A `ReadableStream` or `FormData` body is omitted rather than buffered, since reading it would consume it; `request.bodySize` is `-1` for those, as it always was.
2257
2385
 
2258
2386
  ```ts
2259
2387
  // ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
@@ -2339,6 +2467,54 @@ Pipeline stages in order:
2339
2467
 
2340
2468
  ## Transport Layer
2341
2469
 
2470
+ ### Redirects
2471
+
2472
+ **kinetex follows every redirect itself.** The outgoing request always carries
2473
+ `redirect: "manual"`, and a server-chosen `Location` is resolved, screened and
2474
+ re-dispatched one hop at a time. This is not a tuning knob — it is what makes
2475
+ the per-hop checks below possible at all, because `fetch` following on its own
2476
+ never reports a target back to the caller.
2477
+
2478
+ Every hop is subject to:
2479
+
2480
+ - **SSRF.** `isSafeURL` runs on each `Location` before it is dialled, so a
2481
+ redirect cannot walk the client onto a loopback, private or link-local
2482
+ address. A 302 to `http://127.0.0.1:9/` or to `http://169.254.169.254/`
2483
+ raises `EVALIDATION` ("Unsafe redirect target blocked") rather than opening
2484
+ the socket — the initial request URL gets the same screen.
2485
+ - **`httpsOnly`.** Checked on the target, not just the request you wrote, so an
2486
+ `https:` request cannot be downgraded to cleartext by its response.
2487
+ - **`maxRedirects`.** Enforced per request, default 20. Exhausting it raises
2488
+ `RedirectError` (`EREDIRECT`, "Too many redirects"). `followRedirects: false`
2489
+ — or `maxRedirects: 0` — hands the 3xx back to you instead.
2490
+ - **Loop detection.** A target already visited in this chain is refused with
2491
+ `RedirectError` (`EREDIRECT`, "Redirect loop detected").
2492
+ - **Method downgrade.** Per RFC 7231, 301/302/303 downgrade to `GET` and drop
2493
+ the body; 307/308 preserve both.
2494
+ - **Credentials.** `Authorization`, `apikey` headers, `Cookie` and any
2495
+ declared auth are dropped when a hop changes origin, and kept when it does
2496
+ not. Intermediate `Set-Cookie` headers are captured by the jar, so cookies
2497
+ set on a redirect leg are applied to the next one.
2498
+ - **Scheme.** Anything but `http:` / `https:` is rejected as
2499
+ `EVALIDATION`, so `file:`, `data:` and friends cannot be reached.
2500
+
2501
+ `res.redirected` tells you whether a hop was actually taken, and `res.url` is
2502
+ where the request finally landed. An _unfollowed_ 3xx — `followRedirects:
2503
+ false`, or `maxRedirects: 0` — reports `redirected: false` and the original
2504
+ `res.url`, because nothing was followed: that response is the one you have to
2505
+ read `Location` on and act on yourself.
2506
+
2507
+ A redirect failure is a `RedirectError` with code `EREDIRECT` ("Too many
2508
+ redirects", "Redirect loop detected"), and it is **not** retried. A chain is a
2509
+ deterministic answer from the origin, so replaying it would only multiply the
2510
+ requests against a server already looping: `maxRedirects: 3` makes exactly
2511
+ four requests, not one per retry attempt. The SSRF and `httpsOnly` gates stay
2512
+ `EVALIDATION`, as does an unsafe redirect target.
2513
+
2514
+ The built-in transports enforce the same two gates in their own loops
2515
+ (`https:` only, plus `isSafeURL`), so the protection does not depend on going
2516
+ through `Kinetex`.
2517
+
2342
2518
  ### FetchTransport
2343
2519
 
2344
2520
  Universal fetch-based transport for all runtimes:
@@ -2388,6 +2564,57 @@ Features:
2388
2564
  - Iterative redirect following (not recursive)
2389
2565
  - Backpressure-aware body writes (awaits `drain` events)
2390
2566
 
2567
+ **The transport owns the request line.** `:method`, `:path`, `:scheme` and
2568
+ `:authority` are built from the URL you hand to `send()` and cannot be
2569
+ overridden from `request.headers`. A pseudo-header there is refused the same
2570
+ way any other invalid header is: `EVALIDATION` under `strict: true`, otherwise
2571
+ `onDroppedHeader(name, value)` or a `console.warn`. This is not a formality —
2572
+ `":"` is not a token character, so `FetchTransport` has always dropped such a
2573
+ header, and the two transports now answer a single request the same way.
2574
+
2575
+ Header names are validated as tokens and values as field-values, so a
2576
+ `__proto__` header (legal — it is all token characters) is sent as a real
2577
+ header rather than disappearing into an inherited setter.
2578
+
2579
+ ### Request Bodies
2580
+
2581
+ `FetchTransport` hands the body to `fetch`; `NodeHTTP2Transport` drives
2582
+ `node:http2` directly. The two therefore serialize differently, and on Node
2583
+ the default transport is the latter — so anything fetch would have encoded has
2584
+ to be encoded by kinetex first. `URLSearchParams`, `Blob` and `FormData` are
2585
+ all covered:
2586
+
2587
+ ```ts
2588
+ const form = new FormData();
2589
+ form.append("field", "value");
2590
+ form.append("file", new File([blob], "report.csv", { type: "text/csv" }));
2591
+
2592
+ // → multipart/form-data; boundary=----kinetexFormBoundary<random>
2593
+ const res = await client.POST("/upload").withForm(form).send();
2594
+ ```
2595
+
2596
+ **The boundary and the header are generated together.** The boundary is
2597
+ invented _during_ encoding, so a body encoded after the header block was
2598
+ already written names a boundary no header mentions — and a multipart body
2599
+ whose boundary is not announced is unparseable. Both raw Node paths therefore
2600
+ pre-encode the body before building headers, and a `content-type` you set
2601
+ yourself always wins. `encodeMultipart(form, boundary?)` is exported for
2602
+ callers who want the bytes directly; pass a boundary to make the output
2603
+ deterministic.
2604
+
2605
+ A field name containing CR, LF or a double quote is refused with
2606
+ `EVALIDATION` rather than serialized, since a name is interpolated into a
2607
+ `Content-Disposition` header and could otherwise forge extra part headers. A
2608
+ File's `filename` goes into the same quoted-string context but is
2609
+ percent-escaped rather than refused, so ordinary names keep working.
2610
+
2611
+ `maxRequestSize` counts what actually goes on the wire. For a `FormData`
2612
+ that means the form is encoded once and its real byte length is compared —
2613
+ there is no per-part guess, and the encoding is not repeated on the dispatch
2614
+ path. A `ReadableStream` body is the one type that cannot be measured at all,
2615
+ so it is refused instead of bypassing the limit; pass `maxRequestSize: 0` to
2616
+ opt out, or buffer the body first.
2617
+
2391
2618
  ### Transport Factory
2392
2619
 
2393
2620
  ```ts
@@ -3023,8 +3250,18 @@ const b64 = uint8ArrayToBase64(uint8);
3023
3250
 
3024
3251
  // Object
3025
3252
  const clone = deepClone(original);
3026
- const normalized = normalizeHeaders(rawHeaders); // Lowercase keys
3027
- ```
3253
+ // normalizeHeaders keys are lowercased, and a header that appears more than
3254
+ // once keeps every value rather than only the last one.
3255
+ const normalized = normalizeHeaders(rawHeaders);
3256
+ ```
3257
+
3258
+ **Repeated headers keep every value.** `Set-Cookie` is the one header a
3259
+ `Headers` object yields _separately_ per cookie rather than already joined, so
3260
+ a naive normalisation that assigns as it iterates silently drops every cookie
3261
+ but the last — and the request still looks fine. `normalizeHeaders`
3262
+ accumulates instead, so a response with three `Set-Cookie` lines round-trips
3263
+ back to three, each with its `Expires=Wed, 09 Jun 2021 10:18:14 GMT` intact.
3264
+ `toNodeHeaders` and the `HttpHeaders` type were already correct on this.
3028
3265
 
3029
3266
  ---
3030
3267