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