kinetex 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +413 -52
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3134 -787
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +140 -33
  6. package/dist/cjs/cache.js +58 -7
  7. package/dist/cjs/circuit-breaker.js +53 -4
  8. package/dist/cjs/client.js +726 -188
  9. package/dist/cjs/cookie-parser.js +131 -6
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +691 -70
  12. package/dist/cjs/dedup.js +142 -21
  13. package/dist/cjs/digest.js +195 -38
  14. package/dist/cjs/graphql.js +166 -35
  15. package/dist/cjs/headers.js +327 -46
  16. package/dist/cjs/interceptors.js +227 -24
  17. package/dist/cjs/lifecycle.js +92 -59
  18. package/dist/cjs/logging.js +160 -24
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +178 -30
  22. package/dist/cjs/proxy.js +429 -0
  23. package/dist/cjs/response.js +506 -110
  24. package/dist/cjs/socks5.js +136 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +335 -73
  28. package/dist/cjs/ws.js +62 -12
  29. package/dist/esm/aws-sigv4.js +140 -33
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +58 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +53 -4
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +726 -188
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +131 -6
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +691 -70
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +142 -21
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +195 -38
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +166 -35
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +327 -46
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +227 -24
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +92 -59
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +160 -24
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +178 -30
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +430 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +506 -110
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +136 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +335 -73
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +62 -12
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +15 -2
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +216 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts +38 -3
  91. package/dist/types/dedup.d.ts.map +1 -1
  92. package/dist/types/digest.d.ts +17 -37
  93. package/dist/types/digest.d.ts.map +1 -1
  94. package/dist/types/graphql.d.ts.map +1 -1
  95. package/dist/types/headers.d.ts +68 -31
  96. package/dist/types/headers.d.ts.map +1 -1
  97. package/dist/types/interceptors.d.ts +126 -38
  98. package/dist/types/interceptors.d.ts.map +1 -1
  99. package/dist/types/lifecycle.d.ts +19 -2
  100. package/dist/types/lifecycle.d.ts.map +1 -1
  101. package/dist/types/logging.d.ts +22 -3
  102. package/dist/types/logging.d.ts.map +1 -1
  103. package/dist/types/mod.d.ts +5 -3
  104. package/dist/types/mod.d.ts.map +1 -1
  105. package/dist/types/pagination.d.ts +0 -25
  106. package/dist/types/pagination.d.ts.map +1 -1
  107. package/dist/types/progress.d.ts +1 -1
  108. package/dist/types/progress.d.ts.map +1 -1
  109. package/dist/types/proxy.d.ts +50 -0
  110. package/dist/types/proxy.d.ts.map +1 -0
  111. package/dist/types/response.d.ts +49 -2
  112. package/dist/types/response.d.ts.map +1 -1
  113. package/dist/types/socks5.d.ts.map +1 -1
  114. package/dist/types/sse.d.ts.map +1 -1
  115. package/dist/types/types.d.ts +219 -26
  116. package/dist/types/types.d.ts.map +1 -1
  117. package/dist/types/url.d.ts +0 -25
  118. package/dist/types/url.d.ts.map +1 -1
  119. package/dist/types/utils.d.ts +69 -1
  120. package/dist/types/utils.d.ts.map +1 -1
  121. package/dist/types/ws.d.ts +0 -1
  122. package/dist/types/ws.d.ts.map +1 -1
  123. package/package.json +10 -4
package/README.md CHANGED
@@ -23,13 +23,14 @@ const users = await api.get<User[]>("/users");
23
23
  const post = await api.POST("/posts").withJSON({ title: "Hello" }).bearer("token").json<Post>();
