kinetex 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +919 -445
  2. package/dist/browser/kinetex.esm.js +18 -18
  3. package/dist/browser/kinetex.js +749 -295
  4. package/dist/browser/kinetex.min.js +18 -18
  5. package/dist/cjs/aws-sigv4.js +4 -1
  6. package/dist/cjs/cache.js +52 -14
  7. package/dist/cjs/circuit-breaker.js +24 -4
  8. package/dist/cjs/client.js +495 -131
  9. package/dist/cjs/cookie-parser.js +7 -4
  10. package/dist/cjs/cookie-store.js +16 -8
  11. package/dist/cjs/core.js +83 -35
  12. package/dist/cjs/dedup.js +9 -7
  13. package/dist/cjs/digest.js +26 -0
  14. package/dist/cjs/graphql.js +19 -4
  15. package/dist/cjs/headers.js +64 -8
  16. package/dist/cjs/interceptors.js +86 -33
  17. package/dist/cjs/logging.js +1 -1
  18. package/dist/cjs/pagination.js +14 -6
  19. package/dist/cjs/progress.js +129 -42
  20. package/dist/cjs/socks5.js +36 -21
  21. package/dist/cjs/sse.js +48 -11
  22. package/dist/cjs/utils.js +24 -12
  23. package/dist/cjs/worker.js +6 -6
  24. package/dist/cjs/ws.js +23 -7
  25. package/dist/esm/aws-sigv4.js +4 -1
  26. package/dist/esm/aws-sigv4.js.map +1 -1
  27. package/dist/esm/cache.js +52 -14
  28. package/dist/esm/cache.js.map +1 -1
  29. package/dist/esm/circuit-breaker.js +24 -4
  30. package/dist/esm/circuit-breaker.js.map +1 -1
  31. package/dist/esm/client.js +495 -131
  32. package/dist/esm/client.js.map +1 -1
  33. package/dist/esm/cookie-parser.js +7 -4
  34. package/dist/esm/cookie-parser.js.map +1 -1
  35. package/dist/esm/cookie-store.js +16 -8
  36. package/dist/esm/cookie-store.js.map +1 -1
  37. package/dist/esm/core.js +83 -35
  38. package/dist/esm/core.js.map +1 -1
  39. package/dist/esm/dedup.js +9 -7
  40. package/dist/esm/dedup.js.map +1 -1
  41. package/dist/esm/digest.js +26 -0
  42. package/dist/esm/digest.js.map +1 -1
  43. package/dist/esm/graphql.js +19 -4
  44. package/dist/esm/graphql.js.map +1 -1
  45. package/dist/esm/headers.js +64 -8
  46. package/dist/esm/headers.js.map +1 -1
  47. package/dist/esm/interceptors.js +86 -33
  48. package/dist/esm/interceptors.js.map +1 -1
  49. package/dist/esm/logging.js +1 -1
  50. package/dist/esm/pagination.js +14 -6
  51. package/dist/esm/pagination.js.map +1 -1
  52. package/dist/esm/progress.js +129 -42
  53. package/dist/esm/progress.js.map +1 -1
  54. package/dist/esm/socks5.js +36 -21
  55. package/dist/esm/socks5.js.map +1 -1
  56. package/dist/esm/sse.js +48 -11
  57. package/dist/esm/sse.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/utils.js +24 -12
  60. package/dist/esm/utils.js.map +1 -1
  61. package/dist/esm/worker.js +6 -6
  62. package/dist/esm/worker.js.map +1 -1
  63. package/dist/esm/ws.js +23 -7
  64. package/dist/esm/ws.js.map +1 -1
  65. package/dist/types/aws-sigv4.d.ts.map +1 -1
  66. package/dist/types/cache.d.ts +8 -1
  67. package/dist/types/cache.d.ts.map +1 -1
  68. package/dist/types/circuit-breaker.d.ts.map +1 -1
  69. package/dist/types/client.d.ts +29 -12
  70. package/dist/types/client.d.ts.map +1 -1
  71. package/dist/types/cookie-parser.d.ts.map +1 -1
  72. package/dist/types/cookie-store.d.ts.map +1 -1
  73. package/dist/types/core.d.ts +10 -0
  74. package/dist/types/core.d.ts.map +1 -1
  75. package/dist/types/dedup.d.ts +0 -7
  76. package/dist/types/dedup.d.ts.map +1 -1
  77. package/dist/types/digest.d.ts +14 -0
  78. package/dist/types/digest.d.ts.map +1 -1
  79. package/dist/types/graphql.d.ts.map +1 -1
  80. package/dist/types/headers.d.ts +25 -10
  81. package/dist/types/headers.d.ts.map +1 -1
  82. package/dist/types/interceptors.d.ts.map +1 -1
  83. package/dist/types/logging.d.ts +1 -1
  84. package/dist/types/pagination.d.ts.map +1 -1
  85. package/dist/types/progress.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/sse.d.ts.map +1 -1
  88. package/dist/types/types.d.ts +25 -2
  89. package/dist/types/types.d.ts.map +1 -1
  90. package/dist/types/utils.d.ts.map +1 -1
  91. package/dist/types/worker.d.ts +6 -6
  92. package/dist/types/worker.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +4 -0
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +4 -4
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
 
@@ -99,6 +110,8 @@ const client = kinetex({ baseURL: "https://jsonplaceholder.typicode.com" });
99
110
 
100
111
  // Convenience methods
101
112
  const users = await client.get<User[]>("/users");
113
+ // A plain object is JSON-encoded automatically (content-type: application/json).
114
+ // Set `content-type` yourself to send the value as an already-prepared body.
102
115
  const post = await client.post("/posts", { title: "Hello", body: "World" });
103
116
 
104
117
  // Fluent builder
@@ -129,24 +142,28 @@ console.log(res.status, res.data, res.headers, res.durationMs);
129
142
  ```ts
130
143
  const client = kinetex({
131
144
  // ── 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
145
+ baseURL: "https://api.example.com/v1", // Base URL for relative paths
146
+ headers: { "X-Version": "1.0" }, // Default headers
147
+ params: { api_key: "xxx" }, // Default query params
148
+ timeout: 10000, // Timeout in ms (default: 30000, 0 = no timeout)
149
+ httpVersion: "HTTP/2", // "HTTP/1.1" | "HTTP/2" (default: "HTTP/2")
150
+ throwOnError: true, // Throw on 4xx/5xx (default: true)
151
+ followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
152
+ maxRedirects: 20, // Max redirect hops (default: 20; 0 disables following)
153
+ httpsOnly: false, // Reject non-HTTPS URLs
154
+ maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
155
+ maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
156
+ strictHeaders: false, // Throw on invalid headers vs warn+drop
157
+ onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
158
+ onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
146
159
 
147
160
  // ── Auth ──
148
161
  auth: { type: "bearer", token: "..." },
149
- awsSigning: { credentials: {...}, region: "...", service: "..." },
162
+ awsSigning: {
163
+ credentials: { accessKeyId: "AKID", secretAccessKey: "secret" },
164
+ region: "us-east-1",
165
+ service: "s3",
166
+ },
150
167
 
151
168
  // ── Retry ──
152
169
  retry: { maxRetries: 3, baseDelayMs: 300, statuses: [408, 429, 500, 502, 503, 504] },
@@ -161,45 +178,77 @@ const client = kinetex({
161
178
  // proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
162
179
 
163
180
  // ── Cache ──
164
- cache: { storage: "memory", ttlMs: 60_000, maxEntries: 1000, swr: true },
181
+ cache: { maxEntries: 500, defaultTtlMs: 60_000 }, // see "Caching" for the full CacheConfig
165
182
 
166
183
  // ── Cookie Jar ──
167
- cookieJar: true, // Auto-manage cookies
184
+ cookieJar: true, // Auto-manage cookies
168
185
 
169
186
  // ── Logging ──
170
187
  logger: { level: "info" },
171
188
 
172
189
  // ── HAR Recording ──
173
- har: true, // Enable HTTP Archive recording
190
+ har: true, // Enable HTTP Archive recording
174
191
 
175
192
  // ── Interceptors ──
176
193
  interceptors: {
177
- request: [myReqInterceptor],
194
+ request: [myReqInterceptor],
178
195
  response: [myResInterceptor],
179
- error: [myErrInterceptor],
196
+ error: [myErrInterceptor],
180
197
  },
181
198
 
182
199
  // ── Lifecycle Hooks ──
183
200
  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) => { ... }],
201
+ onBeforeRequest: [
202
+ (req, ctx) => {
203
+ /* ... */
204
+ },
205
+ ],
206
+ onAfterRequest: [
207
+ (req, ctx) => {
208
+ /* ... */
209
+ },
210
+ ],
211
+ onBeforeResponse: [
212
+ (res, ctx) => {
213
+ /* ... */
214
+ },
215
+ ],
216
+ onAfterResponse: [
217
+ (res, ctx) => {
218
+ /* ... */
219
+ },
220
+ ],
221
+ onError: [
222
+ (err, ctx) => {
223
+ /* ... */
224
+ },
225
+ ],
226
+ onRetry: [
227
+ (ctx) => {
228
+ /* ... */
229
+ },
230
+ ],
231
+ onUploadProgress: [
232
+ (ev) => {
233
+ /* ... */
234
+ },
235
+ ],
236
+ onDownloadProgress: [
237
+ (ev) => {
238
+ /* ... */
239
+ },
240
+ ],
192
241
  },
193
242
 
194
243
  // ── Response/Request Transforms ──
195
- transformResponse: (data, res) => data, // Global response transformer
196
- transformRequest: (req) => req, // Global request transformer
244
+ transformResponse: (data, res) => data, // Global response transformer
245
+ transformRequest: (req) => req, // Global request transformer
197
246
 
198
247
  // ── Circuit Breaker Key ──
199
248
  circuitBreakerKeyFn: (req) => `${req.method}:${new URL(req.url).origin}`,
200
249
 
201
250
  // ── Custom fetch ──
202
- fetch: myCustomFetch, // Custom fetch implementation
251
+ fetch: myCustomFetch, // Custom fetch implementation
203
252
 
204
253
  // ── WebSocket defaults ──
205
254
  ws: { highWaterMark: 65536, lowWaterMark: 16384, maxSendRate: 0, keepRooms: true },
@@ -212,7 +261,7 @@ const client = kinetex({
212
261
 
213
262
  ```ts
214
263
  const get = await client.get("/resource");
215
- const post = await client.post("/resource", { key: "value" });
264
+ const post = await client.post("/resource", { key: "value" }); // auto-JSON
216
265
  const put = await client.put("/resource/1", { data: "new" });
217
266
  const patch = await client.patch("/resource/1", { data: "updated" });
218
267
  const del = await client.delete("/resource/1");
@@ -253,7 +302,7 @@ const res = await client.send("/resource", "GET", options);
253
302
 
254
303
  ## Fluent Request Builder
255
304
 
256
- Every method returns `this` for chaining. Call `.send()`, `.json()`, `.text()`, `.bytes()`, `.blob()`, or `.data()` to execute.
305
+ 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
306
 
258
307
  ```ts
259
308
  const client = kinetex({ baseURL: "https://api.example.com" });
@@ -288,13 +337,17 @@ const data = await client
288
337
  .tags("users", "active") // Cache tags
289
338
  .onUploadProgress((ev) => {}) // Upload progress callback
290
339
  .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)
340
+ .send(); // → Promise<KinetexResponse<T>>
341
+
342
+ // The methods above all return `this`. These are the terminal calls — pick
343
+ // exactly one, and nothing may be chained after it:
344
+ const res = await client.GET("/users").send(); // Promise<KinetexResponse<T>>
345
+ const data = await client.GET("/users").json<User>(); // Promise<T> (parsed JSON)
346
+ const str = await client.GET("/users").text(); // Promise<string>
347
+ const buf = await client.GET("/users").bytes(); // Promise<Uint8Array>
348
+ const blob = await client.GET("/users").blob(); // Promise<Blob>
349
+ const same = await client.GET("/users").data<User>(); // Promise<T> (alias for .json)
350
+ client.GET("/users").subscribe(onSuccess, onError); // callback-style (void)
298
351
  ```
