kinetex 1.4.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 (95) hide show
  1. package/README.md +170 -46
  2. package/dist/browser/kinetex.esm.js +29 -29
  3. package/dist/browser/kinetex.js +1143 -791
  4. package/dist/browser/kinetex.min.js +29 -29
  5. package/dist/cjs/aws-sigv4.js +7 -14
  6. package/dist/cjs/cache.js +9 -0
  7. package/dist/cjs/circuit-breaker.js +8 -1
  8. package/dist/cjs/client.js +350 -95
  9. package/dist/cjs/cookie-parser.js +28 -1
  10. package/dist/cjs/core.js +227 -5
  11. package/dist/cjs/dedup.js +93 -10
  12. package/dist/cjs/digest.js +36 -15
  13. package/dist/cjs/graphql.js +2 -11
  14. package/dist/cjs/headers.js +24 -1
  15. package/dist/cjs/interceptors.js +6 -17
  16. package/dist/cjs/lifecycle.js +3 -19
  17. package/dist/cjs/logging.js +12 -29
  18. package/dist/cjs/progress.js +1 -3
  19. package/dist/cjs/proxy.js +19 -2
  20. package/dist/cjs/response.js +202 -75
  21. package/dist/cjs/socks5.js +5 -0
  22. package/dist/cjs/utils.js +115 -27
  23. package/dist/cjs/ws.js +43 -2
  24. package/dist/esm/aws-sigv4.js +7 -14
  25. package/dist/esm/aws-sigv4.js.map +1 -1
  26. package/dist/esm/cache.js +9 -0
  27. package/dist/esm/cache.js.map +1 -1
  28. package/dist/esm/circuit-breaker.js +8 -1
  29. package/dist/esm/circuit-breaker.js.map +1 -1
  30. package/dist/esm/client.js +350 -95
  31. package/dist/esm/client.js.map +1 -1
  32. package/dist/esm/cookie-parser.js +28 -1
  33. package/dist/esm/cookie-parser.js.map +1 -1
  34. package/dist/esm/core.js +227 -5
  35. package/dist/esm/core.js.map +1 -1
  36. package/dist/esm/dedup.js +93 -10
  37. package/dist/esm/dedup.js.map +1 -1
  38. package/dist/esm/digest.js +36 -15
  39. package/dist/esm/digest.js.map +1 -1
  40. package/dist/esm/graphql.js +2 -11
  41. package/dist/esm/graphql.js.map +1 -1
  42. package/dist/esm/headers.js +24 -1
  43. package/dist/esm/headers.js.map +1 -1
  44. package/dist/esm/interceptors.js +6 -17
  45. package/dist/esm/interceptors.js.map +1 -1
  46. package/dist/esm/lifecycle.js +3 -19
  47. package/dist/esm/lifecycle.js.map +1 -1
  48. package/dist/esm/logging.js +12 -29
  49. package/dist/esm/logging.js.map +1 -1
  50. package/dist/esm/progress.js +1 -3
  51. package/dist/esm/progress.js.map +1 -1
  52. package/dist/esm/proxy.js +19 -2
  53. package/dist/esm/proxy.js.map +1 -1
  54. package/dist/esm/response.js +202 -75
  55. package/dist/esm/response.js.map +1 -1
  56. package/dist/esm/socks5.js +5 -0
  57. package/dist/esm/socks5.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/url.js.map +1 -1
  60. package/dist/esm/utils.js +115 -27
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/ws.js +43 -2
  63. package/dist/esm/ws.js.map +1 -1
  64. package/dist/types/aws-sigv4.d.ts.map +1 -1
  65. package/dist/types/cache.d.ts.map +1 -1
  66. package/dist/types/circuit-breaker.d.ts +1 -1
  67. package/dist/types/circuit-breaker.d.ts.map +1 -1
  68. package/dist/types/client.d.ts.map +1 -1
  69. package/dist/types/cookie-parser.d.ts.map +1 -1
  70. package/dist/types/core.d.ts +113 -0
  71. package/dist/types/core.d.ts.map +1 -1
  72. package/dist/types/dedup.d.ts +38 -3
  73. package/dist/types/dedup.d.ts.map +1 -1
  74. package/dist/types/digest.d.ts.map +1 -1
  75. package/dist/types/graphql.d.ts.map +1 -1
  76. package/dist/types/headers.d.ts +23 -4
  77. package/dist/types/headers.d.ts.map +1 -1
  78. package/dist/types/interceptors.d.ts +24 -38
  79. package/dist/types/interceptors.d.ts.map +1 -1
  80. package/dist/types/lifecycle.d.ts.map +1 -1
  81. package/dist/types/logging.d.ts.map +1 -1
  82. package/dist/types/progress.d.ts.map +1 -1
  83. package/dist/types/proxy.d.ts.map +1 -1
  84. package/dist/types/response.d.ts +42 -1
  85. package/dist/types/response.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/types.d.ts +105 -23
  88. package/dist/types/types.d.ts.map +1 -1
  89. package/dist/types/url.d.ts +0 -11
  90. package/dist/types/url.d.ts.map +1 -1
  91. package/dist/types/utils.d.ts +69 -1
  92. package/dist/types/utils.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +0 -1
  94. package/dist/types/ws.d.ts.map +1 -1
  95. 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)