24
24
  ```
25
25
 
26
- Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers**, **Vercel Edge**, and all **WinterCG-compliant** runtimes.
26
+ Works in **Node.js 22+** (LTS lines: 22, 24, 26), **Deno**, **Bun**, **browsers**, **Cloudflare Workers**, **Vercel Edge**, and all **WinterCG-compliant** runtimes.
27
27
 
28
28
  ---
29
29
 
30
30
  ## Table of Contents
31
31
 
32
32
  - [Installation](#installation)
33
+ - [Migrating from 1.4.x](#migrating-from-14x)
33
34
  - [Quick Start](#quick-start)
34
35
  - [Client Configuration](#client-configuration)
35
36
  - [Basic HTTP Methods](#basic-http-methods)
@@ -39,6 +40,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
39
40
  - [Authentication](#authentication)
40
41
  - [Retry](#retry)
41
42
  - [Rate Limiting](#rate-limiting)
43
+ - [Concurrency Limiting](#concurrency-limiting)
42
44
  - [Timeout](#timeout)
43
45
  - [Interceptors](#interceptors)
44
46
  - [Lifecycle Hooks](#lifecycle-hooks)
@@ -60,6 +62,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
60
62
  - [HAR Recording](#har-recording)
61
63
  - [OpenTelemetry Tracing](#opentelemetry-tracing)
62
64
  - [Pipeline Trace](#pipeline-trace)
65
+ - [Private networks, DNS and TLS](#private-networks-dns-and-tls)
63
66
  - [Transport Layer](#transport-layer)
64
67
  - [Runtime Detection](#runtime-detection)
65
68
  - [Child Clients](#child-clients)
@@ -101,6 +104,37 @@ import { kinetex } from "jsr:@kinetexjs/kinetex";
101
104
 
102
105
  ---
103
106
 
107
+ ## Migrating from 1.4.x
108
+
109
+ 1.5.0 contains three breaking changes. Each has a one-line opt-out, so none of them requires you to change anything unless the new default breaks you.
110
+
111
+ **`maxResponseSize` now defaults to 100 MB** instead of unlimited. The body is read in full before you see any of it, so an unbounded default let a single response exhaust the heap. If you legitimately receive responses over 100 MB:
112
+
113
+ ```ts
114
+ kinetex({ maxResponseSize: 0 }); // 0 has always meant "no limit"
115
+ ```
116
+
117
+ **`totalTimeoutMs` now defaults to the effective `timeout`.** `timeout` bounds one attempt, and the backoff between attempts was not bounded by anything — `timeout: 1_000` with `maxRetries: 5` could hold the caller for roughly 37 seconds. The whole-request ceiling now defaults to `timeout`, so if you were relying on retries outliving `timeout`, raise it or remove it:
118
+
119
+ ```ts
120
+ kinetex({ timeout: 1_000, totalTimeoutMs: 0 }); // unbounded retries, as in 1.4.x
121
+ ```
122
+
123
+ **`URLBuilderOptions` is removed.** It was exported from `kinetex/url` but no constructor, factory or method accepted it, so it could never have been passed anywhere. If you imported the name as a type, drop it — `from()` takes a base URL directly, and `sortParams` is a real method.
124
+
125
+ Behaviour changes that are not breaking, but will change what you observe:
126
+
127
+ - A request carrying an `Idempotency-Key` is now retried whatever its method. An unkeyed POST is still attempted exactly once.
128
+ - `Cache-Control: private` responses are no longer cached. This cache is a `Map` in your process, shared across every request it makes, so storing a per-user response served it to whoever asked next. `honorCacheControl: false` opts out.
129
+ - `SameSite=Lax` and `SameSite=Strict` cookies are now sent. The client stated no SameSite context, which by design sends only `Unset` and `None`, so most real sessions were going out without their cookie.
130
+ - A 307/308 redirect carrying a stream or `Blob` body is now refused with a clear `EVALIDATION` rather than failing later as an opaque `ENETWORK`. Those methods preserve the body, and a one-shot body is spent after the first attempt. 301/302/303 are unaffected — they downgrade to GET.
131
+ - A `WebSocket.send()` on a socket that is not OPEN, with `bufferMessages: false`, now reports through `onError` instead of dropping the message silently.
132
+ - A caller-supplied `fetch` is exempt from the pre-flight DNS screen and owns its own resolution. The lexical screen still applies to the initial URL and every redirect hop.
133
+
134
+ See [`CHANGELOG.md`](./CHANGELOG.md) for the full list, including the defects this release fixes.
135
+
136
+ ---
137
+
104
138
  ## Quick Start
105
139
 
106
140
  ```ts