299
352
 
300
353
  ---
@@ -316,8 +369,8 @@ interface SendOptions<T = unknown> {
316
369
  proxy?: ProxyConfig | false; // Proxy config or disable
317
370
  cache?: CacheRequestConfig | false; // Cache config or disable
318
371
  throwOnError?: boolean; // Throw on 4xx/5xx
319
- followRedirects?: boolean; // Follow redirects
320
- maxRedirects?: number; // Max redirects
372
+ followRedirects?: boolean; // Follow redirects (default: true; false returns the 3xx as-is)
373
+ maxRedirects?: number; // Max redirect hops (default: 20; 0 disables following)
321
374
  httpVersion?: HTTPVersion; // Preferred HTTP version
322
375
  maxRequestSize?: number; // Request size limit (bytes)
323
376
  maxResponseSize?: number; // Response size limit (bytes)
@@ -546,13 +599,13 @@ kinetex({
546
599
 
547
600
  ```ts
548
601
  interface InterceptorContext {
549
- request: KinetexRequest;
550
- response: KinetexResponse<unknown> | null;
602
+ request: InterceptorRequest; // url, method, headers, body, signal, meta
603
+ response: InterceptorResponse | null;
551
604
  error: unknown | null;
552
605
  startedAt: number; // Monotonic start time (ms)
553
606
  attempt: number; // Current attempt number
554
607
  aborted: boolean; // Pipeline aborted?
555
- store: Map<symbol | string, unknown>; // Pipeline-scoped shared storage
608
+ store: Map<symbol, unknown>; // Pipeline-scoped shared storage (symbol keys only)
556
609
  }
557
610
  ```
558
611
 
@@ -577,18 +630,25 @@ import {
577
630
  RateLimitError, // thrown by createRateLimitInterceptor
578
631
  } from "kinetex/interceptors";
579
632
 
580
- // Compute request body size (used internally by progress tracking)
581
- computeBodySize(body); // → number | null
633
+ // Compute request body size (used internally by progress tracking).
634
+ // Returns the byte length, 0 for an empty body, or -1 when the size is unknown
635
+ // (e.g. a ReadableStream) — it never returns null.
636
+ computeBodySize(body); // → number
582
637
 
583
- // Combine multiple built-in interceptors
638
+ // Combine multiple built-in interceptors at documented priorities:
639
+ // -100 timeout · -90 rate limit · -80 dedup · -70 cache
640
+ // -50 auth · 50 retry · 90 logging · 95 HAR · 100 metrics
584
641
  const suite = createInterceptorSuite({
642
+ timeout: { timeoutMs: 5000 },
585
643
  retry: { maxRetries: 3 },
586
- auth: { type: "bearer", token: "..." },
587
- cache: { ttlMs: 5000 },
588
- logging: true,
589
- metrics: true,
644
+ rateLimit: { limit: 100, windowMs: 60_000 },
645
+ // `auth` is a bare getToken provider, not an AuthConfig
646
+ auth: { getToken: () => "my-token" },
647
+ // `cache` is a CacheConfig — the key is defaultTtlMs, there is no `ttlMs`
648
+ cache: { defaultTtlMs: 5000 },
649
+ logging: {/* Partial<LoggingConfig> */},
590
650
  });
591
- // suite.retry, suite.auth, suite.cache, suite.logging, suite.metrics, suite.eject
651
+ // suite: { manager, retry, auth, timeout, logging, cache, dedupe, har, metrics }
592
652
  ```
593
653
 
594
654
  ---
@@ -695,7 +755,7 @@ import type {
695
755
 
696
756
  const registry = new HookRegistry();
697
757
 
698
- // All hook types:
758
+ // All hook types (each add* returns a string id used for removal):
699
759
  // - addBeforeRequest(fn, options?)
700
760
  // - addAfterRequest(fn, options?)
701
761
  // - addBeforeResponse(fn, options?)
@@ -705,14 +765,20 @@ const registry = new HookRegistry();
705
765
  // - addOnRedirect(fn, options?)
706
766
  // - addOnUploadProgress(fn, options?)
707
767
  // - addOnDownloadProgress(fn, options?)
768
+ // - addOnCancel(fn, options?)
769
+ // - addOnConnection(fn, options?)
708
770
  // - addAround(fn, options?) // wraps the entire pipeline
709
771
 
710
- registry.addBeforeRequest(myHook, {
711
- priority: 10, // Lower number = runs first (default: 100)
772
+ const id = registry.addBeforeRequest(myHook, {
773
+ id: "my-hook", // optional unique id (auto-generated if omitted)
774
+ priority: 10, // Lower number = runs first (default: 0)
712
775
  once: true, // Auto-eject after first run
713
- if: (req) => req.method === "POST", // Conditional execution
776
+ condition: (ctx) => ctx.request.method === "POST", // receives the HookContext
777
+ safe: true, // swallow+log errors from this hook instead of propagating
714
778
  });
715
- registry.removeBeforeRequest(myHook); // Eject by reference
779
+ registry.remove(id); // eject by id — there is no removeBeforeRequest()
780
+ registry.has(id);
781
+ registry.removeAll();
716
782
 
717
783
  // Attach registry to a client
718
784
  client.attachHookRegistry(registry); // Returns single eject function
@@ -737,9 +803,9 @@ const responsePipe = composeBeforeResponse(fn1, fn2);
737
803
  // Redirect tracking
738
804
  const redirectTracker = new RedirectTracker({ maxRedirects: 5 });
739
805
  // Error classes
740
- class MyHTTPError extends HTTPError {} // extends Error
806
+ class MyHTTPError extends HTTPError {} // extends Error
741
807
  class MyValidationError extends ResponseValidationError {} // extends Error
742
- class TooManyRedirectsError extends Error {} // thrown by HookRegistry
808
+ class TooManyRedirectsError extends Error {} // thrown by HookRegistry
743
809
  ```
744
810
 
745
811
  ### HookEmitter
@@ -748,19 +814,25 @@ For event-style hook emission separate from the registry:
748
814
 
749
815
  ```ts
750
816
  const emitter = new HookEmitter();
751
- emitter.on("beforeRequest", myHandler);
752
- emitter.emit("beforeRequest", req, ctx);
753
- emitter.off("beforeRequest", myHandler);
754
- emitter.clear();
817
+ emitter.on("before:request", (req) => console.log(req.method, req.url));
818
+ await emitter.emit("before:request", req); // async; takes ONE event object
819
+ emitter.off("before:request", handler);
820
+ emitter.once("error", (err) => console.error(err));
821
+ emitter.removeAllListeners(); // all events
822
+ emitter.removeAllListeners("error"); // one event
755
823
  ```
756
824
 
825
+ 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()`.
826
+
757
827
  ### HookOptions
758
828
 
759
829
  ```ts
760
830
  interface HookOptions {
761
- priority?: number; // Lower runs first (default: 100)
831
+ id?: string; // Unique hook id. Auto-generated if omitted.
832
+ priority?: number; // Lower runs first (default: 0)
762
833
  once?: boolean; // Auto-eject after first execution
763
- if?: (req: KinetexRequest) => boolean; // Conditional execution predicate
834
+ condition?: (ctx: HookContext) => boolean; // Conditional predicate — not `if`
835
+ safe?: boolean; // Catch+log hook errors instead of propagating (default: false)
764
836
  }
765
837
  ```
766
838
 
@@ -768,7 +840,16 @@ interface HookOptions {
768
840
 
769
841
  ## Request Deduplication
770
842
 
771
- Coalesces identical concurrent GET/HEAD requests into a single network call:
843
+ Coalesces identical concurrent GET/HEAD requests into a single network call. The dedup key includes a SHA-256 fingerprint of every credential-bearing header, so two users calling the same URL with different `Authorization` / `Cookie` / API-key headers are never coalesced into one response:
844
+
845
+ ```ts
846
+ import { CREDENTIAL_HEADERS } from "kinetex/cache";
847
+ // ["authorization", "proxy-authorization", "cookie", "x-api-key", "apikey", "api-key",
848
+ // "x-auth-token", "x-access-token", "x-refresh-token", "x-session-id", "x-session-token",
849
+ // "x-secret", "x-secret-key", "x-private-key", "x-csrf-token"]
850
+ ```
851
+
852
+ > 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
853
 
773
854
  ```ts
774
855
  client.enableDedup({ windowMs: 50 }); // Also dedupe for 50ms after completion
@@ -805,7 +886,8 @@ const result = await dedup.execute("GET", "unique-key", () => fetchData());
805
886
  const dMap = createDedupMap<KinetexResponse>({ windowMs: 50 });
806
887
 
807
888
  // Metrics
808
- console.log(dedup.hits, dedup.misses, dedup.inFlightCount, dedup.stats);
889
+ console.log(dedup.hits, dedup.misses, dedup.inFlightCount, dedup.keys);
890
+ console.log(dedup.getStats()); // a method — there is no `stats` property
809
891
  ```
810
892
 
811
893
  ---
@@ -818,10 +900,11 @@ Per-origin (or per-key) three-state machine to prevent cascading failures:
818
900
  client.enableCircuitBreaker({
819
901
  failureThreshold: 5, // Failures before OPEN (default: 5)
820
902
  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: {
903
+ successThreshold: 2, // Consecutive successes to CLOSE (default: 2)
904
+ windowSize: 10, // Sliding window size (default: 10)
905
+ halfOpenConcurrency: 1, // Concurrent probes in HALF_OPEN (default: 1)
906
+ // The filter key is `failures`, not `failureFilter`
907
+ failures: {
825
908
  // Which failures count toward threshold
826
909
  networkErrors: true, // ENETWORK errors (default: true)
827
910
  timeouts: true, // ETIMEOUT errors (default: true)
@@ -829,9 +912,10 @@ client.enableCircuitBreaker({
829
912
  statusCodes: [503], // Specific status codes
830
913
  },
831
914
  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),
915
+ onClose: (state) => console.log("Circuit recovered", state),
916
+ onHalfOpen: (state) => console.log("Probing...", state),
917
+ // Every callback receives a CircuitBreakerState, not the request
918
+ onRejected: (state) => console.log("Rejected by CB", state.state, state.failureCount),
835
919
  });
836
920
 
837
921
  // Manual control
@@ -861,17 +945,29 @@ import type {
861
945
  FailureFilter,
862
946
  } from "kinetex/circuit-breaker";
863
947
 
864
- const cb = createCircuitBreaker({
948
+ // A standalone breaker takes a key as its FIRST argument, then the config.
949
+ const cb = createCircuitBreaker("api.example.com", {
865
950
  failureThreshold: 5,
866
951
  resetTimeoutMs: 30_000,
867
952
  });
868
953
 
954
+ await cb.execute(async () => {
955
+ /* throws CircuitOpenError while OPEN */
956
+ });
957
+ cb.state; // "CLOSED" | "OPEN" | "HALF_OPEN"
958
+ cb.snapshot; // CircuitBreakerState
959
+ cb.trip();
960
+ cb.reset();
961
+
869
962
  const registry = new CircuitBreakerRegistry(config);
870
963
  // Thin wrapper that manages a Map<string, CircuitBreaker>
964
+ await registry.execute("https://api.example.com", () => doRequest());
871
965
  registry.get("https://api.example.com"); // → CircuitBreaker
872
966
  registry.snapshots(); // → Record<string, CircuitBreakerState>
873
967
  registry.trip("origin");
874
968
  registry.reset("origin");
969
+ registry.delete("origin");
970
+ registry.size;
875
971
  registry.clear();
876
972
  ```
877
973
 
@@ -879,36 +975,64 @@ registry.clear();
879
975
 
880
976
  ## Caching
881
977
 
882
- RFC 7234 compliant HTTP caching with multiple storage backends:
978
+ 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
979
 
884
980
  ```ts
981
+ import { kinetex, MemoryStorageAdapter } from "kinetex";
982
+
885
983
  const client = kinetex({
886
984
  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
985
+ // maxEntries: 500, // LRU eviction
986
+ // maxSizeBytes: 50 * 1024 * 1024, // total bytes held
987
+ // maxBodySizeBytes: 5 * 1024 * 1024, // skip caching larger bodies
988
+ // defaultTtlMs: 60_000, // when the response has no Cache-Control (max 1 year)
989
+ // maxAbsoluteAgeMs: 7 * 24 * 60 * 60 * 1000, // hard cap regardless of Cache-Control
990
+ // honorCacheControl: true, // respect no-store / no-cache
991
+ // cacheMethods: ["GET", "HEAD"],
992
+ // cacheStatuses: [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
993
+ storage: new MemoryStorageAdapter(), // omit for plain in-memory
994
+ // cacheKey: (req) => `${req.url}`, // may be async
995
+ // namespace: "myapp", // key prefix
895
996
  },
896
997
  });
897
998
 
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
999
+ // Per-request cache control — this is CacheRequestConfig, a different shape
1000
+ // from the client-level CacheConfig above (note `enabled`, not `storage`).
1001
+ client.get("/users", { cache: { ttlMs: 5000 } }); // override TTL for this call
1002
+ client.get("/users", { cache: { tags: ["users"] } }); // tag for later invalidation
1003
+ client.get("/users", { cache: { forceRefresh: true } }); // bypass + re-cache
1004
+ client.get("/users", { cache: { enabled: false } }); // bypass entirely
1005
+ client.get("/users", { cache: false }); // shorthand for { enabled: false }
1006
+
1007
+ // Fluent equivalents
902
1008
  client.GET("/users").cache({ ttlMs: 5000 }).json();
903
- client.GET("/users").noCache().json();
1009
+ client.GET("/users").noCache().json(); // forceRefresh: true
1010
+ ```
904
1011
 
905
- // SWR error callback
906
- kinetex({
907
- cache: { swr: true },
1012
+ **Stale-while-revalidate needs no config flag.** There is no `swr` or `swrTtlMs` option: the SWR window is taken from the response's `Cache-Control: stale-while-revalidate=N`, and within that window kinetex serves the stale copy immediately and revalidates in the background. Two consequences worth knowing:
1013
+
1014
+ - 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.
1015
+ - `onSWRError` is a **top-level client option**, not a member of `cache`:
1016
+
1017
+ ```ts
1018
+ const client = kinetex({
908
1019
  onSWRError: (err, req) => console.error("SWR failed", req.url, err),
909
1020
  });
910
1021
  ```
911
1022
 
1023
+ ### Credential isolation
1024
+
1025
+ 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:
1026
+
1027
+ ```ts
1028
+ import { getAuthFingerprint, CREDENTIAL_HEADERS } from "kinetex/cache";
1029
+
1030
+ await getAuthFingerprint({ authorization: "Bearer …" }); // → "auth:9f86d0…"
1031
+ await getAuthFingerprint({ accept: "*/*" }); // → "" (anonymous → shared entry)
1032
+ ```
1033
+
1034
+ 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.
1035
+
912
1036
  ### Standalone Cache
913
1037
 
914
1038
  ```ts
@@ -924,51 +1048,65 @@ import {
924
1048
  CloudflareKVAdapter,
925
1049
  TwoTierStorageAdapter,
926
1050
  getAuthFingerprint,
1051
+ CREDENTIAL_HEADERS,
927
1052
  } from "kinetex/cache";
928
1053
  import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
1054
+ ```
929
1055
 
1056
+ Each factory takes its storage first, then an optional `CacheConfig` (with `storage` omitted, since the factory supplies it):
1057
+
1058
+ ```ts
930
1059
  // Memory cache
931
- const cache = createMemoryCache({ ttlMs: 60_000, maxEntries: 1000 });
1060
+ const cache = createMemoryCache({ defaultTtlMs: 60_000, maxEntries: 1000 });
932
1061
 
933
- // Browser localStorage cache
934
- const cache = createLocalStorageCache({ prefix: "myapp:" });
1062
+ // Browser localStorage cache — note the prefix is the first positional arg
1063
+ const cache = createLocalStorageCache("myapp:", { defaultTtlMs: 60_000 });
935
1064
 
936
1065
  // Browser sessionStorage cache
937
- const cache = createSessionStorageCache({ prefix: "myapp:" });
1066
+ const cache = createSessionStorageCache("myapp:");
938
1067
 
939
- // Cloudflare KV cache
940
- const cache = createKVCache({ kv: myKVNamespace, ttlMs: 60_000 });
1068
+ // Cloudflare KV cache — the namespace is the first positional arg
1069
+ const cache = createKVCache(myKVNamespace, { defaultTtlMs: 60_000 });
941
1070
 
942
- // Two-tier (L1 memory + L2 storage)
943
- const cache = createTwoTierCache({
944
- tier1: createMemoryCache({ ttlMs: 10_000 }),
945
- tier2: createLocalStorageCache({ prefix: "myapp:" }),
1071
+ // Two-tier: L1 is always in-memory, so you pass only the L2 adapter
1072
+ const cache = createTwoTierCache(new WebStorageAdapter(localStorage, "myapp:"), {
1073
+ defaultTtlMs: 60_000,
946
1074
  });
947
1075
 
948
- // Full HTTPCache
1076
+ // Full HTTPCache — you choose the adapter here
949
1077
  const cache = new HTTPCache({
950
1078
  storage: new MemoryStorageAdapter(),
951
- ttlMs: 60_000,
1079
+ defaultTtlMs: 60_000,
952
1080
  maxEntries: 1000,
953
- vary: true,
1081
+ maxBodySizeBytes: 1_000_000,
1082
+ maxAbsoluteAgeMs: 3_600_000,
954
1083
  namespace: "myapp",
955
1084
  });
956
1085
 
1086
+ const req = { url: "https://api.example.com/users", method: "GET", headers: {} };
1087
+
957
1088
  await cache.set(
958
- { url: "https://api.example.com/users", method: "GET", headers: {} },
1089
+ req,
959
1090
  { status: 200, statusText: "OK", headers: {}, body: "..." },
960
1091
  { tags: ["users"] },
961
1092
  );
962
1093
 
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
1094
+ const entry = await cache.get(req); // CacheEntry | null
1095
+ // entry.response, entry.createdAt, entry.expiresAt, entry.staleUntil, entry.staleOnError
1096
+ // entry.etag, entry.lastModified, entry.varyKey, entry.tags, entry.size
965
1097
 
966
- // Tag-based invalidation
1098
+ // Tag-based and URL-prefix invalidation
967
1099
  await cache.invalidateByTag("users");
1100
+ await cache.invalidateByURL("https://api.example.com/users");
1101
+
1102
+ // Conditional revalidation headers for a stored entry
1103
+ cache.buildConditionalHeaders(entry); // → { "if-none-match": "…" } when an etag exists
968
1104
 
969
- // Cache statistics
970
- const stats: CacheStats = cache.stats; // { size, hits, misses, evictions, hitRate }
971
- cache.clear();
1105
+ // Cache statistics — a method, not a property
1106
+ const stats: CacheStats = cache.getStats();
1107
+ // { hits, misses, staleHits, errors, evictions, totalEntries, totalSizeBytes, hitRate }
1108
+
1109
+ await cache.clear();
972
1110
  ```
973
1111
 
974
1112
  ---
@@ -1031,16 +1169,16 @@ jar.clearForUrl("https://example.com/api");
1031
1169
  interface Cookie {
1032
1170
  name: string;
1033
1171
  value: string;
1034
- domain: string;
1172
+ domain: string; // canonicalized, lowercased, no leading dot
1035
1173
  path: string;
1036
- expires: number | null; // epoch ms
1037
- maxAge: number | null;
1174
+ expires: number; // epoch ms; Infinity = session cookie (no Expires/Max-Age)
1175
+ maxAge: number | null; // raw Max-Age in seconds as parsed, null if absent
1038
1176
  secure: boolean;
1039
1177
  httpOnly: boolean;
1040
- sameSite: "strict" | "lax" | "none";
1041
- createdAt: number;
1042
- lastAccessed: number;
1043
- hostOnly: boolean;
1178
+ sameSite: SameSite; // "Strict" | "Lax" | "None" | "Unset"
1179
+ createdAt: number; // epoch ms
1180
+ lastAccessed: number; // epoch ms
1181
+ hostOnly: boolean; // true = set without a Domain attribute → exact host match only
1044
1182
  }
1045
1183
  ```
1046
1184
 
@@ -1094,14 +1232,26 @@ extractSetCookieHeaders(headers); // → string[]
1094
1232
  splitSetCookieHeaders("a=1, b=2"); // → ["a=1", "b=2"]
1095
1233
  ```
1096
1234
 
1097
- Internal storage model with LRU eviction (per-domain cap 50, global cap 3000). The `CookieStore` class handles the underlying storage:
1235
+ 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
1236
 
1099
1237
  ```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()
1238
+ import { CookieJar, createCookieJar, loadCookieJar } from "kinetex/cookiejar";
1239
+
1240
+ const jar = new CookieJar({
1241
+ maxTotal: 3000, // default 3000
1242
+ maxPerDomain: 50, // default 50
1243
+ domainMatcher: (requestHost, cookieDomain) => requestHost.endsWith(cookieDomain),
1244
+ });
1245
+
1246
+ // Cookies are read and written through the jar, not a raw store:
1247
+ jar.setCookie("session=abc123; Path=/; Secure", { url: "https://example.com/" });
1248
+ jar.getCookies({ url: "https://example.com/page", http: true }); // → Cookie[]
1249
+ jar.getCookieHeader({ url: "https://example.com/page" }); // → "session=abc123"
1250
+ jar.getAll();
1251
+ jar.getForDomain("example.com");
1252
+
1253
+ // Also: clear(), clearExpired(), clearSession(), clearForDomain(),
1254
+ // clearForUrl(), removeCookie(domain, path, name), toJSON(), toString()
1105
1255
  ```
1106
1256
 
1107
1257
  ---
@@ -1132,115 +1282,160 @@ import {
1132
1282
  } from "kinetex/pagination";
1133
1283
  import type { Page, PaginationState } from "kinetex/pagination";
1134
1284
 
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
- });
1285
+ // Core: paginate() takes a single PaginationConfig and an optional strategy name.
1286
+ // It does NOT take a client — you supply a `fetch` that returns the raw response.
1287
+ import {
1288
+ paginate,
1289
+ collectAll,
1290
+ collectPages,
1291
+ takeItems,
1292
+ paginateItems,
1293
+ prefetchPaginate,
1294
+ } from "kinetex/pagination";
1295
+
1296
+ const pages = paginate<Item>(
1297
+ {
1298
+ fetch: (state) => client.get<ItemsResponse>(`/items?offset=${state.offset}&limit=100`),
1299
+ getItems: (res) => res.items,
1300
+ hasNext: (res) => res.items.length === 100,
1301
+ getNext: (res) => ({ offset: res.items.at(-1)!.id }),
1302
+ getTotal: (res) => res.total,
1303
+ perPage: 100,
1304
+ startOffset: 0,
1305
+ maxPages: 10, // 0 = unlimited (default)
1306
+ delayMs: 0,
1307
+ signal: controller.signal,
1308
+ transform: (item) => item,
1309
+ filter: (item) => !item.deleted,
1310
+ onPage: (page) => console.log("fetched page", page.page),
1311
+ },
1312
+ "offset",
1313
+ );
1143
1314
 
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
1315
  for await (const page of pages) {
1204
1316
  console.log(page.items, page.total, page.page, page.hasNext, page.nextCursor);
1205
1317
  }
1206
1318
 
1207
- // Collect all items across all pages
1208
- const allItems = await collectAll(client, { url: "/items", strategy: "cursor" });
1319
+ // ── Per-strategy factories ────────────────────────────────────────────────
1320
+ // These wrap globalThis.fetch (or a `fetch` you pass) and build the config
1321
+ // for you. Each returns an AsyncGenerator<Page<T>> directly.
1322
+
1323
+ // Offset/limit — ?offset=0&limit=100
1324
+ createOffsetPaginator<Item>({
1325
+ url: "https://api.example.com/items",
1326
+ limit: 100,
1327
+ getItems: (data) => data.items,
1328
+ getTotal: (data) => data.total,
1329
+ maxPages: 10,
1330
+ // paramNames: { offset: "o", limit: "l" },
1331
+ // fetch: globalThis.fetch, headers: {}, signal,
1332
+ });
1209
1333
 
1210
- // Collect all page objects
1211
- const allPages = await collectPages(client, { url: "/items", strategy: "page", maxPages: 5 });
1334
+ // Page/per-page — ?page=1&per_page=100
1335
+ createPagePaginator<Item>({
1336
+ url: "https://api.example.com/items",
1337
+ perPage: 50,
1338
+ startPage: 1,
1339
+ getItems: (data) => data.items,
1340
+ // paramNames: { page: "p", perPage: "pp" },
1341
+ });
1342
+
1343
+ // Cursor — ?cursor=abc123
1344
+ createCursorPaginator<Item>({
1345
+ url: "https://api.example.com/items",
1346
+ getItems: (data) => data.items,
1347
+ getNextCursor: (data) => data.nextCursor, // string | null
1348
+ startCursor: null,
1349
+ paramName: "cursor", // default "cursor"
1350
+ });
1351
+
1352
+ // Keyset — ?after_id=123
1353
+ createKeysetPaginator<Item>({
1354
+ url: "https://api.example.com/items",
1355
+ keyParam: "after_id",
1356
+ getItems: (data) => data.items,
1357
+ getLastKey: (items) => String(items.at(-1)!.id),
1358
+ hasMore: (items) => items.length > 0,
1359
+ startKey: null,
1360
+ pageSize: 100,
1361
+ pageSizeParam: "limit",
1362
+ });
1363
+
1364
+ // Relay (GraphQL-style connections)
1365
+ // RelayPaginationOptions has no getItems/getNext: your `fetch` must resolve to a
1366
+ // RelayConnection<T> and the paginator unwraps edges/node and pageInfo itself.
1367
+ createRelayPaginator<Item>({
1368
+ fetch: async ({ first, after }) =>
1369
+ graphql<RelayConnection<Item>>(
1370
+ `
1371
+ query ($first: Int, $after: String) {
1372
+ items(first: $first, after: $after) {
1373
+ edges {
1374
+ node
1375
+ }
1376
+ pageInfo {
1377
+ hasNextPage
1378
+ endCursor
1379
+ }
1380
+ }
1381
+ }
1382
+ `,
1383
+ { first, after },
1384
+ ),
1385
+ first: 100, // page size
1386
+ startCursor: null,
1387
+ maxPages: 10,
1388
+ });
1212
1389
 
1213
- // Take N items across pages
1214
- const first50 = await takeItems(client, { url: "/items", strategy: "offset", perPage: 10 }, 50);
1390
+ // Link header (GitHub-style) — follows the RFC 8288 `Link` header
1391
+ createLinkHeaderPaginator<Item>({
1392
+ url: "https://api.github.com/repos/kinetexjs/kinetex/issues",
1393
+ getItems: (data) => data,
1394
+ headers: { Accept: "application/vnd.github+json" },
1395
+ });
1215
1396
 
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
- }
1397
+ // Page token (Google API-style)
1398
+ createTokenPaginator<Item>({
1399
+ url: "https://www.googleapis.com/books/v1/volumes",
1400
+ getItems: (data) => data.items,
1401
+ getNextToken: (data) => data.nextPageToken,
1402
+ tokenParam: "pageToken",
1403
+ pageSize: 10,
1404
+ });
1221
1405
 
1222
- // Parallel prefetch
1223
- const pages = paginate(client, { url: "/items", strategy: "page", prefetch: 3 });
1406
+ // ── Collection helpers — all take (config, strategy?) ────────────────────
1407
+ const allItems = await collectAll<Item>(config);
1408
+ const allPages = await collectPages<Item>(config);
1409
+ const first50 = await takeItems<Item>(50, config); // (n, config) — n comes FIRST
1410
+ const items = paginateItems<Item>(config); // yields items, not pages
1411
+ const prefetched = prefetchPaginate<Item>(config, "page", 3); // 3 pages in flight
1224
1412
 
1225
- // Merge two paginators
1413
+ // Merge several paginators into one stream
1226
1414
  const merged = mergePaginators(paginator1, paginator2);
1227
1415
 
1228
- // State serialization (resume capability)
1229
- const state: PaginationState = serializePaginationState(paginator);
1230
- const paginator2 = deserializePaginationState(client, state);
1416
+ // ── State serialization (base64 JSON of a PaginationState) ──────────────
1417
+ const serialized: string = serializePaginationState(state);
1418
+ const restored: PaginationState = deserializePaginationState(serialized);
1231
1419
 
1232
- // Convert to async iterator
1420
+ // Convert any AsyncIterable to an AsyncIterableIterator
1233
1421
  const iterator = toPaginationIterator(paginator);
1234
1422
  ```
1235
1423
 
1236
1424
  ### Client-Level Pagination
1237
1425
 
1238
1426
  ```ts
1427
+ // Routes through the full kinetex pipeline. Takes PagePaginationOptions
1428
+ // (minus url/fetch) — there is no `strategy` key; the strategy is implied.
1239
1429
  const pages = await client.paginate("/items", {
1240
- strategy: "page",
1241
1430
  perPage: 50,
1431
+ getItems: (data) => data.items,
1432
+ getTotal: (data) => data.total,
1242
1433
  maxPages: 10,
1243
1434
  });
1435
+
1436
+ for await (const page of pages) {
1437
+ console.log(page.items);
1438
+ }
1244
1439
  ```
1245
1440
 
1246
1441
  ---
@@ -1264,21 +1459,27 @@ import {
1264
1459
  } from "kinetex/sse";
1265
1460
  import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
1266
1461
 
1267
- // SSEClient
1462
+ // SSEClient — note the real option names: reconnect (not autoReconnect),
1463
+ // reconnectDelayMs / maxReconnectDelayMs (not baseDelay / maxDelay).
1464
+ // There is no `onEvent` option; iterate the client instead.
1268
1465
  const sse = new SSEClient({
1269
1466
  url: "https://api.example.com/events",
1270
1467
  method: "POST",
1271
1468
  headers: { Authorization: "Bearer token" },
1272
1469
  body: JSON.stringify({ query: "..." }),
1273
1470
  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
1471
  signal: controller.signal,
1472
+ lastEventId: "", // resume point
1473
+
1474
+ reconnect: true, // default true
1475
+ reconnectDelayMs: 3000, // default 3000
1476
+ maxReconnectDelayMs: 30_000, // default 30000
1477
+ reconnectJitter: 0.3, // default 0.3
1478
+ maxReconnects: 0, // 0 = unlimited (default)
1479
+ onReconnect: (attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`),
1480
+ onParseError: (err, raw) => console.warn("bad SSE frame", raw, err),
1481
+ heartbeatTimeoutMs: 0, // 0 = disabled
1482
+ validateResponse: (res) => res.ok || "stream rejected", // false | string to stop
1282
1483
  });
