kinetex 0.0.2 → 1.0.0-rc.2

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 (231) hide show
  1. package/LICENSE +16 -4
  2. package/README.md +2434 -701
  3. package/dist/browser/kinetex.esm.js +86 -0
  4. package/dist/browser/kinetex.js +19056 -1999
  5. package/dist/browser/kinetex.min.js +85 -8
  6. package/dist/cjs/aws-sigv4.js +903 -0
  7. package/dist/cjs/cache.js +1099 -0
  8. package/dist/cjs/circuit-breaker.js +362 -0
  9. package/dist/cjs/client.js +2890 -0
  10. package/dist/cjs/cookie-parser.js +2182 -0
  11. package/dist/cjs/cookie-store.js +783 -0
  12. package/dist/cjs/cookiejar.js +175 -0
  13. package/dist/cjs/core.js +1267 -0
  14. package/dist/cjs/dedup.js +267 -0
  15. package/dist/cjs/digest.js +552 -0
  16. package/dist/cjs/graphql.js +1086 -0
  17. package/dist/cjs/headers.js +2264 -0
  18. package/dist/cjs/interceptors.js +1115 -0
  19. package/dist/cjs/lifecycle.js +886 -0
  20. package/dist/cjs/logging.js +887 -0
  21. package/dist/cjs/mod.js +98 -0
  22. package/dist/cjs/package.json +1 -3
  23. package/dist/cjs/pagination.js +535 -0
  24. package/dist/cjs/progress.js +694 -0
  25. package/dist/cjs/response.js +1212 -0
  26. package/dist/cjs/socks5.js +681 -0
  27. package/dist/cjs/sse.js +827 -0
  28. package/dist/cjs/types.js +217 -0
  29. package/dist/cjs/url.js +1269 -0
  30. package/dist/cjs/utils.js +901 -0
  31. package/dist/cjs/worker.js +56 -0
  32. package/dist/cjs/ws.js +1099 -0
  33. package/dist/esm/aws-sigv4.js +904 -0
  34. package/dist/esm/aws-sigv4.js.map +1 -0
  35. package/dist/esm/cache.js +1100 -0
  36. package/dist/esm/cache.js.map +1 -0
  37. package/dist/esm/circuit-breaker.js +363 -0
  38. package/dist/esm/circuit-breaker.js.map +1 -0
  39. package/dist/esm/client.js +2891 -0
  40. package/dist/esm/client.js.map +1 -0
  41. package/dist/esm/cookie-parser.js +2183 -0
  42. package/dist/esm/cookie-parser.js.map +1 -0
  43. package/dist/esm/cookie-store.js +784 -0
  44. package/dist/esm/cookie-store.js.map +1 -0
  45. package/dist/esm/cookiejar.js +176 -0
  46. package/dist/esm/cookiejar.js.map +1 -0
  47. package/dist/esm/core.js +1268 -0
  48. package/dist/esm/core.js.map +1 -0
  49. package/dist/esm/dedup.js +268 -0
  50. package/dist/esm/dedup.js.map +1 -0
  51. package/dist/esm/digest.js +553 -0
  52. package/dist/esm/digest.js.map +1 -0
  53. package/dist/esm/graphql.js +1087 -0
  54. package/dist/esm/graphql.js.map +1 -0
  55. package/dist/esm/headers.js +2265 -0
  56. package/dist/esm/headers.js.map +1 -0
  57. package/dist/esm/interceptors.js +1116 -0
  58. package/dist/esm/interceptors.js.map +1 -0
  59. package/dist/esm/lifecycle.js +887 -0
  60. package/dist/esm/lifecycle.js.map +1 -0
  61. package/dist/esm/logging.js +888 -0
  62. package/dist/esm/logging.js.map +1 -0
  63. package/dist/esm/mod.js +99 -0
  64. package/dist/esm/mod.js.map +1 -0
  65. package/dist/esm/package.json +1 -0
  66. package/dist/esm/pagination.js +536 -0
  67. package/dist/esm/pagination.js.map +1 -0
  68. package/dist/esm/progress.js +695 -0
  69. package/dist/esm/progress.js.map +1 -0
  70. package/dist/esm/response.js +1213 -0
  71. package/dist/esm/response.js.map +1 -0
  72. package/dist/esm/socks5.js +682 -0
  73. package/dist/esm/socks5.js.map +1 -0
  74. package/dist/esm/sse.js +828 -0
  75. package/dist/esm/sse.js.map +1 -0
  76. package/dist/esm/types.js +218 -0
  77. package/dist/esm/types.js.map +1 -0
  78. package/dist/esm/url.js +1270 -0
  79. package/dist/esm/url.js.map +1 -0
  80. package/dist/esm/utils.js +902 -0
  81. package/dist/esm/utils.js.map +1 -0
  82. package/dist/esm/worker.js +57 -0
  83. package/dist/esm/worker.js.map +1 -0
  84. package/dist/esm/ws.js +1100 -0
  85. package/dist/esm/ws.js.map +1 -0
  86. package/dist/types/aws-sigv4.d.ts +382 -0
  87. package/dist/types/aws-sigv4.d.ts.map +1 -0
  88. package/dist/types/cache.d.ts +397 -0
  89. package/dist/types/cache.d.ts.map +1 -0
  90. package/dist/types/circuit-breaker.d.ts +241 -0
  91. package/dist/types/circuit-breaker.d.ts.map +1 -0
  92. package/dist/types/client.d.ts +616 -0
  93. package/dist/types/client.d.ts.map +1 -0
  94. package/dist/types/cookie-parser.d.ts +203 -0
  95. package/dist/types/cookie-parser.d.ts.map +1 -0
  96. package/dist/types/cookie-store.d.ts +316 -0
  97. package/dist/types/cookie-store.d.ts.map +1 -0
  98. package/dist/types/cookiejar.d.ts +165 -0
  99. package/dist/types/cookiejar.d.ts.map +1 -0
  100. package/dist/types/core.d.ts +297 -0
  101. package/dist/types/core.d.ts.map +1 -0
  102. package/dist/types/dedup.d.ts +164 -0
  103. package/dist/types/dedup.d.ts.map +1 -0
  104. package/dist/types/digest.d.ts +136 -0
  105. package/dist/types/digest.d.ts.map +1 -0
  106. package/dist/types/graphql.d.ts +530 -0
  107. package/dist/types/graphql.d.ts.map +1 -0
  108. package/dist/types/headers.d.ts +1119 -0
  109. package/dist/types/headers.d.ts.map +1 -0
  110. package/dist/types/interceptors.d.ts +681 -0
  111. package/dist/types/interceptors.d.ts.map +1 -0
  112. package/dist/types/lifecycle.d.ts +553 -0
  113. package/dist/types/lifecycle.d.ts.map +1 -0
  114. package/dist/types/logging.d.ts +518 -0
  115. package/dist/types/logging.d.ts.map +1 -0
  116. package/dist/types/mod.d.ts +92 -0
  117. package/dist/types/mod.d.ts.map +1 -0
  118. package/dist/types/pagination.d.ts +401 -0
  119. package/dist/types/pagination.d.ts.map +1 -0
  120. package/dist/types/progress.d.ts +344 -0
  121. package/dist/types/progress.d.ts.map +1 -0
  122. package/dist/types/response.d.ts +484 -0
  123. package/dist/types/response.d.ts.map +1 -0
  124. package/dist/types/socks5.d.ts +218 -0
  125. package/dist/types/socks5.d.ts.map +1 -0
  126. package/dist/types/sse.d.ts +399 -0
  127. package/dist/types/sse.d.ts.map +1 -0
  128. package/dist/types/types.d.ts +967 -0
  129. package/dist/types/types.d.ts.map +1 -0
  130. package/dist/types/url.d.ts +530 -0
  131. package/dist/types/url.d.ts.map +1 -0
  132. package/dist/types/utils.d.ts +300 -0
  133. package/dist/types/utils.d.ts.map +1 -0
  134. package/dist/types/worker.d.ts +55 -0
  135. package/dist/types/worker.d.ts.map +1 -0
  136. package/dist/types/ws.d.ts +480 -0
  137. package/dist/types/ws.d.ts.map +1 -0
  138. package/package.json +194 -127
  139. package/CHANGELOG.md +0 -50
  140. package/SECURITY.md +0 -31
  141. package/dist/browser/kinetex.cjs.js +0 -9
  142. package/dist/browser/kinetex.esm.min.js +0 -9
  143. package/dist/browser/kinetex.js.map +0 -7
  144. package/dist/cjs/auth/index.cjs +0 -333
  145. package/dist/cjs/auth/index.d.cts +0 -41
  146. package/dist/cjs/auth/index.d.ts.map +0 -1
  147. package/dist/cjs/auth/index.js.map +0 -1
  148. package/dist/cjs/core/chain.cjs +0 -127
  149. package/dist/cjs/core/chain.d.cts +0 -44
  150. package/dist/cjs/core/chain.d.ts.map +0 -1
  151. package/dist/cjs/core/chain.js.map +0 -1
  152. package/dist/cjs/core/transport.cjs +0 -544
  153. package/dist/cjs/core/transport.d.cts +0 -11
  154. package/dist/cjs/core/transport.d.ts.map +0 -1
  155. package/dist/cjs/core/transport.js.map +0 -1
  156. package/dist/cjs/core/types.cjs +0 -54
  157. package/dist/cjs/core/types.d.cts +0 -322
  158. package/dist/cjs/core/types.d.ts.map +0 -1
  159. package/dist/cjs/core/types.js.map +0 -1
  160. package/dist/cjs/index.cjs +0 -272
  161. package/dist/cjs/index.d.cts +0 -12
  162. package/dist/cjs/index.d.ts.map +0 -1
  163. package/dist/cjs/index.js.map +0 -1
  164. package/dist/cjs/middleware/pipeline.cjs +0 -451
  165. package/dist/cjs/middleware/pipeline.d.cts +0 -32
  166. package/dist/cjs/middleware/pipeline.d.ts.map +0 -1
  167. package/dist/cjs/middleware/pipeline.js.map +0 -1
  168. package/dist/cjs/plugins/cookies.cjs +0 -192
  169. package/dist/cjs/plugins/cookies.d.cts +0 -57
  170. package/dist/cjs/plugins/cookies.d.ts.map +0 -1
  171. package/dist/cjs/plugins/cookies.js.map +0 -1
  172. package/dist/cjs/plugins/extras.cjs +0 -316
  173. package/dist/cjs/plugins/extras.d.cts +0 -87
  174. package/dist/cjs/plugins/extras.d.ts.map +0 -1
  175. package/dist/cjs/plugins/extras.js.map +0 -1
  176. package/dist/cjs/plugins/index.cjs +0 -18
  177. package/dist/cjs/plugins/index.d.cts +0 -7
  178. package/dist/cjs/plugins/index.d.ts.map +0 -1
  179. package/dist/cjs/plugins/index.js.map +0 -1
  180. package/dist/cjs/plugins/proxy.cjs +0 -161
  181. package/dist/cjs/plugins/proxy.d.cts +0 -25
  182. package/dist/cjs/plugins/proxy.d.ts.map +0 -1
  183. package/dist/cjs/plugins/proxy.js.map +0 -1
  184. package/dist/cjs/utils/index.cjs +0 -12
  185. package/dist/cjs/utils/index.d.cts +0 -6
  186. package/dist/cjs/utils/index.d.ts.map +0 -1
  187. package/dist/cjs/utils/index.js.map +0 -1
  188. package/dist/esm/auth/index.js +0 -324
  189. package/dist/esm/auth/index.js.map +0 -1
  190. package/dist/esm/core/chain.js +0 -123
  191. package/dist/esm/core/chain.js.map +0 -1
  192. package/dist/esm/core/transport.js +0 -536
  193. package/dist/esm/core/transport.js.map +0 -1
  194. package/dist/esm/core/types.js +0 -46
  195. package/dist/esm/core/types.js.map +0 -1
  196. package/dist/esm/index.js +0 -241
  197. package/dist/esm/index.js.map +0 -1
  198. package/dist/esm/middleware/pipeline.js +0 -438
  199. package/dist/esm/middleware/pipeline.js.map +0 -1
  200. package/dist/esm/plugins/cookies.js +0 -187
  201. package/dist/esm/plugins/cookies.js.map +0 -1
  202. package/dist/esm/plugins/extras.js +0 -306
  203. package/dist/esm/plugins/extras.js.map +0 -1
  204. package/dist/esm/plugins/index.js +0 -4
  205. package/dist/esm/plugins/index.js.map +0 -1
  206. package/dist/esm/plugins/proxy.js +0 -124
  207. package/dist/esm/plugins/proxy.js.map +0 -1
  208. package/dist/esm/utils/index.js +0 -6
  209. package/dist/esm/utils/index.js.map +0 -1
  210. package/dist/types/auth/index.d.ts +0 -41
  211. package/dist/types/auth/index.d.ts.map +0 -1
  212. package/dist/types/core/chain.d.ts +0 -44
  213. package/dist/types/core/chain.d.ts.map +0 -1
  214. package/dist/types/core/transport.d.ts +0 -11
  215. package/dist/types/core/transport.d.ts.map +0 -1
  216. package/dist/types/core/types.d.ts +0 -322
  217. package/dist/types/core/types.d.ts.map +0 -1
  218. package/dist/types/index.d.ts +0 -12
  219. package/dist/types/index.d.ts.map +0 -1
  220. package/dist/types/middleware/pipeline.d.ts +0 -32
  221. package/dist/types/middleware/pipeline.d.ts.map +0 -1
  222. package/dist/types/plugins/cookies.d.ts +0 -57
  223. package/dist/types/plugins/cookies.d.ts.map +0 -1
  224. package/dist/types/plugins/extras.d.ts +0 -87
  225. package/dist/types/plugins/extras.d.ts.map +0 -1
  226. package/dist/types/plugins/index.d.ts +0 -7
  227. package/dist/types/plugins/index.d.ts.map +0 -1
  228. package/dist/types/plugins/proxy.d.ts +0 -25
  229. package/dist/types/plugins/proxy.d.ts.map +0 -1
  230. package/dist/types/utils/index.d.ts +0 -6
  231. package/dist/types/utils/index.d.ts.map +0 -1
package/README.md CHANGED
@@ -1,113 +1,70 @@
1
1
  # kinetex
2
2
 
3
- > **The universal HTTP client for the modern JavaScript ecosystem.**
4
- > Zero runtime dependencies. Every runtime. Every style.
3
+ **Feature-rich, universal TypeScript HTTP client.** Zero dependencies. One codebase, every runtime.
5
4
 
