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