@@ -61,6 +62,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
61
62
  - [HAR Recording](#har-recording)
62
63
  - [OpenTelemetry Tracing](#opentelemetry-tracing)
63
64
  - [Pipeline Trace](#pipeline-trace)
65
+ - [Private networks, DNS and TLS](#private-networks-dns-and-tls)
64
66
  - [Transport Layer](#transport-layer)
65
67
  - [Runtime Detection](#runtime-detection)
66
68
  - [Child Clients](#child-clients)
@@ -102,6 +104,37 @@ import { kinetex } from "jsr:@kinetexjs/kinetex";
102
104
 
103
105
  ---
104
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
+
105
138
  ## Quick Start
106
139
 
107
140
  ```ts
@@ -155,8 +188,10 @@ const client = kinetex({
155
188
  // (see "Redirects" under Transport Layer) — the transport is never
156
189
  // asked to follow, whatever this is set to
157
190
  httpsOnly: false, // Reject non-HTTPS URLs
158
- 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)
159
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
160
195
  strictHeaders: false, // Throw on invalid headers vs warn+drop
161
196
  onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
162
197
  onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
@@ -382,7 +417,7 @@ interface SendOptions<T = unknown> {
382
417
  maxRedirects?: number; // Max redirect hops (default: 20; 0 disables following)
383
418
  httpVersion?: HTTPVersion; // Preferred HTTP version
384
419
  maxRequestSize?: number; // Request size limit (bytes)
385
- maxResponseSize?: number; // Response size limit (bytes)
420
+ maxResponseSize?: number; // Response size limit (bytes, default 104857600; 0 = unlimited)
386
421
  parseResponse?: (raw: Uint8Array, headers: Record<string, string>, url: string) => T | Promise<T>;
387
422
  parseFailure?: (raw: Uint8Array, error: Error) => void;
388
423
  onSuccess?: (res: KinetexResponse<T>) => void;
@@ -470,12 +505,16 @@ client.GET("/public").noAuth().json();
470
505
  kinetex({
471
506
  retry: {
472
507
  maxRetries: 3, // Max attempts (default: 3)
508
+ totalTimeoutMs: 20_000, // Ceiling on the WHOLE request: attempts + backoff (default: = timeout)
473
509
  baseDelayMs: 300, // Exponential back-off base (default: 300)
474
510
  maxDelayMs: 30_000, // Max delay cap (default: 30_000)
475
511
  jitter: 0.3, // Random jitter factor 0-1 (default: 0.3)
476
512
  statuses: [408, 429, 500, 502, 503, 504], // Status codes that trigger retry
477
513
  onNetworkError: true, // Retry on network errors (default: true)
478
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.
479
518
  methods: ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"],
480
519
  shouldRetry: (ctx) => ctx.attempt < 3 && ctx.response?.status === 503, // Custom predicate
481
520
  onRetry: (ctx, delayMs) => console.log(`Retry ${ctx.attempt} in ${delayMs}ms`),
@@ -483,6 +522,38 @@ kinetex({
483
522
  });
484
523
  ```
485
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
+
486
557
  ### RetryContext
487
558
 
488
559
  The `ctx` parameter in `shouldRetry` and `onRetry` is of type `RetryContext`:
@@ -935,7 +1006,7 @@ interface HookOptions {
935
1006
 
936
1007
  ## Request Deduplication
937
1008
 
938
- 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:
939
1010
 
940
1011
  ```ts
941
1012
  import { CREDENTIAL_HEADERS } from "kinetex/cache";
@@ -1082,7 +1153,7 @@ const client = kinetex({
1082
1153
  // maxBodySizeBytes: 5 * 1024 * 1024, // skip caching larger bodies
1083
1154
  // defaultTtlMs: 60_000, // when the response has no Cache-Control (max 1 year)
1084
1155
  // maxAbsoluteAgeMs: 7 * 24 * 60 * 60 * 1000, // hard cap regardless of Cache-Control
1085
- // honorCacheControl: true, // respect no-store / no-cache
1156
+ // honorCacheControl: true, // respect no-store / no-cache / private
1086
1157
  // cacheMethods: ["GET", "HEAD"],
1087
1158
  // cacheStatuses: [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
1088
1159
  storage: new MemoryStorageAdapter(), // omit for plain in-memory
@@ -1687,7 +1758,7 @@ const ws = new WSClient({
1687
1758
  lowWaterMark: 16384,
1688
1759
  maxSendRate: 0, // 0 = unlimited
1689
1760
  keepRooms: true,
1690
- bufferMessages: true,
1761
+ bufferMessages: true, // queue sends made while reconnecting; see below
1691
1762
  maxBufferSize: 1000,
1692
1763
  rooms: ["prices"],
1693
1764
  signal: controller.signal,
@@ -1750,6 +1821,11 @@ ws.leave("prices");
1750
1821
  ws.rooms; // readonly WSSubscribedRoom[]
1751
1822
 
1752
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.
1753
1829
  ws.backpressure; // { bufferedBytes, highWaterMark, lowWaterMark, isBackpressured, ... }
1754
1830
  await ws.drain(30_000); // Wait until outbound buffer is flushed
1755
1831
  await ws.drainAndClose(30_000); // Drain, then close gracefully
@@ -2381,7 +2457,7 @@ HAR logs are routinely exported and shared, so entries are redacted before they
2381
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.
2382
2458
  - **`Location`** — the redirect target is passed through the same URL redaction.
2383
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.
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.
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.
2385
2461
 
2386
2462
  ```ts
2387
2463
  // ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
@@ -2465,6 +2541,61 @@ Pipeline stages in order:
2465
2541
 
2466
2542
  ---
2467
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
+
2468
2599
  ## Transport Layer
2469
2600
 
2470
2601
  ### Redirects
@@ -2490,7 +2621,10 @@ Every hop is subject to:
2490
2621
  - **Loop detection.** A target already visited in this chain is refused with
2491
2622
  `RedirectError` (`EREDIRECT`, "Redirect loop detected").
2492
2623
  - **Method downgrade.** Per RFC 7231, 301/302/303 downgrade to `GET` and drop
2493
- the body; 307/308 preserve both.
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.
2494
2628
  - **Credentials.** `Authorization`, `apikey` headers, `Cookie` and any
2495
2629
  declared auth are dropped when a hop changes origin, and kept when it does
2496
2630
  not. Intermediate `Set-Cookie` headers are captured by the jar, so cookies
@@ -2804,14 +2938,7 @@ import {
2804
2938
  buildDataURL,
2805
2939
  diffURLs,
2806
2940
  } from "kinetex/url";
2807
- import type {
2808
- ParsedURL,
2809
- URLBuilderOptions,
2810
- URLPattern,
2811
- URLPatternMatch,
2812
- URLDiff,
2813
- DataURLParts,
2814
- } from "kinetex/url";
2941
+ import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
2815
2942
 
2816
2943
  // URL Builder (fluent, immutable — every method returns a new builder)
2817
2944
  const builder = URLBuilder.from("https://api.example.com")
@@ -3226,8 +3353,12 @@ isSafeURL("http://169.254.169.254/latest/meta-data/"); // false
3226
3353
  isSafeURL("http://[::ffff:127.0.0.1]/"); // false
3227
3354
 
3228
3355
  // Applied to the initial URL AND to every redirect hop, so a public host cannot
3229
- // bounce a request into the private network. (DNS rebinding is out of scope —
3230
- // 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.
3231
3362
  sanitizeURL("javascript:alert(1)"); // null — invalid or an SSRF risk
3232
3363
  sanitizeURL("https://user:pass@api.example.com/x"); // "https://api.example.com/x" (credentials stripped)
3233
3364
 
@@ -3489,14 +3620,7 @@ import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from
3489
3620
  import type { FetchTransportOptions } from "kinetex/core";
3490
3621
  import type { OTelTracer, OTelSpan } from "kinetex";
3491
3622
  import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
3492
- import type {
3493
- ParsedURL,
3494
- URLBuilderOptions,
3495
- URLPattern,
3496
- URLPatternMatch,
3497
- URLDiff,
3498
- DataURLParts,
3499
- } from "kinetex/url";
3623
+ import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
3500
3624
  ```
3501
3625
 
3502
3626
  ---
@@ -3567,26 +3691,26 @@ import { kinetex } from "kinetex/browser";
3567
3691
 
3568
3692
  ## Runtime Compatibility
3569
3693
 
3570
- | Feature | Node 18+ | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
3571
- | ----------------------------------------- | ------------------------ | -------- | ------------- | ------------- | ------------- | ------------- | ------------- |
3572
- | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3573
- | HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3574
- | HTTP/2 (NodeHTTP2Transport) | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
3575
- | HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
3576
- | WebSocket (WSClient) | ✗¹ (no native WebSocket) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
3577
- | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3578
- | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3579
- | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3580
- | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3581
- | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3582
- | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3583
- | URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3584
- | Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
3585
- | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3586
-
3587
- \* 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.
3588
-
3589
- ¹ 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.
3590
3714
 
3591
3715
  ² Cloudflare Workers exposes a `WebSocket` constructor, but outbound client connections depend on runtime support.
3592
3716