@@ -150,9 +184,14 @@ const client = kinetex({
150
184
  throwOnError: true, // Throw on 4xx/5xx (default: true)
151
185
  followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
152
186
  maxRedirects: 20, // Max redirect hops (default: 20; 0 disables following)
187
+ // kinetex follows every hop itself, so each `Location` is screened
188
+ // (see "Redirects" under Transport Layer) — the transport is never
189
+ // asked to follow, whatever this is set to
153
190
  httpsOnly: false, // Reject non-HTTPS URLs
154
- maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
191
+ maxResponseSize: 10_000_000, // Response body size limit (default: 100 MB; 0 = no limit)
155
192
  maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
193
+ ca: [readFileSync("./corp-root.pem", "utf8")], // Extra CAs to trust (Node transports)
194
+ allowPrivateNetwork: false, // Permit private/loopback destinations
156
195
  strictHeaders: false, // Throw on invalid headers vs warn+drop
157
196
  onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
158
197
  onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
@@ -171,10 +210,15 @@ const client = kinetex({
171
210
  // ── Rate Limit ──
172
211
  rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
173
212
 
213
+ // ── Concurrency Limit ──
214
+ // Bounds requests *in flight*, which rateLimit cannot: a token bucket
215
+ // releases at dispatch, so 100/min still permits 100 simultaneous sockets.
216
+ concurrencyLimit: { maxConcurrent: 10, queue: true, maxQueue: 100 },
217
+
174
218
  // ── 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.
219
+ // HTTP(S) CONNECT proxies work on Node, on both built-in transports.
220
+ // SOCKS5 URLs still throw, pointing at kinetex/socks5.
221
+ // proxy: { url: "http://127.0.0.1:8080" },
178
222
  // proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
179
223
 
180
224
  // ── Cache ──
@@ -373,7 +417,7 @@ interface SendOptions<T = unknown> {
373
417
  maxRedirects?: number; // Max redirect hops (default: 20; 0 disables following)
374
418
  httpVersion?: HTTPVersion; // Preferred HTTP version
375
419
  maxRequestSize?: number; // Request size limit (bytes)
376
- maxResponseSize?: number; // Response size limit (bytes)
420
+ maxResponseSize?: number; // Response size limit (bytes, default 104857600; 0 = unlimited)
377
421
  parseResponse?: (raw: Uint8Array, headers: Record<string, string>, url: string) => T | Promise<T>;
378
422
  parseFailure?: (raw: Uint8Array, error: Error) => void;
379
423
  onSuccess?: (res: KinetexResponse<T>) => void;
@@ -461,12 +505,16 @@ client.GET("/public").noAuth().json();
461
505
  kinetex({
462
506
  retry: {
463
507
  maxRetries: 3, // Max attempts (default: 3)
508
+ totalTimeoutMs: 20_000, // Ceiling on the WHOLE request: attempts + backoff (default: = timeout)
464
509
  baseDelayMs: 300, // Exponential back-off base (default: 300)
465
510
  maxDelayMs: 30_000, // Max delay cap (default: 30_000)
466
511
  jitter: 0.3, // Random jitter factor 0-1 (default: 0.3)
467
512
  statuses: [408, 429, 500, 502, 503, 504], // Status codes that trigger retry
468
513
  onNetworkError: true, // Retry on network errors (default: true)
469
514
  onTimeout: false, // Retry on timeouts (default: false)
515
+ // Only these methods are retried. A request carrying an `Idempotency-Key`
516
+ // is retried whatever its method — the key is the caller's declaration
517
+ // that repeating it is safe. See "Idempotency Keys" below.
470
518
  methods: ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"],
471
519
  shouldRetry: (ctx) => ctx.attempt < 3 && ctx.response?.status === 503, // Custom predicate
472
520
  onRetry: (ctx, delayMs) => console.log(`Retry ${ctx.attempt} in ${delayMs}ms`),
@@ -474,6 +522,38 @@ kinetex({
474
522
  });
475
523
  ```
476
524
 
525
+ `timeout` applies to a single attempt; `totalTimeoutMs` bounds the whole logical
526
+ request, backoff included, and defaults to `timeout`. Set it to `0` to remove
527
+ the ceiling. When the next backoff would exceed the budget the retry is skipped
528
+ rather than slept through, and the original response or error is what the caller
529
+ receives — the budget never substitutes an `ETIMEOUT` for a real cause.
530
+
531
+ ### Idempotency Keys
532
+
533
+ A `POST` is not retried by default, because replaying an unkeyed write can
534
+ double-charge someone. A request that carries an `Idempotency-Key` **is** retried
535
+ whatever its method: the header exists precisely to declare the request
536
+ replayable, and the server collapses the duplicates.
537
+
538
+ ```ts
539
+ import { generateIdempotencyKey, isValidIdempotencyKey } from "kinetex";
540
+
541
+ // Keys are RFC 9562 v4 UUIDs from the runtime CSPRNG.
542
+ const key = generateIdempotencyKey();
543
+ isValidIdempotencyKey(key); // true
544
+
545
+ // Any method, retried on transient failures, same key every attempt —
546
+ // a fresh key per attempt would defeat the mechanism.
547
+ await client.post("/charges", body, { headers: { "idempotency-key": key } });
548
+
549
+ // Or fluently:
550
+ await client.POST("/charges").withJSON(body).idempotencyKey().json();
551
+ ```
552
+
553
+ An unkeyed `POST` is still attempted exactly once. Validation is 1–255 visible
554
+ ASCII, so CR/LF/NUL cannot be smuggled into the headers, and a runtime without
555
+ `crypto.getRandomValues` throws rather than falling back to a guessable value.
556
+
477
557
  ### RetryContext
478
558
 
479
559
  The `ctx` parameter in `shouldRetry` and `onRetry` is of type `RetryContext`:
@@ -516,6 +596,76 @@ kinetex({
516
596
 
517
597
  ---
518
598
 
599
+ ## Concurrency Limiting
600
+
601
+ A counting semaphore bounding how many requests may be **in flight** at once.
602
+ `rateLimit` cannot do this: a token bucket releases at _dispatch_, so
603
+ `rateLimit: { limit: 100 }` per minute still permits 100 simultaneous sockets.
604
+
605
+ ```ts
606
+ kinetex({
607
+ concurrencyLimit: {
608
+ maxConcurrent: 10, // Permits held at once (default: 10; must be a finite number >= 1)
609
+ queue: true, // Queue the excess vs reject (default: true)
610
+ maxQueue: 100, // Queue depth before rejecting (default: 100)
611
+ },
612
+ });
613
+ ```
614
+
615
+ A permit is held for the **whole logical request, retries included**, and
616
+ returned in a `finally` — so a throw, an exhausted retry budget or a `destroy()`
617
+ can never leak one and permanently shrink the pool. On release the permit is
618
+ handed straight to the longest-waiting caller rather than freed and re-taken, so
619
+ `inFlight` never transiently exceeds `maxConcurrent`.
620
+
621
+ When the queue is full (or `queue: false`), the request is rejected with
622
+ `ConcurrencyLimitError`, code `ECONCURRENCY`:
623
+
624
+ ```ts
625
+ import { ConcurrencyLimitError } from "kinetex";
626
+
627
+ try {
628
+ await client.get("/slow");
629
+ } catch (err) {
630
+ if (err instanceof ConcurrencyLimitError) {
631
+ console.log(err.code); // "ECONCURRENCY"
632
+ }
633
+ }
634
+ ```
635
+
636
+ A request cancelled while queued is a **different** failure and is reported as
637
+ such — `code: "EABORT"` with `isAbort`, the same contract every other abort path
638
+ in the library offers. That holds whichever path the acquire took: an idle pool,
639
+ a saturated pool with queueing on, queueing off, and a full queue all answer
640
+ `EABORT` rather than reporting a cancellation as a capacity failure. An
641
+ already-aborted signal is refused before a permit is taken, so none can leak.
642
+
643
+ `maxQueue` must be a non-negative integer, or `Infinity` for an unbounded queue;
644
+ anything else — including `NaN` — throws a `RangeError` at construction rather
645
+ than leaving the cap unbound. `maxConcurrent` is validated the same way.
646
+
647
+ ### Standalone use
648
+
649
+ The limiter is exported on its own, for gating work that is not a request:
650
+
651
+ ```ts
652
+ import { ConcurrencyLimiter, CONCURRENCY_DEFAULTS } from "kinetex";
653
+
654
+ const limiter = new ConcurrencyLimiter({ maxConcurrent: 4 });
655
+ console.log(limiter.inFlight, limiter.waiting, limiter.highWaterMark);
656
+
657
+ await limiter.acquire(signal);
658
+ try {
659
+ await doWork();
660
+ } finally {
661
+ limiter.release();
662
+ }
663
+
664
+ limiter.drain(); // reject everyone still queued (what Kinetex.destroy does)
665
+ ```
666
+
667
+ ---
668
+
519
669
  ## Timeout
520
670
 
521
671
  Default timeout is 30 seconds. Set to 0 for no timeout:
@@ -540,6 +690,15 @@ try {
540
690
 
541
691
  Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
542
692
 
693
+ **A merged signal keeps the reason.** `mergeSignals(a, b)` re-aborts with the
694
+ `reason` of whichever source actually aborted, so
695
+ `signal.reason` survives the combination instead of collapsing to a generic
696
+ `AbortError: This operation was aborted` — including when a source was _already_
697
+ aborted before the call, which is the case a shortcut in the "no live signals
698
+ left" branch used to get wrong. Interceptors rely on this: retry and
699
+ deduplication logic reads `existing.reason` off the combined signal to decide
700
+ what actually went wrong.
701
+
543
702
  ---
544
703
 
545
704
  ## Interceptors
@@ -655,7 +814,14 @@ const suite = createInterceptorSuite({
655
814
 
656
815
  ## Lifecycle Hooks
657
816
 
658
- Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation:
817
+ Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation.
818
+
819
+ `onAfterRequest` fires in exactly the window its name describes: after the
820
+ transport has answered and before the response is built, once per attempt. It
821
+ is the only place you can observe that the round trip is over without also
822
+ having to see the parsed response — and it deliberately does **not** fire on a
823
+ request that never left, so a hook that counts what was sent does not count a
824
+ connection that died first.
659
825
 
660
826
  ```ts
661
827
  kinetex({
@@ -668,7 +834,7 @@ kinetex({
668
834
  ],
669
835
  onAfterRequest: [
670
836
  (req, ctx) => {
671
- /* request was sent */
837
+ /* the wire round trip is over; the response is not built yet */
672
838
  },
673
839
  ],
674
840
  onBeforeResponse: [
@@ -840,7 +1006,7 @@ interface HookOptions {
840
1006
 
841
1007
  ## Request Deduplication
842
1008
 
843
- Coalesces identical concurrent GET/HEAD requests into a single network call. The dedup key includes a SHA-256 fingerprint of every credential-bearing header, so two users calling the same URL with different `Authorization` / `Cookie` / API-key headers are never coalesced into one response:
1009
+ Coalesces identical concurrent GET/HEAD requests into a single network call. The dedup key includes a SHA-256 fingerprint of **every request header**, so two callers are only ever coalesced when they would have received the same response — different `Authorization`, `Cookie`, API key _or_ a tenant/session header each get their own in-flight slot. Per-request tracing headers (`traceparent`, `tracestate`, `baggage`, `x-request-id`, `x-correlation-id`, `x-trace-id`) are excluded, since every request carries its own and including them would disable coalescing entirely:
844
1010
 
845
1011
  ```ts
846
1012
  import { CREDENTIAL_HEADERS } from "kinetex/cache";
@@ -987,7 +1153,7 @@ const client = kinetex({
987
1153
  // maxBodySizeBytes: 5 * 1024 * 1024, // skip caching larger bodies
988
1154
  // defaultTtlMs: 60_000, // when the response has no Cache-Control (max 1 year)
989
1155
  // maxAbsoluteAgeMs: 7 * 24 * 60 * 60 * 1000, // hard cap regardless of Cache-Control
990
- // honorCacheControl: true, // respect no-store / no-cache
1156
+ // honorCacheControl: true, // respect no-store / no-cache / private
991
1157
  // cacheMethods: ["GET", "HEAD"],
992
1158
  // cacheStatuses: [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
993
1159
  storage: new MemoryStorageAdapter(), // omit for plain in-memory
@@ -1009,6 +1175,11 @@ client.GET("/users").cache({ ttlMs: 5000 }).json();
1009
1175
  client.GET("/users").noCache().json(); // forceRefresh: true
1010
1176
  ```
1011
1177
 
1178
+ `forceRefresh` skips the cache **read** for that one request and nothing else:
1179
+ the fresh response is still written, so a later plain request is served from
1180
+ the cache again. It is a bypass, not a purge — use `cache.clear()` or
1181
+ `invalidateTags()` to drop entries.
1182
+
1012
1183
  **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
1184
 
1014
1185
  - 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 +1284,15 @@ await cache.clear();
1113
1284
 
1114
1285
  ## Cookie Jar
1115
1286
 
1116
- Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching:
1287
+ Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching.
1288
+
1289
+ `CookieJar.fromJSON` / `loadCookieJar` are the one public path that ingests
1290
+ externally authored JSON, so they canonicalise `sameSite` on the way in:
1291
+ `"lax"`, `"LAX"` and `"Lax"` all mean `Lax`, and an unrecognised or missing
1292
+ value becomes `Unset`. Without that, a persisted jar could report `count === 1`
1293
+ and send nothing at all, because an unrecognised `SameSite` matches no branch
1294
+ of the retrieval filter. `Unset` still refuses an explicit cross-site request,
1295
+ so a value that cannot be interpreted is never treated as "send everywhere".
1117
1296
 
1118
1297
  ```ts
1119
1298
  // Auto-managed through the client
@@ -1579,7 +1758,7 @@ const ws = new WSClient({
1579
1758
  lowWaterMark: 16384,
1580
1759
  maxSendRate: 0, // 0 = unlimited
1581
1760
  keepRooms: true,
1582
- bufferMessages: true,
1761
+ bufferMessages: true, // queue sends made while reconnecting; see below
1583
1762
  maxBufferSize: 1000,
1584
1763
  rooms: ["prices"],
1585
1764
  signal: controller.signal,
@@ -1642,6 +1821,11 @@ ws.leave("prices");
1642
1821
  ws.rooms; // readonly WSSubscribedRoom[]
1643
1822
 
1644
1823
  // Backpressure
1824
+ // With `bufferMessages: true` (default) a send made while the socket is not
1825
+ // OPEN is queued, up to `maxBufferSize`, oldest evicted first. With it false
1826
+ // there is nowhere to put that message: it is dropped, and the drop is reported
1827
+ // through `onError` — `send()` returns void, so silence would make a lost
1828
+ // message indistinguishable from a delivered one.
1645
1829
  ws.backpressure; // { bufferedBytes, highWaterMark, lowWaterMark, isBackpressured, ... }
1646
1830
  await ws.drain(30_000); // Wait until outbound buffer is flushed
1647
1831
  await ws.drainAndClose(30_000); // Drain, then close gracefully
@@ -2004,6 +2188,17 @@ const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
2004
2188
  isClockSkewError(err); // → boolean
2005
2189
  ```
2006
2190
 
2191
+ **`imdsCredentials` reports failure as a `NetworkError`, never a raw
2192
+ `SyntaxError`.** Every way the EC2 metadata service can disappoint you — an
2193
+ unreachable endpoint, a non-200, a body that is not JSON, or a 200 whose JSON
2194
+ is missing `AccessKeyId` / `SecretAccessKey` / `Token` — surfaces as the same
2195
+ error type, so a `chainCredentials` fallback can catch one thing. A body is
2196
+ accepted only if every required field is a non-empty string; `{}` and
2197
+ `{"AccessKeyId": null}` are refused, and the error names _all_ the fields that
2198
+ were absent rather than making you discover them one round trip at a time.
2199
+ Signing with `undefined` keys would otherwise produce a `SignatureDoesNotMatch`
2200
+ from AWS that points at the caller instead of at the metadata service.
2201
+
2007
2202
  ### Client-Level SigV4
2008
2203
 
2009
2204
  ```ts
@@ -2155,6 +2350,14 @@ Create one per client (do not share it across users). `Kinetex`'s built-in `auth
2155
2350
 
2156
2351
  ## Structured Logging
2157
2352
 
2353
+ Body-field redaction applies to every JSON media type, not just
2354
+ `application/json`: `+json` structured suffixes (`application/vnd.api+json`,
2355
+ `application/hal+json`, `application/problem+json`, …), `text/json`, any
2356
+ casing, and any parameters. `allowedBodyTypes` decides whether a body is logged
2357
+ at all; a body that clears that gate has its `bodyFields` redacted whatever
2358
+ its exact media type, so widening `allowedBodyTypes` cannot quietly start
2359
+ leaking `password` fields.
2360
+
2158
2361
  ```ts
2159
2362
  import {
2160
2363
  HTTPLogger,
@@ -2238,7 +2441,7 @@ const har = client.getHAR();
2238
2441
  // HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
2239
2442
 
2240
2443
  // Each HAREntry contains:
2241
- // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
2444
+ // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize, postData?),
2242
2445
  // response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
2243
2446
  // timings (send, wait, receive), cache
2244
2447
 
@@ -2254,6 +2457,7 @@ HAR logs are routinely exported and shared, so entries are redacted before they
2254
2457
  - **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
2458
  - **`Location`** — the redirect target is passed through the same URL redaction.
2256
2459
  - **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.
2460
+ - **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. For a `json` body the same sensitive names used for headers and query parameters (`password`, `token`, `api_key`, `secret`, …) are replaced with `***REDACTED***` at any nesting depth, since a JSON body is where credentials most often travel and HAR files get shared. Non-JSON bodies keep their text, but the literal values of this request's own credentials — the credential header, the part after its scheme, and sensitive query parameters — are still replaced wherever they appear, which catches a server echoing a token back in an error message.
2257
2461
 
2258
2462
  ```ts
2259
2463
  // ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
@@ -2337,8 +2541,114 @@ Pipeline stages in order:
2337
2541
 
2338
2542
  ---
2339
2543
 
2544
+ ## Private networks, DNS and TLS
2545
+
2546
+ ```ts
2547
+ const client = kinetex({
2548
+ baseURL: "https://internal.svc",
2549
+
2550
+ // Trust a private CA alongside the system store. Without this, a service mesh
2551
+ // or enterprise proxy whose certificate a corporate CA issued fails TLS.
2552
+ // Node's transports only — `fetch` exposes no CA option, so a custom `fetch`
2553
+ // or `dispatcher` setup has to carry its own.
2554
+ ca: [readFileSync("./corp-root.pem", "utf8")],
2555
+
2556
+ // Reach localhost, a service mesh or a local dev server. Off by default.
2557
+ allowPrivateNetwork: true,
2558
+ });
2559
+ ```
2560
+
2561
+ ### How the screen works
2562
+
2563
+ A URL is a name, not an address, so screening the text you typed does not
2564
+ describe what the socket will do. Three layers close that gap:
2565
+
2566
+ 1. **Lexical.** `isSafeURL` rejects a literal loopback, private, link-local or
2567
+ otherwise blocked address, on the initial URL and on every redirect hop.
2568
+ 2. **Resolution, pinned (Node).** `NodeHTTP2Transport` and the HTTP/1.1 path
2569
+ resolve once and hand the _checked_ address to the socket
2570
+ (`createPinnedLookup`). A name answering with both a public and a private
2571
+ address keeps only the public one; one answering with only private space
2572
+ fails with `EBLOCKEDHOST` rather than connecting.
2573
+ 3. **Resolution, pre-flight (`FetchTransport`).** `fetch` exposes no resolver
2574
+ hook — undici owns the connection — so `FetchTransport` resolves before the
2575
+ call and refuses a host whose answers are all blocked. This layer applies
2576
+ only when kinetex is using the runtime's own `fetch`. Supply your own via
2577
+ the `fetch` option and you own resolution end to end: kinetex screens every
2578
+ URL lexically, but it cannot observe which address your dispatcher dials, so
2579
+ checking the system resolver's answer would describe a lookup that never
2580
+ happens. Treat `fetch` the way you would treat a custom `dispatcher` — a
2581
+ decision you have taken responsibility for.
2582
+
2583
+ The second layer closes rebinding completely, because the address that passed
2584
+ the check is the address that is dialled. The third cannot: it is a check
2585
+ followed by a separate resolution inside `fetch`, so a resolver that answers
2586
+ public on the first lookup and private on the second is not caught here. On
2587
+ Node you get the pinned path; on Deno, Bun and edge runtimes this is the
2588
+ residual exposure, and it is worth a runtime-level DNS policy where one is
2589
+ available.
2590
+
2591
+ ### What the opt-out really does
2592
+
2593
+ `allowPrivateNetwork: true` is not a narrower exception — it turns the screen
2594
+ off for the initial URL _and_ for every redirect hop, and it is the reason a
2595
+ redirect to `http://127.0.0.1:9/` is followed rather than refused. Set it only
2596
+ where the URLs you request are not influenced by anyone else: local
2597
+ development, a service mesh you control, and the test suite.
2598
+
2340
2599
  ## Transport Layer
2341
2600
 
2601
+ ### Redirects
2602
+
2603
+ **kinetex follows every redirect itself.** The outgoing request always carries
2604
+ `redirect: "manual"`, and a server-chosen `Location` is resolved, screened and
2605
+ re-dispatched one hop at a time. This is not a tuning knob — it is what makes
2606
+ the per-hop checks below possible at all, because `fetch` following on its own
2607
+ never reports a target back to the caller.
2608
+
2609
+ Every hop is subject to:
2610
+
2611
+ - **SSRF.** `isSafeURL` runs on each `Location` before it is dialled, so a
2612
+ redirect cannot walk the client onto a loopback, private or link-local
2613
+ address. A 302 to `http://127.0.0.1:9/` or to `http://169.254.169.254/`
2614
+ raises `EVALIDATION` ("Unsafe redirect target blocked") rather than opening
2615
+ the socket — the initial request URL gets the same screen.
2616
+ - **`httpsOnly`.** Checked on the target, not just the request you wrote, so an
2617
+ `https:` request cannot be downgraded to cleartext by its response.
2618
+ - **`maxRedirects`.** Enforced per request, default 20. Exhausting it raises
2619
+ `RedirectError` (`EREDIRECT`, "Too many redirects"). `followRedirects: false`
2620
+ — or `maxRedirects: 0` — hands the 3xx back to you instead.
2621
+ - **Loop detection.** A target already visited in this chain is refused with
2622
+ `RedirectError` (`EREDIRECT`, "Redirect loop detected").
2623
+ - **Method downgrade.** Per RFC 7231, 301/302/303 downgrade to `GET` and drop
2624
+ the body; 307/308 preserve both. A 307/308 therefore needs the body a second
2625
+ time, and a `ReadableStream`/`Blob` body is spent after the first attempt — so
2626
+ that hop is refused with `EVALIDATION` rather than silently sent empty. Buffer
2627
+ the body first, or disable redirects for that request.
2628
+ - **Credentials.** `Authorization`, `apikey` headers, `Cookie` and any
2629
+ declared auth are dropped when a hop changes origin, and kept when it does
2630
+ not. Intermediate `Set-Cookie` headers are captured by the jar, so cookies
2631
+ set on a redirect leg are applied to the next one.
2632
+ - **Scheme.** Anything but `http:` / `https:` is rejected as
2633
+ `EVALIDATION`, so `file:`, `data:` and friends cannot be reached.
2634
+
2635
+ `res.redirected` tells you whether a hop was actually taken, and `res.url` is
2636
+ where the request finally landed. An _unfollowed_ 3xx — `followRedirects:
2637
+ false`, or `maxRedirects: 0` — reports `redirected: false` and the original
2638
+ `res.url`, because nothing was followed: that response is the one you have to
2639
+ read `Location` on and act on yourself.
2640
+
2641
+ A redirect failure is a `RedirectError` with code `EREDIRECT` ("Too many
2642
+ redirects", "Redirect loop detected"), and it is **not** retried. A chain is a
2643
+ deterministic answer from the origin, so replaying it would only multiply the
2644
+ requests against a server already looping: `maxRedirects: 3` makes exactly
2645
+ four requests, not one per retry attempt. The SSRF and `httpsOnly` gates stay
2646
+ `EVALIDATION`, as does an unsafe redirect target.
2647
+
2648
+ The built-in transports enforce the same two gates in their own loops
2649
+ (`https:` only, plus `isSafeURL`), so the protection does not depend on going
2650
+ through `Kinetex`.
2651
+
2342
2652
  ### FetchTransport
2343
2653
 
2344
2654
  Universal fetch-based transport for all runtimes:
@@ -2388,6 +2698,57 @@ Features:
2388
2698
  - Iterative redirect following (not recursive)
2389
2699
  - Backpressure-aware body writes (awaits `drain` events)
2390
2700
 
2701
+ **The transport owns the request line.** `:method`, `:path`, `:scheme` and
2702
+ `:authority` are built from the URL you hand to `send()` and cannot be
2703
+ overridden from `request.headers`. A pseudo-header there is refused the same
2704
+ way any other invalid header is: `EVALIDATION` under `strict: true`, otherwise
2705
+ `onDroppedHeader(name, value)` or a `console.warn`. This is not a formality —
2706
+ `":"` is not a token character, so `FetchTransport` has always dropped such a
2707
+ header, and the two transports now answer a single request the same way.
2708
+
2709
+ Header names are validated as tokens and values as field-values, so a
2710
+ `__proto__` header (legal — it is all token characters) is sent as a real
2711
+ header rather than disappearing into an inherited setter.
2712
+
2713
+ ### Request Bodies
2714
+
2715
+ `FetchTransport` hands the body to `fetch`; `NodeHTTP2Transport` drives
2716
+ `node:http2` directly. The two therefore serialize differently, and on Node
2717
+ the default transport is the latter — so anything fetch would have encoded has
2718
+ to be encoded by kinetex first. `URLSearchParams`, `Blob` and `FormData` are
2719
+ all covered:
2720
+
2721
+ ```ts
2722
+ const form = new FormData();
2723
+ form.append("field", "value");
2724
+ form.append("file", new File([blob], "report.csv", { type: "text/csv" }));
2725
+
2726
+ // → multipart/form-data; boundary=----kinetexFormBoundary<random>
2727
+ const res = await client.POST("/upload").withForm(form).send();
2728
+ ```
2729
+
2730
+ **The boundary and the header are generated together.** The boundary is
2731
+ invented _during_ encoding, so a body encoded after the header block was
2732
+ already written names a boundary no header mentions — and a multipart body
2733
+ whose boundary is not announced is unparseable. Both raw Node paths therefore
2734
+ pre-encode the body before building headers, and a `content-type` you set
2735
+ yourself always wins. `encodeMultipart(form, boundary?)` is exported for
2736
+ callers who want the bytes directly; pass a boundary to make the output
2737
+ deterministic.
2738
+
2739
+ A field name containing CR, LF or a double quote is refused with
2740
+ `EVALIDATION` rather than serialized, since a name is interpolated into a
2741
+ `Content-Disposition` header and could otherwise forge extra part headers. A
2742
+ File's `filename` goes into the same quoted-string context but is
2743
+ percent-escaped rather than refused, so ordinary names keep working.
2744
+
2745
+ `maxRequestSize` counts what actually goes on the wire. For a `FormData`
2746
+ that means the form is encoded once and its real byte length is compared —
2747
+ there is no per-part guess, and the encoding is not repeated on the dispatch
2748
+ path. A `ReadableStream` body is the one type that cannot be measured at all,
2749
+ so it is refused instead of bypassing the limit; pass `maxRequestSize: 0` to
2750
+ opt out, or buffer the body first.
2751
+
2391
2752
  ### Transport Factory
2392
2753
 
2393
2754
  ```ts
@@ -2577,14 +2938,7 @@ import {
2577
2938
  buildDataURL,
2578
2939
  diffURLs,
2579
2940
  } from "kinetex/url";
2580
- import type {
2581
- ParsedURL,
2582
- URLBuilderOptions,
2583
- URLPattern,
2584
- URLPatternMatch,
2585
- URLDiff,
2586
- DataURLParts,
2587
- } from "kinetex/url";
2941
+ import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
2588
2942
 
2589
2943
  // URL Builder (fluent, immutable — every method returns a new builder)
2590
2944
  const builder = URLBuilder.from("https://api.example.com")
@@ -2999,8 +3353,12 @@ isSafeURL("http://169.254.169.254/latest/meta-data/"); // false
2999
3353
  isSafeURL("http://[::ffff:127.0.0.1]/"); // false
3000
3354
 
3001
3355
  // Applied to the initial URL AND to every redirect hop, so a public host cannot
3002
- // bounce a request into the private network. (DNS rebinding is out of scope —
3003
- // the check is literal-address based, not a resolution.)
3356
+ // bounce a request into the private network.
3357
+ //
3358
+ // These helpers are literal-address checks. Resolution is handled separately by
3359
+ // the transports: the Node ones resolve once and pin the checked address to the
3360
+ // socket, and `FetchTransport` resolves before the call and refuses a host whose
3361
+ // answers are all blocked. See "Private networks and DNS" below.
3004
3362
  sanitizeURL("javascript:alert(1)"); // null — invalid or an SSRF risk
3005
3363
  sanitizeURL("https://user:pass@api.example.com/x"); // "https://api.example.com/x" (credentials stripped)
3006
3364
 
@@ -3023,9 +3381,19 @@ const b64 = uint8ArrayToBase64(uint8);
3023
3381
 
3024
3382
  // Object
3025
3383
  const clone = deepClone(original);
3026
- const normalized = normalizeHeaders(rawHeaders); // Lowercase keys
3384
+ // normalizeHeaders keys are lowercased, and a header that appears more than
3385
+ // once keeps every value rather than only the last one.
3386
+ const normalized = normalizeHeaders(rawHeaders);
3027
3387
  ```
3028
3388
 
3389
+ **Repeated headers keep every value.** `Set-Cookie` is the one header a
3390
+ `Headers` object yields _separately_ per cookie rather than already joined, so
3391
+ a naive normalisation that assigns as it iterates silently drops every cookie
3392
+ but the last — and the request still looks fine. `normalizeHeaders`
3393
+ accumulates instead, so a response with three `Set-Cookie` lines round-trips
3394
+ back to three, each with its `Expires=Wed, 09 Jun 2021 10:18:14 GMT` intact.
3395
+ `toNodeHeaders` and the `HttpHeaders` type were already correct on this.
3396
+
3029
3397
  ---
3030
3398
 
3031
3399
  ## Error Handling
@@ -3252,14 +3620,7 @@ import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from
3252
3620
  import type { FetchTransportOptions } from "kinetex/core";
3253
3621
  import type { OTelTracer, OTelSpan } from "kinetex";
3254
3622
  import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
3255
- import type {
3256
- ParsedURL,
3257
- URLBuilderOptions,
3258
- URLPattern,
3259
- URLPatternMatch,
3260
- URLDiff,
3261
- DataURLParts,
3262
- } from "kinetex/url";
3623
+ import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
3263
3624
  ```
3264
3625
 
3265
3626
  ---
@@ -3330,26 +3691,26 @@ import { kinetex } from "kinetex/browser";
3330
3691
 
3331
3692
  ## Runtime Compatibility
3332
3693
 
3333
- | Feature | Node 18+ | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
3334
- | ----------------------------------------- | ------------------------ | -------- | ------------- | ------------- | ------------- | ------------- | ------------- |
3335
- | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3336
- | HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3337
- | HTTP/2 (NodeHTTP2Transport) | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
3338
- | HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
3339
- | WebSocket (WSClient) | ✗¹ (no native WebSocket) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
3340
- | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3341
- | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3342
- | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3343
- | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3344
- | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3345
- | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3346
- | URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3347
- | Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
3348
- | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3349
-
3350
- \* HTTP/2+ detection is best-effort and internal. kinetex's own `detectHTTPVersion()` helper (not exported) reports HTTP/2 only when the runtime exposes protocol evidence — a response `httpVersion`/`protocol` property, or an `Alt-Svc` header — and otherwise reports `HTTP/1.1`. The version on every `KinetexResponse` comes from that heuristic, so it is not a guarantee. This is accurate for Node 18's undici fetch, which does not negotiate h2 by default; use `NodeHTTP2Transport` (Node 22+) when you need guaranteed HTTP/2.
3351
-
3352
- ¹ WSClient requires a native `WebSocket` constructor. Node added one in v22 — on Node 18 use a polyfill (`globalThis.WebSocket = require('undici').WebSocket`).
3694
+ | Feature | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
3695
+ | ----------------------------------------- | -------- | ---- | ------------- | ------------- | ------------- | ------------- |
3696
+ | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3697
+ | HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ |
3698
+ | HTTP/2 (NodeHTTP2Transport) | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
3699
+ | HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
3700
+ | WebSocket (WSClient) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
3701
+ | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3702
+ | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3703
+ | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3704
+ | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3705
+ | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3706
+ | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3707
+ | URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3708
+ | Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
3709
+ | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3710
+
3711
+ \* HTTP/2+ detection is best-effort and internal. kinetex's own `detectHTTPVersion()` helper (not exported) reports HTTP/2 only when the runtime exposes protocol evidence — a response `httpVersion`/`protocol` property, or an `Alt-Svc` header — and otherwise reports `HTTP/1.1`. The version on every `KinetexResponse` comes from that heuristic, so it is not a guarantee. This is accurate for any runtime whose `fetch` does not negotiate h2 by default; use `NodeHTTP2Transport` (Node 22+) when you need guaranteed HTTP/2.
3712
+
3713
+ ¹ WSClient requires a native `WebSocket` constructor, which every supported Node.js version (22+) provides.
3353
3714
 
3354
3715
  ² Cloudflare Workers exposes a `WebSocket` constructor, but outbound client connections depend on runtime support.
3355
3716