1283
1484
 
1284
1485
  // Async iteration
@@ -1313,8 +1514,9 @@ const response = createSSEResponse(); // → Response with text/event-stream
1313
1514
  ### Client-Level SSE
1314
1515
 
1315
1516
  ```ts
1517
+ // Takes Partial<SSEClientConfig> and routes through the full kinetex pipeline
1316
1518
  const sseClient = await client.sse("/events", {
1317
- autoReconnect: true,
1519
+ reconnect: true, // not `autoReconnect`
1318
1520
  maxReconnects: 5,
1319
1521
  });
1320
1522
  ```
@@ -1360,24 +1562,35 @@ import type {
1360
1562
 
1361
1563
  const ws = new WSClient({
1362
1564
  url: "wss://api.example.com/live",
1565
+ protocols: "graphql-ws", // or string[]
1363
1566
  headers: { Authorization: "Bearer token" },
1364
- reconnect: true,
1567
+ // There is no `reconnect` boolean or `baseDelay`/`maxDelay` — the real
1568
+ // names are reconnectBaseMs / reconnectMaxMs. maxReconnects: 0 = unlimited.
1365
1569
  maxReconnects: 10,
1366
- baseDelay: 1000,
1367
- maxDelay: 30000,
1570
+ reconnectBaseMs: 1000,
1571
+ reconnectMaxMs: 30_000,
1572
+ reconnectJitter: 0.3,
1368
1573
  connectTimeoutMs: 5000,
1369
- pingIntervalMs: 30000,
1574
+ pingIntervalMs: 30_000,
1575
+ pingPayload: "ping",
1576
+ pongMatcher: "pong", // string | RegExp
1370
1577
  pongTimeoutMs: 5000,
1371
1578
  highWaterMark: 65536,
1372
1579
  lowWaterMark: 16384,
1373
- maxSendRate: 0, // 0 = unlimited
1580
+ maxSendRate: 0, // 0 = unlimited
1374
1581
  keepRooms: true,
1582
+ bufferMessages: true,
1583
+ maxBufferSize: 1000,
1584
+ rooms: ["prices"],
1375
1585
  signal: controller.signal,
1376
1586
 
1587
+ onOpen: (reconnectCount) => console.log(`open (${reconnectCount} prior reconnects)`),
1377
1588
  onMessage: (msg) => console.log(msg.data, msg.json),
1378
1589
  onError: (err) => console.error(err),
1379
1590
  onClose: (code, reason, willReconnect) => {},
1380
- onReconnect: (attempt) => console.log(`Reconnecting (${attempt})`),
1591
+ onReconnect: (attempt, delayMs) => console.log(`Reconnecting (${attempt}) in ${delayMs}ms`),
1592
+ onGiveUp: (totalAttempts) => console.warn("gave up", totalAttempts),
1593
+ onBackpressure: (isBackpressured, info) => console.log("backpressure", info),
1381
1594
  });
1382
1595
 
1383
1596
  await ws.connect();
@@ -1389,11 +1602,15 @@ ws.sendBinary(new Uint8Array([1, 2, 3]));
1389
1602
 
1390
1603
  // Async iteration
1391
1604
  for await (const msg of ws) {
1392
- console.log(msg.data, msg.json?.type);
1605
+ // `json` is `unknown` — narrow it before reading fields.
1606
+ const payload = msg.json as { type?: string } | undefined;
1607
+ console.log(msg.data, payload?.type);
1393
1608
  }
1394
1609
 
1395
- // Request/response correlation
1396
- const reply = await ws.request({ type: "ping" }, (msg) => msg.json?.type === "pong");
1610
+ // Message subscription — returns an eject function (there is no
1611
+ // built-in request/response correlation helper)
1612
+ const off = ws.onMessage((msg) => console.log(msg.data, msg.json));
1613
+ off(); // unsubscribe
1397
1614
 
1398
1615
  // Metrics
1399
1616
  interface WSMetrics {
@@ -1407,7 +1624,9 @@ interface WSMetrics {
1407
1624
  }
1408
1625
 
1409
1626
  // Utility
1410
- const ws = await connectWS("wss://api.example.com/ws", { onMessage: ... });
1627
+ const ws = await connectWS("wss://api.example.com/ws", {
1628
+ onMessage: (msg) => console.log(msg.data),
1629
+ });
1411
1630
 
1412
1631
  // Connection state & health
1413
1632
  ws.state; // "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED" | "RECONNECTING"
@@ -1474,25 +1693,29 @@ const client = new GraphQLClient({
1474
1693
  url: "https://api.example.com/graphql",
1475
1694
  headers: { Authorization: "Bearer token" },
1476
1695
  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 },
1696
+ useGETForQueries: false,
1697
+ enableAPQ: true, // Automatic Persisted Queries (not `apq`)
1698
+ timeoutMs: 10_000,
1699
+ retries: 2, // plain number — there is no `retry: { maxRetries }`
1700
+ retryDelayMs: 300,
1481
1701
  signal: controller.signal,
1482
1702
  links: [
1483
1703
  // Middleware chain
1484
1704
  retryLink({ maxRetries: 3 }),
1485
- authLink({ getToken: () => "..." }),
1705
+ authLink(() => "..."), // authLink(getToken, scheme = "Bearer")
1486
1706
  loggingLink(),
1487
1707
  errorLink(),
1488
1708
  ],
1489
1709
  onRequest: (req) => console.log(req),
1490
- onResponse: (res) => console.log(res),
1710
+ onResponse: (res, req) => console.log(res, req),
1711
+ onError: (err, req) => console.error(err, req),
1491
1712
  });
1492
1713
 
1493
- // Query
1494
- const { data, errors } = await client.query<{ user: { name: string } }>(
1495
- gql`
1714
+ // Query — `query()` resolves to the response's `data` field directly, not to
1715
+ // a `{ data, errors }` envelope. A non-empty `errors` array throws
1716
+ // `GraphQLClientError`, which carries `.graphqlErrors` and `.response`.
1717
+ const data = await client.query<{ user: { name: string } }>(
1718
+ `
1496
1719
  query GetUser($id: ID!) {
1497
1720
  user(id: $id) {
1498
1721
  name
@@ -1502,9 +1725,9 @@ const { data, errors } = await client.query<{ user: { name: string } }>(
1502
1725
  { id: "1" },
1503
1726
  );
1504
1727
 
1505
- // Mutation
1728
+ // Mutation — also resolves to `data`
1506
1729
  const result = await client.mutate<{ updateUser: { success: boolean } }>(
1507
- gql`
1730
+ `
1508
1731
  mutation UpdateUser($id: ID!, $name: String!) {
1509
1732
  updateUser(id: $id, name: $name) {
1510
1733
  success
@@ -1514,9 +1737,10 @@ const result = await client.mutate<{ updateUser: { success: boolean } }>(
1514
1737
  { id: "1", name: "Alice" },
1515
1738
  );
1516
1739
 
1517
- // Subscription (SSE or WebSocket transport)
1518
- const sub = await client.subscribe(
1519
- gql`
1740
+ // Subscription — an async generator. The third argument is
1741
+ // { operationName, signal, url }; there is no `transport` option.
1742
+ const sub = client.subscribe(
1743
+ `
1520
1744
  subscription OnPrice {
1521
1745
  priceUpdate {
1522
1746
  symbol
@@ -1525,25 +1749,29 @@ const sub = await client.subscribe(
1525
1749
  }
1526
1750
  `,
1527
1751
  {},
1528
- { transport: "sse" },
1752
+ { operationName: "OnPrice", signal: controller.signal },
1529
1753
  );
1530
1754
  for await (const event of sub) {
1531
1755
  console.log(event.data);
1532
1756
  }
1533
1757
 
1534
- // Utility
1535
- detectOperationType(gql`query { ... }`); // → "query"
1536
- extractOperationName(gql`query GetUser { ... }`); // → "GetUser"
1758
+ // Utility — these take a raw query string, not a template tag
1759
+ detectOperationType("query { user { id } }"); // → "query"
1760
+ extractOperationName("query GetUser { user { id } }"); // → "GetUser"
1537
1761
  ```
1538
1762
 
1763
+ > **`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`.
1764
+
1539
1765
  ### Client-Level GraphQL
1540
1766
 
1767
+ `client.graphql()` returns a `GraphQLClient` whose transport is routed through the kinetex pipeline (auth, interceptors, rate limiting, circuit breaker, OTel), not a `gql` function.
1768
+
1541
1769
  ```ts
1542
- const gql = await client.graphql("/graphql", {
1543
- apq: true,
1544
- links: [authLink({ getToken: () => "..." })],
1770
+ const gqlClient = await client.graphql("/graphql", {
1771
+ enableAPQ: true,
1772
+ links: [authLink(() => "...")],
1545
1773
  });
1546
- const { data } = await gql.query(query, variables);
1774
+ const data = await gqlClient.query(query, variables);
1547
1775
  ```
1548
1776
 
1549
1777
  ---
@@ -1590,35 +1818,59 @@ const tracker = new ProgressTracker(10_000_000, {
1590
1818
  },
1591
1819
  });
1592
1820
 
1593
- tracker.update(500_000); // 500KB transferred
1594
- tracker.complete(); // Mark done
1595
- tracker.reset(20_000_000); // Reset with new total
1821
+ tracker.update(500_000); // 500KB transferred → ProgressSnapshot
1822
+ tracker.complete(); // Mark done → ProgressSnapshot
1823
+ tracker.snapshot(); // Current ProgressSnapshot
1824
+ // There is no reset(); construct a new ProgressTracker(total, options) instead.
1596
1825
 
1597
- // Wrap a ReadableStream with progress tracking
1598
- const { stream } = withUploadProgress(readableStream, totalBytes, {
1599
- onProgress: (snap) => {},
1600
- });
1826
+ // Wrap an upload body with progress tracking → { stream, tracker }
1827
+ const { stream: uploadStream, tracker: upTracker } = withUploadProgress(
1828
+ readableStream,
1829
+ totalBytes,
1830
+ { onProgress: (snap) => {} },
1831
+ );
1601
1832
 
1602
- const { stream } = withDownloadProgress(response, {
1833
+ // Download tracking returns { response, tracker } — the Response is returned
1834
+ // with an instrumented body, so there is no `stream` property here.
1835
+ const { response: tracked, tracker: downTracker } = withDownloadProgress(response, {
1603
1836
  onProgress: (snap) => {},
1604
1837
  });
1605
1838
 
1606
- // Blob upload progress
1607
- const { stream } = withBlobUploadProgress(blob, {
1839
+ // Blob upload progress → { stream, tracker }
1840
+ const { stream: blobStream, tracker: blobTracker } = withBlobUploadProgress(blob, {
1608
1841
  onProgress: (snap) => {},
1609
1842
  });
1843
+ ```
1844
+
1845
+ 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.
1846
+
1847
+ > 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
1848
 
1611
- const stream = streamWithProgress(readableStream, tracker);
1849
+ ```ts
1850
+ // Wrap a ReadableStream with progress tracking. `streamWithProgress(stream,
1851
+ // total, options)` is an async generator yielding { chunk, progress } — it
1852
+ // returns the generator itself, not an object with a `stream` property.
1853
+ // Its options are `Omit<ProgressOptions, "onProgress">`: progress arrives as
1854
+ // the `progress` field of each yielded value instead of via a callback.
1855
+ for await (const { chunk, progress } of streamWithProgress(readableStream, totalBytes, {
1856
+ throttleHz: 10,
1857
+ })) {
1858
+ // … consume `chunk` …
1859
+ }
1612
1860
 
1613
- // Collect full stream into Uint8Array
1861
+ // Collect a full stream into a Uint8Array
1614
1862
  const bytes = await collectStream(readableStream);
1615
1863
 
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
1864
+ // Multi-part progress — create a per-part tracker, then read the roll-up.
1865
+ // There is no addPart()/update(partId)/total() API.
1866
+ const agg = new MultiPartProgressAggregator(3, (m) => {
1867
+ console.log("overall", m.overall.percent);
1868
+ });
1869
+ const part = agg.createPartTracker(0, 500, { onProgress: (s) => console.log(s.loaded) });
1870
+ part.update(250);
1871
+ part.complete();
1872
+
1873
+ const { parts, overall } = agg.getOverall(); // MultiPartProgress
1622
1874
 
1623
1875
  // Formatters
1624
1876
  formatBytes(1500); // "1.46 KB"
@@ -1676,13 +1928,15 @@ const signer = new SigV4Signer({
1676
1928
  },
1677
1929
  region: "us-east-1",
1678
1930
  service: "s3",
1679
- signingDate: new Date(), // Override signing date
1680
- payloadHash: "UNSIGNED-PAYLOAD", // For streaming
1931
+ unsignedPayload: true, // Send UNSIGNED-PAYLOAD (for streaming) — not `payloadHash`
1681
1932
  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)
1933
+ doubleEncodeUri: true, // RFC 3986 double-encode (not `doubleEncode`)
1685
1934
  });
1935
+ // The SigV4Signer constructor is Omit<SigningConfig, "signingDate" |
1936
+ // "clockSkewSecs">: both are managed internally (the latter tracks detected
1937
+ // clock skew). To pin a signing date, pass a full SigningConfig to
1938
+ // signRequest(request, config) / presignRequest(request, config) instead.
1939
+ // There is no `presignExpires` or `normalizePath` on SigningConfig either.
1686
1940
 
1687
1941
  // Sign a request
1688
1942
  const signed = await signer.sign({
@@ -1708,7 +1962,7 @@ const key = await deriveSigningKey(credentials, dateStamp, region, service);
1708
1962
  const provider = staticCredentials({ accessKeyId: "...", secretAccessKey: "..." });
1709
1963
  const provider = envCredentials(); // AWS_ACCESS_KEY_ID, etc.
1710
1964
  const provider = cachingCredentials(innerProvider, 5 * 60_000); // Cache with TTL
1711
- const provider = imdsCredentials({ retries: 3 }); // EC2 IMDS
1965
+ const provider = imdsCredentials({ timeout: 1000 }); // EC2 IMDS — options are { endpoint?, timeout? }
1712
1966
  const provider = chainCredentials(envCredentials, imdsCredentials); // Fallback chain
1713
1967
 
1714
1968
  // Specialized signers
@@ -1726,15 +1980,24 @@ const policy = signS3PostPolicy(credentials, region, new Date(), {
1726
1980
  });
1727
1981
 
1728
1982
  // 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,
1983
+ // `initChunkedSigning(request, config)` takes a SignableRequest and a
1984
+ // SigningConfig. It returns the seed request plus the state object that
1985
+ // `signChunk` / `signFinalChunk` thread through each chunk — there are no
1986
+ // `sessionToken`, `dateTime`, `chunkIndex`, or `previousSignature` arguments.
1987
+ const { signedRequest, state } = await initChunkedSigning(
1988
+ { url: targetURL, method: "PUT", headers: {}, body: null },
1989
+ { credentials, region, service: "s3" },
1736
1990
  );
1737
- const finalSignature = await signFinalChunk(sessionToken, dateTime, chunkIndex, previousSignature);
1991
+
1992
+ // signChunk returns the wire header and the *updated* state; feed newState
1993
+ // into the next call rather than reusing `state`.
1994
+ for await (const chunk of chunks) {
1995
+ const { chunkHeader, newState } = await signChunk(chunk, state);
1996
+ state = newState;
1997
+ write(chunkHeader);
1998
+ write(chunk);
1999
+ }
2000
+ write(await signFinalChunk(state));
1738
2001
 
1739
2002
  // Clock skew detection
1740
2003
  const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
@@ -1770,24 +2033,31 @@ import {
1770
2033
  } from "kinetex/socks5";
1771
2034
  import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
1772
2035
 
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
- });
2036
+ // Standalone tunnel. All three arguments are required:
2037
+ // (proxy config, target, connector). The result is a raw TCP tunnel —
2038
+ // Socks5Tunnel is { conn, boundAddr, boundPort }. It has NO .send();
2039
+ // the tunnel is not an HTTP client. Speak HTTP/TLS over `tunnel.conn`
2040
+ // yourself, or hand it to a transport that can.
2041
+ const tunnel: Socks5Tunnel = await createSocks5Tunnel(
2042
+ {
2043
+ host: "127.0.0.1", // not `proxyHost`
2044
+ port: 1080, // not `proxyPort`
2045
+ username: "user", // Optional: RFC 1929 auth
2046
+ password: "pass",
2047
+ remoteDns: true, // Resolve hostnames at the proxy
2048
+ connectTimeoutMs: 10_000, // not `connectTimeout`
2049
+ handshakeTimeoutMs: 10_000,
2050
+ maxRetries: 2, // not `retries`
2051
+ retryDelayMs: 500,
2052
+ },
2053
+ { host: "api.example.com", port: 443, tls: true, tlsServerName: "api.example.com" },
2054
+ nodeTcpConnector,
2055
+ );
1782
2056
 
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
- });
2057
+ // tunnel.conn is a TcpConn: { read(buf), write(data), close() }
2058
+ tunnel.boundAddr; // string — address the proxy reported
2059
+ tunnel.boundPort; // number
2060
+ tunnel.conn.close();
1791
2061
 
1792
2062
  // Parse SOCKS5 URL
1793
2063
  const config = parseSocks5Url("socks5://user:pass@127.0.0.1:1080");
@@ -1796,8 +2066,8 @@ const config = parseSocks5Url("socks5://user:pass@127.0.0.1:1080");
1796
2066
  const nodeConnector: TcpConnector = nodeTcpConnector; // Node.js
1797
2067
  const denoConnector: TcpConnector = denoTcpConnector; // Deno
1798
2068
  const customConnector: TcpConnector = socks5Connector({
1799
- proxyHost: "127.0.0.1",
1800
- proxyPort: 1080,
2069
+ host: "127.0.0.1",
2070
+ port: 1080,
1801
2071
  }); // Returns a TcpConnector function
1802
2072
 
1803
2073
  // Client-level proxy
@@ -1815,22 +2085,26 @@ try {
1815
2085
  // Correct way to route through an HTTP(S) proxy — supply a proxy-aware fetch:
1816
2086
  import { ProxyAgent } from "undici"; // npm i undici (Node.js)
1817
2087
  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,
2088
+ fetch: new ProxyAgent("http://127.0.0.1:8080").dispatch.bind(
2089
+ new ProxyAgent("http://127.0.0.1:8080"),
2090
+ ) as typeof fetch,
1819
2091
  });
1820
2092
 
1821
- // Correct way to route through a SOCKS5 proxy — create a tunnel transport:
2093
+ // Correct way to route through a SOCKS5 proxy — dial through the tunnel
2094
+ // and upgrade to TLS, then hand the socket to a fetch implementation that
2095
+ // accepts a custom connection:
1822
2096
  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
- });
2097
+ const socks = await createSocks5Tunnel(
2098
+ { host: "127.0.0.1", port: 1080 },
2099
+ { host: "api.example.com", port: 443, tls: true },
2100
+ nodeTcpConnector,
2101
+ );
2102
+ const tlsSocket = await upgradeToTLS(socks.conn, { servername: "api.example.com" });
2103
+ const viaSocks = kinetex({ fetch: fetchOverSocket(tlsSocket) });
1832
2104
  ```
1833
2105
 
2106
+ `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`.
2107
+
1834
2108
  ---
1835
2109
 
1836
2110
  ## Digest Authentication
@@ -1843,9 +2117,11 @@ import {
1843
2117
  computeDigestResponse,
1844
2118
  formatDigestAuth,
1845
2119
  createDigestAuthorization,
2120
+ createDigestAuthorizer,
1846
2121
  } from "kinetex/digest";
1847
2122
  import type { DigestChallenge } from "kinetex/digest";
1848
2123
 
2124
+ // One-shot: stateless, always nc=00000001
1849
2125
  const authHeader = await createDigestAuthorization(
1850
2126
  `Digest realm="test", nonce="abc123", algorithm=MD5, qop="auth"`,
1851
2127
  "username",
@@ -1856,6 +2132,25 @@ const authHeader = await createDigestAuthorization(
1856
2132
  // → 'Digest username="username", realm="test", nonce="abc123", uri="/resource", response="...", algorithm=MD5, qop=auth, nc=00000001, cnonce="..."'
1857
2133
  ```
1858
2134
 
2135
+ ### Nonce counting (`nc`)
2136
+
2137
+ 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:
2138
+
2139
+ ```ts
2140
+ // The first argument is the raw WWW-Authenticate header string, not a
2141
+ // parsed challenge object.
2142
+ const authorize = createDigestAuthorizer();
2143
+
2144
+ await authorize(challenge, "username", "password", "GET", "/a"); // nc=00000001
2145
+ await authorize(challenge, "username", "password", "GET", "/b"); // nc=00000002
2146
+ await authorize(challenge, "username", "password", "GET", "/c"); // nc=00000003
2147
+
2148
+ // A new nonce from the server resets the counter
2149
+ await authorize(newChallenge, "username", "password", "GET", "/a"); // nc=00000001
2150
+ ```
2151
+
2152
+ 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.
2153
+
1859
2154
  ---
1860
2155
 
1861
2156
  ## Structured Logging
@@ -1881,14 +2176,25 @@ const logger = createLogger({
1881
2176
  level: "info", // "trace" | "debug" | "info" | "warn" | "error" | "silent"
1882
2177
  transports: [
1883
2178
  new ConsoleTransport({ pretty: true }), // Console output
1884
- new JSONTransport({ file: "requests.log" }), // File output
2179
+ new JSONTransport((line) => appendFileSync("requests.log", line)), // one JSON line at a time
1885
2180
  ],
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
2181
+ // Redaction is a structured config, not a flat array of patterns
2182
+ redaction: {
2183
+ headers: ["authorization", "cookie", "x-api-key"],
2184
+ queryParams: ["api_key", "access_token"],
2185
+ bodyFields: ["password", "ssn"],
2186
+ bodyPatterns: [/secret.*/i],
2187
+ maxBodyLength: 1000,
2188
+ logRequestBody: true,
2189
+ logResponseBody: true,
2190
+ allowedBodyTypes: ["application/json"],
2191
+ },
2192
+ sampleRate: 0.5, // Log ~50% of requests (not `sampling`)
2193
+ methods: ["GET", "POST"],
2194
+ statuses: [429, 500, 503],
2195
+ excludeURLs: [/\/health$/],
2196
+ context: { service: "api" },
2197
+ generateId: () => crypto.randomUUID(),
1892
2198
  });
1893
2199
 
1894
2200
  // Client-level logging
@@ -1898,9 +2204,9 @@ kinetex({
1898
2204
  });
1899
2205
 
1900
2206
  // Batching transport (async flush)
1901
- const batch = new BatchingTransport({
1902
- maxBatch: 100,
1903
- flushIntervalMs: 5000,
2207
+ const batch = new BatchingTransport(inner, {
2208
+ maxBatch: 100, // default 100
2209
+ flushMs: 5000, // default 5000 — not `flushIntervalMs`
1904
2210
  });
1905
2211
 
1906
2212
  // Remote transport
@@ -1929,7 +2235,7 @@ await client.get("/users");
1929
2235
  await client.post("/posts", { title: "Test" });
1930
2236
 
1931
2237
  const har = client.getHAR();
1932
- // HARLog { version: "1.2", creator: { name: "kinetex", version: "0.0.3" }, entries: [...] }
2238
+ // HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
1933
2239
 
1934
2240
  // Each HAREntry contains:
1935
2241
  // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
@@ -1940,6 +2246,21 @@ const har = client.getHAR();
1940
2246
  client.clearHAR();
1941
2247
  ```
1942
2248
 
2249
+ ### Redaction
2250
+
2251
+ HAR logs are routinely exported and shared, so entries are redacted before they are recorded:
2252
+
2253
+ - **Headers** — every credential-bearing name (`authorization`, `cookie`, `set-cookie`, `x-api-key`, `apikey`, `x-session-token`, …) is replaced with `***REDACTED***`.
2254
+ - **URLs** — sensitive query parameters (`api_key`, `access_token`, `signature`, `password`, `code`, `sas`, …) and the fragment are masked, in both `request.url` and `request.queryString[]`. Non-sensitive parameters and the rest of the URL are preserved so the log stays useful.
2255
+ - **`Location`** — the redirect target is passed through the same URL redaction.
2256
+ - **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.
2257
+
2258
+ ```ts
2259
+ // ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
2260
+ ```
2261
+
2262
+ > 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.
2263
+
1943
2264
  ---
1944
2265
 
1945
2266
  ## OpenTelemetry Tracing
@@ -1968,9 +2289,15 @@ client.setTracer({
1968
2289
  spanContext() {
1969
2290
  return { traceId: "x", spanId: "y", traceFlags: 1 };
1970
2291
  },
1971
- setAttribute(key, value) { return this; },
1972
- setStatus(status) { return this; },
1973
- recordException(err) { return this; },
2292
+ setAttribute(key, value) {
2293
+ return this;
2294
+ },
2295
+ setStatus(status) {
2296
+ return this;
2297
+ },
2298
+ recordException(err) {
2299
+ return this;
2300
+ },
1974
2301
  end() {},
1975
2302
  };
1976
2303
  },
@@ -2043,6 +2370,7 @@ const transport = new NodeHTTP2Transport({
2043
2370
  maxSessions: 100, // Max concurrent sessions (default: 100)
2044
2371
  connectTimeoutMs: 30_000, // Connection timeout (default: 30_000)
2045
2372
  requestTimeoutMs: 30_000, // Per-request stream timeout (default: 30_000)
2373
+ ca: [readFileSync("corp-ca.pem")], // Trust a private/self-signed CA for this origin
2046
2374
  strict: false, // Strict header validation
2047
2375
  onDroppedHeader: (name, value) => {},
2048
2376
  });
@@ -2115,7 +2443,7 @@ const raw = await sendWithTimeout(transport, request, 5000); // → RawResponse,
2115
2443
  const body = await readRawBody(stream, maxBytes, url, signal); // → Uint8Array, throws SizeLimitError
2116
2444
 
2117
2445
  // Parse body by content-type
2118
- const data = parseBody<MyType>(rawBody, contentType, customParser?, onParseFailure?, headers?, url?);
2446
+ const data = parseBody<MyType>(rawBody, contentType, customParse, onParseFailure, headers, url);
2119
2447
 
2120
2448
  // Decompress body stream
2121
2449
  const decompressed = await decompressBodyStream(body, headers);
@@ -2185,8 +2513,8 @@ High-throughput request batching:
2185
2513
  import { BatchQueue } from "kinetex";
2186
2514
 
2187
2515
  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)
2516
+ maxBatch: 50, // Dispatch in groups of 50 (default: 100; must be a positive integer)
2517
+ flushMs: 10, // Flush after 10ms even if the batch is not full (default: 0)
2190
2518
  });
2191
2519
 
2192
2520
  // Fire many requests — they batch automatically
@@ -2200,6 +2528,8 @@ batch.flush(); // Force flush pending requests
2200
2528
  batch.pendingCount; // Number of queued requests
2201
2529
  ```
2202
2530
 
2531
+ > `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`.
2532
+
2203
2533
  ---
2204
2534
 
2205
2535
  ## URL Utilities
@@ -2256,8 +2586,8 @@ import type {
2256
2586
  DataURLParts,
2257
2587
  } from "kinetex/url";
2258
2588
 
2259
- // URL Builder (fluent, immutable)
2260
- const url = URLBuilder.from("https://api.example.com")
2589
+ // URL Builder (fluent, immutable — every method returns a new builder)
2590
+ const builder = URLBuilder.from("https://api.example.com")
2261
2591
  .withPathname("/v1/users")
2262
2592
  .appendPath("42", "posts")
2263
2593
  .setParam("page", "1")
@@ -2265,29 +2595,30 @@ const url = URLBuilder.from("https://api.example.com")
2265
2595
  .omitParams("internal")
2266
2596
  .redactParams("token")
2267
2597
  .sortParams()
2268
- .addTrailingSlash()
2269
- .toString();
2598
+ .addTrailingSlash();
2599
+
2600
+ builder.toString();
2270
2601
  // → "https://api.example.com/v1/users/42/posts/?limit=10&page=1&token=REDACTED"
2271
2602
 
2272
2603
  URLBuilder.https("api.example.com", "/v1/users"); // Factory
2273
2604
  URLBuilder.http("api.example.com"); // Factory
2274
2605
 
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[]>
2606
+ // Properties — read these off the *builder*, not off toString():
2607
+ builder.href;
2608
+ builder.protocol;
2609
+ builder.hostname;
2610
+ builder.host;
2611
+ builder.port;
2612
+ builder.pathname;
2613
+ builder.search;
2614
+ builder.hash;
2615
+ builder.origin;
2616
+ builder.searchParams; // → URLSearchParams
2617
+ builder.queryObject; // → Record<string, string | string[]>
2287
2618
 
2288
2619
  // Percent encoding
2289
2620
  percentEncode("hello world"); // "hello%20world"
2290
- percentEncode("a b", true); // "a%20b" (reserved not encoded)
2621
+ percentEncode("a b", true); // "a%20b" — true lets reserved chars (:/?#[]@!$&'()*+,;=) pass through
2291
2622
  percentDecode("hello%20world"); // "hello world"
2292
2623
 
2293
2624
  // Query string
@@ -2324,7 +2655,8 @@ isLocalhost("http://localhost:8080"); // true
2324
2655
 
2325
2656
  // URL resolution
2326
2657
  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"
2658
+ relativeURL("https://api.example.com/v1/users", "https://api.example.com"); // "v1/users" (no leading slash)
2659
+ relativeURL("https://other.com/x", "https://api.example.com"); // null — not under base
2328
2660
 
2329
2661
  // Data URLs
2330
2662
  parseDataURL("data:image/png;base64,iVBOR..."); // { mediaType: "image/png", isBase64: true, data: "iVBOR..." }
@@ -2332,7 +2664,7 @@ buildDataURL("hello", "text/plain"); // "data:text/plain;base64,aGVsbG8="
2332
2664
 
2333
2665
  // Redaction
2334
2666
  redactURL("https://api.example.com?token=secret&key=123", "token", "key");
2335
- // → "https://api.example.com?token=REDACTED&key=REDACTED"
2667
+ // → "https://api.example.com/?token=REDACTED&key=REDACTED"
2336
2668
 
2337
2669
  // Diff
2338
2670
  diffURLs("https://a.com/path?a=1", "https://b.com/other?b=2");
@@ -2397,7 +2729,7 @@ import {
2397
2729
  // Forwarded
2398
2730
  parseForwarded,
2399
2731
  normalizeForwardedHeaders,
2400
- getClientIP,
2732
+ getClientIP, // (headers, { trustedHops }) — see below
2401
2733
 
2402
2734
  // Retry
2403
2735
  parseRetryAfter,
@@ -2412,8 +2744,8 @@ import {
2412
2744
  parseAltSvc,
2413
2745
  parseWarning,
2414
2746
  parseParams,
2415
- securityHeaders, // Recommended security headers map
2416
- corsHeaders, // CORS headers map
2747
+ securityHeaders, // (options) => HttpHeaders — recommended security header set
2748
+ corsHeaders, // (options) => HttpHeaders — CORS response headers
2417
2749
 
2418
2750
  // Conversion
2419
2751
  fromNodeHeaders, // node:http.IncomingMessage → Record
@@ -2429,22 +2761,45 @@ HeaderName.CacheControl; // "cache-control"
2429
2761
  HeaderName.ETag; // "etag"
2430
2762
  // ... all standard headers
2431
2763
 
2432
- // Cache-Control parsing
2764
+ // Cache-Control parsing — directives use camelCase keys, not kebab-case
2433
2765
  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" });
2766
+ // → { noCache: false, noStore: false, noTransform: false, onlyIfCached: false,
2767
+ // maxAge: 3600, maxStale: null, minFresh: null, staleIfError: null,
2768
+ // public: true, private: false, mustRevalidate: false, proxyRevalidate: false,
2769
+ // sMaxAge: null, immutable: false, mustUnderstand: false,
2770
+ // staleWhileRevalidate: 300, unknown: {} }
2771
+ formatCacheControl({ public: true, maxAge: 3600 }); // "public, max-age=3600"
2772
+
2773
+ // Content-Type — takes a single object, not (string, options)
2774
+ formatContentType({ mediaType: "application/json", charset: "utf-8" });
2439
2775
  // → "application/json; charset=utf-8"
2440
2776
 
2441
- // Security headers preset
2442
- securityHeaders; // { "x-content-type-options": "nosniff", "x-frame-options": "DENY", ... }
2443
- corsHeaders; // { "access-control-allow-origin": "*", ... }
2777
+ // Security / CORS headers are functions returning HttpHeaders
2778
+ securityHeaders({ hsts: true, csp: "default-src 'self'", frameOptions: "DENY", noSniff: true });
2779
+ corsHeaders({ origin: "*", methods: ["GET", "POST"], credentials: false, maxAge: 600 });
2444
2780
  ```
2445
2781
 
2446
2782
  ---
2447
2783
 
2784
+ ### `getClientIP` — proxy trust
2785
+
2786
+ `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:
2787
+
2788
+ ```ts
2789
+ import { getClientIP, HttpHeaders } from "kinetex/headers";
2790
+
2791
+ const headers = new HttpHeaders(req.headers);
2792
+
2793
+ // X-Forwarded-For: 1.1.1.1, 2.2.2.2, 3.3.3.3
2794
+ getClientIP(headers, { trustedHops: 1 }); // → "3.3.3.3" (written by your edge proxy)
2795
+ getClientIP(headers, { trustedHops: 2 }); // → "2.2.2.2"
2796
+ getClientIP(headers); // → "1.1.1.1" (client-supplied, spoofable — default for back-compat)
2797
+ ```
2798
+
2799
+ `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.
2800
+
2801
+ ---
2802
+
2448
2803
  ## Response Parsing Utilities
2449
2804
 
2450
2805
  ```ts
@@ -2492,17 +2847,17 @@ const formData = await readFormData(response); // Parse as FormData
2492
2847
  const data = await assertOkJSON<MyType>(response); // Throws on non-2xx
2493
2848
  await assertOk(response); // Throws on non-2xx
2494
2849
 
2495
- // Size limiting
2496
- const limited = await readBodyWithLimit(response, {
2850
+ // Size limiting — readBodyWithLimit takes a *stream*, a url, and a limit config
2851
+ const limited = await readBodyWithLimit(response.body, response.url, {
2497
2852
  maxBytes: 1_000_000,
2498
2853
  onExceed: "throw", // "throw" | "truncate" | "abort"
2499
2854
  onExceedCallback: (bytesRead, limit) => log(`Exceeded ${limit}`),
2500
2855
  });
2501
2856
 
2502
- const reader = createLimitedReader(stream, {
2503
- maxBytes: 1_000_000,
2504
- onExceed: "throw",
2505
- });
2857
+ // createLimitedReader takes a byte count and an action — not (stream, config).
2858
+ // It returns a LimitedReader with json/text/bytes/blob/stream/ndjson methods.
2859
+ const reader = createLimitedReader(1_000_000, "throw");
2860
+ const parsed = await reader.json<Response>(response);
2506
2861
 
2507
2862
  // Multipart
2508
2863
  const parts = await parseMultipartResponse(response, boundary);
@@ -2511,16 +2866,17 @@ const parts = await parseMultipartResponse(response, boundary);
2511
2866
  const decompressed = await decompressStream(compressedStream, "gzip");
2512
2867
  const raw = await applyDecompression(rawBody, headers);
2513
2868
 
2514
- // Server-Timing
2869
+ // Server-Timing — returns an array of metrics
2515
2870
  const timings = extractServerTiming(headers);
2516
- // → { dur, desc, ... }
2871
+ // → [{ name: "db", duration: 53, description: null }]
2517
2872
 
2518
2873
  // Response diffing
2519
2874
  const diff = diffResponses(res1, res2);
2520
2875
 
2521
2876
  // Content type
2522
2877
  parseContentType("application/json; charset=utf-8");
2523
- // { type: "application/json", parameters: { charset: "utf-8" } }
2878
+ // { mediaType: "application/json", type: "application", subtype: "json",
2879
+ // charset: "utf-8", boundary: null }
2524
2880
  isJSON(response); // true if content-type is JSON
2525
2881
  isText(response); // true if content-type is text/*
2526
2882
  isBinary(response); // true if binary content-type
@@ -2623,18 +2979,30 @@ import {
2623
2979
  } from "kinetex";
2624
2980
 
2625
2981
  // 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
2982
+ isUint8Array(data); // data is Uint8Array
2983
+ isPlainObject(obj); // obj is Record<string, unknown>
2984
+ isAbortSignal(signal); // signal is AbortSignal
2985
+ isFormData(data); // data is FormData
2986
+ isBlob(data); // data is Blob
2987
+ isAbortError(err); // boolean
2632
2988
  isValidHeaderName("x-foo"); // boolean
2633
- isValidHeaderValue("bar"); // boolean
2989
+ isValidHeaderValue("bar"); // boolean
2634
2990
 
2635
2991
  // Safe URL checking
2636
- isSafeURL("https://evil.com"); // boolean — checks for dangerous protocols
2637
- sanitizeURL("javascript:alert(1)"); // string — stripped or redacted
2992
+ // Rejects non-HTTP(S) schemes and any host that resolves to a blocked literal range:
2993
+ // loopback, RFC 1918, CGNAT, link-local (incl. 169.254.169.254), IETF/TEST-NET,
2994
+ // benchmarking, multicast and reserved space — including IPv4-mapped/compatible
2995
+ // IPv6, 6to4, NAT64, hex/octal/decimal IPv4 literals and WHATWG shortcut hosts.
2996
+ isSafeURL("https://api.example.com"); // true
2997
+ isSafeURL("http://127.0.0.1/"); // false
2998
+ isSafeURL("http://169.254.169.254/latest/meta-data/"); // false
2999
+ isSafeURL("http://[::ffff:127.0.0.1]/"); // false
3000
+
3001
+ // Applied to the initial URL AND to every redirect hop, so a public host cannot
3002
+ // bounce a request into the private network. (DNS rebinding is out of scope —
3003
+ // the check is literal-address based, not a resolution.)
3004
+ sanitizeURL("javascript:alert(1)"); // null — invalid or an SSRF risk
3005
+ sanitizeURL("https://user:pass@api.example.com/x"); // "https://api.example.com/x" (credentials stripped)
2638
3006
 
2639
3007
  // Error construction
2640
3008
  const err = createStructuredError("EVALIDATION", "Invalid config", {
@@ -2730,19 +3098,20 @@ validateErrorCode("INVALID"); // undefined
2730
3098
 
2731
3099
  ### Error Codes
2732
3100
 
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 |
3101
+ | Code | Error Class | Description |
3102
+ | -------------- | ------------------ | ----------------------------------- |
3103
+ | `ENETWORK` | `NetworkError` | Server/endpoint unreachable |
3104
+ | `ETIMEOUT` | `TimeoutError` | Request/connection timeout |
3105
+ | `EABORT` | `AbortError` | Request cancelled by caller |
3106
+ | `EHTTPSTATUS` | `HTTPStatusError` | Server returned 4xx/5xx |
3107
+ | `ESIZELIMIT` | `SizeLimitError` | Response body exceeded size limit |
3108
+ | `EPARSE` | — | Failed to parse response body |
3109
+ | `EVALIDATION` | `ValidationError` | Invalid request configuration |
3110
+ | `EAUTH` | `AuthError` | Authentication failed |
3111
+ | `EPROXY` | `ProxyError` | Proxy configuration error |
3112
+ | `EREDIRECT` | `RedirectError` | Redirect error |
3113
+ | `ECIRCUITOPEN` | `CircuitOpenError` | Rejected by an open circuit breaker |
3114
+ | `EUNKNOWN` | `KinetexError` | Unknown/unexpected |
2746
3115
 
2747
3116
  ---
2748
3117
 
@@ -2752,54 +3121,145 @@ All sub-modules are tree-shakeable with deep import paths:
2752
3121
 
2753
3122
  ```ts
2754
3123
  // 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";
3124
+ import {
3125
+ kinetex,
3126
+ Kinetex,
3127
+ FluentRequest,
3128
+ BatchQueue,
3129
+ createMethodCircuitBreakerKey,
3130
+ } from "kinetex";
3131
+ import type {
3132
+ KinetexConfig,
3133
+ KinetexRequest,
3134
+ KinetexResponse,
3135
+ SendOptions,
3136
+ RetryConfig,
3137
+ RetryContext,
3138
+ AuthConfig,
3139
+ ProxyConfig,
3140
+ HTTPMethod,
3141
+ HTTPVersion,
3142
+ HeadersInit,
3143
+ QueryParams,
3144
+ QueryValue,
3145
+ BodyInit,
3146
+ Runtime,
3147
+ RequestId,
3148
+ Brand,
3149
+ } from "kinetex";
2757
3150
  // Errors
2758
- import { KinetexError, HTTPStatusError, TimeoutError, SizeLimitError, AbortError, NetworkError, ValidationError, AuthError, ProxyError, RedirectError } from "kinetex";
3151
+ import {
3152
+ KinetexError,
3153
+ HTTPStatusError,
3154
+ TimeoutError,
3155
+ SizeLimitError,
3156
+ AbortError,
3157
+ NetworkError,
3158
+ ValidationError,
3159
+ AuthError,
3160
+ ProxyError,
3161
+ RedirectError,
3162
+ } from "kinetex";
2759
3163
  // Types
2760
- import type { InterceptorContext, HookContext, LifecycleHooks, RequestInterceptor, ResponseInterceptor, ErrorInterceptor, ProgressEvent, ProgressCallback, PipelineStep, PipelineStageName, CacheRequestConfig, HAREntry, HARLog } from "kinetex";
3164
+ import type {
3165
+ InterceptorContext,
3166
+ HookContext,
3167
+ LifecycleHooks,
3168
+ RequestInterceptor,
3169
+ ResponseInterceptor,
3170
+ ErrorInterceptor,
3171
+ ProgressEvent,
3172
+ ProgressCallback,
3173
+ PipelineStep,
3174
+ PipelineStageName,
3175
+ CacheRequestConfig,
3176
+ HAREntry,
3177
+ HARLog,
3178
+ } from "kinetex";
2761
3179
 
2762
3180
  // 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";
3181
+ import {} from /* ... */ "kinetex/cache";
3182
+ import {} from /* ... */ "kinetex/sse";
3183
+ import {} from /* ... */ "kinetex/graphql";
3184
+ import {} from /* ... */ "kinetex/pagination";
3185
+ import {} from /* ... */ "kinetex/progress";
3186
+ import {} from /* ... */ "kinetex/logging";
3187
+ import {} from /* ... */ "kinetex/response";
3188
+ import {} from /* ... */ "kinetex/headers";
3189
+ import {} from /* ... */ "kinetex/url";
3190
+ import {} from /* ... */ "kinetex/aws-sigv4";
3191
+ import {} from /* ... */ "kinetex/socks5";
3192
+ import {} from /* ... */ "kinetex/cookiejar";
3193
+ import {} from /* ... */ "kinetex/circuit-breaker";
3194
+ import {} from /* ... */ "kinetex/dedup";
3195
+ import {} from /* ... */ "kinetex/digest";
3196
+ import {} from /* ... */ "kinetex/ws";
3197
+ import {} from /* ... */ "kinetex/cookie-parser";
3198
+ import {} from /* ... */ "kinetex/lifecycle";
3199
+ import {} from /* ... */ "kinetex/interceptors";
3200
+ import {} from /* ... */ "kinetex/core";
3201
+ import {} from /* ... */ "kinetex/worker";
2783
3202
 
2784
3203
  // Types only from sub-modules:
2785
3204
  import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
2786
3205
  import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
2787
- import type { GraphQLRequest, GraphQLResponse, GraphQLError, GraphQLClientConfig, GraphQLLink, GraphQLLinkNext } from "kinetex/graphql";
3206
+ import type {
3207
+ GraphQLRequest,
3208
+ GraphQLResponse,
3209
+ GraphQLError,
3210
+ GraphQLClientConfig,
3211
+ GraphQLLink,
3212
+ GraphQLLinkNext,
3213
+ } from "kinetex/graphql";
2788
3214
  import type { Page, PaginationState } from "kinetex/pagination";
2789
3215
  import type { LogEntry, LogTransport, LoggerConfig } from "kinetex/logging";
2790
3216
  import type { ResponseParseOptions, SizeLimitConfig } from "kinetex/response";
2791
3217
  import type { Cookie, CookieJSON } from "kinetex/cookiejar";
2792
- import type { CircuitState, CircuitBreakerConfig, CircuitBreakerState, FailureFilter } from "kinetex/circuit-breaker";
3218
+ import type {
3219
+ CircuitState,
3220
+ CircuitBreakerConfig,
3221
+ CircuitBreakerState,
3222
+ FailureFilter,
3223
+ } from "kinetex/circuit-breaker";
2793
3224
  import type { DedupOptions } from "kinetex/dedup";
2794
3225
  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";
3226
+ import type {
3227
+ WSState,
3228
+ WSMessage,
3229
+ WSClientConfig,
3230
+ WSCloseEvent,
3231
+ WSBackpressureInfo,
3232
+ WSSubscribedRoom,
3233
+ } from "kinetex/ws";
3234
+ import type {
3235
+ HookRequest,
3236
+ HookResponse,
3237
+ HookError,
3238
+ HookOptions,
3239
+ BeforeRequestHook,
3240
+ AfterRequestHook,
3241
+ BeforeResponseHook,
3242
+ AfterResponseHook,
3243
+ OnErrorHook,
3244
+ OnRetryHook,
3245
+ OnRedirectHook,
3246
+ OnUploadProgressHook,
3247
+ OnDownloadProgressHook,
3248
+ AroundHook,
3249
+ } from "kinetex/lifecycle";
2797
3250
  import type { AWSCredentials, SigningConfig, CredentialProvider } from "kinetex/aws-sigv4";
2798
3251
  import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
2799
3252
  import type { FetchTransportOptions } from "kinetex/core";
2800
3253
  import type { OTelTracer, OTelSpan } from "kinetex";
2801
3254
  import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
2802
- import type { ParsedURL, URLBuilderOptions, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
3255
+ import type {
3256
+ ParsedURL,
3257
+ URLBuilderOptions,
3258
+ URLPattern,
3259
+ URLPatternMatch,
3260
+ URLDiff,
3261
+ DataURLParts,
3262
+ } from "kinetex/url";
2803
3263
  ```
2804
3264
 
2805
3265
  ---
@@ -2809,7 +3269,13 @@ import type { ParsedURL, URLBuilderOptions, URLPattern, URLPatternMatch, URLDiff
2809
3269
  Cloudflare Workers / Vercel Edge / WinterCG safe entry point:
2810
3270
 
2811
3271
  ```ts
2812
- import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreakerKey } from "kinetex/worker";
3272
+ import {
3273
+ kinetex,
3274
+ Kinetex,
3275
+ FluentRequest,
3276
+ BatchQueue,
3277
+ createMethodCircuitBreakerKey,
3278
+ } from "kinetex/worker";
2813
3279
  // Only exports types and classes safe for edge environments.
2814
3280
  // No Node.js-specific imports, no HTTP/2 transport.
2815
3281
  // Also exports error classes: KinetexError, HTTPStatusError, TimeoutError, NetworkError, RedirectError
@@ -2817,6 +3283,8 @@ import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreaker
2817
3283
  const client = kinetex({ baseURL: "https://api.example.com" });
2818
3284
  // Uses FetchTransport (globalThis.fetch) automatically.
2819
3285
  // Defaults to HTTP/1.1 for maximum edge compatibility.
3286
+ // httpVersion: "HTTP/2" is ignored here — the HTTP/2 transport needs node:http2,
3287
+ // which this entry point deliberately excludes. Use the main entry on Node.js.
2820
3288
  ```
2821
3289
 
2822
3290
  ```ts
@@ -2862,24 +3330,24 @@ import { kinetex } from "kinetex/browser";
2862
3330
 
2863
3331
  ## Runtime Compatibility
2864
3332
 
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.
3333
+ | Feature | Node 18+ | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
3334
+ | ----------------------------------------- | ------------------------ | -------- | ------------- | ------------- | ------------- | ------------- | ------------- |
3335
+ | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3336
+ | HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3337
+ | HTTP/2 (NodeHTTP2Transport) | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
3338
+ | HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
3339
+ | WebSocket (WSClient) | ✗¹ (no native WebSocket) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
3340
+ | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3341
+ | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3342
+ | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
3343
+ | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
3344
+ | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3345
+ | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3346
+ | URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3347
+ | Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
3348
+ | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
3349
+
3350
+ \* HTTP/2+ detection is best-effort and internal. kinetex's own `detectHTTPVersion()` helper (not exported) reports HTTP/2 only when the runtime exposes protocol evidence — a response `httpVersion`/`protocol` property, or an `Alt-Svc` header — and otherwise reports `HTTP/1.1`. The version on every `KinetexResponse` comes from that heuristic, so it is not a guarantee. This is accurate for Node 18's undici fetch, which does not negotiate h2 by default; use `NodeHTTP2Transport` (Node 22+) when you need guaranteed HTTP/2.
2883
3351
 
2884
3352
  ¹ WSClient requires a native `WebSocket` constructor. Node added one in v22 — on Node 18 use a polyfill (`globalThis.WebSocket = require('undici').WebSocket`).
2885
3353
 
@@ -2896,16 +3364,22 @@ import { kinetex } from "kinetex/browser";
2896
3364
  ## Resource Cleanup
2897
3365
 
2898
3366
  ```ts
2899
- client.destroy();
3367
+ await client.destroy();
2900
3368
  // Closes all HTTP/2 sessions (NodeHTTP2Transport.destroy())
2901
3369
  // Closes all tracked WebSocket connections
2902
- // Clears cache
2903
3370
  // Clears dedup map
2904
3371
  // Clears circuit breakers
2905
3372
  // Clears all interceptors
2906
3373
  // Nullifies cookie jar and logger references
2907
3374
  ```
2908
3375
 
3376
+ `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:
3377
+
3378
+ ```ts
3379
+ const cache = await client.getCache();
3380
+ await cache?.clear();
3381
+ ```
3382
+
2909
3383
  ---
2910
3384
 
2911
3385
  ## License