kinetex 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +1164 -453
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3127 -715
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +137 -20
  6. package/dist/cjs/cache.js +101 -21
  7. package/dist/cjs/circuit-breaker.js +69 -7
  8. package/dist/cjs/client.js +838 -191
  9. package/dist/cjs/cookie-parser.js +110 -9
  10. package/dist/cjs/cookie-store.js +141 -36
  11. package/dist/cjs/core.js +501 -63
  12. package/dist/cjs/dedup.js +58 -18
  13. package/dist/cjs/digest.js +185 -23
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +362 -48
  16. package/dist/cjs/interceptors.js +285 -29
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +169 -16
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +261 -28
  21. package/dist/cjs/progress.js +282 -52
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +167 -36
  25. package/dist/cjs/sse.js +201 -34
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/worker.js +6 -6
  29. package/dist/cjs/ws.js +32 -16
  30. package/dist/esm/aws-sigv4.js +137 -20
  31. package/dist/esm/aws-sigv4.js.map +1 -1
  32. package/dist/esm/cache.js +101 -21
  33. package/dist/esm/cache.js.map +1 -1
  34. package/dist/esm/circuit-breaker.js +69 -7
  35. package/dist/esm/circuit-breaker.js.map +1 -1
  36. package/dist/esm/client.js +838 -191
  37. package/dist/esm/client.js.map +1 -1
  38. package/dist/esm/cookie-parser.js +110 -9
  39. package/dist/esm/cookie-parser.js.map +1 -1
  40. package/dist/esm/cookie-store.js +141 -36
  41. package/dist/esm/cookie-store.js.map +1 -1
  42. package/dist/esm/core.js +501 -63
  43. package/dist/esm/core.js.map +1 -1
  44. package/dist/esm/dedup.js +58 -18
  45. package/dist/esm/dedup.js.map +1 -1
  46. package/dist/esm/digest.js +185 -23
  47. package/dist/esm/digest.js.map +1 -1
  48. package/dist/esm/graphql.js +164 -24
  49. package/dist/esm/graphql.js.map +1 -1
  50. package/dist/esm/headers.js +362 -48
  51. package/dist/esm/headers.js.map +1 -1
  52. package/dist/esm/interceptors.js +285 -29
  53. package/dist/esm/interceptors.js.map +1 -1
  54. package/dist/esm/lifecycle.js +89 -40
  55. package/dist/esm/lifecycle.js.map +1 -1
  56. package/dist/esm/logging.js +169 -16
  57. package/dist/esm/logging.js.map +1 -1
  58. package/dist/esm/mod.js +3 -2
  59. package/dist/esm/mod.js.map +1 -1
  60. package/dist/esm/pagination.js +261 -28
  61. package/dist/esm/pagination.js.map +1 -1
  62. package/dist/esm/progress.js +282 -52
  63. package/dist/esm/progress.js.map +1 -1
  64. package/dist/esm/proxy.js +413 -0
  65. package/dist/esm/proxy.js.map +1 -0
  66. package/dist/esm/response.js +316 -47
  67. package/dist/esm/response.js.map +1 -1
  68. package/dist/esm/socks5.js +167 -36
  69. package/dist/esm/socks5.js.map +1 -1
  70. package/dist/esm/sse.js +201 -34
  71. package/dist/esm/sse.js.map +1 -1
  72. package/dist/esm/types.js.map +1 -1
  73. package/dist/esm/url.js +191 -45
  74. package/dist/esm/url.js.map +1 -1
  75. package/dist/esm/utils.js +222 -48
  76. package/dist/esm/utils.js.map +1 -1
  77. package/dist/esm/worker.js +6 -6
  78. package/dist/esm/worker.js.map +1 -1
  79. package/dist/esm/ws.js +32 -16
  80. package/dist/esm/ws.js.map +1 -1
  81. package/dist/types/aws-sigv4.d.ts.map +1 -1
  82. package/dist/types/cache.d.ts +27 -2
  83. package/dist/types/cache.d.ts.map +1 -1
  84. package/dist/types/circuit-breaker.d.ts +14 -1
  85. package/dist/types/circuit-breaker.d.ts.map +1 -1
  86. package/dist/types/client.d.ts +98 -23
  87. package/dist/types/client.d.ts.map +1 -1
  88. package/dist/types/cookie-parser.d.ts +0 -17
  89. package/dist/types/cookie-parser.d.ts.map +1 -1
  90. package/dist/types/cookie-store.d.ts.map +1 -1
  91. package/dist/types/core.d.ts +109 -25
  92. package/dist/types/core.d.ts.map +1 -1
  93. package/dist/types/dedup.d.ts +0 -7
  94. package/dist/types/dedup.d.ts.map +1 -1
  95. package/dist/types/digest.d.ts +31 -37
  96. package/dist/types/digest.d.ts.map +1 -1
  97. package/dist/types/graphql.d.ts.map +1 -1
  98. package/dist/types/headers.d.ts +62 -29
  99. package/dist/types/headers.d.ts.map +1 -1
  100. package/dist/types/interceptors.d.ts +102 -0
  101. package/dist/types/interceptors.d.ts.map +1 -1
  102. package/dist/types/lifecycle.d.ts +19 -2
  103. package/dist/types/lifecycle.d.ts.map +1 -1
  104. package/dist/types/logging.d.ts +23 -4
  105. package/dist/types/logging.d.ts.map +1 -1
  106. package/dist/types/mod.d.ts +5 -3
  107. package/dist/types/mod.d.ts.map +1 -1
  108. package/dist/types/pagination.d.ts +0 -25
  109. package/dist/types/pagination.d.ts.map +1 -1
  110. package/dist/types/progress.d.ts +1 -1
  111. package/dist/types/progress.d.ts.map +1 -1
  112. package/dist/types/proxy.d.ts +50 -0
  113. package/dist/types/proxy.d.ts.map +1 -0
  114. package/dist/types/response.d.ts +7 -1
  115. package/dist/types/response.d.ts.map +1 -1
  116. package/dist/types/socks5.d.ts.map +1 -1
  117. package/dist/types/sse.d.ts.map +1 -1
  118. package/dist/types/types.d.ts +139 -5
  119. package/dist/types/types.d.ts.map +1 -1
  120. package/dist/types/url.d.ts +0 -14
  121. package/dist/types/url.d.ts.map +1 -1
  122. package/dist/types/utils.d.ts.map +1 -1
  123. package/dist/types/worker.d.ts +6 -6
  124. package/dist/types/worker.d.ts.map +1 -1
  125. package/dist/types/ws.d.ts.map +1 -1
  126. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,18 @@