6
- [![NPM](https://img.shields.io/npm/v/kinetex)](https://www.npmjs.com/package/kinetex)
7
- [![JSR](https://jsr.io/badges/@kinetexjs/kinetex)](https://jsr.io/@kinetexjs/kinetex)
8
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org/)
9
- [![Coverage](https://codecov.io/gh/kinetexjs/kinetex/branch/main/graph/badge.svg)](https://codecov.io/gh/kinetexjs/kinetex)
10
- [![Downloads](https://img.shields.io/npm/dw/kinetex?style=flat-square&label=Downloads&color=green)](https://npmjs.com/package/kinetex)
11
- [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blueviolet)](https://kinetexjs.github.io/kinetex)
12
-
13
- ---
14
-
15
- ## Why kinetex?
16
-
17
- kinetex is a **batteries-included fetch wrapper** for modern JavaScript runtimes (Node 18+, browsers, Deno, Bun, Cloudflare Workers). It sits above `fetch` and optionally `undici`, adding the things you would otherwise wire up yourself:
18
-
19
- - Built-in auth (Basic, Bearer, OAuth 2.0, AWS SigV4, Digest, API key)
20
- - Stale-while-revalidate cache with custom store support
21
- - Retry with exponential back-off
22
- - Request deduplication
23
- - SSE streaming with auto-reconnect
24
- - GraphQL helper, HAR recording, cookie jar, proxy middleware
25
- - A composable middleware pipeline
26
- - End-to-end TypeScript generics
27
-
28
- **What it is not:** a low-level HTTP client. It does not reimplement HTTP/2, connection pooling, or TLS — those come from `undici` (optional peer dep) or the platform's native `fetch`. If you need raw socket control or maximum throughput without any abstraction overhead, use undici directly.
29
-
30
- See [`benchmarks/http-comparison.mjs`](benchmarks/http-comparison.mjs) for an honest performance comparison.
31
-
32
- ---
33
-
34
- ## ⚡ Performance Benchmarks
35
-
36
- Kinetex is engineered for zero-cost abstraction. By leveraging a highly optimized middleware pipeline and the `undici` engine, it provides a "luxury" feature set (Auth, Retries, Cache, Hooks) with performance that rivals or beats native low-level clients.
37
-
38
- ### 📊 Comparison Table
39
- *Measured on Node.js v22.22.1 (Small JSON payload, 500 measured + 50 warmup, Concurrency: 1)*
40
-
41
- | Client | Req/s | Mean Latency | p99 Latency | Heap Δ (KB/req) |
42
- | :--- | :---: | :---: | :---: | :---: |
43
- | **Kinetex + Undici** ★ | **585** | **1.71 ms** | **2.73 ms** | **4.5 KB** |
44
- | [Axios](https://github.com/axios/axios) | 570 | 1.75 ms | 3.71 ms | 16.6 KB |
45
- | [Node-Fetch](https://github.com/node-fetch/node-fetch) | 518 | 1.93 ms | 3.92 ms | 14.5 KB |
46
- | [Native Fetch](https://nodejs.org/api/globals.html#fetch) | 511 | 1.96 ms | 4.94 ms | 21.6 KB |
47
- | **Kinetex (Standard)** ★ | **509** | **1.96 ms** | **4.86 ms** | **5.4 KB** |
48
- | [Got](https://github.com/sindresorhus/got) | 508 | 1.97 ms | 2.73 ms | 18.5 KB |
49
- | [Ky](https://github.com/sindresorhus/ky) | 485 | 2.06 ms | 2.61 ms | 23.2 KB |
50
- | [Superagent](https://github.com/ladjs/superagent) | 386 | 2.59 ms | 4.42 ms | 22.9 KB |
51
-
52
- ---
53
-
54
- ### 🚀 Key Takeaways
55
-
56
- #### 1. Zero-Cost Middleware Pipeline
57
- The Kinetex pipeline adds only **~0.007ms** of overhead per request. You get advanced features like AWS SigV4, automatic retries, and request deduplication for essentially zero CPU tax. Once the V8 JIT compiler optimizes the pipeline, the overhead becomes practically immeasurable.
5
+ ```ts
6
+ import { kinetex } from "kinetex";
58
7
 
59
- #### 2. Extreme Memory Efficiency
60
- Kinetex uses **75% less memory** per request than native fetch and **70% less** than Axios. In high-traffic production environments, this significantly reduces **Garbage Collection (GC) pressure**, leading to lower CPU spikes and a smaller infrastructure footprint.
8
+ const api = kinetex({ baseURL: "https://api.example.com", timeout: 5000 });
9
+ const users = await api.get<User[]>("/users");
61
10
 
62
- #### 3. Tail Latency Stability
63
- With a p99 latency of **2.73ms**, Kinetex is more predictable under load than native fetch (4.94ms). This prevents the random "lag spikes" often seen in microservices when using standard promise-based wrappers.
11
+ // Or use the fluent builder:
12
+ const post = await api.POST("/posts").withJSON({ title: "Hello" }).bearer("token").json<Post>();
13
+ ```
64
14
 
65
- #### 4. Green Computing
66
- By reducing heap allocation by **15-18KB per request** compared to competitors, Kinetex allows your containers to handle more concurrent traffic with less RAM, directly lowering your cloud computing costs.
15
+ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers**, **Vercel Edge**, and all **WinterCG-compliant** runtimes.
67
16
 
68
17
  ---
69
18
 
70
- > **Note:** While `undici (direct)` and `node:http (raw)` are faster, they lack the high-level features (middleware, automatic JSON parsing, easy auth) that Kinetex provides. Kinetex aims to be the fastest **feature-complete** HTTP client for the Node.js ecosystem.
71
-
72
- *Run the benchmarks yourself:*
73
- ```bash
74
- node --expose-gc benchmarks/http-comparison.mjs
75
- ```
76
- ---
77
-
78
19
  ## Table of Contents
79
20
 
80
21
  - [Installation](#installation)
81
22
  - [Quick Start](#quick-start)
82
- - [Runtime Support](#runtime-support)
83
- - [API Styles](#api-styles)
84
- - [Async / Await](#async--await)
85
- - [Fluent Chain](#fluent-chain)
86
- - [Callback](#callback)
87
- - [Instances & Configuration](#instances--configuration)
88
- - [Request Config Reference](#request-config-reference)
23
+ - [Client Configuration](#client-configuration)
24
+ - [Basic HTTP Methods](#basic-http-methods)
25
+ - [Fluent Request Builder](#fluent-request-builder)
26
+ - [Send Options](#send-options)
89
27
  - [Response Object](#response-object)
90
28
  - [Authentication](#authentication)
91
- - [Schema Validation](#schema-validation)
92
- - [Caching](#caching)
93
29
  - [Retry](#retry)
30
+ - [Rate Limiting](#rate-limiting)
31
+ - [Timeout](#timeout)
94
32
  - [Interceptors](#interceptors)
95
33
  - [Lifecycle Hooks](#lifecycle-hooks)
96
- - [Middleware Pipeline](#middleware-pipeline)
97
- - [SSE Streaming](#sse-streaming)
34
+ - [HookRegistry](#hookregistry)
35
+ - [Request Deduplication](#request-deduplication)
36
+ - [Circuit Breaker](#circuit-breaker)
37
+ - [Caching](#caching)
38
+ - [Cookie Jar](#cookie-jar)
39
+ - [Cookie Parser & Store](#cookie-parser--store)
40
+ - [Pagination](#pagination)
41
+ - [Server-Sent Events (SSE)](#server-sent-events-sse)
42
+ - [WebSocket](#websocket)
98
43
  - [GraphQL](#graphql)
99
- - [File Upload & Progress](#file-upload--progress)
44
+ - [Progress Tracking](#progress-tracking)
45
+ - [AWS SigV4 Signing](#aws-sigv4-signing)
46
+ - [SOCKS5 Proxy](#socks5-proxy)
47
+ - [Digest Authentication](#digest-authentication)
48
+ - [Structured Logging](#structured-logging)
100
49
  - [HAR Recording](#har-recording)
101
- - [Cookie Jar](#cookie-jar)
102
- - [Proxy & SOCKS5](#proxy--socks5)
103
- - [Concurrency & Rate Limiting](#concurrency--rate-limiting)
104
- - [Response Size Limiting](#response-size-limiting)
105
- - [HTTP/2 and HTTP/3](#http2-and-http3)
50
+ - [OpenTelemetry Tracing](#opentelemetry-tracing)
51
+ - [Pipeline Trace](#pipeline-trace)
52
+ - [Transport Layer](#transport-layer)
53
+ - [Runtime Detection](#runtime-detection)
54
+ - [Child Clients](#child-clients)
55
+ - [Batch Queue](#batch-queue)
56
+ - [URL Utilities](#url-utilities)
57
+ - [Header Utilities](#header-utilities)
58
+ - [Response Parsing Utilities](#response-parsing-utilities)
59
+ - [Safe JSON Parsing](#safe-json-parsing)
60
+ - [Type Guards & Utilities](#type-guards--utilities)
106
61
  - [Error Handling](#error-handling)
107
- - [Request Deduplication](#request-deduplication)
108
- - [Logging](#logging)
109
- - [CDN / Browser Usage](#cdn--browser-usage)
110
- - [Development](#development)
62
+ - [Deep Imports](#deep-imports)
63
+ - [Worker Entry Point](#worker-entry-point)
64
+ - [Browser Usage](#browser-usage)
65
+ - [Runtime Compatibility](#runtime-compatibility)
66
+ - [Resource Cleanup](#resource-cleanup)
67
+ - [License](#license)
111
68
 
112
69
  ---
113
70
 
@@ -117,15 +74,18 @@ node --expose-gc benchmarks/http-comparison.mjs
117
74
  npm install kinetex
118
75
  ```
119
76
 
120
- ### Optional peer dependencies
121
-
122
- Install only what you need — kinetex works without any of them.
77
+ ```bash
78
+ deno add jsr:@kinetexjs/kinetex
79
+ ```
123
80
 
124
81
  ```bash
125
- npm install undici # HTTP/2 + HTTP/3 in Node.js (auto-detected)
126
- npm install zod # Schema validation
127
- npm install valibot # Alternative schema validation
128
- npm install socks-proxy-agent # SOCKS5 proxy support
82
+ bun add kinetex
83
+ ```
84
+
85
+ **JSR:**
86
+
87
+ ```ts
88
+ import { kinetex } from "jsr:@kinetexjs/kinetex";
129
89
  ```
130
90
 
131
91
  ---
@@ -133,960 +93,2733 @@ npm install socks-proxy-agent # SOCKS5 proxy support
133
93
  ## Quick Start
134
94
 
135
95
  ```ts
136
- import kinetex from 'kinetex';
96
+ import { kinetex } from "kinetex";
137
97
 
138
- // Simple GET
139
- const { data } = await kinetex.get<User>('https://api.example.com/users/1');
140
- console.log(data.name);
98
+ const client = kinetex({ baseURL: "https://jsonplaceholder.typicode.com" });
141
99
 
142
- // POST with JSON body
143
- const { data: created } = await kinetex.post('https://api.example.com/users', {
144
- json: { name: 'Alice', email: 'alice@example.com' },
145
- });
100
+ // Convenience methods
101
+ const users = await client.get<User[]>("/users");
102
+ const post = await client.post("/posts", { title: "Hello", body: "World" });
146
103
 
147
- // Fluent chain
148
- const { data } = await kinetex
149
- .chain('https://api.example.com/users')
150
- .bearer('my-token')
151
- .query({ page: 1, limit: 20 })
104
+ // Fluent builder
105
+ const data = await client
106
+ .GET("/users")
107
+ .header("X-API-Key", "secret")
108
+ .param("page", "1")
109
+ .bearer("my-token")
152
110
  .timeout(5000)
153
- .retry(3);
111
+ .noThrow()
112
+ .json<User[]>();
113
+
114
+ // Low-level send
115
+ const res = await client.send("/users", "GET", {
116
+ headers: { Accept: "application/json" },
117
+ params: { limit: "10" },
118
+ throwOnError: false,
119
+ });
120
+
121
+ // Response
122
+ console.log(res.status, res.data, res.headers, res.durationMs);
154
123
  ```
155
124
 
156
125
  ---
157
126
 
158
- ## Runtime Support
127
+ ## Client Configuration
159
128
 
160
- kinetex works identically across all major JavaScript runtimes. Import it the same way everywhere.
129
+ ```ts
130
+ const client = kinetex({
131
+ // ── Core ──
132
+ baseURL: "https://api.example.com/v1", // Base URL for relative paths
133
+ headers: { "X-Version": "1.0" }, // Default headers
134
+ params: { api_key: "xxx" }, // Default query params
135
+ timeout: 10000, // Timeout in ms (default: 30000, 0 = no timeout)
136
+ httpVersion: "HTTP/2", // "HTTP/1.1" | "HTTP/2" (default: "HTTP/2")
137
+ throwOnError: true, // Throw on 4xx/5xx (default: true)
138
+ followRedirects: true, // Follow redirects (default: true)
139
+ maxRedirects: 10, // Max redirects (default: 10)
140
+ httpsOnly: false, // Reject non-HTTPS URLs
141
+ maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
142
+ maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
143
+ strictHeaders: false, // Throw on invalid headers vs warn+drop
144
+ onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
145
+ onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
146
+
147
+ // ── Auth ──
148
+ auth: { type: "bearer", token: "..." },
149
+ awsSigning: { credentials: {...}, region: "...", service: "..." },
150
+
151
+ // ── Retry ──
152
+ retry: { maxRetries: 3, baseDelayMs: 300, statuses: [408, 429, 500, 502, 503, 504] },
153
+
154
+ // ── Rate Limit ──
155
+ rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
156
+
157
+ // ── Proxy ──
158
+ proxy: { url: "socks5://127.0.0.1:1080" },
159
+
160
+ // ── Cache ──
161
+ cache: { storage: "memory", ttlMs: 60_000, maxEntries: 1000, swr: true },
162
+
163
+ // ── Cookie Jar ──
164
+ cookieJar: true, // Auto-manage cookies
165
+
166
+ // ── Logging ──
167
+ logger: { level: "info" },
168
+
169
+ // ── HAR Recording ──
170
+ har: true, // Enable HTTP Archive recording
171
+
172
+ // ── Interceptors ──
173
+ interceptors: {
174
+ request: [myReqInterceptor],
175
+ response: [myResInterceptor],
176
+ error: [myErrInterceptor],
177
+ },
161
178
 
162
- ### Node.js
179
+ // ── Lifecycle Hooks ──
180
+ hooks: {
181
+ onBeforeRequest: [(req, ctx) => { ... }],
182
+ onAfterRequest: [(req, ctx) => { ... }],
183
+ onBeforeResponse: [(res, ctx) => { ... }],
184
+ onAfterResponse: [(res, ctx) => { ... }],
185
+ onError: [(err, ctx) => { ... }],
186
+ onRetry: [(ctx) => { ... }],
187
+ onUploadProgress: [(ev) => { ... }],
188
+ onDownloadProgress: [(ev) => { ... }],
189
+ },
163
190
 
164
- ```ts
165
- import kinetex from 'kinetex'; # ESM ✅
191
+ // ── Response/Request Transforms ──
192
+ transformResponse: (data, res) => data, // Global response transformer
193
+ transformRequest: (req) => req, // Global request transformer
166
194
 
167
- const kinetex = require('kinetex); # CJS ✅
195
+ // ── Circuit Breaker Key ──
196
+ circuitBreakerKeyFn: (req) => `${req.method}:${new URL(req.url).origin}`,
168
197
 
169
- const { data } = await kinetex.get('https://api.example.com/users');
198
+ // ── Custom fetch ──
199
+ fetch: myCustomFetch, // Custom fetch implementation
200
+
201
+ // ── WebSocket defaults ──
202
+ ws: { highWaterMark: 65536, lowWaterMark: 16384, maxSendRate: 0, keepRooms: true },
203
+ });
170
204
  ```
171
205
 
172
- ### Deno
206
+ ---
173
207
 
174
- ```ts
175
- import kinetex, { create } from 'npm:kinetex';
176
- // or via JSR:
177
- import kinetex from 'jsr:@kinetexjs/kinetex';
208
+ ## Basic HTTP Methods
178
209
 
179
- const { data } = await kinetex.get('https://api.example.com/users');
210
+ ```ts
211
+ const get = await client.get("/resource");
212
+ const post = await client.post("/resource", { key: "value" });
213
+ const put = await client.put("/resource/1", { data: "new" });
214
+ const patch = await client.patch("/resource/1", { data: "updated" });
215
+ const del = await client.delete("/resource/1");
216
+ const head = await client.head("/resource"); // → KinetexResponse<null>
217
+ const opts = await client.options("/resource");
180
218
  ```
181
219
 
182
- > **Note:** `proxyMiddleware()` has no effect in Deno — configure proxies at the OS level or use the `HTTPS_PROXY` environment variable.
183
-
184
- ### Bun
220
+ All accept optional `SendOptions` as the last argument:
185
221
 
186
222
  ```ts
187
- import kinetex from 'kinetex'; // identical to Node.js
223
+ const res = await client.get("/users", {
224
+ headers: { "X-Custom": "value" },
225
+ params: { page: "1", limit: "10" },
226
+ timeout: 5000,
227
+ signal: controller.signal,
228
+ throwOnError: false,
229
+ onDownloadProgress: (ev) => console.log(ev.percent),
230
+ parseResponse: (raw, headers, url) => myParser(raw),
231
+ parseFailure: (raw, error) => console.warn("JSON parse failed", error),
232
+ onSuccess: (res) => console.log("Got:", res.data),
233
+ onError: (err) => console.error("Failed:", err.message),
234
+ meta: { traceId: "abc" },
235
+ tags: ["users"],
236
+ cache: { ttlMs: 5000 },
237
+ maxResponseSize: 1_000_000,
238
+ httpVersion: "HTTP/1.1",
239
+ });
188
240
  ```
189
241
 
190
- ### Cloudflare Workers
242
+ ### `send()` — Low-Level API
191
243
 
192
244
  ```ts
193
- import { create, auth } from 'kinetex';
245
+ const res = await client.send("/resource", "GET", options);
246
+ // Equivalent to the convenience methods but with explicit method parameter.
247
+ ```
194
248
 
195
- const baseApi = create({ baseURL: 'https://api.example.com', timeout: 5000 });
249
+ ---
196
250
 
197
- export default {
198
- async fetch(request: Request, env: Env): Promise<Response> {
199
- const api = baseApi.extend(auth.bearer(env.API_TOKEN));
200
- const { data } = await api.get('/users');
201
- return Response.json(data);
202
- },
203
- };
251
+ ## Fluent Request Builder
252
+
253
+ Every method returns `this` for chaining. Call `.send()`, `.json()`, `.text()`, `.bytes()`, `.blob()`, or `.data()` to execute.
204
254
 
205
- interface Env { API_TOKEN: string; }
255
+ ```ts
256
+ const client = kinetex({ baseURL: "https://api.example.com" });
257
+
258
+ // Full chain
259
+ const data = await client
260
+ .GET("/users") // or .POST, .PUT, .PATCH, .DELETE, .request(method, url)
261
+ .header("X-Custom", "value") // single header
262
+ .headers({ "X-A": "1", "X-B": "2" }) // multiple headers
263
+ .param("page", "1") // single query param
264
+ .params({ limit: "10", sort: "name" }) // multiple params
265
+ .query({ filter: "active" }) // alias for .params()
266
+ .body("raw text") // raw body
267
+ .withJSON({ key: "value" }) // JSON body (sets Content-Type)
268
+ .withBody("text") // alias for .body()
269
+ .withForm(formData) // FormData body
270
+ .bearer("token") // Bearer auth
271
+ .basic("user", "pass") // Basic auth
272
+ .apiKey("X-Key", "value") // API key auth
273
+ .digest("user", "pass") // Digest auth
274
+ .noAuth() // Skip auth
275
+ .retry(3, { baseDelayMs: 1000 }) // Max retries + optional config
276
+ .noRetry() // Skip retry
277
+ .timeout(5000) // Timeout in ms
278
+ .proxy({ url: "socks5://..." }) // Proxy config
279
+ .cache({ ttlMs: 5000 }) // Cache config
280
+ .noCache() // Force fresh fetch
281
+ .maxSize(1_000_000) // Max response size
282
+ .http2() // Prefer HTTP/2
283
+ .http1() // Force HTTP/1.1
284
+ .noThrow() // Don't throw on 4xx/5xx
285
+ .meta({ requestId: "abc" }) // Arbitrary metadata
286
+ .signal(controller.signal) // AbortSignal
287
+ .tags("users", "active") // Cache tags
288
+ .onUploadProgress((ev) => {}) // Upload progress callback
289
+ .onDownloadProgress((ev) => {}) // Download progress callback
290
+ .send() // → Promise<KinetexResponse<T>>
291
+ .json<T>() // → Promise<T> (parsed JSON data)
292
+ .text() // → Promise<string>
293
+ .bytes() // → Promise<Uint8Array>
294
+ .blob() // → Promise<Blob>
295
+ .data<T>() // → Promise<T> (alias for .json)
296
+ .subscribe(onSuccess, onError); // callback-style (void)
206
297
  ```
207
298
 
208
- ### Browser / CDN
299
+ ---
209
300
 
210
- ```html
211
- <!-- unpkg -->
212
- <script src="https://unpkg.com/kinetex/dist/browser/kinetex.min.js"></script>
301
+ ## Send Options
213
302
 
214
- <!-- jsDelivr -->
215
- <script src="https://cdn.jsdelivr.net/npm/kinetex/dist/browser/kinetex.min.js"></script>
303
+ Full `SendOptions` interface passed to `.send()` and convenience methods:
216
304
 
217
- <script>
218
- kinetex.get('https://api.example.com/data').then(({ data }) => console.log(data));
219
- </script>
305
+ ```ts
306
+ interface SendOptions<T = unknown> {
307
+ baseURL?: string; // Override base URL
308
+ headers?: HeadersInit; // Additional/override headers
309
+ params?: QueryParams; // Query parameters
310
+ body?: BodyInit; // Request body
311
+ timeout?: number; // Timeout in ms
312
+ signal?: AbortSignal; // Cancellation signal
313
+ retry?: Partial<RetryConfig> | false; // Retry config or disable
314
+ auth?: AuthConfig | false; // Auth config or disable
315
+ proxy?: ProxyConfig | false; // Proxy config or disable
316
+ cache?: CacheRequestConfig | false; // Cache config or disable
317
+ throwOnError?: boolean; // Throw on 4xx/5xx
318
+ followRedirects?: boolean; // Follow redirects
319
+ maxRedirects?: number; // Max redirects
320
+ httpVersion?: HTTPVersion; // Preferred HTTP version
321
+ maxRequestSize?: number; // Request size limit (bytes)
322
+ maxResponseSize?: number; // Response size limit (bytes)
323
+ parseResponse?: (raw: Uint8Array, headers: Record<string, string>, url: string) => T | Promise<T>;
324
+ parseFailure?: (raw: Uint8Array, error: Error) => void;
325
+ onSuccess?: (res: KinetexResponse<T>) => void;
326
+ onError?: (err: KinetexError) => void;
327
+ onUploadProgress?: ProgressCallback;
328
+ onDownloadProgress?: ProgressCallback;
329
+ tags?: string[]; // Cache invalidation tags
330
+ meta?: Record<string, unknown>; // Arbitrary metadata
331
+ }
220
332
  ```
221
333
 
222
- ESM in the browser:
334
+ ---
223
335
 
224
- ```html
225
- <script type="module">
226
- import kinetex from 'https://cdn.jsdelivr.net/npm/kinetex/dist/browser/kinetex.esm.min.js';
227
- const { data } = await kinetex.get('https://api.example.com/posts');
228
- </script>
336
+ ## Response Object
337
+
338
+ ```ts
339
+ interface KinetexResponse<T = unknown> {
340
+ status: number; // HTTP status code
341
+ statusText: string; // HTTP status text
342
+ headers: Record<string, string>; // Normalized (lowercased) response headers
343
+ data: T; // Parsed response body
344
+ rawBody: Uint8Array | null; // Raw response body bytes
345
+ url: string; // Final URL after redirects
346
+ cached: boolean; // Whether served from cache
347
+ redirected: boolean; // Whether request was redirected
348
+ httpVersion: HTTPVersion; // Detected protocol version
349
+ durationMs: number; // Total request duration in ms
350
+ request: KinetexRequest; // The originating request
351
+ attempt: number; // Attempt number (1 = first try)
352
+ }
229
353
  ```
230
354
 
231
355
  ---
232
356
 
233
- ## API Styles
357
+ ## Authentication
358
+
359
+ ### Client-Level Auth
360
+
361
+ ```ts
362
+ // Bearer token
363
+ kinetex({ auth: { type: "bearer", token: "my-jwt" } });
364
+ kinetex({ auth: { type: "bearer", token: async () => await refresh() } });
365
+
366
+ // HTTP Basic (credentials zeroized from memory after use)
367
+ kinetex({ auth: { type: "basic", username: "user", password: "pass" } });
234
368
 
235
- ### Async / Await
369
+ // API Key (custom header)
370
+ kinetex({ auth: { type: "apikey", header: "X-API-Key", key: "my-key" } });
371
+ kinetex({ auth: { type: "apikey", header: "X-API-Key", key: async () => await getKey() } });
236
372
 
237
- The default style — clean, typed, predictable.
373
+ // HTTP Digest (auto-handles 401 → challenge parse → retry)
374
+ kinetex({ auth: { type: "digest", username: "user", password: "pass" } });
375
+
376
+ // Custom auth handler
377
+ kinetex({
378
+ auth: {
379
+ type: "custom",
380
+ apply: (req) => ({ ...req, headers: { ...req.headers, "X-Custom-Auth": "value" } }),
381
+ },
382
+ });
383
+ ```
384
+
385
+ ### Per-Request Auth
238
386
 
239
387
  ```ts
240
- import kinetex from 'kinetex';
388
+ client.get("/public", { auth: false }); // Disable
389
+ client.get("/admin", { auth: { type: "bearer", token: t } }); // Override
390
+ ```
241
391
 
242
- // GET shorthand (call instance directly)
243
- const { data } = await kinetex<User[]>('https://api.example.com/users');
392
+ ### Fluent Auth
244
393
 
245
- // HTTP verb methods
246
- const { data } = await kinetex.get<User>('/users/1');
247
- const { data } = await kinetex.post('/users', { json: { name: 'Alice' } });
248
- const { data } = await kinetex.put('/users/1', { json: { name: 'Bob' } });
249
- const { data } = await kinetex.patch('/users/1', { json: { email: 'bob@example.com' } });
250
- const { data } = await kinetex.delete('/users/1');
251
- const { data } = await kinetex.head('/users');
252
- const { data } = await kinetex.options('/users');
394
+ ```ts
395
+ client.GET("/admin").bearer("token").json();
396
+ client.GET("/login").basic("user", "pass").json();
397
+ client.GET("/api").apiKey("X-Key", "value").json();
398
+ client.GET("/digest").digest("user", "pass").json();
399
+ client.GET("/public").noAuth().json();
253
400
  ```
254
401
 
255
- ### Fluent Chain
402
+ ---
256
403
 
257
- Build requests incrementally. The chain is a `Promise` — `await` it when ready.
404
+ ## Retry
258
405
 
259
406
  ```ts
260
- const { data } = await kinetex
261
- .chain('https://api.example.com/users')
262
- .method('POST')
263
- .bearer('my-token')
264
- .header('X-Request-ID', crypto.randomUUID())
265
- .json({ name: 'Alice' })
266
- .timeout(5000)
267
- .retry(3);
407
+ kinetex({
408
+ retry: {
409
+ maxRetries: 3, // Max attempts (default: 3)
410
+ baseDelayMs: 300, // Exponential back-off base (default: 300)
411
+ maxDelayMs: 30_000, // Max delay cap (default: 30_000)
412
+ jitter: 0.3, // Random jitter factor 0-1 (default: 0.3)
413
+ statuses: [408, 429, 500, 502, 503, 504], // Status codes that trigger retry
414
+ onNetworkError: true, // Retry on network errors (default: true)
415
+ onTimeout: false, // Retry on timeouts (default: false)
416
+ methods: ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"],
417
+ shouldRetry: (ctx) => ctx.attempt < 3 && ctx.response?.status === 503, // Custom predicate
418
+ onRetry: (ctx, delayMs) => console.log(`Retry ${ctx.attempt} in ${delayMs}ms`),
419
+ },
420
+ });
268
421
  ```
269
422
 
270
- **Chain method reference:**
423
+ ### RetryContext
271
424
 
272
- | Method | Description |
273
- |--------|-------------|
274
- | `.method(m)` | HTTP method |
275
- | `.header(k, v)` | Add a single header |
276
- | `.headers(obj)` | Merge multiple headers |
277
- | `.query(params)` | URL search params |
278
- | `.send(body)` | Raw body |
279
- | `.json(data)` | JSON body — sets `Content-Type: application/json` |
280
- | `.form(data)` | URL-encoded form body |
281
- | `.multipart(data)` | `multipart/form-data` (FormData or plain record) |
282
- | `.auth(value)` | `Authorization` header verbatim |
283
- | `.bearer(token)` | `Authorization: Bearer <token>` |
284
- | `.basic(user, pass)` | `Authorization: Basic <base64>` |
285
- | `.timeout(ms)` | Request timeout |
286
- | `.retry(n)` | Retry count |
287
- | `.accept(type)` | `Accept` header |
288
- | `.type(ct)` | `Content-Type` header |
289
- | `.schema(v)` | Validate response with zod / valibot / custom |
290
- | `.signal(s)` | `AbortSignal` for cancellation |
291
- | `.cancel()` | Returns an `AbortController` wired to this request — call `.abort()` on it when you want to cancel |
292
- | `.onUpload(fn)` | Upload progress callback |
293
- | `.onDownload(fn)` | Download progress callback |
294
- | `.as<U>()` | Re-type the response generic |
425
+ The `ctx` parameter in `shouldRetry` and `onRetry` is of type `RetryContext`:
295
426
 
296
427
  ```ts
297
- // Multipart upload
298
- const fd = new FormData();
299
- fd.append('file', fileBlob, 'avatar.png');
300
- await kinetex.chain('/upload').multipart(fd);
428
+ interface RetryContext {
429
+ attempt: number;
430
+ maxRetries: number;
431
+ response?: KinetexResponse<unknown>;
432
+ error?: unknown;
433
+ request: KinetexRequest;
434
+ delayMs?: number;
435
+ }
436
+ ```
301
437
 
302
- // Or from a plain record (auto-converted to FormData)
303
- await kinetex.chain('/upload').multipart({ file: fileBlob, caption: 'photo' });
438
+ Per-request override:
304
439
 
305
- // Cancel a request — call controller.abort() when you actually want to cancel.
306
- // cancel() does NOT abort immediately; the request fires lazily on first .then()
307
- // so you need the controller to exist before it starts.
308
- const chain = kinetex.chain('https://api.example.com/long-poll');
309
- const controller = chain.cancel(); // wires the signal — NOT aborted yet
310
- // ... start the request
311
- const promise = chain; // triggers dispatch
312
- // ... later:
313
- controller.abort(); // now it cancels
440
+ ```ts
441
+ client.get("/data", { retry: { maxRetries: 5, statuses: [500, 502] } });
442
+ client.get("/data", { retry: false }); // Disable retry
443
+ client.GET("/data").retry(2, { baseDelayMs: 1000 }).json(); // Fluent
444
+ client.GET("/data").noRetry().json(); // Fluent disable
314
445
  ```
315
446
 
316
- ### Callback
447
+ ---
317
448
 
318
- Drop-in compatible with the `request` package callback style.
449
+ ## Rate Limiting
450
+
451
+ Built-in token-bucket rate limiter, registered as the highest-priority request interceptor:
319
452
 
320
453
  ```ts
321
- kinetex.callback('https://api.example.com/data', {}, (err, res, data) => {
322
- if (err) return console.error(err);
323
- console.log(data);
454
+ kinetex({
455
+ rateLimit: {
456
+ limit: 100, // Requests per window (default: 60)
457
+ windowMs: 60_000, // Window size in ms (default: 60_000)
458
+ queue: true, // Queue excess requests vs reject (default: true)
459
+ maxQueue: 100, // Max queued requests (default: 100)
460
+ },
324
461
  });
325
462
  ```
326
463
 
327
464
  ---
328
465
 
329
- ## Instances & Configuration
466
+ ## Timeout
330
467
 
331
- Create isolated instances with their own defaults, middleware, and interceptors.
468
+ Default timeout is 30 seconds. Set to 0 for no timeout:
332
469
 
333
470
  ```ts
334
- import { create } from 'kinetex';
471
+ const client = kinetex({ timeout: 10000 }); // Client-wide timeout
472
+ client.get("/slow", { timeout: 30000 }); // Per-request override
473
+ client.GET("/slow").timeout(30000).json(); // Fluent override
474
+ ```
335
475
 
336
- const api = create({
337
- baseURL: 'https://api.example.com',
338
- timeout: 10_000,
339
- retry: { limit: 3, delay: (n) => 100 * 2 ** n },
340
- headers: { 'X-App': 'my-app/1.0' },
341
- });
476
+ Timeout throws `TimeoutError`:
342
477
 
343
- // Scoped sub-instance (inherits parent defaults)
344
- const usersApi = api.create({ baseURL: 'https://api.example.com/users' });
478
+ ```ts
479
+ try {
480
+ await client.get("/slow", { timeout: 100 });
481
+ } catch (err) {
482
+ if (err instanceof TimeoutError) {
483
+ console.log(`Timed out after ${err.timeoutMs}ms`);
484
+ }
485
+ }
486
+ ```
487
+
488
+ Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
489
+
490
+ ---
491
+
492
+ ## Interceptors
345
493
 
346
- // Extend with middleware — returns a new instance, does not mutate
347
- const authedApi = api.extend(auth.bearer('secret'));
494
+ Three types of interceptors: **request**, **response**, and **error**. Each returns an eject function.
348
495
 
349
- // Add middleware in-place — mutates this instance
350
- api.use(myLoggingMiddleware);
496
+ ### Request Interceptor
351
497
 
352
- // Cancel ALL in-flight requests on this instance (e.g. on component unmount)
353
- api.cancelAll();
354
- // The instance resets automatically — subsequent requests work normally
498
+ ```ts
499
+ const eject = client.useRequest(async (ctx) => {
500
+ ctx.request.headers["X-Request-ID"] = crypto.randomUUID();
501
+ // Return void → keep current request
502
+ // Return KinetexRequest → replace request
503
+ // Return KinetexResponse → short-circuit (skip network)
504
+ // Throw → abort with error
505
+ });
506
+ // eject() removes the interceptor
507
+ ```
508
+
509
+ ### Response Interceptor
510
+
511
+ ```ts
512
+ client.useResponse(async (ctx) => {
513
+ if (ctx.response) {
514
+ console.log(`Response: ${ctx.response.status}`);
515
+ }
516
+ // Return void → keep current response
517
+ // Return KinetexResponse → replace response
518
+ // Return KinetexRequest → trigger a retry
519
+ });
355
520
  ```
356
521
 
357
- ### Timeout granularity
522
+ ### Error Interceptor
358
523
 
359
524
  ```ts
360
- // Simple: single number applies to the whole request lifecycle
361
- await api.get('/data', { timeout: 5000 });
525
+ client.useError(async (ctx) => {
526
+ console.error("Error:", ctx.error);
527
+ // Return void → rethrow original error
528
+ // Return KinetexResponse → recover with synthetic response
529
+ // Throw → replace with different error
530
+ });
531
+ ```
362
532
 
363
- // Granular: separate timeouts for connection vs full response
364
- await api.get('/data', {
365
- timeout: {
366
- request: 30_000, // abort if total request takes > 30s
367
- response: 5_000, // abort if no first byte within 5s (TTFB guard)
533
+ ### Config-Level Interceptors
534
+
535
+ ```ts
536
+ kinetex({
537
+ interceptors: {
538
+ request: [fn1, fn2],
539
+ response: [fn3, fn4],
540
+ error: [fn5],
368
541
  },
369
542
  });
370
543
  ```
371
544
 
545
+ ### InterceptorContext
546
+
547
+ ```ts
548
+ interface InterceptorContext {
549
+ request: KinetexRequest;
550
+ response: KinetexResponse<unknown> | null;
551
+ error: unknown | null;
552
+ startedAt: number; // Monotonic start time (ms)
553
+ attempt: number; // Current attempt number
554
+ aborted: boolean; // Pipeline aborted?
555
+ store: Map<symbol | string, unknown>; // Pipeline-scoped shared storage
556
+ }
557
+ ```
558
+
559
+ ### Built-in Interceptors
560
+
561
+ All available from `kinetex/interceptors`:
562
+
563
+ ```ts
564
+ import {
565
+ createRetryInterceptor,
566
+ createAuthInterceptor,
567
+ createTimeoutInterceptor,
568
+ createLoggingInterceptor,
569
+ createCacheInterceptor,
570
+ createDedupeInterceptor,
571
+ createRateLimitInterceptor,
572
+ createHARInterceptor,
573
+ createMetricsInterceptor,
574
+ createInterceptorSuite,
575
+ InterceptorManager,
576
+ computeBodySize,
577
+ RateLimitError, // thrown by createRateLimitInterceptor
578
+ } from "kinetex/interceptors";
579
+
580
+ // Compute request body size (used internally by progress tracking)
581
+ computeBodySize(body); // → number | null
582
+
583
+ // Combine multiple built-in interceptors
584
+ const suite = createInterceptorSuite({
585
+ retry: { maxRetries: 3 },
586
+ auth: { type: "bearer", token: "..." },
587
+ cache: { ttlMs: 5000 },
588
+ logging: true,
589
+ metrics: true,
590
+ });
591
+ // suite.retry, suite.auth, suite.cache, suite.logging, suite.metrics, suite.eject
592
+ ```
593
+
372
594
  ---
373
595
 
374
- ## Request Config Reference
596
+ ## Lifecycle Hooks
375
597
 
376
- Every request method accepts a `RequestConfig` object. All fields are optional except `url`.
598
+ Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation:
377
599
 
378
600
  ```ts
379
- interface RequestConfig<T = unknown> {
380
- url: string;
381
- method?: string; // default: 'GET'
382
-
383
- // ── Headers & Body ─────────────────────────────────────────────────────
384
- headers?: Record<string,string> | [string,string][] | Headers;
385
- body?: BodyInit; // raw body
386
- json?: unknown; // serialised + Content-Type: application/json
387
- form?: Record<string, string|number|boolean>; // application/x-www-form-urlencoded
388
- searchParams?: Record<string, string|number|boolean|string[]> | URLSearchParams | string;
389
-
390
- // ── Response ───────────────────────────────────────────────────────────
391
- responseType?: 'json'|'text'|'blob'|'arrayBuffer'|'formData'|'stream';
392
- schema?: ZodSchema | ValibotSchema | { parse(d: unknown): T }; // validates body
393
- throwHttpErrors?: boolean; // default: true
394
- decompress?: boolean; // default: true — set false to get raw compressed bytes
395
-
396
- // ── Network ───────────────────────────────────────────────────────────
397
- baseURL?: string;
398
- timeout?: number | {
399
- request?: number; // total request timeout (ms)
400
- response?: number; // TTFB timeout — aborts if first byte not received in time
401
- };
402
- retry?: number | RetryConfig;
403
- cache?: CacheConfig | false;
404
- dedupe?: boolean; // default: true for GET
405
- signal?: AbortSignal;
406
- credentials?: RequestCredentials;
407
- followRedirects?: boolean; // default: true
408
- maxRedirects?: number; // default: 10
409
- transport?: 'fetch' | 'undici'; // force transport
410
-
411
- // ── Callbacks ─────────────────────────────────────────────────────────
412
- onUploadProgress?: (event: ProgressEvent) => void; // Node + browser
413
- onDownloadProgress?: (event: ProgressEvent) => void;
414
-
415
- // ── Observability ─────────────────────────────────────────────────────
416
- hooks?: HookConfig;
417
- logger?: Logger;
418
- har?: boolean;
601
+ kinetex({
602
+ hooks: {
603
+ onBeforeRequest: [
604
+ (req, ctx) => {
605
+ console.log(`→ ${req.method} ${req.url}`);
606
+ return req; // Return modified request, or void
607
+ },
608
+ ],
609
+ onAfterRequest: [
610
+ (req, ctx) => {
611
+ /* request was sent */
612
+ },
613
+ ],
614
+ onBeforeResponse: [
615
+ (res, ctx) => {
616
+ console.log(`← ${res.status}`);
617
+ return res; // Return modified response, or void
618
+ },
619
+ ],
620
+ onAfterResponse: [
621
+ (res, ctx) => {
622
+ /* response processed */
623
+ },
624
+ ],
625
+ onError: [
626
+ (err, ctx) => {
627
+ console.error(err);
628
+ return recoveredResponse; // Return KinetexResponse to recover, or void to rethrow
629
+ },
630
+ ],
631
+ onRetry: [(ctx) => console.log(`Retry ${ctx.attempt}/${ctx.maxRetries}`)],
632
+ onUploadProgress: [(ev) => console.log(`Upload: ${ev.percent}%`)],
633
+ onDownloadProgress: [(ev) => console.log(`Download: ${ev.percent}%`)],
634
+ },
635
+ });
636
+ ```
637
+
638
+ ### HookContext
639
+
640
+ ```ts
641
+ interface HookContext {
642
+ request: KinetexRequest;
643
+ response: KinetexResponse<unknown> | null;
644
+ error: unknown | null;
645
+ startedAt: number;
646
+ attempt: number;
647
+ meta: Record<string, unknown>;
419
648
  }
420
649
  ```
421
650
 
422
651
  ---
423
652
 
424
- ## Response Object
653
+ ## HookRegistry
425
654
 
426
- Every request resolves to a `KinetexResponse<T>`:
655
+ For advanced hook management — priority ordering, one-shot hooks, conditional execution:
427
656
 
428
657
  ```ts
429
- const res = await kinetex.get<User>('/users/1');
658
+ import {
659
+ HookRegistry,
660
+ HookEmitter,
661
+ RedirectTracker,
662
+ TooManyRedirectsError,
663
+ HTTPError,
664
+ ResponseValidationError,
665
+ createLoggingHooks,
666
+ createTimingHook,
667
+ createBodyNormalizationHook,
668
+ createAbortHook,
669
+ createHookContext,
670
+ tap,
671
+ injectHeaders,
672
+ withBaseURL,
673
+ throwOnHTTPError,
674
+ validateResponse,
675
+ composeBeforeRequest,
676
+ composeBeforeResponse,
677
+ composeAround,
678
+ } from "kinetex/lifecycle";
679
+ import type {
680
+ HookRequest,
681
+ HookResponse,
682
+ HookError,
683
+ HookOptions,
684
+ BeforeRequestHook,
685
+ AfterRequestHook,
686
+ BeforeResponseHook,
687
+ AfterResponseHook,
688
+ OnErrorHook,
689
+ OnRetryHook,
690
+ OnRedirectHook,
691
+ OnUploadProgressHook,
692
+ OnDownloadProgressHook,
693
+ AroundHook,
694
+ } from "kinetex/lifecycle";
695
+
696
+ const registry = new HookRegistry();
697
+
698
+ // All hook types:
699
+ // - addBeforeRequest(fn, options?)
700
+ // - addAfterRequest(fn, options?)
701
+ // - addBeforeResponse(fn, options?)
702
+ // - addAfterResponse(fn, options?)
703
+ // - addOnError(fn, options?)
704
+ // - addOnRetry(fn, options?)
705
+ // - addOnRedirect(fn, options?)
706
+ // - addOnUploadProgress(fn, options?)
707
+ // - addOnDownloadProgress(fn, options?)
708
+ // - addAround(fn, options?) // wraps the entire pipeline
709
+
710
+ registry.addBeforeRequest(myHook, {
711
+ priority: 10, // Lower number = runs first (default: 100)
712
+ once: true, // Auto-eject after first run
713
+ if: (req) => req.method === "POST", // Conditional execution
714
+ });
715
+ registry.removeBeforeRequest(myHook); // Eject by reference
716
+
717
+ // Attach registry to a client
718
+ client.attachHookRegistry(registry); // Returns single eject function
719
+
720
+ // Built-in hook factories:
721
+ const { beforeRequest, afterResponse, onError } = createLoggingHooks();
722
+ const timingHook = createTimingHook();
723
+ const normalizeBody = createBodyNormalizationHook();
724
+ const abortHook = createAbortHook();
725
+ const ctx = createHookContext(req, res, err); // Manual HookContext creation
726
+
727
+ // Utility hooks
728
+ const injectCustomHeaders = injectHeaders({ "X-Internal": "true" }); // BeforeRequestHook
729
+ const withBase = withBaseURL("https://api.example.com"); // BeforeRequestHook
730
+ const throwOnError = throwOnHTTPError(); // BeforeResponseHook
731
+ const validator = validateResponse((res) => res.status < 500); // BeforeResponseHook
732
+ const tapped = tap((value) => console.log(value)); // Passthrough logger
733
+
734
+ // Composition
735
+ const pipeline = composeBeforeRequest(fn1, fn2, fn3);
736
+ const responsePipe = composeBeforeResponse(fn1, fn2);
737
+ // Redirect tracking
738
+ const redirectTracker = new RedirectTracker({ maxRedirects: 5 });
739
+ // Error classes
740
+ class MyHTTPError extends HTTPError {} // extends Error
741
+ class MyValidationError extends ResponseValidationError {} // extends Error
742
+ class TooManyRedirectsError extends Error {} // thrown by HookRegistry
743
+ ```
744
+
745
+ ### HookEmitter
746
+
747
+ For event-style hook emission separate from the registry:
430
748
 
431
- res.data // T — parsed body
432
- res.status // number — HTTP status code
433
- res.statusText // string
434
- res.headers // Headers — response headers
435
- res.response // Response — original fetch Response (for streaming)
436
- res.request // RequestConfig — originating request
437
- res.fromCache // boolean — true if served from cache
438
- res.retries // number — retry attempts made
439
- res.timing // { start, end, duration, ttfb, ... }
440
- res.harEntry // HarEntry — present when har: true
749
+ ```ts
750
+ const emitter = new HookEmitter();
751
+ emitter.on("beforeRequest", myHandler);
752
+ emitter.emit("beforeRequest", req, ctx);
753
+ emitter.off("beforeRequest", myHandler);
754
+ emitter.clear();
755
+ ```
756
+
757
+ ### HookOptions
758
+
759
+ ```ts
760
+ interface HookOptions {
761
+ priority?: number; // Lower runs first (default: 100)
762
+ once?: boolean; // Auto-eject after first execution
763
+ if?: (req: KinetexRequest) => boolean; // Conditional execution predicate
764
+ }
441
765
  ```
442
766
 
443
767
  ---
444
768
 
445
- ## Authentication
769
+ ## Request Deduplication
770
+
771
+ Coalesces identical concurrent GET/HEAD requests into a single network call:
446
772
 
447
773
  ```ts
448
- import { auth, create } from 'kinetex';
774
+ client.enableDedup({ windowMs: 50 }); // Also dedupe for 50ms after completion
775
+
776
+ // These 3 calls make exactly ONE network request:
777
+ const [a, b, c] = await Promise.all([
778
+ client.get("/users"),
779
+ client.get("/users"),
780
+ client.get("/users"),
781
+ ]);
449
782
 
450
- // Basic auth
451
- create().extend(auth.basic('username', 'password'));
783
+ console.log(client.dedupMetrics);
784
+ // { hits: 2, misses: 1, inFlightCount: 0 }
452
785
 
453
- // Bearer token (static)
454
- create().extend(auth.bearer('my-token'));
786
+ client.disableDedup();
787
+ ```
455
788
 
456
- // Bearer token (async — refreshed on each request)
457
- create().extend(auth.bearer(async () => getAccessToken()));
789
+ Uses `DedupMap` internally:
458
790
 
459
- // API key in a header
460
- create().extend(auth.apiKey('key-value', { header: 'X-API-Key' }));
791
+ ```ts
792
+ import { DedupMap, createDedupMap } from "kinetex/dedup";
793
+ import type { DedupOptions } from "kinetex/dedup";
461
794
 
462
- // API key as a query param
463
- create().extend(auth.apiKey('key-value', { query: 'api_key' }));
795
+ const dedup = new DedupMap<KinetexResponse>({
796
+ keyFn: (method, url, headers) => `${method}:${url}:${headers?.["authorization"]}`,
797
+ windowMs: 100, // Keep completed response for 100ms
798
+ methods: ["GET", "HEAD"], // Methods to deduplicate
799
+ signal: controller.signal,
800
+ });
464
801
 
465
- // OAuth 2.0 client credentials — auto-fetches and refreshes tokens
466
- create().extend(auth.oauth2({
467
- tokenUrl: 'https://auth.example.com/oauth/token',
468
- clientId: process.env.CLIENT_ID,
469
- clientSecret: process.env.CLIENT_SECRET,
470
- scope: 'read:users write:posts',
471
- onToken: (token) => console.log('New token, expires in:', token.expires_in),
472
- }));
802
+ const result = await dedup.execute("GET", "unique-key", () => fetchData());
473
803
 
474
- // AWS SigV4 — signed using Web Crypto, no extra dependency
475
- create().extend(auth.aws({
476
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
477
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
478
- sessionToken: process.env.AWS_SESSION_TOKEN, // optional
479
- region: 'us-east-1',
480
- service: 's3',
481
- }));
804
+ // Simple factory
805
+ const dMap = createDedupMap<KinetexResponse>({ windowMs: 50 });
482
806
 
483
- // Digest auth — RFC 2617 compliant MD5 (nginx, Apache compatible)
484
- create().extend(auth.digest('username', 'password'));
807
+ // Metrics
808
+ console.log(dedup.hits, dedup.misses, dedup.inFlightCount, dedup.stats);
485
809
  ```
486
810
 
487
811
  ---
488
812
 
489
- ## Schema Validation
490
-
491
- kinetex integrates with any schema library that exposes `.parse()` or `.safeParse()`.
813
+ ## Circuit Breaker
492
814
 
493
- ### Zod
815
+ Per-origin (or per-key) three-state machine to prevent cascading failures:
494
816
 
495
817
  ```ts
496
- import { z } from 'zod';
497
- // import * as z from 'zod';
498
- import kinetex from 'kinetex';
499
-
500
- const UserSchema = z.object({
501
- id: z.number(),
502
- name: z.string(),
503
- email: z.string().email(),
818
+ client.enableCircuitBreaker({
819
+ failureThreshold: 5, // Failures before OPEN (default: 5)
820
+ 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: {
825
+ // Which failures count toward threshold
826
+ networkErrors: true, // ENETWORK errors (default: true)
827
+ timeouts: true, // ETIMEOUT errors (default: true)
828
+ serverErrors: false, // HTTP 5xx (default: false)
829
+ statusCodes: [503], // Specific status codes
830
+ },
831
+ 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),
504
835
  });
505
836
 
506
- // data is typed and validated at runtime — throws ValidationError on mismatch
507
- const { data } = await kinetex.get('https://api.example.com/users/1', {
508
- schema: UserSchema,
509
- });
837
+ // Manual control
838
+ client.tripCircuit("https://api.example.com");
839
+ client.resetCircuit("https://api.example.com");
510
840
 
511
- console.log(data.name); // string ✅
841
+ // Inspect state
842
+ console.log(client.circuitSnapshots);
843
+
844
+ client.disableCircuitBreaker();
512
845
  ```
513
846
 
514
- ### Valibot
847
+ ### Standalone
515
848
 
516
849
  ```ts
517
- import * as v from 'valibot';
850
+ import {
851
+ CircuitBreaker,
852
+ CircuitBreakerRegistry,
853
+ createCircuitBreaker,
854
+ createCircuitBreakerRegistry,
855
+ CircuitOpenError,
856
+ } from "kinetex/circuit-breaker";
857
+ import type {
858
+ CircuitState,
859
+ CircuitBreakerConfig,
860
+ CircuitBreakerState,
861
+ FailureFilter,
862
+ } from "kinetex/circuit-breaker";
863
+
864
+ const cb = createCircuitBreaker({
865
+ failureThreshold: 5,
866
+ resetTimeoutMs: 30_000,
867
+ });
518
868
 
519
- const UserSchema = v.object({ id: v.number(), name: v.string() });
520
- const { data } = await kinetex.get('/users/1', { schema: UserSchema });
869
+ const registry = new CircuitBreakerRegistry(config);
870
+ // Thin wrapper that manages a Map<string, CircuitBreaker>
871
+ registry.get("https://api.example.com"); // → CircuitBreaker
872
+ registry.snapshots(); // → Record<string, CircuitBreakerState>
873
+ registry.trip("origin");
874
+ registry.reset("origin");
875
+ registry.clear();
521
876
  ```
522
877
 
523
- ### Custom validator
878
+ ---
879
+
880
+ ## Caching
524
881
 
525
- Any object with `.parse()` or `.safeParse()` works:
882
+ RFC 7234 compliant HTTP caching with multiple storage backends:
526
883
 
527
884
  ```ts
528
- const { data } = await kinetex.get('/users/1', {
529
- schema: {
530
- parse: (raw) => {
531
- if (!raw || typeof raw !== 'object') throw new Error('Invalid');
532
- return raw as User;
533
- },
885
+ const client = kinetex({
886
+ 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
534
895
  },
535
896
  });
897
+
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
902
+ client.GET("/users").cache({ ttlMs: 5000 }).json();
903
+ client.GET("/users").noCache().json();
904
+
905
+ // SWR error callback
906
+ kinetex({
907
+ cache: { swr: true },
908
+ onSWRError: (err, req) => console.error("SWR failed", req.url, err),
909
+ });
536
910
  ```
537
911
 
538
- ### Chain API
912
+ ### Standalone Cache
539
913
 
540
914
  ```ts
541
- const { data } = await kinetex
542
- .chain('/users/1')
543
- .schema(UserSchema);
915
+ import {
916
+ HTTPCache,
917
+ createMemoryCache,
918
+ createLocalStorageCache,
919
+ createKVCache,
920
+ createTwoTierCache,
921
+ createSessionStorageCache,
922
+ MemoryStorageAdapter,
923
+ WebStorageAdapter,
924
+ CloudflareKVAdapter,
925
+ TwoTierStorageAdapter,
926
+ getAuthFingerprint,
927
+ } from "kinetex/cache";
928
+ import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
929
+
930
+ // Memory cache
931
+ const cache = createMemoryCache({ ttlMs: 60_000, maxEntries: 1000 });
932
+
933
+ // Browser localStorage cache
934
+ const cache = createLocalStorageCache({ prefix: "myapp:" });
935
+
936
+ // Browser sessionStorage cache
937
+ const cache = createSessionStorageCache({ prefix: "myapp:" });
938
+
939
+ // Cloudflare KV cache
940
+ const cache = createKVCache({ kv: myKVNamespace, ttlMs: 60_000 });
941
+
942
+ // Two-tier (L1 memory + L2 storage)
943
+ const cache = createTwoTierCache({
944
+ tier1: createMemoryCache({ ttlMs: 10_000 }),
945
+ tier2: createLocalStorageCache({ prefix: "myapp:" }),
946
+ });
947
+
948
+ // Full HTTPCache
949
+ const cache = new HTTPCache({
950
+ storage: new MemoryStorageAdapter(),
951
+ ttlMs: 60_000,
952
+ maxEntries: 1000,
953
+ vary: true,
954
+ namespace: "myapp",
955
+ });
956
+
957
+ await cache.set(
958
+ { url: "https://api.example.com/users", method: "GET", headers: {} },
959
+ { status: 200, statusText: "OK", headers: {}, body: "..." },
960
+ { tags: ["users"] },
961
+ );
962
+
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
965
+
966
+ // Tag-based invalidation
967
+ await cache.invalidateByTag("users");
968
+
969
+ // Cache statistics
970
+ const stats: CacheStats = cache.stats; // { size, hits, misses, evictions, hitRate }
971
+ cache.clear();
544
972
  ```
545
973
 
546
974
  ---
547
975
 
548
- ## Caching
549
-
550
- Built-in in-memory cache with stale-while-revalidate support. Works with all response types.
976
+ ## Cookie Jar
551
977
 
552
- The default cache store is **per-instance** — each `create()` call gets its own isolated store, so cache entries from one instance never bleed into another. To share a cache across instances, pass a custom `store` explicitly.
978
+ Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching:
553
979
 
554
980
  ```ts
555
- // Cache for 60s, serve stale for up to 5 more minutes while revalidating in the background
556
- const { data, fromCache } = await kinetex.get('/api/config', {
557
- cache: { ttl: 60_000, swr: 300_000 },
981
+ // Auto-managed through the client
982
+ const client = kinetex({ baseURL: "https://httpbin.org", cookieJar: true });
983
+ await client.get("/cookies/set/test/value");
984
+ const res = await client.get("/cookies");
985
+ console.log(res.data.cookies.test); // "value"
986
+
987
+ // Standalone
988
+ import { CookieJar, createCookieJar, loadCookieJar } from "kinetex/cookiejar";
989
+ import type { Cookie, CookieJSON } from "kinetex/cookiejar";
990
+
991
+ const jar = createCookieJar();
992
+
993
+ // From Set-Cookie header
994
+ jar.setCookie("session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax", {
995
+ url: "https://example.com/login",
558
996
  });
559
- console.log('from cache?', fromCache);
560
997
 
561
- // Opt out for one request
562
- await api.get('/realtime-data', { cache: false });
998
+ // From fetch Response headers
999
+ jar.processResponseHeaders(response.headers, {
1000
+ url: "https://example.com/login",
1001
+ });
563
1002
 
564
- // Custom cache key
565
- await api.get('/users', {
566
- cache: {
567
- ttl: 30_000,
568
- key: (req) => `v2:${req.url}`,
569
- },
1003
+ // Get cookies for a request
1004
+ const header = jar.getCookieHeader({
1005
+ url: "https://example.com/api/users",
1006
+ http: true, // Include HttpOnly cookies (default: true)
1007
+ sameSiteContext: "strict", // "strict" | "lax" | "cross-site" (default: "strict")
570
1008
  });
1009
+ // → "session=abc123"
1010
+
1011
+ const cookies: Cookie[] = jar.getCookies({
1012
+ url: "https://example.com/api",
1013
+ http: false, // Exclude HttpOnly cookies (for document.cookie)
1014
+ });
1015
+
1016
+ // Serialization
1017
+ const json: CookieJSON[] = jar.toJSON();
1018
+ const jar2 = loadCookieJar(json);
1019
+
1020
+ // Clear
1021
+ jar.clear();
1022
+ jar.clearExpired();
1023
+ jar.clearSession();
1024
+ jar.clearForDomain("example.com");
1025
+ jar.clearForUrl("https://example.com/api");
571
1026
  ```
572
1027
 
573
- ### Custom store (e.g. Redis)
1028
+ ### Cookie Interface
574
1029
 
575
1030
  ```ts
576
- import { create } from 'kinetex';
577
-
578
- const api = create({
579
- cache: {
580
- ttl: 30_000,
581
- store: {
582
- get: (key) => redis.get(key).then(v => v ? JSON.parse(v) : undefined),
583
- set: (key, entry) => redis.setex(
584
- key,
585
- Math.ceil((entry.expiresAt - Date.now()) / 1000),
586
- JSON.stringify(entry)
587
- ),
588
- delete: (key) => redis.del(key),
589
- clear: () => redis.flushdb(),
590
- },
591
- },
592
- });
1031
+ interface Cookie {
1032
+ name: string;
1033
+ value: string;
1034
+ domain: string;
1035
+ path: string;
1036
+ expires: number | null; // epoch ms
1037
+ maxAge: number | null;
1038
+ secure: boolean;
1039
+ httpOnly: boolean;
1040
+ sameSite: "strict" | "lax" | "none";
1041
+ createdAt: number;
1042
+ lastAccessed: number;
1043
+ hostOnly: boolean;
1044
+ }
593
1045
  ```
594
1046
 
595
1047
  ---
596
1048
 
597
- ## Retry
1049
+ ## Cookie Parser & Store
1050
+
1051
+ Full RFC 6265 §5.1 + §5.2 Set-Cookie header parser with public suffix list:
598
1052
 
599
1053
  ```ts
600
- const { data } = await kinetex.get('/api/data', {
601
- retry: {
602
- limit: 4,
603
- statusCodes: [429, 500, 502, 503, 504],
604
- methods: ['GET', 'POST'],
605
- delay: (attempt) => Math.min(200 * 2 ** attempt, 10_000), // exponential back-off
606
- onNetworkError: true,
607
- onRetry: (attempt, err, req) => console.warn(`Retry ${attempt} for ${req.url}:`, err),
608
- },
609
- });
1054
+ import {
1055
+ parseCookieDate,
1056
+ getPublicSuffix,
1057
+ getRegistrableDomain,
1058
+ isPublicSuffix,
1059
+ decodeIDNLabel,
1060
+ canonicalizeDomainFull,
1061
+ isIPAddress,
1062
+ domainMatch,
1063
+ defaultPath,
1064
+ pathMatch,
1065
+ parseSetCookieHeader,
1066
+ splitSetCookieHeaders,
1067
+ extractSetCookieHeaders,
1068
+ } from "kinetex/cookie-parser";
1069
+
1070
+ const parsed = parseSetCookieHeader(
1071
+ "session=abc123; Path=/; Domain=.example.com; Secure; HttpOnly; SameSite=Lax; Max-Age=3600",
1072
+ );
1073
+ // → ParsedCookie {
1074
+ // name: "session",
1075
+ // value: "abc123",
1076
+ // domain: "example.com",
1077
+ // path: "/",
1078
+ // expires: null,
1079
+ // maxAge: 3600,
1080
+ // secure: true,
1081
+ // httpOnly: true,
1082
+ // sameSite: "Lax",
1083
+ // sameParty: false,
1084
+ // priority: null,
1085
+ // partitioned: false,
1086
+ // }
1087
+
1088
+ domainMatch("api.example.com", "example.com"); // → true
1089
+ pathMatch("/api/users", "/api"); // → true
1090
+ isPublicSuffix("com"); // → true
1091
+ getRegistrableDomain("api.example.com"); // → "example.com"
1092
+ defaultPath("/api/users"); // → "/api"
1093
+ extractSetCookieHeaders(headers); // → string[]
1094
+ splitSetCookieHeaders("a=1, b=2"); // → ["a=1", "b=2"]
1095
+ ```
1096
+
1097
+ Internal storage model with LRU eviction (per-domain cap 50, global cap 3000). The `CookieStore` class handles the underlying storage:
610
1098
 
611
- // Shorthand — retries 3 times with defaults
612
- const { data } = await kinetex.get('/api/data', { retry: 3 });
1099
+ ```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()
613
1105
  ```
614
1106
 
615
1107
  ---
616
1108
 
617
- ## Interceptors
1109
+ ## Pagination
618
1110
 
619
- Axios-compatible interceptor API. Interceptors run as part of the request pipeline.
1111
+ Seven pagination strategies with async iteration:
620
1112
 
621
1113
  ```ts
622
- import { create } from 'kinetex';
1114
+ import {
1115
+ paginate,
1116
+ collectAll,
1117
+ collectPages,
1118
+ takeItems,
1119
+ paginateItems,
1120
+ mergePaginators,
1121
+ parseLinkHeaderNext,
1122
+ createOffsetPaginator,
1123
+ createPagePaginator,
1124
+ createCursorPaginator,
1125
+ createKeysetPaginator,
1126
+ createRelayPaginator,
1127
+ createLinkHeaderPaginator,
1128
+ createTokenPaginator,
1129
+ toPaginationIterator,
1130
+ serializePaginationState,
1131
+ deserializePaginationState,
1132
+ } from "kinetex/pagination";
1133
+ import type { Page, PaginationState } from "kinetex/pagination";
1134
+
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
+ });
623
1143
 
624
- const api = create({ baseURL: 'https://api.example.com' });
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
+ });
625
1153
 
626
- // Request interceptor — add a tracing header to every request
627
- const reqId = api.interceptors.request.use(async (config) => ({
628
- ...config,
629
- headers: { ...config.headers, 'X-Request-ID': crypto.randomUUID() },
630
- }));
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
+ });
631
1163
 
632
- // Response interceptor — unwrap a nested payload
633
- const resId = api.interceptors.response.use((response) => ({
634
- ...response,
635
- data: response.data?.payload ?? response.data,
636
- }));
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
+ });
637
1174
 
638
- // Remove individual interceptors
639
- api.interceptors.request.eject(reqId);
640
- api.interceptors.response.eject(resId);
641
- ```
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
+ });
642
1183
 
643
- ---
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
+ });
644
1191
 
645
- ## Lifecycle Hooks
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
+ for await (const page of pages) {
1204
+ console.log(page.items, page.total, page.page, page.hasNext, page.nextCursor);
1205
+ }
646
1206
 
647
- Fine-grained hooks for observability, modification, and error handling. Set per-request or on an instance.
1207
+ // Collect all items across all pages
1208
+ const allItems = await collectAll(client, { url: "/items", strategy: "cursor" });
1209
+
1210
+ // Collect all page objects
1211
+ const allPages = await collectPages(client, { url: "/items", strategy: "page", maxPages: 5 });
1212
+
1213
+ // Take N items across pages
1214
+ const first50 = await takeItems(client, { url: "/items", strategy: "offset", perPage: 10 }, 50);
1215
+
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
+ }
1221
+
1222
+ // Parallel prefetch
1223
+ const pages = paginate(client, { url: "/items", strategy: "page", prefetch: 3 });
1224
+
1225
+ // Merge two paginators
1226
+ const merged = mergePaginators(paginator1, paginator2);
1227
+
1228
+ // State serialization (resume capability)
1229
+ const state: PaginationState = serializePaginationState(paginator);
1230
+ const paginator2 = deserializePaginationState(client, state);
1231
+
1232
+ // Convert to async iterator
1233
+ const iterator = toPaginationIterator(paginator);
1234
+ ```
1235
+
1236
+ ### Client-Level Pagination
648
1237
 
649
1238
  ```ts
650
- const { data } = await kinetex.get('/api/data', {
651
- hooks: {
652
- // Modify the request before it is sent
653
- beforeRequest: [
654
- (config) => ({ ...config, url: config.url + '?source=kinetex' }),
655
- ],
656
- // Inspect or transform the response
657
- afterResponse: [
658
- (response) => {
659
- console.log(`${response.status} in ${response.timing.duration}ms`);
660
- return response;
661
- },
662
- ],
663
- // React to any error (does not suppress it)
664
- onError: [
665
- (err) => metrics.increment('http.error', { status: err.response?.status }),
666
- ],
667
- },
1239
+ const pages = await client.paginate("/items", {
1240
+ strategy: "page",
1241
+ perPage: 50,
1242
+ maxPages: 10,
668
1243
  });
669
1244
  ```
670
1245
 
671
1246
  ---
672
1247
 
673
- ## Middleware Pipeline
674
-
675
- The full power of kinetex — compose arbitrary request/response transforms into the pipeline.
1248
+ ## Server-Sent Events (SSE)
676
1249
 
677
1250
  ```ts
678
- import { create, compose } from 'kinetex';
679
- import type { Middleware } from 'kinetex';
1251
+ import {
1252
+ SSEClient,
1253
+ SSEParser,
1254
+ SSETransformStream,
1255
+ SSERouter,
1256
+ createSSEStream,
1257
+ createJSONSSEStream,
1258
+ parseSSEText,
1259
+ jsonSSE,
1260
+ SSEServerResponse,
1261
+ SSEError,
1262
+ SSEMaxReconnectsError,
1263
+ createSSEResponse,
1264
+ } from "kinetex/sse";
1265
+ import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
1266
+
1267
+ // SSEClient
1268
+ const sse = new SSEClient({
1269
+ url: "https://api.example.com/events",
1270
+ method: "POST",
1271
+ headers: { Authorization: "Bearer token" },
1272
+ body: JSON.stringify({ query: "..." }),
1273
+ 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
+ signal: controller.signal,
1282
+ });
680
1283
 
681
- // Write a middleware
682
- const timingMiddleware: Middleware = async (request, next) => {
683
- const start = Date.now();
684
- const response = await next(request);
685
- console.log(`${request.method} ${request.url} — ${Date.now() - start}ms`);
686
- return response;
687
- };
1284
+ // Async iteration
1285
+ for await (const event of sse) {
1286
+ console.log(event.id, event.event, event.data);
1287
+ }
688
1288
 
689
- const api = create({ baseURL: 'https://api.example.com' });
690
- api.use(timingMiddleware);
1289
+ // JSON SSE events
1290
+ const jsonStream = createJSONSSEStream<{ price: number }>("https://api.example.com/prices");
1291
+ for await (const event of jsonStream) {
1292
+ console.log(event.data.price);
1293
+ }
691
1294
 
692
- // Compose multiple middlewares manually
693
- const pipeline = compose([timingMiddleware, authMiddleware], coreHandler);
694
- ```
1295
+ // SSE transform stream
1296
+ const parser = new SSEParser();
1297
+ const stream = new SSETransformStream();
1298
+ readableStream.pipeThrough(stream).pipeTo(writableStream);
695
1299
 
696
- ---
1300
+ // Named event routing
1301
+ const router = new SSERouter();
1302
+ router.on("price_update", (event) => {
1303
+ /* ... */
1304
+ });
1305
+ router.on("order_filled", (event) => {
1306
+ /* ... */
1307
+ });
697
1308
 
698
- ## SSE Streaming
1309
+ // Server-side SSE response builder
1310
+ const response = createSSEResponse(); // → Response with text/event-stream
1311
+ ```
699
1312
 
700
- Server-Sent Events with automatic reconnection, `Last-Event-ID` tracking, and server-side `retry:` interval support.
1313
+ ### Client-Level SSE
701
1314
 
702
1315
  ```ts
703
- import { sse } from 'kinetex/plugins';
1316
+ const sseClient = await client.sse("/events", {
1317
+ autoReconnect: true,
1318
+ maxReconnects: 5,
1319
+ });
1320
+ ```
1321
+
1322
+ ---
704
1323
 
705
- const controller = new AbortController();
706
- setTimeout(() => controller.abort(), 30_000); // 30s limit
1324
+ ## WebSocket
707
1325
 
708
- for await (const event of sse('https://api.example.com/events', {
1326
+ ```ts
1327
+ import {
1328
+ WSClient,
1329
+ connectWS,
1330
+ WSError,
1331
+ WSMaxReconnectsError,
1332
+ WSConnectTimeoutError,
1333
+ WSRateLimitError,
1334
+ } from "kinetex/ws";
1335
+ import type {
1336
+ WSState,
1337
+ WSMessage,
1338
+ WSClientConfig,
1339
+ WSCloseEvent,
1340
+ WSBackpressureInfo,
1341
+ WSSubscribedRoom,
1342
+ } from "kinetex/ws";
1343
+
1344
+ const ws = new WSClient({
1345
+ url: "wss://api.example.com/live",
1346
+ headers: { Authorization: "Bearer token" },
1347
+ reconnect: true,
1348
+ maxReconnects: 10,
1349
+ baseDelay: 1000,
1350
+ maxDelay: 30000,
1351
+ connectTimeoutMs: 5000,
1352
+ pingIntervalMs: 30000,
1353
+ pongTimeoutMs: 5000,
1354
+ highWaterMark: 65536,
1355
+ lowWaterMark: 16384,
1356
+ maxSendRate: 0, // 0 = unlimited
1357
+ keepRooms: true,
709
1358
  signal: controller.signal,
710
- headers: { Authorization: 'Bearer my-token' },
711
- maxRetries: 10, // Infinity by default
712
- })) {
713
- console.log(`[${event.event ?? 'message'}]`, event.data);
714
- if (event.id) console.log('event id:', event.id);
715
- if (event.event === 'done') break;
1359
+
1360
+ onMessage: (msg) => console.log(msg.data, msg.json),
1361
+ onError: (err) => console.error(err),
1362
+ onClose: (code, reason, willReconnect) => {},
1363
+ onReconnect: (attempt) => console.log(`Reconnecting (${attempt})`),
1364
+ });
1365
+
1366
+ await ws.connect();
1367
+
1368
+ // Send
1369
+ ws.send("raw message");
1370
+ ws.sendJSON({ type: "subscribe", channel: "prices" });
1371
+ ws.sendBinary(new Uint8Array([1, 2, 3]));
1372
+
1373
+ // Async iteration
1374
+ for await (const msg of ws) {
1375
+ console.log(msg.data, msg.json?.type);
1376
+ }
1377
+
1378
+ // Request/response correlation
1379
+ const reply = await ws.request({ type: "ping" }, (msg) => msg.json?.type === "pong");
1380
+
1381
+ // Metrics
1382
+ interface WSMetrics {
1383
+ messagesSent: number;
1384
+ messagesReceived: number;
1385
+ bytesSent: number;
1386
+ bytesReceived: number;
1387
+ reconnectCount: number;
1388
+ totalConnectAttempts: number;
1389
+ uptimeMs: number | null;
716
1390
  }
1391
+
1392
+ // Utility
1393
+ const ws = await connectWS("wss://api.example.com/ws", { onMessage: ... });
717
1394
  ```
718
1395
 
719
- **SSE event fields:**
1396
+ ### Client-Level WebSocket
720
1397
 
721
1398
  ```ts
722
- interface SSEEvent {
723
- data: string; // event data
724
- event?: string; // event type (from "event:" line)
725
- id?: string; // event id (from "id:" line)
726
- retry?: number; // retry interval in ms (from "retry:" line)
727
- }
1399
+ const ws = await client.ws("wss://api.example.com/live", {
1400
+ onMessage: (msg) => console.log(msg.json),
1401
+ });
1402
+ // Inherits client headers, auth, cookies, and circuit breaker protection
728
1403
  ```
729
1404
 
730
1405
  ---
731
1406
 
732
1407
  ## GraphQL
733
1408
 
734
- Type-safe GraphQL requests with automatic error detection.
735
-
736
1409
  ```ts
737
- import { create } from 'kinetex';
738
- import { graphqlPlugin } from 'kinetex/plugins';
739
-
740
- const api = create({ baseURL: 'https://api.example.com' });
741
-
742
- interface UserData { user: { id: string; name: string } }
743
- interface UserVars { id: string }
1410
+ import {
1411
+ GraphQLClient,
1412
+ createGraphQLClient,
1413
+ gql,
1414
+ detectOperationType,
1415
+ extractOperationName,
1416
+ clearAPQCache,
1417
+ getAPQMetrics,
1418
+ authLink,
1419
+ errorLink,
1420
+ loggingLink,
1421
+ retryLink,
1422
+ GraphQLClientError,
1423
+ } from "kinetex/graphql";
1424
+ import type {
1425
+ GraphQLRequest,
1426
+ GraphQLResponse,
1427
+ GraphQLError,
1428
+ GraphQLClientConfig,
1429
+ GraphQLLink,
1430
+ GraphQLLinkNext,
1431
+ } from "kinetex/graphql";
1432
+
1433
+ const client = new GraphQLClient({
1434
+ url: "https://api.example.com/graphql",
1435
+ headers: { Authorization: "Bearer token" },
1436
+ fetch: globalThis.fetch,
1437
+ apq: true, // Automatic Persisted Queries
1438
+ fetchPersistedQuery: false, // Fetch persisted queries from storage
1439
+ apqHash: "sha256", // Hash algorithm
1440
+ retry: { maxRetries: 2 },
1441
+ signal: controller.signal,
1442
+ links: [
1443
+ // Middleware chain
1444
+ retryLink({ maxRetries: 3 }),
1445
+ authLink({ getToken: () => "..." }),
1446
+ loggingLink(),
1447
+ errorLink(),
1448
+ ],
1449
+ onRequest: (req) => console.log(req),
1450
+ onResponse: (res) => console.log(res),
1451
+ });
744
1452
 
745
- const config = graphqlPlugin<UserVars, UserData>('/graphql', {
746
- query: `
1453
+ // Query
1454
+ const { data, errors } = await client.query<{ user: { name: string } }>(
1455
+ gql`
747
1456
  query GetUser($id: ID!) {
748
- user(id: $id) { id name }
1457
+ user(id: $id) {
1458
+ name
1459
+ }
749
1460
  }
750
1461
  `,
751
- variables: { id: '42' },
752
- operationName: 'GetUser',
753
- });
1462
+ { id: "1" },
1463
+ );
1464
+
1465
+ // Mutation
1466
+ const result = await client.mutate<{ updateUser: { success: boolean } }>(
1467
+ gql`
1468
+ mutation UpdateUser($id: ID!, $name: String!) {
1469
+ updateUser(id: $id, name: $name) {
1470
+ success
1471
+ }
1472
+ }
1473
+ `,
1474
+ { id: "1", name: "Alice" },
1475
+ );
1476
+
1477
+ // Subscription (SSE or WebSocket transport)
1478
+ const sub = await client.subscribe(
1479
+ gql`
1480
+ subscription OnPrice {
1481
+ priceUpdate {
1482
+ symbol
1483
+ price
1484
+ }
1485
+ }
1486
+ `,
1487
+ {},
1488
+ { transport: "sse" },
1489
+ );
1490
+ for await (const event of sub) {
1491
+ console.log(event.data);
1492
+ }
754
1493
 
755
- const { data } = await api.post(config.url, config);
756
- // Throws automatically if data.errors is present
757
- console.log(data.data?.user.name);
1494
+ // Utility
1495
+ detectOperationType(gql`query { ... }`); // → "query"
1496
+ extractOperationName(gql`query GetUser { ... }`); // → "GetUser"
758
1497
  ```
759
1498
 
760
- ---
1499
+ ### Client-Level GraphQL
761
1500
 
762
- ## File Upload & Progress
1501
+ ```ts
1502
+ const gql = await client.graphql("/graphql", {
1503
+ apq: true,
1504
+ links: [authLink({ getToken: () => "..." })],
1505
+ });
1506
+ const { data } = await gql.query(query, variables);
1507
+ ```
763
1508
 
764
- ### Multipart upload
1509
+ ---
1510
+
1511
+ ## Progress Tracking
765
1512
 
766
1513
  ```ts
767
- const form = new FormData();
768
- form.append('title', 'My Upload');
769
- form.append('file', fileBlob, 'document.pdf');
1514
+ import {
1515
+ ProgressTracker,
1516
+ withUploadProgress,
1517
+ withDownloadProgress,
1518
+ withBlobUploadProgress,
1519
+ streamWithProgress,
1520
+ MultiPartProgressAggregator,
1521
+ xhrFetch,
1522
+ formatProgress,
1523
+ throttleProgress,
1524
+ formatBytes,
1525
+ formatRate,
1526
+ formatETA,
1527
+ collectStream,
1528
+ } from "kinetex/progress";
1529
+
1530
+ // Per-request progress
1531
+ client.post("/upload", largeBlob, {
1532
+ onUploadProgress: (ev) => {
1533
+ console.log(`${ev.percent}% @ ${formatRate(ev.rate)} — ETA ${formatETA(ev.eta)}`);
1534
+ },
1535
+ });
1536
+
1537
+ client.get("/large-file", {
1538
+ onDownloadProgress: (ev) => {
1539
+ console.log(`Downloaded ${formatBytes(ev.loaded)}/${formatBytes(ev.total)}`);
1540
+ },
1541
+ });
1542
+
1543
+ // Standalone progress tracker
1544
+ const tracker = new ProgressTracker(10_000_000, {
1545
+ throttleHz: 10,
1546
+ smoothingFactor: 0.3,
1547
+ signal: controller.signal,
1548
+ onProgress: (snap) => {
1549
+ console.log(snap.percent, formatRate(snap.rate), formatETA(snap.eta));
1550
+ },
1551
+ });
1552
+
1553
+ tracker.update(500_000); // 500KB transferred
1554
+ tracker.complete(); // Mark done
1555
+ tracker.reset(20_000_000); // Reset with new total
1556
+
1557
+ // Wrap a ReadableStream with progress tracking
1558
+ const { stream } = withUploadProgress(readableStream, totalBytes, {
1559
+ onProgress: (snap) => {},
1560
+ });
1561
+
1562
+ const { stream } = withDownloadProgress(response, {
1563
+ onProgress: (snap) => {},
1564
+ });
1565
+
1566
+ // Blob upload progress
1567
+ const { stream } = withBlobUploadProgress(blob, {
1568
+ onProgress: (snap) => {},
1569
+ });
770
1570
 
771
- const { data } = await kinetex.post('/upload', { body: form });
1571
+ const stream = streamWithProgress(readableStream, tracker);
1572
+
1573
+ // Collect full stream into Uint8Array
1574
+ const bytes = await collectStream(readableStream);
1575
+
1576
+ // Multi-part progress
1577
+ const agg = new MultiPartProgressAggregator();
1578
+ const partId = agg.addPart(0, 500); // part index, bytes
1579
+ agg.update(partId, 250);
1580
+ agg.complete(partId);
1581
+ const total = agg.total(); // ProgressSnapshot with overall progress
1582
+
1583
+ // Formatters
1584
+ formatBytes(1500); // "1.46 KB"
1585
+ formatRate(2_500_000); // "2.38 MB/s"
1586
+ formatETA(3661); // "1h 1m 1s"
1587
+ formatProgress({
1588
+ loaded: 500,
1589
+ total: 1000,
1590
+ percent: 50,
1591
+ rate: 1000,
1592
+ eta: 500,
1593
+ elapsed: 2000,
1594
+ done: false,
1595
+ });
1596
+ // → "50.0% · 1.00 KB/s · ETA 0.5s"
772
1597
  ```
773
1598
 
774
- ### Upload progress
1599
+ ---
775
1600
 
776
- In Node.js, `onUploadProgress` wraps the request body in a progress-tracking `ReadableStream` that emits events as undici reads each chunk. In Bun, upload progress uses Bun's native fetch streaming. In the browser, kinetex automatically falls back to `XMLHttpRequest` to provide real upload progress events.
1601
+ ## AWS SigV4 Signing
777
1602
 
778
1603
  ```ts
779
- import type { ProgressEvent as KinetexProgressEvent } from 'kinetex';
780
-
781
- await kinetex.post('/upload', {
782
- body: fileBuffer,
783
- onUploadProgress: (event: KinetexProgressEvent) => {
784
- console.log(`${event.percent?.toFixed(0)}% — ${(event.bytesPerSecond / 1024).toFixed(1)} KB/s`);
1604
+ import {
1605
+ SigV4Signer,
1606
+ signRequest,
1607
+ presignRequest,
1608
+ deriveSigningKey,
1609
+ staticCredentials,
1610
+ envCredentials,
1611
+ cachingCredentials,
1612
+ imdsCredentials,
1613
+ chainCredentials,
1614
+ formatAmzDate,
1615
+ formatDateStamp,
1616
+ sigV4UriEncode,
1617
+ detectClockSkew,
1618
+ isClockSkewError,
1619
+ createS3Signer,
1620
+ createAPIGatewaySigner,
1621
+ createSTSSigner,
1622
+ createDynamoDBSigner,
1623
+ signS3PostPolicy,
1624
+ initChunkedSigning,
1625
+ signChunk,
1626
+ signFinalChunk,
1627
+ } from "kinetex/aws-sigv4";
1628
+ import type { AWSCredentials, SigningConfig, CredentialProvider } from "kinetex/aws-sigv4";
1629
+
1630
+ const signer = new SigV4Signer({
1631
+ credentials: {
1632
+ accessKeyId: "AKID",
1633
+ secretAccessKey: "secret",
1634
+ sessionToken: "token", // Optional: for STS/AssumeRole
1635
+ expiration: "2024-12-31T23:59:59Z", // Optional: ISO 8601
785
1636
  },
1637
+ region: "us-east-1",
1638
+ service: "s3",
1639
+ signingDate: new Date(), // Override signing date
1640
+ payloadHash: "UNSIGNED-PAYLOAD", // For streaming
1641
+ unsignedHeaders: ["x-amz-content-sha256"], // Headers to skip
1642
+ presignExpires: 3600, // Presigned URL TTL (seconds)
1643
+ doubleEncode: true, // RFC 3986 double-encode (default: true)
1644
+ normalizePath: true, // Normalize path before signing (default: true)
1645
+ });
1646
+
1647
+ // Sign a request
1648
+ const signed = await signer.sign({
1649
+ method: "PUT",
1650
+ url: "https://my-bucket.s3.amazonaws.com/file.txt",
1651
+ headers: { "x-amz-acl": "public-read" },
1652
+ body: new Uint8Array([1, 2, 3]),
1653
+ });
1654
+
1655
+ // Presigned URL (no body signing)
1656
+ const signed = await signer.presign({
1657
+ method: "GET",
1658
+ url: "https://my-bucket.s3.amazonaws.com/file.txt",
1659
+ headers: {},
786
1660
  });
1661
+
1662
+ // Standalone helpers
1663
+ const signed = await signRequest(request, config);
1664
+ const presignedURL = await presignRequest(request, config);
1665
+ const key = await deriveSigningKey(credentials, dateStamp, region, service);
1666
+
1667
+ // Credential providers
1668
+ const provider = staticCredentials({ accessKeyId: "...", secretAccessKey: "..." });
1669
+ const provider = envCredentials(); // AWS_ACCESS_KEY_ID, etc.
1670
+ const provider = cachingCredentials(innerProvider, 5 * 60_000); // Cache with TTL
1671
+ const provider = imdsCredentials({ retries: 3 }); // EC2 IMDS
1672
+ const provider = chainCredentials(envCredentials, imdsCredentials); // Fallback chain
1673
+
1674
+ // Specialized signers
1675
+ const s3Signer = createS3Signer({ credentials, region });
1676
+ const apiGateway = createAPIGatewaySigner({ credentials, region });
1677
+ const stsSigner = createSTSSigner({ credentials, region });
1678
+ const dynamoSigner = createDynamoDBSigner({ credentials, region });
1679
+
1680
+ // S3 POST policy
1681
+ const policy = signS3PostPolicy(credentials, region, new Date(), {
1682
+ bucket: "my-bucket",
1683
+ key: "uploads/${filename}",
1684
+ expires: 3600,
1685
+ conditions: [["starts-with", "$key", "uploads/"]],
1686
+ });
1687
+
1688
+ // Chunked upload signing (S3 streaming)
1689
+ const { sessionToken, dateTime } = await initChunkedSigning(credentials, region, "s3", new Date());
1690
+ const chunkSignature = await signChunk(
1691
+ sessionToken,
1692
+ dateTime,
1693
+ chunkData,
1694
+ chunkIndex,
1695
+ previousSignature,
1696
+ );
1697
+ const finalSignature = await signFinalChunk(sessionToken, dateTime, chunkIndex, previousSignature);
1698
+
1699
+ // Clock skew detection
1700
+ const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
1701
+ isClockSkewError(err); // → boolean
787
1702
  ```
788
1703
 
789
- ### Download progress
1704
+ ### Client-Level SigV4
790
1705
 
791
1706
  ```ts
792
- await kinetex.get('/large-file.zip', {
793
- responseType: 'blob',
794
- onDownloadProgress: ({ loaded, total, percent }) => {
795
- progressBar.value = percent ?? 0;
1707
+ kinetex({
1708
+ baseURL: "https://execute-api.us-east-1.amazonaws.com",
1709
+ awsSigning: {
1710
+ credentials: { accessKeyId: "...", secretAccessKey: "..." },
1711
+ region: "us-east-1",
1712
+ service: "execute-api",
796
1713
  },
797
1714
  });
1715
+ // All requests are automatically signed via a request interceptor
798
1716
  ```
799
1717
 
800
- **`ProgressEvent` fields:**
1718
+ ---
1719
+
1720
+ ## SOCKS5 Proxy
801
1721
 
802
1722
  ```ts
803
- interface ProgressEvent {
804
- loaded: number; // bytes transferred so far
805
- total: number | undefined; // total bytes (undefined if unknown)
806
- percent: number | undefined; // 0–100 (undefined if total unknown)
807
- transferredBytes: number; // same as loaded
808
- bytesPerSecond: number; // current transfer speed
809
- }
1723
+ import {
1724
+ createSocks5Tunnel,
1725
+ parseSocks5Url,
1726
+ socks5Connector,
1727
+ denoTcpConnector,
1728
+ nodeTcpConnector,
1729
+ Socks5Error,
1730
+ } from "kinetex/socks5";
1731
+ import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
1732
+
1733
+ // Standalone tunnel
1734
+ const tunnel = await createSocks5Tunnel({
1735
+ proxyHost: "127.0.0.1",
1736
+ proxyPort: 1080,
1737
+ username: "user", // Optional: RFC 1929 auth
1738
+ password: "pass",
1739
+ connectTimeout: 10_000, // Connection timeout
1740
+ retries: 2, // Connection retries
1741
+ });
1742
+
1743
+ const response = await tunnel.send({
1744
+ url: "https://api.example.com/data",
1745
+ method: "GET",
1746
+ headers: { Accept: "application/json" },
1747
+ body: null,
1748
+ signal: null,
1749
+ meta: {},
1750
+ });
1751
+
1752
+ // Parse SOCKS5 URL
1753
+ const config = parseSocks5Url("socks5://user:pass@127.0.0.1:1080");
1754
+
1755
+ // Runtime-specific TCP connectors
1756
+ const nodeConnector: TcpConnector = nodeTcpConnector; // Node.js
1757
+ const denoConnector: TcpConnector = denoTcpConnector; // Deno
1758
+ const customConnector: TcpConnector = socks5Connector({
1759
+ proxyHost: "127.0.0.1",
1760
+ proxyPort: 1080,
1761
+ }); // Returns a TcpConnector function
1762
+
1763
+ // Client-level proxy
1764
+ kinetex({
1765
+ proxy: { url: "socks5://127.0.0.1:1080", username: "user", password: "pass" },
1766
+ });
810
1767
  ```
811
1768
 
812
1769
  ---
813
1770
 
814
- ## HAR Recording
1771
+ ## Digest Authentication
815
1772
 
816
- Record all HTTP traffic as a [HAR 1.2](https://www.softwareishard.com/blog/har-12-spec/) file for debugging, testing, or replaying.
1773
+ Full RFC 7616 implementation with MD5 (pure JS), SHA-256, and SHA-512-256:
817
1774
 
818
1775
  ```ts
819
- import { create } from 'kinetex';
820
- import { writeFileSync } from 'node:fs';
1776
+ import {
1777
+ parseDigestChallenge,
1778
+ computeDigestResponse,
1779
+ formatDigestAuth,
1780
+ createDigestAuthorization,
1781
+ } from "kinetex/digest";
1782
+ import type { DigestChallenge } from "kinetex/digest";
1783
+
1784
+ const authHeader = await createDigestAuthorization(
1785
+ `Digest realm="test", nonce="abc123", algorithm=MD5, qop="auth"`,
1786
+ "username",
1787
+ "password",
1788
+ "GET",
1789
+ "/resource",
1790
+ );
1791
+ // → 'Digest username="username", realm="test", nonce="abc123", uri="/resource", response="...", algorithm=MD5, qop=auth, nc=00000001, cnonce="..."'
1792
+ ```
821
1793
 
822
- const api = create();
1794
+ ---
823
1795
 
824
- await api.get('https://api.example.com/users', { har: true });
825
- await api.post('https://api.example.com/posts', { json: { title: 'test' }, har: true });
1796
+ ## Structured Logging
826
1797
 
827
- const har = api.exportHAR();
828
- // har.log.version === '1.2'
829
- // har.log.entries — array of HarEntry
1798
+ ```ts
1799
+ import {
1800
+ HTTPLogger,
1801
+ ConsoleTransport,
1802
+ JSONTransport,
1803
+ Redactor,
1804
+ BatchingTransport,
1805
+ RemoteTransport,
1806
+ MultiTransport,
1807
+ createLogger,
1808
+ createProductionLogger,
1809
+ createDevelopmentLogger,
1810
+ LogLevel,
1811
+ toOTelSpan,
1812
+ } from "kinetex/logging";
1813
+ import type { LogEntry, LogTransport, LoggerConfig } from "kinetex/logging";
1814
+
1815
+ const logger = createLogger({
1816
+ level: "info", // "trace" | "debug" | "info" | "warn" | "error" | "silent"
1817
+ transports: [
1818
+ new ConsoleTransport({ pretty: true }), // Console output
1819
+ new JSONTransport({ file: "requests.log" }), // File output
1820
+ ],
1821
+ redact: ["authorization", "cookie", "x-api-key", /secret.*/i], // Redaction patterns
1822
+ redactBody: true, // Redact request/response bodies
1823
+ bodyTruncate: 1000, // Truncate bodies to N chars
1824
+ requestIdHeader: "x-request-id", // Extract request ID from this header
1825
+ sampling: 0.5, // Log only 50% of requests
1826
+ filter: (entry) => entry.status !== 200, // Only log non-200 responses
1827
+ });
1828
+
1829
+ // Client-level logging
1830
+ kinetex({
1831
+ logger: { level: "info" }, // Auto-creates logger
1832
+ logger: false, // Disable logging
1833
+ });
830
1834
 
831
- writeFileSync('trace.har', JSON.stringify(har, null, 2));
832
- // Open in Chrome DevTools → Network → Import HAR
1835
+ // Batching transport (async flush)
1836
+ const batch = new BatchingTransport({
1837
+ maxBatch: 100,
1838
+ flushIntervalMs: 5000,
1839
+ });
1840
+
1841
+ // Remote transport
1842
+ const remote = new RemoteTransport({
1843
+ url: "https://logging.example.com/ingest",
1844
+ headers: { Authorization: "Bearer token" },
1845
+ });
1846
+
1847
+ // Multi transport (fan-out)
1848
+ const multi = new MultiTransport([new ConsoleTransport(), new JSONTransport()]);
1849
+
1850
+ // Convert log entry to OpenTelemetry span
1851
+ const span = toOTelSpan(entry);
833
1852
  ```
834
1853
 
835
1854
  ---
836
1855
 
837
- ## Cookie Jar
1856
+ ## HAR Recording
838
1857
 
839
- RFC 6265 compliant cookie jar — no dependencies.
1858
+ HTTP Archive 1.2 format with O(1) ring buffer:
840
1859
 
841
1860
  ```ts
842
- import { create } from 'kinetex';
843
- import { cookieJar, withCookies } from 'kinetex/plugins';
1861
+ const client = kinetex({ baseURL: "https://api.example.com", har: true });
1862
+
1863
+ await client.get("/users");
1864
+ await client.post("/posts", { title: "Test" });
1865
+
1866
+ const har = client.getHAR();
1867
+ // HARLog { version: "1.2", creator: { name: "kinetex", version: "0.0.3" }, entries: [...] }
1868
+
1869
+ // Each HAREntry contains:
1870
+ // startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
1871
+ // response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
1872
+ // timings (send, wait, receive), cache
844
1873
 
845
- const jar = cookieJar();
846
- const api = create().use(withCookies(jar));
1874
+ // Clear entries
1875
+ client.clearHAR();
1876
+ ```
847
1877
 
848
- // Cookies from Set-Cookie headers are stored automatically
849
- await api.get('https://example.com/login');
1878
+ ---
850
1879
 
851
- // Stored cookies are sent automatically on subsequent requests
852
- await api.get('https://example.com/dashboard');
1880
+ ## OpenTelemetry Tracing
853
1881
 
854
- // Manual jar operations
855
- const all = jar.getAll(); // all stored cookies
856
- const header = jar.getCookieString('https://example.com/'); // "name=value; ..."
857
- jar.delete('session', 'example.com', '/'); // remove one cookie
858
- jar.clear(); // remove all
1882
+ ```ts
1883
+ // With @opentelemetry/api
1884
+ import { trace } from "@opentelemetry/api";
1885
+ client.setTracer(trace.getTracer("my-service"));
1886
+
1887
+ // All outgoing requests automatically get:
1888
+ // - W3C traceparent header injection
1889
+ // - HTTP span creation (semantic conventions)
1890
+ // - Error recording on failures
1891
+ // - Status code attributes
1892
+
1893
+ // Manual trace propagation (no OTel SDK)
1894
+ client.get("/users", { meta: { traceId: "abc123" } });
1895
+
1896
+ // The OTelTracer and OTelSpan interfaces are minimal and compatible with @opentelemetry/api:
1897
+ import type { OTelTracer, OTelSpan } from "kinetex";
1898
+
1899
+ // Custom tracer implementation
1900
+ client.setTracer({
1901
+ startSpan(name, options?) {
1902
+ return {
1903
+ spanContext() {
1904
+ return { traceId: "x", spanId: "y", traceFlags: 1 };
1905
+ },
1906
+ setAttribute(key, value) { return this; },
1907
+ setStatus(status) { return this; },
1908
+ recordException(err) { return this; },
1909
+ end() {},
1910
+ };
1911
+ },
1912
+ });
859
1913
  ```
860
1914
 
861
1915
  ---
862
1916
 
863
- ## Proxy & SOCKS5
1917
+ ## Pipeline Trace
864
1918
 
865
- > **Node.js and Bun only.** Calling proxy middleware in browser, edge, or Deno environments logs a `console.warn` and falls through — there is no silent failure.
1919
+ Observability hook for every processing stage:
866
1920
 
867
1921
  ```ts
868
- import { create } from 'kinetex';
869
- import { proxyMiddleware, envProxy } from 'kinetex/plugins';
1922
+ kinetex({
1923
+ onPipelineTrace: (step) => {
1924
+ console.log(
1925
+ `[${step.stage}] ${step.requestId} attempt=${step.attempt} ${step.event} +${step.elapsedMs}ms`,
1926
+ );
1927
+ },
1928
+ });
1929
+ ```
1930
+
1931
+ Pipeline stages in order:
1932
+
1933
+ 1. `request_interceptors` — request interceptor pipeline
1934
+ 2. `lifecycle_before` — `onBeforeRequest` hooks
1935
+ 3. `auth` — auth header injection
1936
+ 4. `cache_lookup` — cache read (short-circuits on HIT)
1937
+ 5. `transport_send` — actual HTTP send
1938
+ 6. `response_decompression` — content-encoding decompression
1939
+ 7. `response_parse` — body parsing (JSON, text, binary)
1940
+ 8. `cache_store` — cache write
1941
+ 9. `response_interceptors` — response interceptor pipeline
1942
+ 10. `lifecycle_after` — `onAfterResponse` hooks
1943
+ 11. `retry` — retry decision + delay (on failure only)
1944
+ 12. `error_interceptors` — error interceptor pipeline (on error only)
1945
+
1946
+ ---
1947
+
1948
+ ## Transport Layer
1949
+
1950
+ ### FetchTransport
1951
+
1952
+ Universal fetch-based transport for all runtimes:
1953
+
1954
+ ```ts
1955
+ import { FetchTransport, createTransport } from "kinetex/core";
1956
+ import type { FetchTransportOptions } from "kinetex/core";
1957
+
1958
+ const transport = new FetchTransport({
1959
+ fetchFn: globalThis.fetch, // Custom fetch implementation
1960
+ strict: true, // Throw on invalid headers (default: false)
1961
+ onDroppedHeader: (name, value) => log(`Dropped: ${name}`),
1962
+ });
1963
+
1964
+ const raw: RawResponse = await transport.send(request);
1965
+ // RawResponse { status, statusText, headers, body, url, redirected, httpVersion, alreadyDecompressed }
1966
+ ```
1967
+
1968
+ ### NodeHTTP2Transport
870
1969
 
871
- // Explicit HTTP/HTTPS proxy
872
- const api = create().use(proxyMiddleware({
873
- url: 'http://proxy.corp.internal:3128',
874
- auth: { username: 'user', password: 'pass' },
875
- noProxy: ['internal.corp.com', '.local'],
876
- }));
1970
+ Node.js HTTP/2 transport with session pooling, ALPN fallback, and keepalive pings:
877
1971
 
878
- // Read from environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
879
- const api2 = create().use(envProxy());
1972
+ ```ts
1973
+ import { NodeHTTP2Transport } from "kinetex/core";
1974
+
1975
+ const transport = new NodeHTTP2Transport({
1976
+ sessionTTLMs: 5 * 60_000, // Session lifetime (default: 300_000)
1977
+ pingIntervalMs: 30_000, // Keepalive ping interval (default: 30_000)
1978
+ maxSessions: 100, // Max concurrent sessions (default: 100)
1979
+ connectTimeoutMs: 30_000, // Connection timeout (default: 30_000)
1980
+ requestTimeoutMs: 30_000, // Per-request stream timeout (default: 30_000)
1981
+ strict: false, // Strict header validation
1982
+ onDroppedHeader: (name, value) => {},
1983
+ });
1984
+
1985
+ transport.destroy(); // Close all sessions and timers
1986
+ ```
880
1987
 
881
- // SOCKS5 proxy — requires: npm install socks-proxy-agent
882
- const api3 = create().use(proxyMiddleware({
883
- url: 'socks5://proxy.example.com:1080',
884
- protocol: 'socks5',
885
- }));
1988
+ Features:
1989
+
1990
+ - Session pooling per-origin with LRU eviction
1991
+ - Concurrent creation serialization (only one session per origin)
1992
+ - ALPN negotiation failure → automatic HTTP/1.1 fallback via `FetchTransport`
1993
+ - Configurable connect and request timeouts
1994
+ - Keepalive pings with dead-session detection
1995
+ - Iterative redirect following (not recursive)
1996
+ - Backpressure-aware body writes (awaits `drain` events)
1997
+
1998
+ ### Transport Factory
1999
+
2000
+ ```ts
2001
+ import { createTransport } from "kinetex/core";
2002
+
2003
+ // Auto-selects based on runtime:
2004
+ // - Node.js with preferHTTP2=true → NodeHTTP2Transport (with HTTP/1.1 fallback)
2005
+ // - All other runtimes → FetchTransport
2006
+ const transport = createTransport(
2007
+ globalThis.fetch, // fetch function
2008
+ true, // prefer HTTP/2
2009
+ { sessionTTLMs: 300_000 }, // session pool options
2010
+ { strict: true }, // strict header validation
2011
+ );
886
2012
  ```
887
2013
 
888
- **`ProxyConfig` fields:**
2014
+ ### Raw Response
889
2015
 
890
2016
  ```ts
891
- interface ProxyConfig {
892
- url?: string; // proxy URL
893
- protocol?: 'http' | 'https' | 'socks5';
894
- auth?: { username: string; password: string };
895
- noProxy?: string[]; // hostnames / patterns to bypass
896
- headers?: Record<string, string>; // extra headers sent to proxy
2017
+ interface RawResponse {
2018
+ status: number;
2019
+ statusText: string;
2020
+ headers: Record<string, string>;
2021
+ body: ReadableStream<Uint8Array> | null;
2022
+ url: string;
2023
+ redirected: boolean;
2024
+ httpVersion: HTTPVersion;
2025
+ alreadyDecompressed?: boolean; // True if runtime auto-decompressed
2026
+ }
2027
+
2028
+ interface Transport {
2029
+ send(request: KinetexRequest): Promise<RawResponse>;
897
2030
  }
898
2031
  ```
899
2032
 
2033
+ ### Core Utilities
2034
+
2035
+ ```ts
2036
+ import {
2037
+ sendWithTimeout,
2038
+ readRawBody,
2039
+ parseBody,
2040
+ decompressBodyStream,
2041
+ HAS_NATIVE_FETCH,
2042
+ HTTP_STATUS_TEXTS,
2043
+ } from "kinetex/core";
2044
+ import type { FetchTransportOptions } from "kinetex/core";
2045
+
2046
+ // Timeout wrapper
2047
+ const raw = await sendWithTimeout(transport, request, 5000); // → RawResponse, throws TimeoutError
2048
+
2049
+ // Read body stream with size limit
2050
+ const body = await readRawBody(stream, maxBytes, url, signal); // → Uint8Array, throws SizeLimitError
2051
+
2052
+ // Parse body by content-type
2053
+ const data = parseBody<MyType>(rawBody, contentType, customParser?, onParseFailure?, headers?, url?);
2054
+
2055
+ // Decompress body stream
2056
+ const decompressed = await decompressBodyStream(body, headers);
2057
+
2058
+ // HTTP status text lookup
2059
+ HTTP_STATUS_TEXTS[404]; // "Not Found"
2060
+ HTTP_STATUS_TEXTS[500]; // "Internal Server Error"
2061
+ ```
2062
+
900
2063
  ---
901
2064
 
902
- ## Concurrency & Rate Limiting
2065
+ ## Runtime Detection
903
2066
 
904
2067
  ```ts
905
- import { create } from 'kinetex';
906
- import { concurrencyLimit, rateLimit } from 'kinetex/plugins';
907
-
908
- const api = create({ baseURL: 'https://api.example.com' })
909
- .use(concurrencyLimit(5)) // max 5 in-flight requests
910
- .use(rateLimit({ requestsPerSecond: 10, burst: 20 })); // token-bucket rate limiter
2068
+ import {
2069
+ detectRuntime,
2070
+ RUNTIME,
2071
+ IS_NODE,
2072
+ HAS_NATIVE_FETCH,
2073
+ setRuntime,
2074
+ getEffectiveRuntime,
2075
+ NodeHTTP2Transport,
2076
+ FetchTransport,
2077
+ } from "kinetex/core";
2078
+
2079
+ detectRuntime(); // "node" | "deno" | "bun" | "browser" | "cloudflare-workers" | "edge" | "unknown"
2080
+ RUNTIME; // Cached runtime value (detected once at module load)
2081
+ IS_NODE; // True if Node.js
2082
+ HAS_NATIVE_FETCH; // True if globalThis.fetch is available
2083
+
2084
+ // Override for testing
2085
+ setRuntime("cloudflare-workers"); // Force runtime for test environments
2086
+ setRuntime(null); // Restore auto-detection
2087
+ getEffectiveRuntime(); // Returns override if set, else RUNTIME
2088
+
2089
+ // Also from utils.ts:
2090
+ import { getRuntime, isNodeEnvironment, isBrowserEnvironment, hasNativeFetch } from "kinetex";
911
2091
  ```
912
2092
 
913
- **`rateLimit` options:**
2093
+ ---
2094
+
2095
+ ## Child Clients
2096
+
2097
+ ```ts
2098
+ const parent = kinetex({
2099
+ baseURL: "https://api.example.com",
2100
+ headers: { "X-Version": "1.0" },
2101
+ timeout: 10000,
2102
+ });
914
2103
 
915
- | Option | Type | Description |
916
- |--------|------|-------------|
917
- | `requestsPerSecond` | `number` | Sustained rate |
918
- | `burst` | `number` | Max burst size (default: `requestsPerSecond`) |
2104
+ const admin = parent.extend({
2105
+ baseURL: "https://api.example.com/admin",
2106
+ headers: { "X-Admin": "true" },
2107
+ auth: { type: "bearer", token: adminToken },
2108
+ });
2109
+ // Inherits: timeout, headers (merged), params (merged), interceptors (merged),
2110
+ // circuit breaker, dedup, OTel tracer from parent
2111
+ ```
919
2112
 
920
2113
  ---
921
2114
 
922
- ## Response Size Limiting
2115
+ ## Batch Queue
923
2116
 
924
- Protect against unexpectedly large responses. Checks `Content-Length` header first, then falls back to measuring the actual body.
2117
+ High-throughput request batching:
925
2118
 
926
2119
  ```ts
927
- import { create, ResponseSizeError } from 'kinetex/plugins';
928
- import { responseSizeLimit } from 'kinetex/plugins';
2120
+ import { BatchQueue } from "kinetex";
929
2121
 
930
- const api = create().use(responseSizeLimit(5 * 1024 * 1024)); // 5 MB limit
2122
+ const batch = new BatchQueue(client, {
2123
+ maxBatch: 50, // Flush when 50 requests queued (default: 100)
2124
+ flushMs: 10, // Flush after 10ms even if batch not full (default: 0)
2125
+ });
931
2126
 
932
- try {
933
- const { data } = await api.get('/large-file');
934
- } catch (err) {
935
- if (err instanceof ResponseSizeError) {
936
- console.log(`Too large: ${err.actualBytes} bytes (limit: ${err.maxBytes})`);
937
- }
938
- }
2127
+ // Fire many requests — they batch automatically
2128
+ const [r1, r2, r3] = await Promise.all([
2129
+ batch.enqueue("/events", "POST", { body: JSON.stringify(e1) }),
2130
+ batch.enqueue("/events", "POST", { body: JSON.stringify(e2) }),
2131
+ batch.enqueue("/events", "POST", { body: JSON.stringify(e3) }),
2132
+ ]);
2133
+
2134
+ batch.flush(); // Force flush pending requests
2135
+ batch.pendingCount; // Number of queued requests
939
2136
  ```
940
2137
 
941
2138
  ---
942
2139
 
943
- ## HTTP/2 and HTTP/3
2140
+ ## URL Utilities
944
2141
 
945
- ```bash
946
- npm install undici
2142
+ ```ts
2143
+ import {
2144
+ URLBuilder,
2145
+ percentEncode,
2146
+ percentDecode,
2147
+ encodePathComponent,
2148
+ encodeQueryValue,
2149
+ stringifyQuery,
2150
+ parseQuery,
2151
+ mergeQuery,
2152
+ pickQuery,
2153
+ omitQuery,
2154
+ joinPath,
2155
+ normalizePath,
2156
+ pathSegments,
2157
+ fillPathParams,
2158
+ normalizeURL,
2159
+ compilePattern,
2160
+ expandTemplate,
2161
+ getOrigin,
2162
+ isSameOrigin,
2163
+ isSameSite,
2164
+ isAbsolute,
2165
+ isRelative,
2166
+ isHTTPS,
2167
+ isHTTP,
2168
+ isDataURL,
2169
+ isBlobURL,
2170
+ isLocalhost,
2171
+ safeParseURL,
2172
+ withTrailingSlash,
2173
+ withoutTrailingSlash,
2174
+ stripHash,
2175
+ stripQuery,
2176
+ urlExtension,
2177
+ urlFilename,
2178
+ redactURL,
2179
+ resolveURL,
2180
+ relativeURL,
2181
+ parseDataURL,
2182
+ buildDataURL,
2183
+ diffURLs,
2184
+ } from "kinetex/url";
2185
+ import type {
2186
+ ParsedURL,
2187
+ URLBuilderOptions,
2188
+ URLPattern,
2189
+ URLPatternMatch,
2190
+ URLDiff,
2191
+ DataURLParts,
2192
+ } from "kinetex/url";
2193
+
2194
+ // URL Builder (fluent, immutable)
2195
+ const url = URLBuilder.from("https://api.example.com")
2196
+ .withPathname("/v1/users")
2197
+ .appendPath("42", "posts")
2198
+ .setParam("page", "1")
2199
+ .setParam("limit", "10")
2200
+ .omitParams("internal")
2201
+ .redactParams("token")
2202
+ .sortParams()
2203
+ .addTrailingSlash()
2204
+ .toString();
2205
+ // → "https://api.example.com/v1/users/42/posts/?limit=10&page=1&token=REDACTED"
2206
+
2207
+ URLBuilder.https("api.example.com", "/v1/users"); // Factory
2208
+ URLBuilder.http("api.example.com"); // Factory
2209
+
2210
+ // Properties:
2211
+ url.href;
2212
+ url.protocol;
2213
+ url.hostname;
2214
+ url.host;
2215
+ url.port;
2216
+ url.pathname;
2217
+ url.search;
2218
+ url.hash;
2219
+ url.origin;
2220
+ url.searchParams; // → URLSearchParams
2221
+ url.queryObject; // → Record<string, string | string[]>
2222
+
2223
+ // Percent encoding
2224
+ percentEncode("hello world"); // "hello%20world"
2225
+ percentEncode("a b", true); // "a%20b" (reserved not encoded)
2226
+ percentDecode("hello%20world"); // "hello world"
2227
+
2228
+ // Query string
2229
+ stringifyQuery({ a: "1", b: ["2", "3"] }, { sort: true, arrayFormat: "bracket" });
2230
+ // → "a=1&b[]=2&b[]=3"
2231
+ parseQuery("?a=1&b=2"); // { a: "1", b: "2" }
2232
+ mergeQuery({ a: "1" }, { b: "2" });
2233
+ pickQuery({ a: "1", b: "2", c: "3" }, "a", "c"); // { a: "1", c: "3" }
2234
+ omitQuery({ a: "1", b: "2" }, "a"); // { b: "2" }
2235
+
2236
+ // Path utilities
2237
+ joinPath("api", "v1", "users"); // "/api/v1/users"
2238
+ normalizePath("//api///v1/./users/.."); // "/api/v1"
2239
+ pathSegments("/api/v1/users"); // ["api", "v1", "users"]
2240
+ fillPathParams("/users/:id/posts/:postId", { id: "42", postId: "99" });
2241
+ // → "/users/42/posts/99"
2242
+
2243
+ // URL normalization
2244
+ normalizeURL("https://API.EXAMPLE.COM:443/path/", { sortParams: true, removeFragment: true });
2245
+
2246
+ // URL pattern matching
2247
+ const pattern = compilePattern("/users/:id/posts/:postId");
2248
+ pattern.test("/users/42/posts/99"); // true
2249
+ pattern.match("/users/42/posts/99"); // { params: { id: "42", postId: "99" }, wildcards: [], groups: {} }
2250
+
2251
+ // Classification
2252
+ isAbsolute("https://example.com"); // true
2253
+ isRelative("/path"); // true
2254
+ isHTTPS("https://example.com"); // true
2255
+ isHTTP("http://example.com"); // true
2256
+ isDataURL("data:text/plain,hello"); // true
2257
+ isBlobURL("blob:..."); // true
2258
+ isLocalhost("http://localhost:8080"); // true
2259
+
2260
+ // URL resolution
2261
+ resolveURL("/v1/users", "https://api.example.com"); // "https://api.example.com/v1/users"
2262
+ relativeURL("https://api.example.com/v1/users", "https://api.example.com"); // "/v1/users"
2263
+
2264
+ // Data URLs
2265
+ parseDataURL("data:image/png;base64,iVBOR..."); // { mediaType: "image/png", isBase64: true, data: "iVBOR..." }
2266
+ buildDataURL("hello", "text/plain"); // "data:text/plain;base64,aGVsbG8="
2267
+
2268
+ // Redaction
2269
+ redactURL("https://api.example.com?token=secret&key=123", "token", "key");
2270
+ // → "https://api.example.com?token=REDACTED&key=REDACTED"
2271
+
2272
+ // Diff
2273
+ diffURLs("https://a.com/path?a=1", "https://b.com/other?b=2");
2274
+ // → { hostname: ["a.com", "b.com"], pathname: ["/path", "/other"], addedParams: { b: "2" }, ... }
2275
+
2276
+ // Safe parse
2277
+ safeParseURL("not a url"); // null
2278
+
2279
+ // Misc
2280
+ stripHash("https://example.com#section"); // "https://example.com"
2281
+ stripQuery("https://example.com?a=1"); // "https://example.com"
2282
+ urlExtension("https://example.com/file.txt"); // "txt"
2283
+ urlFilename("https://example.com/file.txt"); // "file.txt"
2284
+ withTrailingSlash("https://example.com/path"); // "https://example.com/path/"
2285
+ withoutTrailingSlash("https://example.com/path/"); // "https://example.com/path"
2286
+ ```
2287
+
2288
+ ---
2289
+
2290
+ ## Header Utilities
2291
+
2292
+ ```ts
2293
+ import {
2294
+ HeaderName, // All standard header names as constants
2295
+ HttpHeaders, // Full headers class with typed accessors
2296
+ RichHeaders, // Enhanced headers with parsing methods
2297
+ createHeaders, // Create mutable headers
2298
+ createRequestHeaders, // Create request headers
2299
+ createResponseHeaders, // Create response headers
2300
+ createImmutableHeaders, // Create immutable headers
2301
+
2302
+ // Content
2303
+ formatContentType,
2304
+ parseContentDisposition,
2305
+ formatContentDisposition,
2306
+ parseContentLanguage,
2307
+
2308
+ // Cache
2309
+ parseCacheControl,
2310
+ formatCacheControl,
2311
+
2312
+ // Auth
2313
+ parseAuthorization,
2314
+ parseWWWAuthenticate,
2315
+ formatBearer,
2316
+ formatBasic,
2317
+
2318
+ // Negotiation
2319
+ parseAccept,
2320
+ parseAcceptEncoding,
2321
+ parseAcceptLanguage,
2322
+ negotiateContentType,
2323
+
2324
+ // Range
2325
+ parseRange,
2326
+ parseContentRange,
2327
+
2328
+ // Links
2329
+ parseLinkHeader,
2330
+ formatLinkHeader,
2331
+
2332
+ // Forwarded
2333
+ parseForwarded,
2334
+ normalizeForwardedHeaders,
2335
+ getClientIP,
2336
+
2337
+ // Retry
2338
+ parseRetryAfter,
2339
+
2340
+ // Security
2341
+ parseHSTS,
2342
+ formatHSTS,
2343
+ parseCSP,
2344
+ formatCSP,
2345
+ parseServerTiming,
2346
+ formatServerTiming,
2347
+ parseAltSvc,
2348
+ parseWarning,
2349
+ parseParams,
2350
+ securityHeaders, // Recommended security headers map
2351
+ corsHeaders, // CORS headers map
2352
+
2353
+ // Conversion
2354
+ fromNodeHeaders, // node:http.IncomingMessage → Record
2355
+ toNodeHeaders, // Record → node:http.OutgoingHttpHeaders
2356
+ fromWebHeaders, // fetch Headers → Record
2357
+ } from "kinetex/headers";
2358
+
2359
+ // HeaderName constants
2360
+ HeaderName.ContentType; // "content-type"
2361
+ HeaderName.ContentLength; // "content-length"
2362
+ HeaderName.Authorization; // "authorization"
2363
+ HeaderName.CacheControl; // "cache-control"
2364
+ HeaderName.ETag; // "etag"
2365
+ // ... all standard headers
2366
+
2367
+ // Cache-Control parsing
2368
+ parseCacheControl("public, max-age=3600, stale-while-revalidate=300");
2369
+ // → { public: true, "max-age": 3600, "stale-while-revalidate": 300 }
2370
+ formatCacheControl({ public: true, "max-age": 3600 }); // "public, max-age=3600"
2371
+
2372
+ // Content-Type
2373
+ formatContentType("application/json", { charset: "utf-8" });
2374
+ // → "application/json; charset=utf-8"
2375
+
2376
+ // Security headers preset
2377
+ securityHeaders; // { "x-content-type-options": "nosniff", "x-frame-options": "DENY", ... }
2378
+ corsHeaders; // { "access-control-allow-origin": "*", ... }
947
2379
  ```
948
2380
 
949
- When `undici` is installed, kinetex automatically uses it in Node.js for HTTP/2 and HTTP/3 support. Bun uses its own optimized native fetch. Falls back to native `fetch` transparently when unavailable.
2381
+ ---
950
2382
 
951
- Connections to each origin are pooled via a persistent `undici.Pool` (10 connections per origin, keep-alive 30s). This means multiple requests to the same host reuse the same TCP/TLS/HTTP2 session — true multiplexing, not just HTTP/2 framing.
2383
+ ## Response Parsing Utilities
952
2384
 
953
2385
  ```ts
954
- // Force native fetch for one request (e.g. when streaming to a non-undici consumer)
955
- const { data } = await kinetex.get('/api', { transport: 'fetch' });
2386
+ import {
2387
+ readJSON,
2388
+ readText,
2389
+ readBytes,
2390
+ readStream,
2391
+ assertOk,
2392
+ assertOkJSON,
2393
+ parseContentType,
2394
+ isJSON,
2395
+ isText,
2396
+ decodeBody,
2397
+ readBlob,
2398
+ readNDJSON,
2399
+ readJSONStream,
2400
+ readFormData,
2401
+ readBodyWithLimit,
2402
+ parseMultipartResponse,
2403
+ diffResponses,
2404
+ HTTPResponseError,
2405
+ ResponseSizeLimitError,
2406
+ ContentTypeError,
2407
+ ResponseDecodeError,
2408
+ normalizeResponse,
2409
+ isBinary,
2410
+ decompressStream,
2411
+ applyDecompression,
2412
+ extractServerTiming,
2413
+ createLimitedReader,
2414
+ } from "kinetex/response";
2415
+ import type { ResponseParseOptions, SizeLimitConfig } from "kinetex/response";
2416
+
2417
+ // Read helpers
2418
+ const json = await readJSON(response); // Parse as JSON
2419
+ const text = await readText(response); // Parse as text
2420
+ const bytes = await readBytes(response); // Read as Uint8Array
2421
+ const stream = await readStream(response); // Get ReadableStream
2422
+ const blob = await readBlob(response); // Read as Blob
2423
+ const ndjson = await readNDJSON(response); // Parse NDJSON line-by-line
2424
+ const formData = await readFormData(response); // Parse as FormData
2425
+
2426
+ // JSON with assertion
2427
+ const data = await assertOkJSON<MyType>(response); // Throws on non-2xx
2428
+ await assertOk(response); // Throws on non-2xx
2429
+
2430
+ // Size limiting
2431
+ const limited = await readBodyWithLimit(response, {
2432
+ maxBytes: 1_000_000,
2433
+ onExceed: "throw", // "throw" | "truncate" | "abort"
2434
+ onExceedCallback: (bytesRead, limit) => log(`Exceeded ${limit}`),
2435
+ });
2436
+
2437
+ const reader = createLimitedReader(stream, {
2438
+ maxBytes: 1_000_000,
2439
+ onExceed: "throw",
2440
+ });
2441
+
2442
+ // Multipart
2443
+ const parts = await parseMultipartResponse(response, boundary);
956
2444
 
957
- // Force undici for all requests on this instance
958
- const api = create({ transport: 'undici' });
2445
+ // Decompression
2446
+ const decompressed = await decompressStream(compressedStream, "gzip");
2447
+ const raw = await applyDecompression(rawBody, headers);
959
2448
 
960
- // Opt out of automatic response decompression (get raw compressed bytes)
961
- const { data } = await kinetex.get('/compressed', { decompress: false });
2449
+ // Server-Timing
2450
+ const timings = extractServerTiming(headers);
2451
+ // → { dur, desc, ... }
2452
+
2453
+ // Response diffing
2454
+ const diff = diffResponses(res1, res2);
2455
+
2456
+ // Content type
2457
+ parseContentType("application/json; charset=utf-8");
2458
+ // { type: "application/json", parameters: { charset: "utf-8" } }
2459
+ isJSON(response); // true if content-type is JSON
2460
+ isText(response); // true if content-type is text/*
2461
+ isBinary(response); // true if binary content-type
962
2462
  ```
963
2463
 
964
- > **Note:** connection pooling is per-process, not per-instance. All kinetex instances share the same `undici.Pool` for a given origin.
2464
+ ---
2465
+
2466
+ ## Safe JSON Parsing
2467
+
2468
+ DoS-protected JSON parsing with configurable limits:
2469
+
2470
+ ```ts
2471
+ import { safeJSONParse, tryParseJSON, parseUntrustedJSON } from "kinetex";
2472
+ import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
2473
+
2474
+ // Safe parsing with limits
2475
+ const result: SafeJSONParseResult<User[]> = safeJSONParse(jsonString, {
2476
+ maxDepth: 32,
2477
+ maxStringLength: 10_000_000, // 10MB
2478
+ maxArrayLength: 10_000,
2479
+ maxObjectKeys: 1_000,
2480
+ allowNonFinite: false,
2481
+ });
2482
+ if (result.success) {
2483
+ console.log(result.value);
2484
+ } else {
2485
+ console.error(result.error, result.message);
2486
+ }
2487
+
2488
+ // Try parse (returns string fallback on failure)
2489
+ const data = tryParseJSON<User[]>(jsonString); // User[] | string
2490
+
2491
+ // Reduced limits for untrusted input
2492
+ const result = parseUntrustedJSON(untrustedJson);
2493
+ // maxDepth: 16, maxStringLength: 1MB, maxArrayLength: 1000, maxObjectKeys: 100
2494
+ ```
965
2495
 
966
2496
  ---
967
2497
 
968
- ## Error Handling
2498
+ ## Type Guards & Utilities
969
2499
 
970
- All errors extend `KinetexError`. Catch the specific subclass you care about.
2500
+ General-purpose utility functions for type-safe request/response handling:
971
2501
 
972
2502
  ```ts
973
- import { HTTPError, TimeoutError, ValidationError, KinetexError } from 'kinetex';
2503
+ import {
2504
+ // Runtime
2505
+ getRuntime,
2506
+ isNodeEnvironment,
2507
+ isBrowserEnvironment,
2508
+ hasNativeFetch,
2509
+
2510
+ // Type guards
2511
+ isUint8Array,
2512
+ isArrayBuffer,
2513
+ isReadableStream,
2514
+ isHeaders,
2515
+ isAbortSignal,
2516
+ isPlainObject,
2517
+ isFormData,
2518
+ isBlob,
2519
+ isURLSearchParams,
2520
+ isAbortError,
2521
+
2522
+ // Header validation
2523
+ isValidHeaderName,
2524
+ isValidHeaderValue,
2525
+
2526
+ // URL security
2527
+ isSafeURL,
2528
+ sanitizeURL,
2529
+
2530
+ // Error utilities
2531
+ createStructuredError,
2532
+ formatError,
2533
+
2534
+ // Performance / timing
2535
+ perfNow,
2536
+ sleep,
2537
+ mergeSignals,
2538
+
2539
+ // Binary data
2540
+ concatUint8Arrays,
2541
+ toUint8Array,
2542
+ uint8ArrayToBase64,
2543
+
2544
+ // Object utilities
2545
+ deepClone,
2546
+ normalizeHeaders,
2547
+ } from "kinetex";
2548
+
2549
+ // Type guards
2550
+ isUint8Array(data); // data is Uint8Array
2551
+ isPlainObject(obj); // obj is Record<string, unknown>
2552
+ isAbortSignal(signal); // signal is AbortSignal
2553
+ isFormData(data); // data is FormData
2554
+ isBlob(data); // data is Blob
2555
+ isAbortError(err); // boolean
2556
+ isValidHeaderName("x-foo"); // boolean
2557
+ isValidHeaderValue("bar"); // boolean
2558
+
2559
+ // Safe URL checking
2560
+ isSafeURL("https://evil.com"); // boolean — checks for dangerous protocols
2561
+ sanitizeURL("javascript:alert(1)"); // string — stripped or redacted
2562
+
2563
+ // Error construction
2564
+ const err = createStructuredError("EVALIDATION", "Invalid config", {
2565
+ request: myRequest,
2566
+ cause: originalError,
2567
+ });
2568
+ formatError(err); // "EVALIDATION: Invalid config"
2569
+
2570
+ // Timing
2571
+ const start = perfNow(); // High-resolution monotonic time
2572
+ await sleep(1000); // Promise-based delay
2573
+ const signal = mergeSignals(signal1, signal2); // Combined AbortSignal
2574
+
2575
+ // Binary
2576
+ const combined = concatUint8Arrays([buf1, buf2]);
2577
+ const uint8 = toUint8Array(arrayBuffer);
2578
+ const b64 = uint8ArrayToBase64(uint8);
2579
+
2580
+ // Object
2581
+ const clone = deepClone(original);
2582
+ const normalized = normalizeHeaders(rawHeaders); // Lowercase keys
2583
+ ```
2584
+
2585
+ ---
2586
+
2587
+ ## Error Handling
2588
+
2589
+ ```ts
2590
+ import {
2591
+ KinetexError,
2592
+ HTTPStatusError,
2593
+ TimeoutError,
2594
+ SizeLimitError,
2595
+ AbortError,
2596
+ NetworkError,
2597
+ ValidationError,
2598
+ AuthError,
2599
+ ProxyError,
2600
+ RedirectError,
2601
+ } from "kinetex";
2602
+ import { validateErrorCode, toRequestId } from "kinetex";
974
2603
 
975
2604
  try {
976
- await kinetex.get('https://api.example.com/protected');
2605
+ await client.get("/data");
977
2606
  } catch (err) {
978
- if (err instanceof HTTPError) {
979
- console.log(err.response?.status); // 401, 404, 429 ...
980
- console.log(err.response?.data); // parsed error body
981
- console.log(err.request.url); // originating URL
2607
+ if (err instanceof HTTPStatusError) {
2608
+ // Server returned 4xx/5xx
2609
+ console.log(err.status); // HTTP status code
2610
+ console.log(err.isClientError); // 4xx?
2611
+ console.log(err.isServerError); // 5xx?
2612
+ console.log(err.response); // KinetexResponse
982
2613
  } else if (err instanceof TimeoutError) {
983
- console.log('Timed out:', err.message);
984
- } else if (err instanceof ValidationError) {
985
- console.log('Schema mismatch:', err.validationError);
2614
+ console.log(`Timed out after ${err.timeoutMs}ms`);
2615
+ } else if (err instanceof SizeLimitError) {
2616
+ console.log(`${err.bytesRead} > ${err.limit}`);
2617
+ } else if (err instanceof NetworkError) {
2618
+ // Connectivity issue
2619
+ } else if (err instanceof AbortError) {
2620
+ // Cancelled
2621
+ } else if (err instanceof AuthError) {
2622
+ // Auth failed
2623
+ } else if (err instanceof ProxyError) {
2624
+ // Proxy error
2625
+ } else if (err instanceof RedirectError) {
2626
+ // Redirect error
986
2627
  } else if (err instanceof KinetexError) {
987
- console.log('kinetex error:', err.originalCause);
2628
+ console.log(err.code); // Machine-readable error code
2629
+ console.log(err.request); // Originating request
2630
+ console.log(err.response); // Response (if available)
2631
+ console.log(err.cause); // Underlying cause
2632
+ console.log(err.isNetwork, err.isTimeout, err.isAbort, err.isHTTPError, err.isProxy);
988
2633
  }
989
2634
  }
990
- ```
991
-
992
- ### Opt out of throwing on non-2xx responses
993
2635
 
994
- ```ts
995
- const res = await kinetex.get('/api/data', { throwHttpErrors: false });
996
- if (res.status === 404) {
997
- console.log('not found');
2636
+ // KinetexError base class
2637
+ class KinetexError extends Error {
2638
+ readonly code: KinetexErrorCode;
2639
+ readonly request?: KinetexRequest;
2640
+ readonly response?: KinetexResponse<unknown>;
2641
+ readonly cause?: unknown;
2642
+ get isNetwork(): boolean;
2643
+ get isProxy(): boolean;
2644
+ get isTimeout(): boolean;
2645
+ get isAbort(): boolean;
2646
+ get isHTTPError(): boolean;
2647
+ get status(): number | null;
998
2648
  }
999
- ```
1000
2649
 
1001
- **Error types:**
2650
+ // Validate error codes
2651
+ validateErrorCode("ENETWORK"); // "ENETWORK"
2652
+ validateErrorCode("INVALID"); // undefined
2653
+ ```
1002
2654
 
1003
- | Class | When thrown |
1004
- |-------|-------------|
1005
- | `KinetexError` | Base class for all errors |
1006
- | `HTTPError` | Non-2xx response (when `throwHttpErrors: true`) |
1007
- | `TimeoutError` | Request exceeded `timeout` |
1008
- | `ValidationError` | Response failed schema validation |
1009
- | `ResponseSizeError` | Response exceeded `responseSizeLimit` |
2655
+ ### Error Codes
2656
+
2657
+ | Code | Error Class | Description |
2658
+ | ------------- | ----------------- | --------------------------------- |
2659
+ | `ENETWORK` | `NetworkError` | Server/endpoint unreachable |
2660
+ | `ETIMEOUT` | `TimeoutError` | Request/connection timeout |
2661
+ | `EABORT` | `AbortError` | Request cancelled by caller |
2662
+ | `EHTTPSTATUS` | `HTTPStatusError` | Server returned 4xx/5xx |
2663
+ | `ESIZELIMIT` | `SizeLimitError` | Response body exceeded size limit |
2664
+ | `EPARSE` | — | Failed to parse response body |
2665
+ | `EVALIDATION` | `ValidationError` | Invalid request configuration |
2666
+ | `EAUTH` | `AuthError` | Authentication failed |
2667
+ | `EPROXY` | `ProxyError` | Proxy configuration error |
2668
+ | `EREDIRECT` | `RedirectError` | Redirect error |
2669
+ | `EUNKNOWN` | `KinetexError` | Unknown/unexpected |
1010
2670
 
1011
2671
  ---
1012
2672
 
1013
- ## Request Deduplication
2673
+ ## Deep Imports
1014
2674
 
1015
- Identical in-flight GET requests on the **same instance** are collapsed into a single network call. All callers receive the same response.
2675
+ All sub-modules are tree-shakeable with deep import paths:
1016
2676
 
1017
2677
  ```ts
1018
- const api = create({ baseURL: 'https://api.example.com' });
1019
-
1020
- // All three fire simultaneously — only one HTTP request is made
1021
- const [r1, r2, r3] = await Promise.all([
1022
- api.get('/config'),
1023
- api.get('/config'),
1024
- api.get('/config'),
1025
- ]);
1026
-
1027
- // Opt out per-request
1028
- await api.get('/always-fresh', { dedupe: false });
2678
+ // Core
2679
+ import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreakerKey } from "kinetex";
2680
+ import type { KinetexConfig, KinetexRequest, KinetexResponse, SendOptions, RetryConfig, RetryContext, AuthConfig, ProxyConfig, HTTPMethod, HTTPVersion, HeadersInit, QueryParams, QueryValue, BodyInit, Runtime, RequestId, Brand } from "kinetex";
2681
+ // Errors
2682
+ import { KinetexError, HTTPStatusError, TimeoutError, SizeLimitError, AbortError, NetworkError, ValidationError, AuthError, ProxyError, RedirectError } from "kinetex";
2683
+ // Types
2684
+ import type { InterceptorContext, HookContext, LifecycleHooks, RequestInterceptor, ResponseInterceptor, ErrorInterceptor, ProgressEvent, ProgressCallback, PipelineStep, PipelineStageName, CacheRequestConfig, HAREntry, HARLog } from "kinetex";
2685
+
2686
+ // Sub-modules (tree-shakeable):
2687
+ import { ... } from "kinetex/cache";
2688
+ import { ... } from "kinetex/sse";
2689
+ import { ... } from "kinetex/graphql";
2690
+ import { ... } from "kinetex/pagination";
2691
+ import { ... } from "kinetex/progress";
2692
+ import { ... } from "kinetex/logging";
2693
+ import { ... } from "kinetex/response";
2694
+ import { ... } from "kinetex/headers";
2695
+ import { ... } from "kinetex/url";
2696
+ import { ... } from "kinetex/aws-sigv4";
2697
+ import { ... } from "kinetex/socks5";
2698
+ import { ... } from "kinetex/cookiejar";
2699
+ import { ... } from "kinetex/circuit-breaker";
2700
+ import { ... } from "kinetex/dedup";
2701
+ import { ... } from "kinetex/digest";
2702
+ import { ... } from "kinetex/ws";
2703
+ import { ... } from "kinetex/lifecycle";
2704
+ import { ... } from "kinetex/interceptors";
2705
+ import { ... } from "kinetex/core";
2706
+ import { ... } from "kinetex/worker";
2707
+
2708
+ // Types only from sub-modules:
2709
+ import type { CacheEntry, CacheStats, CacheConfig, CacheStorageAdapter } from "kinetex/cache";
2710
+ import type { SSEEvent, SSEClientConfig, JSONSSEEvent } from "kinetex/sse";
2711
+ import type { GraphQLRequest, GraphQLResponse, GraphQLError, GraphQLClientConfig, GraphQLLink, GraphQLLinkNext } from "kinetex/graphql";
2712
+ import type { Page, PaginationState } from "kinetex/pagination";
2713
+ import type { LogEntry, LogTransport, LoggerConfig } from "kinetex/logging";
2714
+ import type { ResponseParseOptions, SizeLimitConfig } from "kinetex/response";
2715
+ import type { Cookie, CookieJSON } from "kinetex/cookiejar";
2716
+ import type { CircuitState, CircuitBreakerConfig, CircuitBreakerState, FailureFilter } from "kinetex/circuit-breaker";
2717
+ import type { DedupOptions } from "kinetex/dedup";
2718
+ import type { DigestChallenge } from "kinetex/digest";
2719
+ import type { WSState, WSMessage, WSClientConfig, WSCloseEvent, WSBackpressureInfo, WSSubscribedRoom } from "kinetex/ws";
2720
+ import type { HookRequest, HookResponse, HookError, HookOptions, BeforeRequestHook, AfterRequestHook, BeforeResponseHook, AfterResponseHook, OnErrorHook, OnRetryHook, OnRedirectHook, OnUploadProgressHook, OnDownloadProgressHook, AroundHook } from "kinetex/lifecycle";
2721
+ import type { AWSCredentials, SigningConfig, CredentialProvider } from "kinetex/aws-sigv4";
2722
+ import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from "kinetex/socks5";
2723
+ import type { FetchTransportOptions } from "kinetex/core";
2724
+ import type { OTelTracer, OTelSpan } from "kinetex";
2725
+ import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
2726
+ import type { ParsedURL, URLBuilderOptions, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
1029
2727
  ```
1030
2728
 
1031
- Deduplication state is **instance-scoped** — two separate `create()` calls never share inflight maps.
1032
-
1033
- **Authorization-aware:** the dedup key includes the `Authorization` header value, so two requests to the same URL but with different credentials (e.g. different user tokens in a multi-tenant server) are never collapsed into the same in-flight request.
1034
-
1035
2729
  ---
1036
2730
 
1037
- ## Logging
2731
+ ## Worker Entry Point
1038
2732
 
1039
- Attach a logger to an instance or individual request for structured observability.
2733
+ Cloudflare Workers / Vercel Edge / WinterCG safe entry point:
1040
2734
 
1041
2735
  ```ts
1042
- import { create } from 'kinetex';
1043
- import type { Logger } from 'kinetex';
2736
+ import { kinetex, Kinetex, FluentRequest, BatchQueue, createMethodCircuitBreakerKey } from "kinetex/worker";
2737
+ // Only exports types and classes safe for edge environments.
2738
+ // No Node.js-specific imports, no HTTP/2 transport.
2739
+ // Also exports error classes: KinetexError, HTTPStatusError, TimeoutError, NetworkError, RedirectError
2740
+
2741
+ const client = kinetex({ baseURL: "https://api.example.com" });
2742
+ // Uses FetchTransport (globalThis.fetch) automatically.
2743
+ // Defaults to HTTP/1.1 for maximum edge compatibility.
2744
+ ```
1044
2745
 
1045
- const logger: Logger = {
1046
- request: (config) => console.log(`→ ${config.method} ${config.url}`),
1047
- response: (res) => console.log(`← ${res.status} ${res.timing.duration}ms`),
1048
- error: (err) => console.error(`✗ ${err.message}`),
2746
+ ```ts
2747
+ // Cloudflare Workers example
2748
+ export default {
2749
+ async fetch(request: Request): Promise<Response> {
2750
+ const client = kinetex({ baseURL: "https://api.example.com" });
2751
+ const res = await client.get("/data");
2752
+ return new Response(JSON.stringify(res.data), {
2753
+ headers: { "content-type": "application/json" },
2754
+ });
2755
+ },
1049
2756
  };
1050
-
1051
- // On an instance
1052
- const api = create({ logger });
1053
-
1054
- // Or per-request
1055
- await kinetex.get('/api/data', { logger });
1056
2757
  ```
1057
2758
 
1058
2759
  ---
1059
2760
 
1060
- ## Development
2761
+ ## Browser Usage
1061
2762
 
1062
- ```bash
1063
- git clone https://github.com/kinetexjs/kinetex.git
1064
- cd kinetex && npm install
2763
+ ```html
2764
+ <!-- UMD script (window.kinetex) -->
2765
+ <script src="https://unpkg.com/kinetex/dist/browser/kinetex.min.js"></script>
2766
+ <script>
2767
+ const client = kinetex.default({ baseURL: "/api" });
2768
+ client.get("/users").then((res) => console.log(res.data));
2769
+ </script>
2770
+
2771
+ <!-- ESM -->
2772
+ <script type="module">
2773
+ import { kinetex } from "https://unpkg.com/kinetex/dist/browser/kinetex.esm.js";
2774
+ const client = kinetex({ baseURL: "/api" });
2775
+ </script>
2776
+ ```
1065
2777
 
1066
- npm run typecheck # TypeScript — 0 errors
1067
- npm run lint # ESLint — 0 warnings
1068
- npm run build # ESM + CJS + types + browser bundles
2778
+ From npm (with bundler):
1069
2779
 
1070
- npm test # ~149 tests, 0 failures (core suite)
1071
- npm run test:all # ~460 tests, 0 failures (full suite)
1072
- npm run test:coverage # coverage report (≥ 95% ESM line coverage)
1073
- npm run bench # benchmark vs native fetch and node:http (add more: npm install --no-save axios got ky undici)
2780
+ ```ts
2781
+ import { kinetex } from "kinetex/browser";
2782
+ // browser-specific entry — no Node.js fallbacks
2783
+ ```
1074
2784
 
1075
- # Run individual runtimes
1076
- npm run test:bun
1077
- npm run test:deno
2785
+ ---
1078
2786
 
1079
- # Generate local API docs
1080
- npm run docs
1081
- open docs/index.html
2787
+ ## Runtime Compatibility
2788
+
2789
+ | Feature | Node 18+ | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
2790
+ | --------------------------- | -------- | -------- | --------- | --- | ------------ | ---------- | ----------- |
2791
+ | HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2792
+ | HTTP/2 (fetch) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2793
+ | HTTP/2 (NodeHTTP2Transport) | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
2794
+ | HTTP/3 (detection) | — | — | — | — | experimental | ✓ | — |
2795
+ | WebSocket (WSClient) | ✓ | ✓ | ✓ | ✓ | ✓ | partial | ✗ |
2796
+ | SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
2797
+ | Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
2798
+ | DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
2799
+ | Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
2800
+ | crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2801
+ | ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2802
+ | URLPattern | — | — | ✓ | — | ✓ | ✓ | — |
2803
+ | Brotli decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
2804
+ | Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
1082
2805
 
1083
- # Create a release
1084
- node scripts/release.mjs patch # or minor / major
1085
- git push && git push --tags
2806
+ ---
2807
+
2808
+ ## Resource Cleanup
2809
+
2810
+ ```ts
2811
+ client.destroy();
2812
+ // Closes all HTTP/2 sessions (NodeHTTP2Transport.destroy())
2813
+ // Closes all tracked WebSocket connections
2814
+ // Clears cache
2815
+ // Clears dedup map
2816
+ // Clears circuit breakers
2817
+ // Clears all interceptors
2818
+ // Nullifies cookie jar and logger references
1086
2819
  ```
1087
2820
 
1088
2821
  ---
1089
2822
 
1090
2823
  ## License
1091
2824
 
1092
- [MIT](LICENSE) © Qasim Ali
2825
+ MIT