1
- # kinetex
1
+ <div align="center">
2
+
3
+ <img src="https://raw.githubusercontent.com/GlobalTechInfo/Database/main/images/kinetex.png" alt="kinetex" width="100%" />
4
+
5
+ [![NPM](https://img.shields.io/npm/v/kinetex.svg)](https://www.npmjs.com/package/kinetex)
6
+ [![JSR](https://jsr.io/badges/@kinetexjs/kinetex)](https://jsr.io/@kinetexjs/kinetex)
7
+ [![codecov](https://codecov.io/gh/kinetexjs/kinetex/branch/main/graph/badge.svg)](https://codecov.io/gh/kinetexjs/kinetex)
8
+ [![Downloads](https://img.shields.io/npm/dw/kinetex?style=flat-square&label=Downloads&color=green)](https://npmjs.com/package/kinetex)
9
+
10
+ </div>
2
11
 
3
12
  **Feature-rich, universal TypeScript HTTP client.** Zero dependencies. One codebase, every runtime.
4
13
 
14
+ ---
15
+
5
16
  ```ts
6
17
  import { kinetex } from "kinetex";
7
18
 
@@ -28,6 +39,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
28
39
  - [Authentication](#authentication)
29
40
  - [Retry](#retry)
30
41
  - [Rate Limiting](#rate-limiting)
42
+ - [Concurrency Limiting](#concurrency-limiting)
31
43
  - [Timeout](#timeout)
32
44
  - [Interceptors](#interceptors)
33
45
  - [Lifecycle Hooks](#lifecycle-hooks)
@@ -99,6 +111,8 @@ const client = kinetex({ baseURL: "https://jsonplaceholder.typicode.com" });
99
111
 
100
112
  // Convenience methods
101
113
  const users = await client.get<User[]>("/users");
114
+ // A plain object is JSON-encoded automatically (content-type: application/json).
115
+ // Set `content-type` yourself to send the value as an already-prepared body.
102
116
  const post = await client.post("/posts", { title: "Hello", body: "World" });
103
117
 
104
118
  // Fluent builder
@@ -129,24 +143,31 @@ console.log(res.status, res.data, res.headers, res.durationMs);
129
143
  ```ts
130
144
  const client = kinetex({
131
145
  // ── Core ──
132
- baseURL: "https://api.example.com/v1", // Base URL for relative paths
133
- headers: { "X-Version": "1.0" }, // Default headers
134
- params: { api_key: "xxx" }, // Default query params
135
- timeout: 10000, // Timeout in ms (default: 30000, 0 = no timeout)
136
- httpVersion: "HTTP/2", // "HTTP/1.1" | "HTTP/2" (default: "HTTP/2")
137
- throwOnError: true, // Throw on 4xx/5xx (default: true)
138
- followRedirects: true, // Follow redirects (default: true)
139
- maxRedirects: 10, // Max redirects (default: 10)
140
- httpsOnly: false, // Reject non-HTTPS URLs
141
- maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
142
- maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
143
- strictHeaders: false, // Throw on invalid headers vs warn+drop
144
- onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
145
- onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
146
+ baseURL: "https://api.example.com/v1", // Base URL for relative paths
147
+ headers: { "X-Version": "1.0" }, // Default headers
148
+ params: { api_key: "xxx" }, // Default query params
149
+ timeout: 10000, // Timeout in ms (default: 30000, 0 = no timeout)
150
+ httpVersion: "HTTP/2", // "HTTP/1.1" | "HTTP/2" (default: "HTTP/2")
151
+ throwOnError: true, // Throw on 4xx/5xx (default: true)
152
+ followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
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
157
+ httpsOnly: false, // Reject non-HTTPS URLs
158
+ maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
159
+ maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
160
+ strictHeaders: false, // Throw on invalid headers vs warn+drop
161
+ onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
162
+ onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
146
163
 
147
164
  // ── Auth ──
148
165
  auth: { type: "bearer", token: "..." },
149
- awsSigning: { credentials: {...}, region: "...", service: "..." },
166
+ awsSigning: {
167
+ credentials: { accessKeyId: "AKID", secretAccessKey: "secret" },
168
+ region: "us-east-1",
169
+ service: "s3",
170
+ },
150
171
 
151
172
  // ── Retry ──
152
173
  retry: { maxRetries: 3, baseDelayMs: 300, statuses: [408, 429, 500, 502, 503, 504] },
@@ -154,52 +175,89 @@ const client = kinetex({
154
175
  // ── Rate Limit ──
155
176
  rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
156
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
+
157
183
  // ── Proxy ──
158
- // NOTE: `proxy` fails fast — kinetex's built-in transports cannot route
159
- // through it. Use the `fetch` option with a proxy-capable agent for
160
- // 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" },
161
187
  // proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
162
188
 
163
189
  // ── Cache ──
164
- cache: { storage: "memory", ttlMs: 60_000, maxEntries: 1000, swr: true },
190
+ cache: { maxEntries: 500, defaultTtlMs: 60_000 }, // see "Caching" for the full CacheConfig
165
191
 
166
192
  // ── Cookie Jar ──
167
- cookieJar: true, // Auto-manage cookies
193
+ cookieJar: true, // Auto-manage cookies
168
194
 
169
195
  // ── Logging ──
170
196
  logger: { level: "info" },
171
197
 
172
198
  // ── HAR Recording ──
173
- har: true, // Enable HTTP Archive recording
199
+ har: true, // Enable HTTP Archive recording
174
200
 
175
201
  // ── Interceptors ──
176
202
  interceptors: {
177
- request: [myReqInterceptor],
203
+ request: [myReqInterceptor],
178
204
  response: [myResInterceptor],
179
- error: [myErrInterceptor],
205
+ error: [myErrInterceptor],
180
206
  },
181
207
 
182
208
  // ── Lifecycle Hooks ──
183
209
  hooks: {
184
- onBeforeRequest: [(req, ctx) => { ... }],
185
- onAfterRequest: [(req, ctx) => { ... }],
186
- onBeforeResponse: [(res, ctx) => { ... }],
187
- onAfterResponse: [(res, ctx) => { ... }],
188
- onError: [(err, ctx) => { ... }],
189
- onRetry: [(ctx) => { ... }],
190
- onUploadProgress: [(ev) => { ... }],
191
- onDownloadProgress: [(ev) => { ... }],
210
+ onBeforeRequest: [
211
+ (req, ctx) => {
212
+ /* ... */
213
+ },
214
+ ],
215
+ onAfterRequest: [
216
+ (req, ctx) => {
217
+ /* ... */
218
+ },
219
+ ],
220
+ onBeforeResponse: [
221
+ (res, ctx) => {
222
+ /* ... */
223
+ },
224
+ ],
225
+ onAfterResponse: [
226
+ (res, ctx) => {
227
+ /* ... */
228
+ },
229
+ ],
230
+ onError: [
231
+ (err, ctx) => {
232
+ /* ... */
233
+ },
234
+ ],
235
+ onRetry: [
236
+ (ctx) => {
237
+ /* ... */
238
+ },
239
+ ],
240
+ onUploadProgress: [
241
+ (ev) => {
242
+ /* ... */
243
+ },
244
+ ],
245
+ onDownloadProgress: [
246
+ (ev) => {
247
+ /* ... */
248
+ },
249
+ ],
192
250
  },
193
251
 
194
252
  // ── Response/Request Transforms ──
195
- transformResponse: (data, res) => data, // Global response transformer
196
- transformRequest: (req) => req, // Global request transformer
253
+ transformResponse: (data, res) => data, // Global response transformer
254
+ transformRequest: (req) => req, // Global request transformer
197
255
 
198
256
  // ── Circuit Breaker Key ──
199
257
  circuitBreakerKeyFn: (req) => `${req.method}:${new URL(req.url).origin}`,
200
258
 
201
259
  // ── Custom fetch ──
202
- fetch: myCustomFetch, // Custom fetch implementation
260
+ fetch: myCustomFetch, // Custom fetch implementation
203
261
 
204
262
  // ── WebSocket defaults ──
205
263
  ws: { highWaterMark: 65536, lowWaterMark: 16384, maxSendRate: 0, keepRooms: true },
@@ -212,7 +270,7 @@ const client = kinetex({
212
270
 
213
271
  ```ts
214
272
  const get = await client.get("/resource");
215
- const post = await client.post("/resource", { key: "value" });
273
+ const post = await client.post("/resource", { key: "value" }); // auto-JSON
216
274
  const put = await client.put("/resource/1", { data: "new" });
217
275
  const patch = await client.patch("/resource/1", { data: "updated" });
218
276
  const del = await client.delete("/resource/1");
@@ -253,7 +311,7 @@ const res = await client.send("/resource", "GET", options);
253
311
 
254
312
  ## Fluent Request Builder
255
313
 
256
- Every method returns `this` for chaining. Call `.send()`, `.json()`, `.text()`, `.bytes()`, `.blob()`, or `.data()` to execute.
314
+ Every builder method returns `this` for chaining. Call one terminal method — `.send()`, `.json()`, `.text()`, `.bytes()`, `.blob()`, `.data()`, or `.subscribe()` — to execute; they return promises, not the builder.
257
315
 
258
316
  ```ts
259
317
  const client = kinetex({ baseURL: "https://api.example.com" });
@@ -288,13 +346,17 @@ const data = await client
288
346
  .tags("users", "active") // Cache tags
289
347
  .onUploadProgress((ev) => {}) // Upload progress callback
290
348
  .onDownloadProgress((ev) => {}) // Download progress callback
291
- .send() // → Promise<KinetexResponse<T>>
292
- .json<T>() // → Promise<T> (parsed JSON data)
293
- .text() // → Promise<string>
294
- .bytes() // → Promise<Uint8Array>
295
- .blob() // → Promise<Blob>
296
- .data<T>() // → Promise<T> (alias for .json)
297
- .subscribe(onSuccess, onError); // callback-style (void)
349
+ .send(); // → Promise<KinetexResponse<T>>
350
+
351
+ // The methods above all return `this`. These are the terminal calls — pick
352
+ // exactly one, and nothing may be chained after it:
353
+ const res = await client.GET("/users").send(); // Promise<KinetexResponse<T>>
354
+ const data = await client.GET("/users").json<User>(); // Promise<T> (parsed JSON)
355
+ const str = await client.GET("/users").text(); // Promise<string>
356
+ const buf = await client.GET("/users").bytes(); // Promise<Uint8Array>
357
+ const blob = await client.GET("/users").blob(); // Promise<Blob>
358
+ const same = await client.GET("/users").data<User>(); // Promise<T> (alias for .json)
359
+ client.GET("/users").subscribe(onSuccess, onError); // callback-style (void)
298
360
  ```
299
361
 
300
362
  ---
@@ -316,8 +378,8 @@ interface SendOptions<T = unknown> {
316
378
  proxy?: ProxyConfig | false; // Proxy config or disable
317
379
  cache?: CacheRequestConfig | false; // Cache config or disable
318
380
  throwOnError?: boolean; // Throw on 4xx/5xx
319
- followRedirects?: boolean; // Follow redirects
320
- maxRedirects?: number; // Max redirects
381
+ followRedirects?: boolean; // Follow redirects (default: true; false returns the 3xx as-is)
382
+ maxRedirects?: number; // Max redirect hops (default: 20; 0 disables following)
321
383
  httpVersion?: HTTPVersion; // Preferred HTTP version
322
384
  maxRequestSize?: number; // Request size limit (bytes)
323
385
  maxResponseSize?: number; // Response size limit (bytes)
@@ -463,6 +525,76 @@ kinetex({
463
525
 
464
526
  ---
465
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
+
466
598
  ## Timeout
467
599
 
468
600
  Default timeout is 30 seconds. Set to 0 for no timeout:
@@ -487,6 +619,15 @@ try {
487
619
 
488
620
  Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
489
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
+
490
631
  ---
491
632
 
492
633
  ## Interceptors
@@ -546,13 +687,13 @@ kinetex({
546
687
 
547
688
  ```ts
548
689
  interface InterceptorContext {
549
- request: KinetexRequest;
550
- response: KinetexResponse<unknown> | null;
690
+ request: InterceptorRequest; // url, method, headers, body, signal, meta
691
+ response: InterceptorResponse | null;
551
692
  error: unknown | null;
552
693
  startedAt: number; // Monotonic start time (ms)
553
694
  attempt: number; // Current attempt number
554
695
  aborted: boolean; // Pipeline aborted?
555
- store: Map<symbol | string, unknown>; // Pipeline-scoped shared storage
696
+ store: Map<symbol, unknown>; // Pipeline-scoped shared storage (symbol keys only)
556
697
  }
557
698
  ```
558
699
 
@@ -577,25 +718,39 @@ import {
577
718
  RateLimitError, // thrown by createRateLimitInterceptor
578
719
  } from "kinetex/interceptors";
579
720
 
580
- // Compute request body size (used internally by progress tracking)
581
- computeBodySize(body); // → number | null
721
+ // Compute request body size (used internally by progress tracking).
722
+ // Returns the byte length, 0 for an empty body, or -1 when the size is unknown
723
+ // (e.g. a ReadableStream) — it never returns null.
724
+ computeBodySize(body); // → number
582
725
 
583
- // Combine multiple built-in interceptors
726
+ // Combine multiple built-in interceptors at documented priorities:
727
+ // -100 timeout · -90 rate limit · -80 dedup · -70 cache
728
+ // -50 auth · 50 retry · 90 logging · 95 HAR · 100 metrics
584
729
  const suite = createInterceptorSuite({
730
+ timeout: { timeoutMs: 5000 },
585
731
  retry: { maxRetries: 3 },
586
- auth: { type: "bearer", token: "..." },
587
- cache: { ttlMs: 5000 },
588
- logging: true,
589
- metrics: true,
732
+ rateLimit: { limit: 100, windowMs: 60_000 },
733
+ // `auth` is a bare getToken provider, not an AuthConfig
734
+ auth: { getToken: () => "my-token" },
735
+ // `cache` is a CacheConfig — the key is defaultTtlMs, there is no `ttlMs`
736
+ cache: { defaultTtlMs: 5000 },
737
+ logging: {/* Partial<LoggingConfig> */},
590
738
  });
591
- // suite.retry, suite.auth, suite.cache, suite.logging, suite.metrics, suite.eject
739
+ // suite: { manager, retry, auth, timeout, logging, cache, dedupe, har, metrics }
592
740
  ```
593
741
 
594
742
  ---
595
743
 
596
744
  ## Lifecycle Hooks
597
745
 
598
- 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.
599
754
 
600
755
  ```ts
601
756
  kinetex({
@@ -608,7 +763,7 @@ kinetex({
608
763
  ],
609
764
  onAfterRequest: [
610
765
  (req, ctx) => {
611
- /* request was sent */
766
+ /* the wire round trip is over; the response is not built yet */
612
767
  },
613
768
  ],
614
769
  onBeforeResponse: [
@@ -695,7 +850,7 @@ import type {
695
850
 
696
851
  const registry = new HookRegistry();
697
852
 
698
- // All hook types:
853
+ // All hook types (each add* returns a string id used for removal):
699
854
  // - addBeforeRequest(fn, options?)
700
855
  // - addAfterRequest(fn, options?)
701
856
  // - addBeforeResponse(fn, options?)
@@ -705,14 +860,20 @@ const registry = new HookRegistry();
705
860
  // - addOnRedirect(fn, options?)
706
861
  // - addOnUploadProgress(fn, options?)
707
862
  // - addOnDownloadProgress(fn, options?)
863
+ // - addOnCancel(fn, options?)
864
+ // - addOnConnection(fn, options?)
708
865
  // - addAround(fn, options?) // wraps the entire pipeline
709
866
 
710
- registry.addBeforeRequest(myHook, {
711
- priority: 10, // Lower number = runs first (default: 100)
867
+ const id = registry.addBeforeRequest(myHook, {
868
+ id: "my-hook", // optional unique id (auto-generated if omitted)
869
+ priority: 10, // Lower number = runs first (default: 0)
712
870
  once: true, // Auto-eject after first run
713
- if: (req) => req.method === "POST", // Conditional execution
871
+ condition: (ctx) => ctx.request.method === "POST", // receives the HookContext
872
+ safe: true, // swallow+log errors from this hook instead of propagating
714
873
  });
715
- registry.removeBeforeRequest(myHook); // Eject by reference
874
+ registry.remove(id); // eject by id — there is no removeBeforeRequest()
875
+ registry.has(id);
876
+ registry.removeAll();
716
877
 
717
878
  // Attach registry to a client
718
879
  client.attachHookRegistry(registry); // Returns single eject function
@@ -737,9 +898,9 @@ const responsePipe = composeBeforeResponse(fn1, fn2);
737
898
  // Redirect tracking
738
899
  const redirectTracker = new RedirectTracker({ maxRedirects: 5 });
739
900
  // Error classes
740
- class MyHTTPError extends HTTPError {} // extends Error
901
+ class MyHTTPError extends HTTPError {} // extends Error
741
902
  class MyValidationError extends ResponseValidationError {} // extends Error
742
- class TooManyRedirectsError extends Error {} // thrown by HookRegistry
903
+ class TooManyRedirectsError extends Error {} // thrown by HookRegistry
743
904
  ```
744
905
 
745
906
  ### HookEmitter
@@ -748,19 +909,25 @@ For event-style hook emission separate from the registry:
748
909
 
749
910
  ```ts
750
911
  const emitter = new HookEmitter();
751
- emitter.on("beforeRequest", myHandler);
752
- emitter.emit("beforeRequest", req, ctx);
753
- emitter.off("beforeRequest", myHandler);
754
- emitter.clear();
912
+ emitter.on("before:request", (req) => console.log(req.method, req.url));
913
+ await emitter.emit("before:request", req); // async; takes ONE event object
914
+ emitter.off("before:request", handler);
915
+ emitter.once("error", (err) => console.error(err));
916
+ emitter.removeAllListeners(); // all events
917
+ emitter.removeAllListeners("error"); // one event
755
918
  ```
756
919
 
920
+ Event names are colon-separated and typed: `before:request`, `after:request`, `before:response`, `after:response`, `error`, `retry`, `redirect`, `upload:progress`, `download:progress`, `cancel`, `connection`. `emit()` returns a promise and isolates listener errors. There is no `clear()` — it is `removeAllListeners()`.
921
+
757
922
  ### HookOptions
758
923
 
759
924
  ```ts
760
925
  interface HookOptions {
761
- priority?: number; // Lower runs first (default: 100)
926
+ id?: string; // Unique hook id. Auto-generated if omitted.
927
+ priority?: number; // Lower runs first (default: 0)
762
928
  once?: boolean; // Auto-eject after first execution
763
- if?: (req: KinetexRequest) => boolean; // Conditional execution predicate
929
+ condition?: (ctx: HookContext) => boolean; // Conditional predicate — not `if`
930
+ safe?: boolean; // Catch+log hook errors instead of propagating (default: false)
764
931
  }
765
932
  ```
766
933
 
@@ -768,7 +935,16 @@ interface HookOptions {
768
935
 
769
936
  ## Request Deduplication
770
937
 
771
- Coalesces identical concurrent GET/HEAD requests into a single network call:
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:
939
+
940
+ ```ts
941
+ import { CREDENTIAL_HEADERS } from "kinetex/cache";
942
+ // ["authorization", "proxy-authorization", "cookie", "x-api-key", "apikey", "api-key",
943
+ // "x-auth-token", "x-access-token", "x-refresh-token", "x-session-id", "x-session-token",
944
+ // "x-secret", "x-secret-key", "x-private-key", "x-csrf-token"]
945
+ ```
946
+
947
+ > If you authenticate with a header outside that list, supply a `keyFn` that includes it (see below) — otherwise two identities can share one in-flight slot.
772
948
 
773
949
  ```ts
774
950
  client.enableDedup({ windowMs: 50 }); // Also dedupe for 50ms after completion
@@ -805,7 +981,8 @@ const result = await dedup.execute("GET", "unique-key", () => fetchData());
805
981
  const dMap = createDedupMap<KinetexResponse>({ windowMs: 50 });
806
982
 
807
983
  // Metrics
808
- console.log(dedup.hits, dedup.misses, dedup.inFlightCount, dedup.stats);
984
+ console.log(dedup.hits, dedup.misses, dedup.inFlightCount, dedup.keys);
985
+ console.log(dedup.getStats()); // a method — there is no `stats` property
809
986
  ```
810
987
 
811
988
  ---
@@ -818,10 +995,11 @@ Per-origin (or per-key) three-state machine to prevent cascading failures:
818
995
  client.enableCircuitBreaker({
819
996
  failureThreshold: 5, // Failures before OPEN (default: 5)
820
997
  resetTimeoutMs: 30_000, // Time before HALF_OPEN probe (default: 30_000)
821
- successThreshold: 3, // Consecutive successes to CLOSE (default: 3)
822
- windowSize: 10, // Sliding window size (0 = consecutive count) (default: 10)
823
- halfOpenMaxRequests: 1, // Concurrent probes in HALF_OPEN (default: 1)
824
- failureFilter: {
998
+ successThreshold: 2, // Consecutive successes to CLOSE (default: 2)
999
+ windowSize: 10, // Sliding window size (default: 10)
1000
+ halfOpenConcurrency: 1, // Concurrent probes in HALF_OPEN (default: 1)
1001
+ // The filter key is `failures`, not `failureFilter`
1002
+ failures: {
825
1003
  // Which failures count toward threshold
826
1004
  networkErrors: true, // ENETWORK errors (default: true)
827
1005
  timeouts: true, // ETIMEOUT errors (default: true)
@@ -829,9 +1007,10 @@ client.enableCircuitBreaker({
829
1007
  statusCodes: [503], // Specific status codes
830
1008
  },
831
1009
  onOpen: (state) => console.log("Circuit OPEN", state),
832
- onClose: (state) => console.log("Circuit recovered"),
833
- onHalfOpen: (state) => console.log("Probing..."),
834
- onRejected: (req) => console.log("Rejected by CB", req.url),
1010
+ onClose: (state) => console.log("Circuit recovered", state),
1011
+ onHalfOpen: (state) => console.log("Probing...", state),
1012
+ // Every callback receives a CircuitBreakerState, not the request
1013
+ onRejected: (state) => console.log("Rejected by CB", state.state, state.failureCount),
835
1014
  });
836
1015
 
837
1016
  // Manual control
@@ -861,17 +1040,29 @@ import type {
861
1040
  FailureFilter,
862
1041
  } from "kinetex/circuit-breaker";
863
1042
 
864
- const cb = createCircuitBreaker({
1043
+ // A standalone breaker takes a key as its FIRST argument, then the config.
1044
+ const cb = createCircuitBreaker("api.example.com", {
865
1045
  failureThreshold: 5,
866
1046
  resetTimeoutMs: 30_000,
867
1047
  });
868
1048
 
1049
+ await cb.execute(async () => {
1050
+ /* throws CircuitOpenError while OPEN */
1051
+ });
1052
+ cb.state; // "CLOSED" | "OPEN" | "HALF_OPEN"
1053
+ cb.snapshot; // CircuitBreakerState
1054
+ cb.trip();
1055
+ cb.reset();
1056
+
869
1057
  const registry = new CircuitBreakerRegistry(config);
870
1058
  // Thin wrapper that manages a Map<string, CircuitBreaker>
1059
+ await registry.execute("https://api.example.com", () => doRequest());
871
1060
  registry.get("https://api.example.com"); // → CircuitBreaker
872
1061
  registry.snapshots(); // → Record<string, CircuitBreakerState>
873
1062
  registry.trip("origin");
874
1063
  registry.reset("origin");
1064
+ registry.delete("origin");
1065
+ registry.size;
875
1066
  registry.clear();
876
1067
  ```
877
1068
 
@@ -879,36 +1070,69 @@ registry.clear();
879
1070
 
880
1071
  ## Caching
881
1072
 
882
- RFC 7234 compliant HTTP caching with multiple storage backends:
1073
+ RFC 7234 compliant HTTP caching with pluggable storage backends. Freshness comes from the response's `Cache-Control` headers, and `Vary` is always honoured — there is no toggle for either.
883
1074
 
884
1075
  ```ts
1076
+ import { kinetex, MemoryStorageAdapter } from "kinetex";
1077
+
885
1078
  const client = kinetex({
886
1079
  cache: {
887
- storage: "memory", // "memory" | "localStorage" | "kv"
888
- ttlMs: 60_000, // Default TTL (default: 60_000)
889
- maxEntries: 1000, // Max cached entries (default: 1000)
890
- maxBodySize: 1_000_000, // Max body size to cache
891
- swr: true, // Stale-while-revalidate (default: false)
892
- swrTtlMs: 30_000, // SWR TTL (default: ttlMs * 0.1)
893
- vary: true, // Respect Vary header (default: true)
894
- namespace: "myapp", // Cache namespace prefix
1080
+ // maxEntries: 500, // LRU eviction
1081
+ // maxSizeBytes: 50 * 1024 * 1024, // total bytes held
1082
+ // maxBodySizeBytes: 5 * 1024 * 1024, // skip caching larger bodies
1083
+ // defaultTtlMs: 60_000, // when the response has no Cache-Control (max 1 year)
1084
+ // maxAbsoluteAgeMs: 7 * 24 * 60 * 60 * 1000, // hard cap regardless of Cache-Control
1085
+ // honorCacheControl: true, // respect no-store / no-cache
1086
+ // cacheMethods: ["GET", "HEAD"],
1087
+ // cacheStatuses: [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
1088
+ storage: new MemoryStorageAdapter(), // omit for plain in-memory
1089
+ // cacheKey: (req) => `${req.url}`, // may be async
1090
+ // namespace: "myapp", // key prefix
895
1091
  },
896
1092
  });
897
1093
 
898
- // Per-request cache control
899
- client.get("/users", { cache: { ttlMs: 5000 } });
900
- client.get("/users", { cache: { forceRefresh: true } }); // Bypass + re-cache
901
- client.get("/users", { cache: false }); // Bypass entirely
1094
+ // Per-request cache control — this is CacheRequestConfig, a different shape
1095
+ // from the client-level CacheConfig above (note `enabled`, not `storage`).
1096
+ client.get("/users", { cache: { ttlMs: 5000 } }); // override TTL for this call
1097
+ client.get("/users", { cache: { tags: ["users"] } }); // tag for later invalidation
1098
+ client.get("/users", { cache: { forceRefresh: true } }); // bypass + re-cache
1099
+ client.get("/users", { cache: { enabled: false } }); // bypass entirely
1100
+ client.get("/users", { cache: false }); // shorthand for { enabled: false }
1101
+
1102
+ // Fluent equivalents
902
1103
  client.GET("/users").cache({ ttlMs: 5000 }).json();
903
- client.GET("/users").noCache().json();
1104
+ client.GET("/users").noCache().json(); // forceRefresh: true
1105
+ ```
904
1106
 
905
- // SWR error callback
906
- kinetex({
907
- cache: { swr: true },
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
+
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:
1113
+
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.
1115
+ - `onSWRError` is a **top-level client option**, not a member of `cache`:
1116
+
1117
+ ```ts
1118
+ const client = kinetex({
908
1119
  onSWRError: (err, req) => console.error("SWR failed", req.url, err),
909
1120
  });
910
1121
  ```
911
1122
 
1123
+ ### Credential isolation
1124
+
1125
+ Cache keys include a SHA-256 fingerprint of every header in `CREDENTIAL_HEADERS`, so a response fetched with one user's `Authorization`/`Cookie`/API key is never served to another. `getAuthFingerprint(headers)` exposes the same function for custom key functions:
1126
+
1127
+ ```ts
1128
+ import { getAuthFingerprint, CREDENTIAL_HEADERS } from "kinetex/cache";
1129
+
1130
+ await getAuthFingerprint({ authorization: "Bearer …" }); // → "auth:9f86d0…"
1131
+ await getAuthFingerprint({ accept: "*/*" }); // → "" (anonymous → shared entry)
1132
+ ```
1133
+
1134
+ Default `cacheStatuses` are `200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501` — error responses are not cached, and `304` is handled by revalidation rather than stored.
1135
+
912
1136
  ### Standalone Cache
913
1137
 
914
1138
  ```ts
@@ -924,58 +1148,80 @@ import {
924
1148
  CloudflareKVAdapter,
925
1149
  TwoTierStorageAdapter,
926
1150
  getAuthFingerprint,
1151
+ CREDENTIAL_HEADERS,
927
1152
  } from "kinetex/cache";
928
1153
  import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
1154
+ ```
1155
+
1156
+ Each factory takes its storage first, then an optional `CacheConfig` (with `storage` omitted, since the factory supplies it):
929
1157
 
1158
+ ```ts
930
1159
  // Memory cache
931
- const cache = createMemoryCache({ ttlMs: 60_000, maxEntries: 1000 });
1160
+ const cache = createMemoryCache({ defaultTtlMs: 60_000, maxEntries: 1000 });
932
1161
 
933
- // Browser localStorage cache
934
- const cache = createLocalStorageCache({ prefix: "myapp:" });
1162
+ // Browser localStorage cache — note the prefix is the first positional arg
1163
+ const cache = createLocalStorageCache("myapp:", { defaultTtlMs: 60_000 });
935
1164
 
936
1165
  // Browser sessionStorage cache
937
- const cache = createSessionStorageCache({ prefix: "myapp:" });
1166
+ const cache = createSessionStorageCache("myapp:");
938
1167
 
939
- // Cloudflare KV cache
940
- const cache = createKVCache({ kv: myKVNamespace, ttlMs: 60_000 });
1168
+ // Cloudflare KV cache — the namespace is the first positional arg
1169
+ const cache = createKVCache(myKVNamespace, { defaultTtlMs: 60_000 });
941
1170
 
942
- // Two-tier (L1 memory + L2 storage)
943
- const cache = createTwoTierCache({
944
- tier1: createMemoryCache({ ttlMs: 10_000 }),
945
- tier2: createLocalStorageCache({ prefix: "myapp:" }),
1171
+ // Two-tier: L1 is always in-memory, so you pass only the L2 adapter
1172
+ const cache = createTwoTierCache(new WebStorageAdapter(localStorage, "myapp:"), {
1173
+ defaultTtlMs: 60_000,
946
1174
  });
947
1175
 
948
- // Full HTTPCache
1176
+ // Full HTTPCache — you choose the adapter here
949
1177
  const cache = new HTTPCache({
950
1178
  storage: new MemoryStorageAdapter(),
951
- ttlMs: 60_000,
1179
+ defaultTtlMs: 60_000,
952
1180
  maxEntries: 1000,
953
- vary: true,
1181
+ maxBodySizeBytes: 1_000_000,
1182
+ maxAbsoluteAgeMs: 3_600_000,
954
1183
  namespace: "myapp",
955
1184
  });
956
1185
 
1186
+ const req = { url: "https://api.example.com/users", method: "GET", headers: {} };
1187
+
957
1188
  await cache.set(
958
- { url: "https://api.example.com/users", method: "GET", headers: {} },
1189
+ req,
959
1190
  { status: 200, statusText: "OK", headers: {}, body: "..." },
960
1191
  { tags: ["users"] },
961
1192
  );
962
1193
 
963
- const entry = await cache.get({ url: "https://api.example.com/users", method: "GET", headers: {} });
964
- // entry.response, entry.stale, entry.ttlMs, entry.tags, entry.cachedAt, entry.hitCount
1194
+ const entry = await cache.get(req); // CacheEntry | null
1195
+ // entry.response, entry.createdAt, entry.expiresAt, entry.staleUntil, entry.staleOnError
1196
+ // entry.etag, entry.lastModified, entry.varyKey, entry.tags, entry.size
965
1197
 
966
- // Tag-based invalidation
1198
+ // Tag-based and URL-prefix invalidation
967
1199
  await cache.invalidateByTag("users");
1200
+ await cache.invalidateByURL("https://api.example.com/users");
1201
+
1202
+ // Conditional revalidation headers for a stored entry
1203
+ cache.buildConditionalHeaders(entry); // → { "if-none-match": "…" } when an etag exists
1204
+
1205
+ // Cache statistics — a method, not a property
1206
+ const stats: CacheStats = cache.getStats();
1207
+ // { hits, misses, staleHits, errors, evictions, totalEntries, totalSizeBytes, hitRate }
968
1208
 
969
- // Cache statistics
970
- const stats: CacheStats = cache.stats; // { size, hits, misses, evictions, hitRate }
971
- cache.clear();
1209
+ await cache.clear();
972
1210
  ```
973
1211
 
974
1212
  ---
975
1213
 
976
1214
  ## Cookie Jar
977
1215
 
978
- 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".
979
1225
 
980
1226
  ```ts
981
1227
  // Auto-managed through the client
@@ -1031,16 +1277,16 @@ jar.clearForUrl("https://example.com/api");
1031
1277
  interface Cookie {
1032
1278
  name: string;
1033
1279
  value: string;
1034
- domain: string;
1280
+ domain: string; // canonicalized, lowercased, no leading dot
1035
1281
  path: string;
1036
- expires: number | null; // epoch ms
1037
- maxAge: number | null;
1282
+ expires: number; // epoch ms; Infinity = session cookie (no Expires/Max-Age)
1283
+ maxAge: number | null; // raw Max-Age in seconds as parsed, null if absent
1038
1284
  secure: boolean;
1039
1285
  httpOnly: boolean;
1040
- sameSite: "strict" | "lax" | "none";
1041
- createdAt: number;
1042
- lastAccessed: number;
1043
- hostOnly: boolean;
1286
+ sameSite: SameSite; // "Strict" | "Lax" | "None" | "Unset"
1287
+ createdAt: number; // epoch ms
1288
+ lastAccessed: number; // epoch ms
1289
+ hostOnly: boolean; // true = set without a Domain attribute → exact host match only
1044
1290
  }
1045
1291
  ```
1046
1292
 
@@ -1094,14 +1340,26 @@ extractSetCookieHeaders(headers); // → string[]
1094
1340
  splitSetCookieHeaders("a=1, b=2"); // → ["a=1", "b=2"]
1095
1341
  ```
1096
1342
 
1097
- Internal storage model with LRU eviction (per-domain cap 50, global cap 3000). The `CookieStore` class handles the underlying storage:
1343
+ The jar stores cookies in a three-level map (domain → path → name) with LRU eviction — a per-domain cap of 50 and a global cap of 3000 by default. There is no separate `CookieStore` class and no `kinetex/cookie-store` entry point; `CookieJar` **is** the store. Tune the caps and domain matching through its constructor:
1098
1344
 
1099
1345
  ```ts
1100
- import { CookieStore } from "kinetex/cookie-store";
1101
- const store = new CookieStore({ domainLimit: 50, globalLimit: 3000, signal: controller.signal });
1102
- store.add(cookie);
1103
- store.get("https://example.com", { http: true });
1104
- // Also: clear(), clearExpired(), clearSession(), clearForDomain(), clearForUrl(), toJSON()
1346
+ import { CookieJar, createCookieJar, loadCookieJar } from "kinetex/cookiejar";
1347
+
1348
+ const jar = new CookieJar({
1349
+ maxTotal: 3000, // default 3000
1350
+ maxPerDomain: 50, // default 50
1351
+ domainMatcher: (requestHost, cookieDomain) => requestHost.endsWith(cookieDomain),
1352
+ });
1353
+
1354
+ // Cookies are read and written through the jar, not a raw store:
1355
+ jar.setCookie("session=abc123; Path=/; Secure", { url: "https://example.com/" });
1356
+ jar.getCookies({ url: "https://example.com/page", http: true }); // → Cookie[]
1357
+ jar.getCookieHeader({ url: "https://example.com/page" }); // → "session=abc123"
1358
+ jar.getAll();
1359
+ jar.getForDomain("example.com");
1360
+
1361
+ // Also: clear(), clearExpired(), clearSession(), clearForDomain(),
1362
+ // clearForUrl(), removeCookie(domain, path, name), toJSON(), toString()
1105
1363
  ```
1106
1364
 
1107
1365
  ---
@@ -1132,115 +1390,160 @@ import {
1132
1390
  } from "kinetex/pagination";
1133
1391
  import type { Page, PaginationState } from "kinetex/pagination";
1134
1392
 
1135
- // Offset strategy: ?offset=0&limit=100
1136
- const pages = paginate(client, {
1137
- url: "/items",
1138
- strategy: "offset",
1139
- perPage: 100,
1140
- maxPages: 10, // Stop after N pages
1141
- initialOffset: 0,
1142
- });
1393
+ // Core: paginate() takes a single PaginationConfig and an optional strategy name.
1394
+ // It does NOT take a client — you supply a `fetch` that returns the raw response.
1395
+ import {
1396
+ paginate,
1397
+ collectAll,
1398
+ collectPages,
1399
+ takeItems,
1400
+ paginateItems,
1401
+ prefetchPaginate,
1402
+ } from "kinetex/pagination";
1403
+
1404
+ const pages = paginate<Item>(
1405
+ {
1406
+ fetch: (state) => client.get<ItemsResponse>(`/items?offset=${state.offset}&limit=100`),
1407
+ getItems: (res) => res.items,
1408
+ hasNext: (res) => res.items.length === 100,
1409
+ getNext: (res) => ({ offset: res.items.at(-1)!.id }),
1410
+ getTotal: (res) => res.total,
1411
+ perPage: 100,
1412
+ startOffset: 0,
1413
+ maxPages: 10, // 0 = unlimited (default)
1414
+ delayMs: 0,
1415
+ signal: controller.signal,
1416
+ transform: (item) => item,
1417
+ filter: (item) => !item.deleted,
1418
+ onPage: (page) => console.log("fetched page", page.page),
1419
+ },
1420
+ "offset",
1421
+ );
1143
1422
 
1144
- // Page strategy: ?page=1&per_page=100
1145
- const pages = paginate(client, {
1146
- url: "/items",
1147
- strategy: "page",
1148
- perPage: 50,
1149
- maxPages: 5,
1150
- pageParam: "page", // Query param name (default: "page")
1151
- perPageParam: "per_page", // Query param name (default: "per_page")
1152
- });
1153
-
1154
- // Cursor strategy: ?cursor=abc123
1155
- const pages = paginate(client, {
1156
- url: "/items",
1157
- strategy: "cursor",
1158
- perPage: 100,
1159
- getCursor: (res) => res.data.nextCursor,
1160
- setCursor: (url, cursor) => ({ ...url, query: { ...url.query, after: cursor } }),
1161
- getItems: (res) => res.data.items,
1162
- });
1163
-
1164
- // Keyset strategy: ?after=2024-01-01
1165
- const pages = paginate(client, {
1166
- url: "/items",
1167
- strategy: "keyset",
1168
- perPage: 100,
1169
- initialKey: new Date().toISOString(),
1170
- getKey: (res) => res.data.lastTimestamp,
1171
- setKey: (url, key) => ({ ...url, query: { ...url.query, after: key } }),
1172
- getItems: (res) => res.data.items,
1173
- });
1174
-
1175
- // Relay strategy (GraphQL-style edges/node/pageInfo)
1176
- const pages = paginate(client, {
1177
- url: "/items",
1178
- strategy: "relay",
1179
- perPage: 100,
1180
- getItems: (res) => res.data.edges.map((e: any) => e.node),
1181
- getPageInfo: (res) => res.data.pageInfo,
1182
- });
1183
-
1184
- // Link header strategy (GitHub-style)
1185
- const pages = paginate(client, {
1186
- url: "/items",
1187
- strategy: "link-header",
1188
- getItems: (res) => res.data,
1189
- parseNext: (res) => parseLinkHeaderNext(res.headers["link"]),
1190
- });
1191
-
1192
- // Token strategy (Google API-style)
1193
- const pages = paginate(client, {
1194
- url: "/items",
1195
- strategy: "token",
1196
- perPage: 100,
1197
- getToken: (res) => res.data.nextPageToken,
1198
- setToken: (url, token) => ({ ...url, query: { ...url.query, pageToken: token } }),
1199
- getItems: (res) => res.data.items,
1200
- });
1201
-
1202
- // Consume pages
1203
1423
  for await (const page of pages) {
1204
1424
  console.log(page.items, page.total, page.page, page.hasNext, page.nextCursor);
1205
1425
  }
1206
1426
 
1207
- // Collect all items across all pages
1208
- const allItems = await collectAll(client, { url: "/items", strategy: "cursor" });
1427
+ // ── Per-strategy factories ────────────────────────────────────────────────
1428
+ // These wrap globalThis.fetch (or a `fetch` you pass) and build the config
1429
+ // for you. Each returns an AsyncGenerator<Page<T>> directly.
1209
1430
 
1210
- // Collect all page objects
1211
- const allPages = await collectPages(client, { url: "/items", strategy: "page", maxPages: 5 });
1431
+ // Offset/limit — ?offset=0&limit=100
1432
+ createOffsetPaginator<Item>({
1433
+ url: "https://api.example.com/items",
1434
+ limit: 100,
1435
+ getItems: (data) => data.items,
1436
+ getTotal: (data) => data.total,
1437
+ maxPages: 10,
1438
+ // paramNames: { offset: "o", limit: "l" },
1439
+ // fetch: globalThis.fetch, headers: {}, signal,
1440
+ });
1212
1441
 
1213
- // Take N items across pages
1214
- const first50 = await takeItems(client, { url: "/items", strategy: "offset", perPage: 10 }, 50);
1442
+ // Page/per-page — ?page=1&per_page=100
1443
+ createPagePaginator<Item>({
1444
+ url: "https://api.example.com/items",
1445
+ perPage: 50,
1446
+ startPage: 1,
1447
+ getItems: (data) => data.items,
1448
+ // paramNames: { page: "p", perPage: "pp" },
1449
+ });
1450
+
1451
+ // Cursor — ?cursor=abc123
1452
+ createCursorPaginator<Item>({
1453
+ url: "https://api.example.com/items",
1454
+ getItems: (data) => data.items,
1455
+ getNextCursor: (data) => data.nextCursor, // string | null
1456
+ startCursor: null,
1457
+ paramName: "cursor", // default "cursor"
1458
+ });
1459
+
1460
+ // Keyset — ?after_id=123
1461
+ createKeysetPaginator<Item>({
1462
+ url: "https://api.example.com/items",
1463
+ keyParam: "after_id",
1464
+ getItems: (data) => data.items,
1465
+ getLastKey: (items) => String(items.at(-1)!.id),
1466
+ hasMore: (items) => items.length > 0,
1467
+ startKey: null,
1468
+ pageSize: 100,
1469
+ pageSizeParam: "limit",
1470
+ });
1471
+
1472
+ // Relay (GraphQL-style connections)
1473
+ // RelayPaginationOptions has no getItems/getNext: your `fetch` must resolve to a
1474
+ // RelayConnection<T> and the paginator unwraps edges/node and pageInfo itself.
1475
+ createRelayPaginator<Item>({
1476
+ fetch: async ({ first, after }) =>
1477
+ graphql<RelayConnection<Item>>(
1478
+ `
1479
+ query ($first: Int, $after: String) {
1480
+ items(first: $first, after: $after) {
1481
+ edges {
1482
+ node
1483
+ }
1484
+ pageInfo {
1485
+ hasNextPage
1486
+ endCursor
1487
+ }
1488
+ }
1489
+ }
1490
+ `,
1491
+ { first, after },
1492
+ ),
1493
+ first: 100, // page size
1494
+ startCursor: null,
1495
+ maxPages: 10,
1496
+ });
1215
1497
 
1216
- // Paginate items directly (yield items, not pages)
1217
- const items = paginateItems(client, { url: "/items", strategy: "page" });
1218
- for await (const item of items) {
1219
- console.log(item);
1220
- }
1498
+ // Link header (GitHub-style) — follows the RFC 8288 `Link` header
1499
+ createLinkHeaderPaginator<Item>({
1500
+ url: "https://api.github.com/repos/kinetexjs/kinetex/issues",
1501
+ getItems: (data) => data,
1502
+ headers: { Accept: "application/vnd.github+json" },
1503
+ });
1221
1504
 
1222
- // Parallel prefetch
1223
- const pages = paginate(client, { url: "/items", strategy: "page", prefetch: 3 });
1505
+ // Page token (Google API-style)
1506
+ createTokenPaginator<Item>({
1507
+ url: "https://www.googleapis.com/books/v1/volumes",
1508
+ getItems: (data) => data.items,
1509
+ getNextToken: (data) => data.nextPageToken,
1510
+ tokenParam: "pageToken",
1511
+ pageSize: 10,
1512
+ });
1224
1513
 
1225
- // Merge two paginators
1514
+ // ── Collection helpers — all take (config, strategy?) ────────────────────
1515
+ const allItems = await collectAll<Item>(config);
1516
+ const allPages = await collectPages<Item>(config);
1517
+ const first50 = await takeItems<Item>(50, config); // (n, config) — n comes FIRST
1518
+ const items = paginateItems<Item>(config); // yields items, not pages
1519
+ const prefetched = prefetchPaginate<Item>(config, "page", 3); // 3 pages in flight
1520
+
1521
+ // Merge several paginators into one stream
1226
1522
  const merged = mergePaginators(paginator1, paginator2);
1227
1523
 
1228
- // State serialization (resume capability)
1229
- const state: PaginationState = serializePaginationState(paginator);
1230
- const paginator2 = deserializePaginationState(client, state);
1524
+ // ── State serialization (base64 JSON of a PaginationState) ──────────────
1525
+ const serialized: string = serializePaginationState(state);
1526
+ const restored: PaginationState = deserializePaginationState(serialized);
1231
1527
 
1232
- // Convert to async iterator
1528
+ // Convert any AsyncIterable to an AsyncIterableIterator
1233
1529
  const iterator = toPaginationIterator(paginator);
1234
1530
  ```
1235
1531
 
1236
1532
  ### Client-Level Pagination
1237
1533
 
1238
1534
  ```ts
1535
+ // Routes through the full kinetex pipeline. Takes PagePaginationOptions
1536
+ // (minus url/fetch) — there is no `strategy` key; the strategy is implied.
1239
1537
  const pages = await client.paginate("/items", {
1240
- strategy: "page",
1241
1538
  perPage: 50,
1539
+ getItems: (data) => data.items,
1540
+ getTotal: (data) => data.total,
1242
1541
  maxPages: 10,
1243
1542
  });
1543
+
1544
+ for await (const page of pages) {
1545
+ console.log(page.items);
1546
+ }
1244
1547
  ```
1245
1548
 
1246
1549
  ---
@@ -1264,21 +1567,27 @@ import {
1264
1567
  } from "kinetex/sse";
1265
1568
  import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
1266
1569
 
1267
- // SSEClient
1570
+ // SSEClient — note the real option names: reconnect (not autoReconnect),
1571
+ // reconnectDelayMs / maxReconnectDelayMs (not baseDelay / maxDelay).
1572
+ // There is no `onEvent` option; iterate the client instead.
1268
1573
  const sse = new SSEClient({
1269
1574
  url: "https://api.example.com/events",
1270
1575
  method: "POST",
1271
1576
  headers: { Authorization: "Bearer token" },
1272
1577
  body: JSON.stringify({ query: "..." }),
1273
1578
  fetch: globalThis.fetch,
1274
- onEvent: (event) => {
1275
- console.log(event.id, event.event, event.data);
1276
- },
1277
- autoReconnect: true,
1278
- maxReconnects: 10,
1279
- baseDelay: 1000,
1280
- maxDelay: 30000,
1281
1579
  signal: controller.signal,
1580
+ lastEventId: "", // resume point
1581
+
1582
+ reconnect: true, // default true
1583
+ reconnectDelayMs: 3000, // default 3000
1584
+ maxReconnectDelayMs: 30_000, // default 30000
1585
+ reconnectJitter: 0.3, // default 0.3
1586
+ maxReconnects: 0, // 0 = unlimited (default)
1587
+ onReconnect: (attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`),
1588
+ onParseError: (err, raw) => console.warn("bad SSE frame", raw, err),
1589
+ heartbeatTimeoutMs: 0, // 0 = disabled
1590
+ validateResponse: (res) => res.ok || "stream rejected", // false | string to stop
1282
1591
  });
1283
1592
 
1284
1593
  // Async iteration
@@ -1313,8 +1622,9 @@ const response = createSSEResponse(); // → Response with text/event-stream
1313
1622
  ### Client-Level SSE
1314
1623
 
1315
1624
  ```ts
1625
+ // Takes Partial<SSEClientConfig> and routes through the full kinetex pipeline
1316
1626
  const sseClient = await client.sse("/events", {
1317
- autoReconnect: true,
1627
+ reconnect: true, // not `autoReconnect`
1318
1628
  maxReconnects: 5,
1319
1629
  });
1320
1630
  ```
@@ -1360,24 +1670,35 @@ import type {
1360
1670
 
1361
1671
  const ws = new WSClient({
1362
1672
  url: "wss://api.example.com/live",
1673
+ protocols: "graphql-ws", // or string[]
1363
1674
  headers: { Authorization: "Bearer token" },
1364
- reconnect: true,
1675
+ // There is no `reconnect` boolean or `baseDelay`/`maxDelay` — the real
1676
+ // names are reconnectBaseMs / reconnectMaxMs. maxReconnects: 0 = unlimited.
1365
1677
  maxReconnects: 10,
1366
- baseDelay: 1000,
1367
- maxDelay: 30000,
1678
+ reconnectBaseMs: 1000,
1679
+ reconnectMaxMs: 30_000,
1680
+ reconnectJitter: 0.3,
1368
1681
  connectTimeoutMs: 5000,
1369
- pingIntervalMs: 30000,
1682
+ pingIntervalMs: 30_000,
1683
+ pingPayload: "ping",
1684
+ pongMatcher: "pong", // string | RegExp
1370
1685
  pongTimeoutMs: 5000,
1371
1686
  highWaterMark: 65536,
1372
1687
  lowWaterMark: 16384,
1373
- maxSendRate: 0, // 0 = unlimited
1688
+ maxSendRate: 0, // 0 = unlimited
1374
1689
  keepRooms: true,
1690
+ bufferMessages: true,
1691
+ maxBufferSize: 1000,
1692
+ rooms: ["prices"],
1375
1693
  signal: controller.signal,
1376
1694
 
1695
+ onOpen: (reconnectCount) => console.log(`open (${reconnectCount} prior reconnects)`),
1377
1696
  onMessage: (msg) => console.log(msg.data, msg.json),
1378
1697
  onError: (err) => console.error(err),
1379
1698
  onClose: (code, reason, willReconnect) => {},
1380
- onReconnect: (attempt) => console.log(`Reconnecting (${attempt})`),
1699
+ onReconnect: (attempt, delayMs) => console.log(`Reconnecting (${attempt}) in ${delayMs}ms`),
1700
+ onGiveUp: (totalAttempts) => console.warn("gave up", totalAttempts),
1701
+ onBackpressure: (isBackpressured, info) => console.log("backpressure", info),
1381
1702
  });
1382
1703
 
1383
1704
  await ws.connect();
@@ -1389,11 +1710,15 @@ ws.sendBinary(new Uint8Array([1, 2, 3]));
1389
1710
 
1390
1711
  // Async iteration
1391
1712
  for await (const msg of ws) {
1392
- console.log(msg.data, msg.json?.type);
1713
+ // `json` is `unknown` — narrow it before reading fields.
1714
+ const payload = msg.json as { type?: string } | undefined;
1715
+ console.log(msg.data, payload?.type);
1393
1716
  }
1394
1717
 
1395
- // Request/response correlation
1396
- const reply = await ws.request({ type: "ping" }, (msg) => msg.json?.type === "pong");
1718
+ // Message subscription — returns an eject function (there is no
1719
+ // built-in request/response correlation helper)
1720
+ const off = ws.onMessage((msg) => console.log(msg.data, msg.json));
1721
+ off(); // unsubscribe
1397
1722
 
1398
1723
  // Metrics
1399
1724
  interface WSMetrics {
@@ -1407,7 +1732,9 @@ interface WSMetrics {
1407
1732
  }
1408
1733
 
1409
1734
  // Utility
1410
- const ws = await connectWS("wss://api.example.com/ws", { onMessage: ... });
1735
+ const ws = await connectWS("wss://api.example.com/ws", {
1736
+ onMessage: (msg) => console.log(msg.data),
1737
+ });
1411
1738
 
1412
1739
  // Connection state & health
1413
1740
  ws.state; // "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED" | "RECONNECTING"
@@ -1474,25 +1801,29 @@ const client = new GraphQLClient({
1474
1801
  url: "https://api.example.com/graphql",
1475
1802
  headers: { Authorization: "Bearer token" },
1476
1803
  fetch: globalThis.fetch,
1477
- apq: true, // Automatic Persisted Queries
1478
- fetchPersistedQuery: false, // Fetch persisted queries from storage
1479
- apqHash: "sha256", // Hash algorithm
1480
- retry: { maxRetries: 2 },
1804
+ useGETForQueries: false,
1805
+ enableAPQ: true, // Automatic Persisted Queries (not `apq`)
1806
+ timeoutMs: 10_000,
1807
+ retries: 2, // plain number — there is no `retry: { maxRetries }`
1808
+ retryDelayMs: 300,
1481
1809
  signal: controller.signal,
1482
1810
  links: [
1483
1811
  // Middleware chain
1484
1812
  retryLink({ maxRetries: 3 }),
1485
- authLink({ getToken: () => "..." }),
1813
+ authLink(() => "..."), // authLink(getToken, scheme = "Bearer")
1486
1814
  loggingLink(),
1487
1815
  errorLink(),
1488
1816
  ],
1489
1817
  onRequest: (req) => console.log(req),
1490
- onResponse: (res) => console.log(res),
1818
+ onResponse: (res, req) => console.log(res, req),
1819
+ onError: (err, req) => console.error(err, req),
1491
1820
  });
1492
1821
 
1493
- // Query
1494
- const { data, errors } = await client.query<{ user: { name: string } }>(
1495
- gql`
1822
+ // Query — `query()` resolves to the response's `data` field directly, not to
1823
+ // a `{ data, errors }` envelope. A non-empty `errors` array throws
1824
+ // `GraphQLClientError`, which carries `.graphqlErrors` and `.response`.
1825
+ const data = await client.query<{ user: { name: string } }>(
1826
+ `
1496
1827
  query GetUser($id: ID!) {
1497
1828
  user(id: $id) {
1498
1829
  name
@@ -1502,9 +1833,9 @@ const { data, errors } = await client.query<{ user: { name: string } }>(
1502
1833
  { id: "1" },
1503
1834
  );
1504
1835
 
1505
- // Mutation
1836
+ // Mutation — also resolves to `data`
1506
1837
  const result = await client.mutate<{ updateUser: { success: boolean } }>(
1507
- gql`
1838
+ `
1508
1839
  mutation UpdateUser($id: ID!, $name: String!) {
1509
1840
  updateUser(id: $id, name: $name) {
1510
1841
  success
@@ -1514,9 +1845,10 @@ const result = await client.mutate<{ updateUser: { success: boolean } }>(
1514
1845
  { id: "1", name: "Alice" },
1515
1846
  );
1516
1847
 
1517
- // Subscription (SSE or WebSocket transport)
1518
- const sub = await client.subscribe(
1519
- gql`
1848
+ // Subscription — an async generator. The third argument is
1849
+ // { operationName, signal, url }; there is no `transport` option.
1850
+ const sub = client.subscribe(
1851
+ `
1520
1852
  subscription OnPrice {
1521
1853
  priceUpdate {
1522
1854
  symbol
@@ -1525,25 +1857,29 @@ const sub = await client.subscribe(
1525
1857
  }
1526
1858
  `,
1527
1859
  {},
1528
- { transport: "sse" },
1860
+ { operationName: "OnPrice", signal: controller.signal },
1529
1861
  );
1530
1862
  for await (const event of sub) {
1531
1863
  console.log(event.data);
1532
1864
  }
1533
1865
 
1534
- // Utility
1535
- detectOperationType(gql`query { ... }`); // → "query"
1536
- extractOperationName(gql`query GetUser { ... }`); // → "GetUser"
1866
+ // Utility — these take a raw query string, not a template tag
1867
+ detectOperationType("query { user { id } }"); // → "query"
1868
+ extractOperationName("query GetUser { user { id } }"); // → "GetUser"
1537
1869
  ```
1538
1870
 
1871
+ > **`gql` is a one-shot function, not a template tag.** `gql(url, query, variables?, headers?)` creates a throwaway `GraphQLClient`, runs the query, and returns its `data`. For anything repeated, construct a `GraphQLClient` (or `createGraphQLClient(config)`) instead. There is no tagged-template form of `gql`.
1872
+
1539
1873
  ### Client-Level GraphQL
1540
1874
 
1875
+ `client.graphql()` returns a `GraphQLClient` whose transport is routed through the kinetex pipeline (auth, interceptors, rate limiting, circuit breaker, OTel), not a `gql` function.
1876
+
1541
1877
  ```ts
1542
- const gql = await client.graphql("/graphql", {
1543
- apq: true,
1544
- links: [authLink({ getToken: () => "..." })],
1878
+ const gqlClient = await client.graphql("/graphql", {
1879
+ enableAPQ: true,
1880
+ links: [authLink(() => "...")],
1545
1881
  });
1546
- const { data } = await gql.query(query, variables);
1882
+ const data = await gqlClient.query(query, variables);
1547
1883
  ```
1548
1884
 
1549
1885
  ---
@@ -1590,35 +1926,59 @@ const tracker = new ProgressTracker(10_000_000, {
1590
1926
  },
1591
1927
  });
1592
1928
 
1593
- tracker.update(500_000); // 500KB transferred
1594
- tracker.complete(); // Mark done
1595
- tracker.reset(20_000_000); // Reset with new total
1929
+ tracker.update(500_000); // 500KB transferred → ProgressSnapshot
1930
+ tracker.complete(); // Mark done → ProgressSnapshot
1931
+ tracker.snapshot(); // Current ProgressSnapshot
1932
+ // There is no reset(); construct a new ProgressTracker(total, options) instead.
1596
1933
 
1597
- // Wrap a ReadableStream with progress tracking
1598
- const { stream } = withUploadProgress(readableStream, totalBytes, {
1599
- onProgress: (snap) => {},
1600
- });
1934
+ // Wrap an upload body with progress tracking → { stream, tracker }
1935
+ const { stream: uploadStream, tracker: upTracker } = withUploadProgress(
1936
+ readableStream,
1937
+ totalBytes,
1938
+ { onProgress: (snap) => {} },
1939
+ );
1601
1940
 
1602
- const { stream } = withDownloadProgress(response, {
1941
+ // Download tracking returns { response, tracker } — the Response is returned
1942
+ // with an instrumented body, so there is no `stream` property here.
1943
+ const { response: tracked, tracker: downTracker } = withDownloadProgress(response, {
1603
1944
  onProgress: (snap) => {},
1604
1945
  });
1605
1946
 
1606
- // Blob upload progress
1607
- const { stream } = withBlobUploadProgress(blob, {
1947
+ // Blob upload progress → { stream, tracker }
1948
+ const { stream: blobStream, tracker: blobTracker } = withBlobUploadProgress(blob, {
1608
1949
  onProgress: (snap) => {},
1609
1950
  });
1951
+ ```
1952
+
1953
+ All three upload/download wrappers are **pull-based**: the source is read one chunk per downstream demand, so backpressure reaches the underlying socket/file and a multi-gigabyte transfer is not buffered in memory. Cancelling the returned stream (or aborting the supplied `signal`) propagates to the source, and the tracker is always completed or errored on the way out.
1954
+
1955
+ > Upload progress is not available for a body that cannot be replayed on retry. Retrying a `ReadableStream`/`Blob` body now fails fast with `EVALIDATION` (it was previously consumed by the first attempt, so the retry silently sent an empty body). Buffer the body first, or disable retry for that request.
1610
1956
 
1611
- const stream = streamWithProgress(readableStream, tracker);
1957
+ ```ts
1958
+ // Wrap a ReadableStream with progress tracking. `streamWithProgress(stream,
1959
+ // total, options)` is an async generator yielding { chunk, progress } — it
1960
+ // returns the generator itself, not an object with a `stream` property.
1961
+ // Its options are `Omit<ProgressOptions, "onProgress">`: progress arrives as
1962
+ // the `progress` field of each yielded value instead of via a callback.
1963
+ for await (const { chunk, progress } of streamWithProgress(readableStream, totalBytes, {
1964
+ throttleHz: 10,
1965
+ })) {
1966
+ // … consume `chunk` …
1967
+ }
1612
1968
 
1613
- // Collect full stream into Uint8Array
1969
+ // Collect a full stream into a Uint8Array
1614
1970
  const bytes = await collectStream(readableStream);
1615
1971
 
1616
- // Multi-part progress
1617
- const agg = new MultiPartProgressAggregator();
1618
- const partId = agg.addPart(0, 500); // part index, bytes
1619
- agg.update(partId, 250);
1620
- agg.complete(partId);
1621
- const total = agg.total(); // ProgressSnapshot with overall progress
1972
+ // Multi-part progress — create a per-part tracker, then read the roll-up.
1973
+ // There is no addPart()/update(partId)/total() API.
1974
+ const agg = new MultiPartProgressAggregator(3, (m) => {
1975
+ console.log("overall", m.overall.percent);
1976
+ });
1977
+ const part = agg.createPartTracker(0, 500, { onProgress: (s) => console.log(s.loaded) });
1978
+ part.update(250);
1979
+ part.complete();
1980
+
1981
+ const { parts, overall } = agg.getOverall(); // MultiPartProgress
1622
1982
 
1623
1983
  // Formatters
1624
1984
  formatBytes(1500); // "1.46 KB"
@@ -1676,13 +2036,15 @@ const signer = new SigV4Signer({
1676
2036
  },
1677
2037
  region: "us-east-1",
1678
2038
  service: "s3",
1679
- signingDate: new Date(), // Override signing date
1680
- payloadHash: "UNSIGNED-PAYLOAD", // For streaming
2039
+ unsignedPayload: true, // Send UNSIGNED-PAYLOAD (for streaming) — not `payloadHash`
1681
2040
  unsignedHeaders: ["x-amz-content-sha256"], // Headers to skip
1682
- presignExpires: 3600, // Presigned URL TTL (seconds)
1683
- doubleEncode: true, // RFC 3986 double-encode (default: true)
1684
- normalizePath: true, // Normalize path before signing (default: true)
2041
+ doubleEncodeUri: true, // RFC 3986 double-encode (not `doubleEncode`)
1685
2042
  });
2043
+ // The SigV4Signer constructor is Omit<SigningConfig, "signingDate" |
2044
+ // "clockSkewSecs">: both are managed internally (the latter tracks detected
2045
+ // clock skew). To pin a signing date, pass a full SigningConfig to
2046
+ // signRequest(request, config) / presignRequest(request, config) instead.
2047
+ // There is no `presignExpires` or `normalizePath` on SigningConfig either.
1686
2048
 
1687
2049
  // Sign a request
1688
2050
  const signed = await signer.sign({
@@ -1708,7 +2070,7 @@ const key = await deriveSigningKey(credentials, dateStamp, region, service);
1708
2070
  const provider = staticCredentials({ accessKeyId: "...", secretAccessKey: "..." });
1709
2071
  const provider = envCredentials(); // AWS_ACCESS_KEY_ID, etc.
1710
2072
  const provider = cachingCredentials(innerProvider, 5 * 60_000); // Cache with TTL
1711
- const provider = imdsCredentials({ retries: 3 }); // EC2 IMDS
2073
+ const provider = imdsCredentials({ timeout: 1000 }); // EC2 IMDS — options are { endpoint?, timeout? }
1712
2074
  const provider = chainCredentials(envCredentials, imdsCredentials); // Fallback chain
1713
2075
 
1714
2076
  // Specialized signers
@@ -1726,21 +2088,41 @@ const policy = signS3PostPolicy(credentials, region, new Date(), {
1726
2088
  });
1727
2089
 
1728
2090
  // Chunked upload signing (S3 streaming)
1729
- const { sessionToken, dateTime } = await initChunkedSigning(credentials, region, "s3", new Date());
1730
- const chunkSignature = await signChunk(
1731
- sessionToken,
1732
- dateTime,
1733
- chunkData,
1734
- chunkIndex,
1735
- previousSignature,
2091
+ // `initChunkedSigning(request, config)` takes a SignableRequest and a
2092
+ // SigningConfig. It returns the seed request plus the state object that
2093
+ // `signChunk` / `signFinalChunk` thread through each chunk — there are no
2094
+ // `sessionToken`, `dateTime`, `chunkIndex`, or `previousSignature` arguments.
2095
+ const { signedRequest, state } = await initChunkedSigning(
2096
+ { url: targetURL, method: "PUT", headers: {}, body: null },
2097
+ { credentials, region, service: "s3" },
1736
2098
  );
1737
- const finalSignature = await signFinalChunk(sessionToken, dateTime, chunkIndex, previousSignature);
2099
+
2100
+ // signChunk returns the wire header and the *updated* state; feed newState
2101
+ // into the next call rather than reusing `state`.
2102
+ for await (const chunk of chunks) {
2103
+ const { chunkHeader, newState } = await signChunk(chunk, state);
2104
+ state = newState;
2105
+ write(chunkHeader);
2106
+ write(chunk);
2107
+ }
2108
+ write(await signFinalChunk(state));
1738
2109
 
1739
2110
  // Clock skew detection
1740
2111
  const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
1741
2112
  isClockSkewError(err); // → boolean
1742
2113
  ```
1743
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
+
1744
2126
  ### Client-Level SigV4
1745
2127
 
1746
2128
  ```ts
@@ -1770,24 +2152,31 @@ import {
1770
2152
  } from "kinetex/socks5";
1771
2153
  import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
1772
2154
 
1773
- // Standalone tunnel
1774
- const tunnel = await createSocks5Tunnel({
1775
- proxyHost: "127.0.0.1",
1776
- proxyPort: 1080,
1777
- username: "user", // Optional: RFC 1929 auth
1778
- password: "pass",
1779
- connectTimeout: 10_000, // Connection timeout
1780
- retries: 2, // Connection retries
1781
- });
2155
+ // Standalone tunnel. All three arguments are required:
2156
+ // (proxy config, target, connector). The result is a raw TCP tunnel —
2157
+ // Socks5Tunnel is { conn, boundAddr, boundPort }. It has NO .send();
2158
+ // the tunnel is not an HTTP client. Speak HTTP/TLS over `tunnel.conn`
2159
+ // yourself, or hand it to a transport that can.
2160
+ const tunnel: Socks5Tunnel = await createSocks5Tunnel(
2161
+ {
2162
+ host: "127.0.0.1", // not `proxyHost`
2163
+ port: 1080, // not `proxyPort`
2164
+ username: "user", // Optional: RFC 1929 auth
2165
+ password: "pass",
2166
+ remoteDns: true, // Resolve hostnames at the proxy
2167
+ connectTimeoutMs: 10_000, // not `connectTimeout`
2168
+ handshakeTimeoutMs: 10_000,
2169
+ maxRetries: 2, // not `retries`
2170
+ retryDelayMs: 500,
2171
+ },
2172
+ { host: "api.example.com", port: 443, tls: true, tlsServerName: "api.example.com" },
2173
+ nodeTcpConnector,
2174
+ );
1782
2175
 
1783
- const response = await tunnel.send({
1784
- url: "https://api.example.com/data",
1785
- method: "GET",
1786
- headers: { Accept: "application/json" },
1787
- body: null,
1788
- signal: null,
1789
- meta: {},
1790
- });
2176
+ // tunnel.conn is a TcpConn: { read(buf), write(data), close() }
2177
+ tunnel.boundAddr; // string — address the proxy reported
2178
+ tunnel.boundPort; // number
2179
+ tunnel.conn.close();
1791
2180
 
1792
2181
  // Parse SOCKS5 URL
1793
2182
  const config = parseSocks5Url("socks5://user:pass@127.0.0.1:1080");
@@ -1796,8 +2185,8 @@ const config = parseSocks5Url("socks5://user:pass@127.0.0.1:1080");
1796
2185
  const nodeConnector: TcpConnector = nodeTcpConnector; // Node.js
1797
2186
  const denoConnector: TcpConnector = denoTcpConnector; // Deno
1798
2187
  const customConnector: TcpConnector = socks5Connector({
1799
- proxyHost: "127.0.0.1",
1800
- proxyPort: 1080,
2188
+ host: "127.0.0.1",
2189
+ port: 1080,
1801
2190
  }); // Returns a TcpConnector function
1802
2191
 
1803
2192
  // Client-level proxy
@@ -1815,22 +2204,26 @@ try {
1815
2204
  // Correct way to route through an HTTP(S) proxy — supply a proxy-aware fetch:
1816
2205
  import { ProxyAgent } from "undici"; // npm i undici (Node.js)
1817
2206
  const proxied = kinetex({
1818
- fetch: new ProxyAgent("http://127.0.0.1:8080").dispatch.bind(new ProxyAgent("http://127.0.0.1:8080")) as typeof fetch,
2207
+ fetch: new ProxyAgent("http://127.0.0.1:8080").dispatch.bind(
2208
+ new ProxyAgent("http://127.0.0.1:8080"),
2209
+ ) as typeof fetch,
1819
2210
  });
1820
2211
 
1821
- // Correct way to route through a SOCKS5 proxy — create a tunnel transport:
2212
+ // Correct way to route through a SOCKS5 proxy — dial through the tunnel
2213
+ // and upgrade to TLS, then hand the socket to a fetch implementation that
2214
+ // accepts a custom connection:
1822
2215
  import { createSocks5Tunnel } from "kinetex/socks5";
1823
- const tunnel = createSocks5Tunnel({ proxyHost: "127.0.0.1", proxyPort: 1080 });
1824
- const viaSocks = await tunnel.send({
1825
- url: "https://api.example.com/data",
1826
- method: "GET",
1827
- headers: {},
1828
- body: null,
1829
- signal: null,
1830
- meta: {},
1831
- });
2216
+ const socks = await createSocks5Tunnel(
2217
+ { host: "127.0.0.1", port: 1080 },
2218
+ { host: "api.example.com", port: 443, tls: true },
2219
+ nodeTcpConnector,
2220
+ );
2221
+ const tlsSocket = await upgradeToTLS(socks.conn, { servername: "api.example.com" });
2222
+ const viaSocks = kinetex({ fetch: fetchOverSocket(tlsSocket) });
1832
2223
  ```
1833
2224
 
2225
+ `createSocks5Tunnel` is a low-level primitive. It gives you a connected socket and nothing else — no request building, no redirect handling, no kinetex pipeline. For full kinetex behaviour over SOCKS5, connect a fetch implementation to the tunnel and pass it as the client's `fetch`.
2226
+
1834
2227
  ---
1835
2228
 
1836
2229
  ## Digest Authentication
@@ -1843,9 +2236,11 @@ import {
1843
2236
  computeDigestResponse,
1844
2237
  formatDigestAuth,
1845
2238
  createDigestAuthorization,
2239
+ createDigestAuthorizer,
1846
2240
  } from "kinetex/digest";
1847
2241
  import type { DigestChallenge } from "kinetex/digest";
1848
2242
 
2243
+ // One-shot: stateless, always nc=00000001
1849
2244
  const authHeader = await createDigestAuthorization(
1850
2245
  `Digest realm="test", nonce="abc123", algorithm=MD5, qop="auth"`,
1851
2246
  "username",
@@ -1856,10 +2251,37 @@ const authHeader = await createDigestAuthorization(
1856
2251
  // → 'Digest username="username", realm="test", nonce="abc123", uri="/resource", response="...", algorithm=MD5, qop=auth, nc=00000001, cnonce="..."'
1857
2252
  ```
1858
2253
 
2254
+ ### Nonce counting (`nc`)
2255
+
2256
+ RFC 7616 requires `nc` — the hex request count for the current nonce — to **increase on every request that reuses a nonce**. Servers with replay protection enabled (nginx, Apache with `AuthDigestNonceLifetime`) reject a repeated `nc=00000001`, so a long-lived client needs the stateful authorizer:
2257
+
2258
+ ```ts
2259
+ // The first argument is the raw WWW-Authenticate header string, not a
2260
+ // parsed challenge object.
2261
+ const authorize = createDigestAuthorizer();
2262
+
2263
+ await authorize(challenge, "username", "password", "GET", "/a"); // nc=00000001
2264
+ await authorize(challenge, "username", "password", "GET", "/b"); // nc=00000002
2265
+ await authorize(challenge, "username", "password", "GET", "/c"); // nc=00000003
2266
+
2267
+ // A new nonce from the server resets the counter
2268
+ await authorize(newChallenge, "username", "password", "GET", "/a"); // nc=00000001
2269
+ ```
2270
+
2271
+ Create one per client (do not share it across users). `Kinetex`'s built-in `auth: { type: "digest" }` interceptor uses it automatically: it answers the `401` challenge, retries once, and increments `nc` on every subsequent request.
2272
+
1859
2273
  ---
1860
2274
 
1861
2275
  ## Structured Logging
1862
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
+
1863
2285
  ```ts
1864
2286
  import {
1865
2287
  HTTPLogger,
@@ -1881,14 +2303,25 @@ const logger = createLogger({
1881
2303
  level: "info", // "trace" | "debug" | "info" | "warn" | "error" | "silent"
1882
2304
  transports: [
1883
2305
  new ConsoleTransport({ pretty: true }), // Console output
1884
- new JSONTransport({ file: "requests.log" }), // File output
2306
+ new JSONTransport((line) => appendFileSync("requests.log", line)), // one JSON line at a time
1885
2307
  ],
1886
- redact: ["authorization", "cookie", "x-api-key", /secret.*/i], // Redaction patterns
1887
- redactBody: true, // Redact request/response bodies
1888
- bodyTruncate: 1000, // Truncate bodies to N chars
1889
- requestIdHeader: "x-request-id", // Extract request ID from this header
1890
- sampling: 0.5, // Log only 50% of requests
1891
- filter: (entry) => entry.status !== 200, // Only log non-200 responses
2308
+ // Redaction is a structured config, not a flat array of patterns
2309
+ redaction: {
2310
+ headers: ["authorization", "cookie", "x-api-key"],
2311
+ queryParams: ["api_key", "access_token"],
2312
+ bodyFields: ["password", "ssn"],
2313
+ bodyPatterns: [/secret.*/i],
2314
+ maxBodyLength: 1000,
2315
+ logRequestBody: true,
2316
+ logResponseBody: true,
2317
+ allowedBodyTypes: ["application/json"],
2318
+ },
2319
+ sampleRate: 0.5, // Log ~50% of requests (not `sampling`)
2320
+ methods: ["GET", "POST"],
2321
+ statuses: [429, 500, 503],
2322
+ excludeURLs: [/\/health$/],
2323
+ context: { service: "api" },
2324
+ generateId: () => crypto.randomUUID(),
1892
2325
  });
1893
2326
 
1894
2327
  // Client-level logging
@@ -1898,9 +2331,9 @@ kinetex({
1898
2331
  });
1899
2332
 
1900
2333
  // Batching transport (async flush)
1901
- const batch = new BatchingTransport({
1902
- maxBatch: 100,
1903
- flushIntervalMs: 5000,
2334
+ const batch = new BatchingTransport(inner, {
2335
+ maxBatch: 100, // default 100
2336
+ flushMs: 5000, // default 5000 — not `flushIntervalMs`
1904
2337
  });
1905
2338
 
1906
2339
  // Remote transport
@@ -1929,10 +2362,10 @@ await client.get("/users");
1929
2362
  await client.post("/posts", { title: "Test" });
1930
2363
 
1931
2364
  const har = client.getHAR();
1932
- // HARLog { version: "1.2", creator: { name: "kinetex", version: "0.0.3" }, entries: [...] }
2365
+ // HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
1933
2366
 
1934
2367
  // Each HAREntry contains:
1935
- // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
2368
+ // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize, postData?),
1936
2369
  // response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
1937
2370
  // timings (send, wait, receive), cache
1938
2371
 
@@ -1940,6 +2373,22 @@ const har = client.getHAR();
1940
2373
  client.clearHAR();
1941
2374
  ```
1942
2375
 
2376
+ ### Redaction
2377
+
2378
+ HAR logs are routinely exported and shared, so entries are redacted before they are recorded:
2379
+
2380
+ - **Headers** — every credential-bearing name (`authorization`, `cookie`, `set-cookie`, `x-api-key`, `apikey`, `x-session-token`, …) is replaced with `***REDACTED***`.
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.
2382
+ - **`Location`** — the redirect target is passed through the same URL redaction.
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.
2385
+
2386
+ ```ts
2387
+ // ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
2388
+ ```
2389
+
2390
+ > Redaction is deliberately conservative. If a credential travels in a non-standard header or parameter, add it to your own allow/deny handling before exporting the log.
2391
+
1943
2392
  ---
1944
2393
 
1945
2394
  ## OpenTelemetry Tracing
@@ -1968,9 +2417,15 @@ client.setTracer({
1968
2417
  spanContext() {
1969
2418
  return { traceId: "x", spanId: "y", traceFlags: 1 };
1970
2419
  },
1971
- setAttribute(key, value) { return this; },
1972
- setStatus(status) { return this; },
1973
- recordException(err) { return this; },
2420
+ setAttribute(key, value) {
2421
+ return this;
2422
+ },
2423
+ setStatus(status) {
2424
+ return this;
2425
+ },
2426
+ recordException(err) {
2427
+ return this;
2428
+ },
1974
2429
  end() {},
1975
2430
  };
1976
2431
  },
@@ -2012,6 +2467,54 @@ Pipeline stages in order:
2012
2467
 
2013
2468
  ## Transport Layer
2014
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
+
2015
2518
  ### FetchTransport
2016
2519
 
2017
2520
  Universal fetch-based transport for all runtimes:
@@ -2043,6 +2546,7 @@ const transport = new NodeHTTP2Transport({
2043
2546
  maxSessions: 100, // Max concurrent sessions (default: 100)
2044
2547
  connectTimeoutMs: 30_000, // Connection timeout (default: 30_000)
2045
2548
  requestTimeoutMs: 30_000, // Per-request stream timeout (default: 30_000)
2549
+ ca: [readFileSync("corp-ca.pem")], // Trust a private/self-signed CA for this origin
2046
2550
  strict: false, // Strict header validation
2047
2551
  onDroppedHeader: (name, value) => {},
2048
2552
  });
@@ -2060,6 +2564,57 @@ Features:
2060
2564
  - Iterative redirect following (not recursive)
2061
2565
  - Backpressure-aware body writes (awaits `drain` events)
2062
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
+
2063
2618
  ### Transport Factory
2064
2619
 
2065
2620
  ```ts
@@ -2115,7 +2670,7 @@ const raw = await sendWithTimeout(transport, request, 5000); // → RawResponse,
2115
2670
  const body = await readRawBody(stream, maxBytes, url, signal); // → Uint8Array, throws SizeLimitError
2116
2671
 
2117
2672
  // Parse body by content-type
2118
- const data = parseBody<MyType>(rawBody, contentType, customParser?, onParseFailure?, headers?, url?);
2673
+ const data = parseBody<MyType>(rawBody, contentType, customParse, onParseFailure, headers, url);
2119
2674
 
2120
2675
  // Decompress body stream
2121
2676
  const decompressed = await decompressBodyStream(body, headers);
@@ -2185,8 +2740,8 @@ High-throughput request batching:
2185
2740
  import { BatchQueue } from "kinetex";
2186
2741
 
2187
2742
  const batch = new BatchQueue(client, {
2188
- maxBatch: 50, // Flush when 50 requests queued (default: 100)
2189
- flushMs: 10, // Flush after 10ms even if batch not full (default: 0)
2743
+ maxBatch: 50, // Dispatch in groups of 50 (default: 100; must be a positive integer)
2744
+ flushMs: 10, // Flush after 10ms even if the batch is not full (default: 0)
2190
2745
  });
2191
2746
 
2192
2747
  // Fire many requests — they batch automatically
@@ -2200,6 +2755,8 @@ batch.flush(); // Force flush pending requests
2200
2755
  batch.pendingCount; // Number of queued requests
2201
2756
  ```
2202
2757
 
2758
+ > `maxBatch` is a **batching size, not a concurrency limit**: every request taken out of the queue is dispatched immediately and in parallel, and `flush()` drains the whole queue the same way. Use it to bound how much is dispatched per tick, and a rate limiter or semaphore to bound actual parallelism. `maxBatch: 0` (or a negative/fractional value) throws a `RangeError` in the constructor, as does a negative or non-finite `flushMs`.
2759
+
2203
2760
  ---
2204
2761
 
2205
2762
  ## URL Utilities
@@ -2256,8 +2813,8 @@ import type {
2256
2813
  DataURLParts,
2257
2814
  } from "kinetex/url";
2258
2815
 
2259
- // URL Builder (fluent, immutable)
2260
- const url = URLBuilder.from("https://api.example.com")
2816
+ // URL Builder (fluent, immutable — every method returns a new builder)
2817
+ const builder = URLBuilder.from("https://api.example.com")
2261
2818
  .withPathname("/v1/users")
2262
2819
  .appendPath("42", "posts")
2263
2820
  .setParam("page", "1")
@@ -2265,29 +2822,30 @@ const url = URLBuilder.from("https://api.example.com")
2265
2822
  .omitParams("internal")
2266
2823
  .redactParams("token")
2267
2824
  .sortParams()
2268
- .addTrailingSlash()
2269
- .toString();
2825
+ .addTrailingSlash();
2826
+
2827
+ builder.toString();
2270
2828
  // → "https://api.example.com/v1/users/42/posts/?limit=10&page=1&token=REDACTED"
2271
2829
 
2272
2830
  URLBuilder.https("api.example.com", "/v1/users"); // Factory
2273
2831
  URLBuilder.http("api.example.com"); // Factory
2274
2832
 
2275
- // Properties:
2276
- url.href;
2277
- url.protocol;
2278
- url.hostname;
2279
- url.host;
2280
- url.port;
2281
- url.pathname;
2282
- url.search;
2283
- url.hash;
2284
- url.origin;
2285
- url.searchParams; // → URLSearchParams
2286
- url.queryObject; // → Record<string, string | string[]>
2833
+ // Properties — read these off the *builder*, not off toString():
2834
+ builder.href;
2835
+ builder.protocol;
2836
+ builder.hostname;
2837
+ builder.host;
2838
+ builder.port;
2839
+ builder.pathname;
2840
+ builder.search;
2841
+ builder.hash;
2842
+ builder.origin;
2843
+ builder.searchParams; // → URLSearchParams
2844
+ builder.queryObject; // → Record<string, string | string[]>
2287
2845
 
2288
2846
  // Percent encoding
2289
2847
  percentEncode("hello world"); // "hello%20world"
2290
- percentEncode("a b", true); // "a%20b" (reserved not encoded)
2848
+ percentEncode("a b", true); // "a%20b" — true lets reserved chars (:/?#[]@!$&'()*+,;=) pass through
2291
2849
  percentDecode("hello%20world"); // "hello world"
2292
2850
 
2293
2851
  // Query string
@@ -2324,7 +2882,8 @@ isLocalhost("http://localhost:8080"); // true
2324
2882
 
2325
2883
  // URL resolution
2326
2884
  resolveURL("/v1/users", "https://api.example.com"); // "https://api.example.com/v1/users"
2327
- relativeURL("https://api.example.com/v1/users", "https://api.example.com"); // "/v1/users"
2885
+ relativeURL("https://api.example.com/v1/users", "https://api.example.com"); // "v1/users" (no leading slash)
2886
+ relativeURL("https://other.com/x", "https://api.example.com"); // null — not under base
2328
2887
 
2329
2888
  // Data URLs
2330
2889
  parseDataURL("data:image/png;base64,iVBOR..."); // { mediaType: "image/png", isBase64: true, data: "iVBOR..." }
@@ -2332,7 +2891,7 @@ buildDataURL("hello", "text/plain"); // "data:text/plain;base64,aGVsbG8="
2332
2891
 
2333
2892
  // Redaction
2334
2893
  redactURL("https://api.example.com?token=secret&key=123", "token", "key");
2335
- // → "https://api.example.com?token=REDACTED&key=REDACTED"
2894
+ // → "https://api.example.com/?token=REDACTED&key=REDACTED"
2336
2895
 
2337
2896
  // Diff
2338
2897
  diffURLs("https://a.com/path?a=1", "https://b.com/other?b=2");
@@ -2397,7 +2956,7 @@ import {
2397
2956
  // Forwarded
2398
2957
  parseForwarded,
2399
2958
  normalizeForwardedHeaders,
2400
- getClientIP,
2959
+ getClientIP, // (headers, { trustedHops }) — see below
2401
2960
 
2402
2961
  // Retry
2403
2962
  parseRetryAfter,
@@ -2412,8 +2971,8 @@ import {
2412
2971
  parseAltSvc,
2413
2972
  parseWarning,
2414
2973
  parseParams,
2415
- securityHeaders, // Recommended security headers map
2416
- corsHeaders, // CORS headers map
2974
+ securityHeaders, // (options) => HttpHeaders — recommended security header set
2975
+ corsHeaders, // (options) => HttpHeaders — CORS response headers
2417
2976
 
2418
2977
  // Conversion
2419
2978
  fromNodeHeaders, // node:http.IncomingMessage → Record
@@ -2429,22 +2988,45 @@ HeaderName.CacheControl; // "cache-control"
2429
2988
  HeaderName.ETag; // "etag"
2430
2989
  // ... all standard headers
2431
2990
 
2432
- // Cache-Control parsing
2991
+ // Cache-Control parsing — directives use camelCase keys, not kebab-case
2433
2992
  parseCacheControl("public, max-age=3600, stale-while-revalidate=300");
2434
- // → { public: true, "max-age": 3600, "stale-while-revalidate": 300 }
2435
- formatCacheControl({ public: true, "max-age": 3600 }); // "public, max-age=3600"
2436
-
2437
- // Content-Type
2438
- formatContentType("application/json", { charset: "utf-8" });
2993
+ // → { noCache: false, noStore: false, noTransform: false, onlyIfCached: false,
2994
+ // maxAge: 3600, maxStale: null, minFresh: null, staleIfError: null,
2995
+ // public: true, private: false, mustRevalidate: false, proxyRevalidate: false,
2996
+ // sMaxAge: null, immutable: false, mustUnderstand: false,
2997
+ // staleWhileRevalidate: 300, unknown: {} }
2998
+ formatCacheControl({ public: true, maxAge: 3600 }); // "public, max-age=3600"
2999
+
3000
+ // Content-Type — takes a single object, not (string, options)
3001
+ formatContentType({ mediaType: "application/json", charset: "utf-8" });
2439
3002
  // → "application/json; charset=utf-8"
2440
3003
 
2441
- // Security headers preset
2442
- securityHeaders; // { "x-content-type-options": "nosniff", "x-frame-options": "DENY", ... }
2443
- corsHeaders; // { "access-control-allow-origin": "*", ... }
3004
+ // Security / CORS headers are functions returning HttpHeaders
3005
+ securityHeaders({ hsts: true, csp: "default-src 'self'", frameOptions: "DENY", noSniff: true });
3006
+ corsHeaders({ origin: "*", methods: ["GET", "POST"], credentials: false, maxAge: 600 });
2444
3007
  ```
2445
3008
 
2446
3009
  ---
2447
3010
 
3011
+ ### `getClientIP` — proxy trust
3012
+
3013
+ `X-Forwarded-For` is client-controlled: the left-most entry is whatever the caller sent. `getClientIP` therefore takes a `trustedHops` count — the number of reverse proxies you actually operate — and returns the address the nearest trusted proxy appended:
3014
+
3015
+ ```ts
3016
+ import { getClientIP, HttpHeaders } from "kinetex/headers";
3017
+
3018
+ const headers = new HttpHeaders(req.headers);
3019
+
3020
+ // X-Forwarded-For: 1.1.1.1, 2.2.2.2, 3.3.3.3
3021
+ getClientIP(headers, { trustedHops: 1 }); // → "3.3.3.3" (written by your edge proxy)
3022
+ getClientIP(headers, { trustedHops: 2 }); // → "2.2.2.2"
3023
+ getClientIP(headers); // → "1.1.1.1" (client-supplied, spoofable — default for back-compat)
3024
+ ```
3025
+
3026
+ `for="…"` quoting, `[ipv6]:port` and a bare `:port` suffix are normalized away. ⚠️ Never use the default (`trustedHops: 0`) result for access control, rate limiting or audit trails; the value is not validated as an IP address.
3027
+
3028
+ ---
3029
+
2448
3030
  ## Response Parsing Utilities
2449
3031
 
2450
3032
  ```ts
@@ -2492,17 +3074,17 @@ const formData = await readFormData(response); // Parse as FormData
2492
3074
  const data = await assertOkJSON<MyType>(response); // Throws on non-2xx
2493
3075
  await assertOk(response); // Throws on non-2xx
2494
3076
 
2495
- // Size limiting
2496
- const limited = await readBodyWithLimit(response, {
3077
+ // Size limiting — readBodyWithLimit takes a *stream*, a url, and a limit config
3078
+ const limited = await readBodyWithLimit(response.body, response.url, {
2497
3079
  maxBytes: 1_000_000,
2498
3080
  onExceed: "throw", // "throw" | "truncate" | "abort"
2499
3081
  onExceedCallback: (bytesRead, limit) => log(`Exceeded ${limit}`),
2500
3082
  });
2501
3083
 
2502
- const reader = createLimitedReader(stream, {
2503
- maxBytes: 1_000_000,
2504
- onExceed: "throw",
2505
- });
3084
+ // createLimitedReader takes a byte count and an action — not (stream, config).
3085
+ // It returns a LimitedReader with json/text/bytes/blob/stream/ndjson methods.
3086
+ const reader = createLimitedReader(1_000_000, "throw");
3087
+ const parsed = await reader.json<Response>(response);
2506
3088
 
2507
3089
  // Multipart
2508
3090
  const parts = await parseMultipartResponse(response, boundary);
@@ -2511,16 +3093,17 @@ const parts = await parseMultipartResponse(response, boundary);
2511
3093
  const decompressed = await decompressStream(compressedStream, "gzip");
2512
3094
  const raw = await applyDecompression(rawBody, headers);
2513
3095
 
2514
- // Server-Timing
3096
+ // Server-Timing — returns an array of metrics
2515
3097
  const timings = extractServerTiming(headers);
2516
- // → { dur, desc, ... }
3098
+ // → [{ name: "db", duration: 53, description: null }]
2517
3099
 
2518
3100
  // Response diffing
2519
3101
  const diff = diffResponses(res1, res2);
2520
3102
 
2521
3103
  // Content type
2522
3104
  parseContentType("application/json; charset=utf-8");
2523
- // { type: "application/json", parameters: { charset: "utf-8" } }
3105
+ // { mediaType: "application/json", type: "application", subtype: "json",
3106
+ // charset: "utf-8", boundary: null }
2524
3107
  isJSON(response); // true if content-type is JSON
2525
3108
  isText(response); // true if content-type is text/*
2526
3109
  isBinary(response); // true if binary content-type
@@ -2623,18 +3206,30 @@ import {
2623
3206
  } from "kinetex";
2624
3207
 
2625
3208
  // Type guards
2626
- isUint8Array(data); // data is Uint8Array
2627
- isPlainObject(obj); // obj is Record<string, unknown>
2628
- isAbortSignal(signal); // signal is AbortSignal
2629
- isFormData(data); // data is FormData
2630
- isBlob(data); // data is Blob
2631
- isAbortError(err); // boolean
3209
+ isUint8Array(data); // data is Uint8Array
3210
+ isPlainObject(obj); // obj is Record<string, unknown>
3211
+ isAbortSignal(signal); // signal is AbortSignal
3212
+ isFormData(data); // data is FormData
3213
+ isBlob(data); // data is Blob
3214
+ isAbortError(err); // boolean
2632
3215
  isValidHeaderName("x-foo"); // boolean
2633
- isValidHeaderValue("bar"); // boolean
3216
+ isValidHeaderValue("bar"); // boolean
2634
3217
 
2635
3218
  // Safe URL checking
2636
- isSafeURL("https://evil.com"); // boolean — checks for dangerous protocols
2637
- sanitizeURL("javascript:alert(1)"); // string — stripped or redacted
3219
+ // Rejects non-HTTP(S) schemes and any host that resolves to a blocked literal range:
3220
+ // loopback, RFC 1918, CGNAT, link-local (incl. 169.254.169.254), IETF/TEST-NET,
3221
+ // benchmarking, multicast and reserved space — including IPv4-mapped/compatible
3222
+ // IPv6, 6to4, NAT64, hex/octal/decimal IPv4 literals and WHATWG shortcut hosts.
3223
+ isSafeURL("https://api.example.com"); // true
3224
+ isSafeURL("http://127.0.0.1/"); // false
3225
+ isSafeURL("http://169.254.169.254/latest/meta-data/"); // false
3226
+ isSafeURL("http://[::ffff:127.0.0.1]/"); // false
3227
+
3228
+ // 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.)
3231
+ sanitizeURL("javascript:alert(1)"); // null — invalid or an SSRF risk
3232
+ sanitizeURL("https://user:pass@api.example.com/x"); // "https://api.example.com/x" (credentials stripped)
2638
3233
 
2639
3234
  // Error construction
2640
3235
  const err = createStructuredError("EVALIDATION", "Invalid config", {
@@ -2655,9 +3250,19 @@ const b64 = uint8ArrayToBase64(uint8);
2655
3250
 
2656
3251
  // Object
2657
3252
  const clone = deepClone(original);
2658
- const normalized = normalizeHeaders(rawHeaders); // Lowercase keys
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);
2659
3256
  ```
2660
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.
3265
+
2661
3266
  ---
2662
3267
 
2663
3268
  ## Error Handling
@@ -2730,19 +3335,20 @@ validateErrorCode("INVALID"); // undefined
2730
3335
 
2731
3336
  ### Error Codes
2732
3337
 
2733
- | Code | Error Class | Description |
2734
- | ------------- | ----------------- | --------------------------------- |
2735
- | `ENETWORK` | `NetworkError` | Server/endpoint unreachable |
2736
- | `ETIMEOUT` | `TimeoutError` | Request/connection timeout |
2737
- | `EABORT` | `AbortError` | Request cancelled by caller |
2738
- | `EHTTPSTATUS` | `HTTPStatusError` | Server returned 4xx/5xx |
2739
- | `ESIZELIMIT` | `SizeLimitError` | Response body exceeded size limit |
2740
- | `EPARSE` | — | Failed to parse response body |
2741
- | `EVALIDATION` | `ValidationError` | Invalid request configuration |
2742
- | `EAUTH` | `AuthError` | Authentication failed |
2743
- | `EPROXY` | `ProxyError` | Proxy configuration error |
2744
- | `EREDIRECT` | `RedirectError` | Redirect error |
2745
- | `EUNKNOWN` | `KinetexError` | Unknown/unexpected |
3338
+ | Code | Error Class | Description |
3339
+ | -------------- | ------------------ | ----------------------------------- |
3340
+ | `ENETWORK` | `NetworkError` | Server/endpoint unreachable |
3341
+ | `ETIMEOUT` | `TimeoutError` | Request/connection timeout |
3342
+ | `EABORT` | `AbortError` | Request cancelled by caller |
3343
+ | `EHTTPSTATUS` | `HTTPStatusError` | Server returned 4xx/5xx |
3344
+ | `ESIZELIMIT` | `SizeLimitError` | Response body exceeded size limit |
3345
+ | `EPARSE` | — | Failed to parse response body |
3346
+ | `EVALIDATION` | `ValidationError` | Invalid request configuration |
3347
+ | `EAUTH` | `AuthError` | Authentication failed |
3348
+ | `EPROXY` | `ProxyError` | Proxy configuration error |
3349
+ | `EREDIRECT` | `RedirectError` | Redirect error |
3350
+ | `ECIRCUITOPEN` | `CircuitOpenError` | Rejected by an open circuit breaker |
3351
+ | `EUNKNOWN` | `KinetexError` | Unknown/unexpected |
2746
3352
 
2747
3353
  ---
2748
3354
 
@@ -2752,54 +3358,145 @@ All sub-modules are tree-shakeable with deep import paths:
2752
3358
 
2753
3359
  ```ts
2754
3360
  // Core
2755
- import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreakerKey } from "kinetex";
2756
- import type { KinetexConfig, KinetexRequest, KinetexResponse, SendOptions, RetryConfig, RetryContext, AuthConfig, ProxyConfig, HTTPMethod, HTTPVersion, HeadersInit, QueryParams, QueryValue, BodyInit, Runtime, RequestId, Brand } from "kinetex";
3361
+ import {
3362
+ kinetex,
3363
+ Kinetex,
3364
+ FluentRequest,
3365
+ BatchQueue,
3366
+ createMethodCircuitBreakerKey,
3367
+ } from "kinetex";
3368
+ import type {
3369
+ KinetexConfig,
3370
+ KinetexRequest,
3371
+ KinetexResponse,
3372
+ SendOptions,
3373
+ RetryConfig,
3374
+ RetryContext,
3375
+ AuthConfig,
3376
+ ProxyConfig,
3377
+ HTTPMethod,
3378
+ HTTPVersion,
3379
+ HeadersInit,
3380
+ QueryParams,
3381
+ QueryValue,
3382
+ BodyInit,
3383
+ Runtime,
3384
+ RequestId,
3385
+ Brand,
3386
+ } from "kinetex";
2757
3387
  // Errors
2758
- import { KinetexError, HTTPStatusError, TimeoutError, SizeLimitError, AbortError, NetworkError, ValidationError, AuthError, ProxyError, RedirectError } from "kinetex";
3388
+ import {
3389
+ KinetexError,
3390
+ HTTPStatusError,
3391
+ TimeoutError,
3392
+ SizeLimitError,
3393
+ AbortError,
3394
+ NetworkError,
3395
+ ValidationError,
3396
+ AuthError,
3397
+ ProxyError,
3398
+ RedirectError,
3399
+ } from "kinetex";
2759
3400
  // Types
2760
- import type { InterceptorContext, HookContext, LifecycleHooks, RequestInterceptor, ResponseInterceptor, ErrorInterceptor, ProgressEvent, ProgressCallback, PipelineStep, PipelineStageName, CacheRequestConfig, HAREntry, HARLog } from "kinetex";
3401
+ import type {
3402
+ InterceptorContext,
3403
+ HookContext,
3404
+ LifecycleHooks,
3405
+ RequestInterceptor,
3406
+ ResponseInterceptor,
3407
+ ErrorInterceptor,
3408
+ ProgressEvent,
3409
+ ProgressCallback,
3410
+ PipelineStep,
3411
+ PipelineStageName,
3412
+ CacheRequestConfig,
3413
+ HAREntry,
3414
+ HARLog,
3415
+ } from "kinetex";
2761
3416
 
2762
3417
  // Sub-modules (tree-shakeable):
2763
- import { ... } from "kinetex/cache";
2764
- import { ... } from "kinetex/sse";
2765
- import { ... } from "kinetex/graphql";
2766
- import { ... } from "kinetex/pagination";
2767
- import { ... } from "kinetex/progress";
2768
- import { ... } from "kinetex/logging";
2769
- import { ... } from "kinetex/response";
2770
- import { ... } from "kinetex/headers";
2771
- import { ... } from "kinetex/url";
2772
- import { ... } from "kinetex/aws-sigv4";
2773
- import { ... } from "kinetex/socks5";
2774
- import { ... } from "kinetex/cookiejar";
2775
- import { ... } from "kinetex/circuit-breaker";
2776
- import { ... } from "kinetex/dedup";
2777
- import { ... } from "kinetex/digest";
2778
- import { ... } from "kinetex/ws";
2779
- import { ... } from "kinetex/lifecycle";
2780
- import { ... } from "kinetex/interceptors";
2781
- import { ... } from "kinetex/core";
2782
- import { ... } from "kinetex/worker";
3418
+ import {} from /* ... */ "kinetex/cache";
3419
+ import {} from /* ... */ "kinetex/sse";
3420
+ import {} from /* ... */ "kinetex/graphql";
3421
+ import {} from /* ... */ "kinetex/pagination";
3422
+ import {} from /* ... */ "kinetex/progress";
3423
+ import {} from /* ... */ "kinetex/logging";
3424
+ import {} from /* ... */ "kinetex/response";
3425
+ import {} from /* ... */ "kinetex/headers";
3426
+ import {} from /* ... */ "kinetex/url";
3427
+ import {} from /* ... */ "kinetex/aws-sigv4";
3428
+ import {} from /* ... */ "kinetex/socks5";
3429
+ import {} from /* ... */ "kinetex/cookiejar";
3430
+ import {} from /* ... */ "kinetex/circuit-breaker";
3431
+ import {} from /* ... */ "kinetex/dedup";
3432
+ import {} from /* ... */ "kinetex/digest";
3433
+ import {} from /* ... */ "kinetex/ws";
3434
+ import {} from /* ... */ "kinetex/cookie-parser";
3435
+ import {} from /* ... */ "kinetex/lifecycle";
3436
+ import {} from /* ... */ "kinetex/interceptors";
3437
+ import {} from /* ... */ "kinetex/core";
3438
+ import {} from /* ... */ "kinetex/worker";
2783
3439
 
2784
3440
  // Types only from sub-modules:
2785
3441
  import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
2786
3442
  import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
2787
- import type { GraphQLRequest, GraphQLResponse, GraphQLError, GraphQLClientConfig, GraphQLLink, GraphQLLinkNext } from "kinetex/graphql";
3443
+ import type {
3444
+ GraphQLRequest,
3445
+ GraphQLResponse,
3446
+ GraphQLError,
3447
+ GraphQLClientConfig,
3448
+ GraphQLLink,
3449
+ GraphQLLinkNext,
3450
+ } from "kinetex/graphql";
2788
3451
  import type { Page, PaginationState } from "kinetex/pagination";
2789
3452
  import type { LogEntry, LogTransport, LoggerConfig } from "kinetex/logging";
2790
3453
  import type { ResponseParseOptions, SizeLimitConfig } from "kinetex/response";
2791
3454
  import type { Cookie, CookieJSON } from "kinetex/cookiejar";
2792
- import type { CircuitState, CircuitBreakerConfig, CircuitBreakerState, FailureFilter } from "kinetex/circuit-breaker";
3455
+ import type {
3456
+ CircuitState,
3457
+ CircuitBreakerConfig,
3458
+ CircuitBreakerState,
3459
+ FailureFilter,
3460
+ } from "kinetex/circuit-breaker";
2793
3461
  import type { DedupOptions } from "kinetex/dedup";
2794
3462
  import type { DigestChallenge } from "kinetex/digest";
2795
- import type { WSState, WSMessage, WSClientConfig, WSCloseEvent, WSBackpressureInfo, WSSubscribedRoom } from "kinetex/ws";
2796
- import type { HookRequest, HookResponse, HookError, HookOptions, BeforeRequestHook, AfterRequestHook, BeforeResponseHook, AfterResponseHook, OnErrorHook, OnRetryHook, OnRedirectHook, OnUploadProgressHook, OnDownloadProgressHook, AroundHook } from "kinetex/lifecycle";
3463
+ import type {
3464
+ WSState,
3465
+ WSMessage,
3466
+ WSClientConfig,
3467
+ WSCloseEvent,
3468
+ WSBackpressureInfo,
3469
+ WSSubscribedRoom,
3470
+ } from "kinetex/ws";
3471
+ import type {
3472
+ HookRequest,
3473
+ HookResponse,
3474
+ HookError,
3475
+ HookOptions,
3476
+ BeforeRequestHook,
3477
+ AfterRequestHook,
3478
+ BeforeResponseHook,
3479
+ AfterResponseHook,
3480
+ OnErrorHook,
3481
+ OnRetryHook,
3482
+ OnRedirectHook,
3483
+ OnUploadProgressHook,
3484
+ OnDownloadProgressHook,
3485
+ AroundHook,
3486
+ } from "kinetex/lifecycle";
2797
3487
  import type { AWSCredentials, SigningConfig, CredentialProvider } from "kinetex/aws-sigv4";
2798
3488
  import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
2799
3489
  import type { FetchTransportOptions } from "kinetex/core";
2800
3490
  import type { OTelTracer, OTelSpan } from "kinetex";
2801
3491
  import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
2802
- import type { ParsedURL, URLBuilderOptions, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
3492
+ import type {
3493
+ ParsedURL,
3494
+ URLBuilderOptions,
3495
+ URLPattern,
3496
+ URLPatternMatch,
3497
+ URLDiff,
3498
+ DataURLParts,
3499
+ } from "kinetex/url";
2803
3500
  ```
2804
3501
 
2805
3502
  ---
@@ -2809,7 +3506,13 @@ import type { ParsedURL, URLBuilderOptions, URLPattern, URLPatternMatch, URLDiff
2809
3506
  Cloudflare Workers / Vercel Edge / WinterCG safe entry point:
2810
3507
 
2811
3508
  ```ts
2812
- import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreakerKey } from "kinetex/worker";
3509
+ import {
3510
+ kinetex,
3511
+ Kinetex,
3512
+ FluentRequest,
3513
+ BatchQueue,
3514
+ createMethodCircuitBreakerKey,
3515
+ } from "kinetex/worker";
2813
3516
  // Only exports types and classes safe for edge environments.
2814
3517
  // No Node.js-specific imports, no HTTP/2 transport.
2815
3518
  // Also exports error classes: KinetexError, HTTPStatusError, TimeoutError, NetworkError, RedirectError
@@ -2817,6 +3520,8 @@ import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreaker
2817
3520
  const client = kinetex({ baseURL: "https://api.example.com" });
2818
3521
  // Uses FetchTransport (globalThis.fetch) automatically.
2819
3522
  // Defaults to HTTP/1.1 for maximum edge compatibility.
3523
+ // httpVersion: "HTTP/2" is ignored here — the HTTP/2 transport needs node:http2,
3524
+ // which this entry point deliberately excludes. Use the main entry on Node.js.
2820
3525
  ```
2821
3526
 
2822
3527
  ```ts
@@ -2862,24 +3567,24 @@ import { kinetex } from "kinetex/browser";
2862
3567
 
2863
3568
  ## Runtime Compatibility
2864
3569
 
2865
- | Feature | Node 18+ | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
2866
- | --------------------------- | -------- | -------- | --------- | --- | ------------ | ---------- | ----------- |
2867
- | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2868
- | HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2869
- | HTTP/2 (NodeHTTP2Transport) | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
2870
- | HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
2871
- | WebSocket (WSClient) | ✗¹ (no native WebSocket) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
2872
- | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
2873
- | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
2874
- | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
2875
- | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
2876
- | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2877
- | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2878
- | URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2879
- | Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
2880
- | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2881
-
2882
- \* HTTP/2+ detection is best-effort: `detectHTTPVersion()` reports HTTP/2 only when the runtime exposes protocol evidence (response `httpVersion`/`protocol` properties, or an `Alt-Svc` header); otherwise it reports `HTTP/1.1`. This is accurate for Node 18's undici fetch, which does not negotiate h2 by default — use `NodeHTTP2Transport` (Node 22+) for guaranteed HTTP/2.
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.
2883
3588
 
2884
3589
  ¹ WSClient requires a native `WebSocket` constructor. Node added one in v22 — on Node 18 use a polyfill (`globalThis.WebSocket = require('undici').WebSocket`).
2885
3590
 
@@ -2896,16 +3601,22 @@ import { kinetex } from "kinetex/browser";
2896
3601
  ## Resource Cleanup
2897
3602
 
2898
3603
  ```ts
2899
- client.destroy();
3604
+ await client.destroy();
2900
3605
  // Closes all HTTP/2 sessions (NodeHTTP2Transport.destroy())
2901
3606
  // Closes all tracked WebSocket connections
2902
- // Clears cache
2903
3607
  // Clears dedup map
2904
3608
  // Clears circuit breakers
2905
3609
  // Clears all interceptors
2906
3610
  // Nullifies cookie jar and logger references
2907
3611
  ```
2908
3612
 
3613
+ `destroy()` releases resources — it does **not** delete cached data. A user-supplied storage adapter (`localStorage`, Cloudflare KV, Redis, …) would otherwise lose every persisted entry on teardown. To empty the cache explicitly:
3614
+
3615
+ ```ts
3616
+ const cache = await client.getCache();
3617
+ await cache?.clear();
3618
+ ```
3619
+
2909
3620
  ---
2910
3621
 
2911
3622
  ## License