kinetex 1.3.0 → 1.5.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 +413 -52
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3134 -787
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +140 -33
- package/dist/cjs/cache.js +58 -7
- package/dist/cjs/circuit-breaker.js +53 -4
- package/dist/cjs/client.js +726 -188
- package/dist/cjs/cookie-parser.js +131 -6
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +691 -70
- package/dist/cjs/dedup.js +142 -21
- package/dist/cjs/digest.js +195 -38
- package/dist/cjs/graphql.js +166 -35
- package/dist/cjs/headers.js +327 -46
- package/dist/cjs/interceptors.js +227 -24
- package/dist/cjs/lifecycle.js +92 -59
- package/dist/cjs/logging.js +160 -24
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +178 -30
- package/dist/cjs/proxy.js +429 -0
- package/dist/cjs/response.js +506 -110
- package/dist/cjs/socks5.js +136 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +335 -73
- package/dist/cjs/ws.js +62 -12
- package/dist/esm/aws-sigv4.js +140 -33
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +58 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +53 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +726 -188
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +131 -6
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +125 -28
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +691 -70
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +142 -21
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +195 -38
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +166 -35
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +327 -46
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +227 -24
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +92 -59
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +160 -24
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +247 -22
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +178 -30
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +430 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +506 -110
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +136 -15
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +173 -43
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +335 -73
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +62 -12
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +19 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +15 -2
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +69 -11
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +216 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +38 -3
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +17 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +68 -31
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +126 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +22 -3
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +49 -2
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +219 -26
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -25
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts +69 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts +0 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -23,13 +23,14 @@ const users = await api.get<User[]>("/users");
|
|
|
23
23
|
const post = await api.POST("/posts").withJSON({ title: "Hello" }).bearer("token").json<Post>();
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Works in **Node.js
|
|
26
|
+
Works in **Node.js 22+** (LTS lines: 22, 24, 26), **Deno**, **Bun**, **browsers**, **Cloudflare Workers**, **Vercel Edge**, and all **WinterCG-compliant** runtimes.
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
30
|
## Table of Contents
|
|
31
31
|
|
|
32
32
|
- [Installation](#installation)
|
|
33
|
+
- [Migrating from 1.4.x](#migrating-from-14x)
|
|
33
34
|
- [Quick Start](#quick-start)
|
|
34
35
|
- [Client Configuration](#client-configuration)
|
|
35
36
|
- [Basic HTTP Methods](#basic-http-methods)
|
|
@@ -39,6 +40,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
|
|
|
39
40
|
- [Authentication](#authentication)
|
|
40
41
|
- [Retry](#retry)
|
|
41
42
|
- [Rate Limiting](#rate-limiting)
|
|
43
|
+
- [Concurrency Limiting](#concurrency-limiting)
|
|
42
44
|
- [Timeout](#timeout)
|
|
43
45
|
- [Interceptors](#interceptors)
|
|
44
46
|
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
@@ -60,6 +62,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
|
|
|
60
62
|
- [HAR Recording](#har-recording)
|
|
61
63
|
- [OpenTelemetry Tracing](#opentelemetry-tracing)
|
|
62
64
|
- [Pipeline Trace](#pipeline-trace)
|
|
65
|
+
- [Private networks, DNS and TLS](#private-networks-dns-and-tls)
|
|
63
66
|
- [Transport Layer](#transport-layer)
|
|
64
67
|
- [Runtime Detection](#runtime-detection)
|
|
65
68
|
- [Child Clients](#child-clients)
|
|
@@ -101,6 +104,37 @@ import { kinetex } from "jsr:@kinetexjs/kinetex";
|
|
|
101
104
|
|
|
102
105
|
---
|
|
103
106
|
|
|
107
|
+
## Migrating from 1.4.x
|
|
108
|
+
|
|
109
|
+
1.5.0 contains three breaking changes. Each has a one-line opt-out, so none of them requires you to change anything unless the new default breaks you.
|
|
110
|
+
|
|
111
|
+
**`maxResponseSize` now defaults to 100 MB** instead of unlimited. The body is read in full before you see any of it, so an unbounded default let a single response exhaust the heap. If you legitimately receive responses over 100 MB:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
kinetex({ maxResponseSize: 0 }); // 0 has always meant "no limit"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**`totalTimeoutMs` now defaults to the effective `timeout`.** `timeout` bounds one attempt, and the backoff between attempts was not bounded by anything — `timeout: 1_000` with `maxRetries: 5` could hold the caller for roughly 37 seconds. The whole-request ceiling now defaults to `timeout`, so if you were relying on retries outliving `timeout`, raise it or remove it:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
kinetex({ timeout: 1_000, totalTimeoutMs: 0 }); // unbounded retries, as in 1.4.x
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**`URLBuilderOptions` is removed.** It was exported from `kinetex/url` but no constructor, factory or method accepted it, so it could never have been passed anywhere. If you imported the name as a type, drop it — `from()` takes a base URL directly, and `sortParams` is a real method.
|
|
124
|
+
|
|
125
|
+
Behaviour changes that are not breaking, but will change what you observe:
|
|
126
|
+
|
|
127
|
+
- A request carrying an `Idempotency-Key` is now retried whatever its method. An unkeyed POST is still attempted exactly once.
|
|
128
|
+
- `Cache-Control: private` responses are no longer cached. This cache is a `Map` in your process, shared across every request it makes, so storing a per-user response served it to whoever asked next. `honorCacheControl: false` opts out.
|
|
129
|
+
- `SameSite=Lax` and `SameSite=Strict` cookies are now sent. The client stated no SameSite context, which by design sends only `Unset` and `None`, so most real sessions were going out without their cookie.
|
|
130
|
+
- A 307/308 redirect carrying a stream or `Blob` body is now refused with a clear `EVALIDATION` rather than failing later as an opaque `ENETWORK`. Those methods preserve the body, and a one-shot body is spent after the first attempt. 301/302/303 are unaffected — they downgrade to GET.
|
|
131
|
+
- A `WebSocket.send()` on a socket that is not OPEN, with `bufferMessages: false`, now reports through `onError` instead of dropping the message silently.
|
|
132
|
+
- A caller-supplied `fetch` is exempt from the pre-flight DNS screen and owns its own resolution. The lexical screen still applies to the initial URL and every redirect hop.
|
|
133
|
+
|
|
134
|
+
See [`CHANGELOG.md`](./CHANGELOG.md) for the full list, including the defects this release fixes.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
104
138
|
## Quick Start
|
|
105
139
|
|
|
106
140
|
```ts
|
|
@@ -150,9 +184,14 @@ const client = kinetex({
|
|
|
150
184
|
throwOnError: true, // Throw on 4xx/5xx (default: true)
|
|
151
185
|
followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
|
|
152
186
|
maxRedirects: 20, // Max redirect hops (default: 20; 0 disables following)
|
|
187
|
+
// kinetex follows every hop itself, so each `Location` is screened
|
|
188
|
+
// (see "Redirects" under Transport Layer) — the transport is never
|
|
189
|
+
// asked to follow, whatever this is set to
|
|
153
190
|
httpsOnly: false, // Reject non-HTTPS URLs
|
|
154
|
-
maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
|
|
191
|
+
maxResponseSize: 10_000_000, // Response body size limit (default: 100 MB; 0 = no limit)
|
|
155
192
|
maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
|
|
193
|
+
ca: [readFileSync("./corp-root.pem", "utf8")], // Extra CAs to trust (Node transports)
|
|
194
|
+
allowPrivateNetwork: false, // Permit private/loopback destinations
|
|
156
195
|
strictHeaders: false, // Throw on invalid headers vs warn+drop
|
|
157
196
|
onPipelineTrace: (step) => console.log(step), // Pipeline observability callback
|
|
158
197
|
onSWRError: (err, req) => log(err), // Background SWR revalidation error callback
|
|
@@ -171,10 +210,15 @@ const client = kinetex({
|
|
|
171
210
|
// ── Rate Limit ──
|
|
172
211
|
rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
|
|
173
212
|
|
|
213
|
+
// ── Concurrency Limit ──
|
|
214
|
+
// Bounds requests *in flight*, which rateLimit cannot: a token bucket
|
|
215
|
+
// releases at dispatch, so 100/min still permits 100 simultaneous sockets.
|
|
216
|
+
concurrencyLimit: { maxConcurrent: 10, queue: true, maxQueue: 100 },
|
|
217
|
+
|
|
174
218
|
// ── Proxy ──
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
219
|
+
// HTTP(S) CONNECT proxies work on Node, on both built-in transports.
|
|
220
|
+
// SOCKS5 URLs still throw, pointing at kinetex/socks5.
|
|
221
|
+
// proxy: { url: "http://127.0.0.1:8080" },
|
|
178
222
|
// proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
|
|
179
223
|
|
|
180
224
|
// ── Cache ──
|
|
@@ -373,7 +417,7 @@ interface SendOptions<T = unknown> {
|
|
|
373
417
|
maxRedirects?: number; // Max redirect hops (default: 20; 0 disables following)
|
|
374
418
|
httpVersion?: HTTPVersion; // Preferred HTTP version
|
|
375
419
|
maxRequestSize?: number; // Request size limit (bytes)
|
|
376
|
-
maxResponseSize?: number; // Response size limit (bytes)
|
|
420
|
+
maxResponseSize?: number; // Response size limit (bytes, default 104857600; 0 = unlimited)
|
|
377
421
|
parseResponse?: (raw: Uint8Array, headers: Record<string, string>, url: string) => T | Promise<T>;
|
|
378
422
|
parseFailure?: (raw: Uint8Array, error: Error) => void;
|
|
379
423
|
onSuccess?: (res: KinetexResponse<T>) => void;
|
|
@@ -461,12 +505,16 @@ client.GET("/public").noAuth().json();
|
|
|
461
505
|
kinetex({
|
|
462
506
|
retry: {
|
|
463
507
|
maxRetries: 3, // Max attempts (default: 3)
|
|
508
|
+
totalTimeoutMs: 20_000, // Ceiling on the WHOLE request: attempts + backoff (default: = timeout)
|
|
464
509
|
baseDelayMs: 300, // Exponential back-off base (default: 300)
|
|
465
510
|
maxDelayMs: 30_000, // Max delay cap (default: 30_000)
|
|
466
511
|
jitter: 0.3, // Random jitter factor 0-1 (default: 0.3)
|
|
467
512
|
statuses: [408, 429, 500, 502, 503, 504], // Status codes that trigger retry
|
|
468
513
|
onNetworkError: true, // Retry on network errors (default: true)
|
|
469
514
|
onTimeout: false, // Retry on timeouts (default: false)
|
|
515
|
+
// Only these methods are retried. A request carrying an `Idempotency-Key`
|
|
516
|
+
// is retried whatever its method — the key is the caller's declaration
|
|
517
|
+
// that repeating it is safe. See "Idempotency Keys" below.
|
|
470
518
|
methods: ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"],
|
|
471
519
|
shouldRetry: (ctx) => ctx.attempt < 3 && ctx.response?.status === 503, // Custom predicate
|
|
472
520
|
onRetry: (ctx, delayMs) => console.log(`Retry ${ctx.attempt} in ${delayMs}ms`),
|
|
@@ -474,6 +522,38 @@ kinetex({
|
|
|
474
522
|
});
|
|
475
523
|
```
|
|
476
524
|
|
|
525
|
+
`timeout` applies to a single attempt; `totalTimeoutMs` bounds the whole logical
|
|
526
|
+
request, backoff included, and defaults to `timeout`. Set it to `0` to remove
|
|
527
|
+
the ceiling. When the next backoff would exceed the budget the retry is skipped
|
|
528
|
+
rather than slept through, and the original response or error is what the caller
|
|
529
|
+
receives — the budget never substitutes an `ETIMEOUT` for a real cause.
|
|
530
|
+
|
|
531
|
+
### Idempotency Keys
|
|
532
|
+
|
|
533
|
+
A `POST` is not retried by default, because replaying an unkeyed write can
|
|
534
|
+
double-charge someone. A request that carries an `Idempotency-Key` **is** retried
|
|
535
|
+
whatever its method: the header exists precisely to declare the request
|
|
536
|
+
replayable, and the server collapses the duplicates.
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
import { generateIdempotencyKey, isValidIdempotencyKey } from "kinetex";
|
|
540
|
+
|
|
541
|
+
// Keys are RFC 9562 v4 UUIDs from the runtime CSPRNG.
|
|
542
|
+
const key = generateIdempotencyKey();
|
|
543
|
+
isValidIdempotencyKey(key); // true
|
|
544
|
+
|
|
545
|
+
// Any method, retried on transient failures, same key every attempt —
|
|
546
|
+
// a fresh key per attempt would defeat the mechanism.
|
|
547
|
+
await client.post("/charges", body, { headers: { "idempotency-key": key } });
|
|
548
|
+
|
|
549
|
+
// Or fluently:
|
|
550
|
+
await client.POST("/charges").withJSON(body).idempotencyKey().json();
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
An unkeyed `POST` is still attempted exactly once. Validation is 1–255 visible
|
|
554
|
+
ASCII, so CR/LF/NUL cannot be smuggled into the headers, and a runtime without
|
|
555
|
+
`crypto.getRandomValues` throws rather than falling back to a guessable value.
|
|
556
|
+
|
|
477
557
|
### RetryContext
|
|
478
558
|
|
|
479
559
|
The `ctx` parameter in `shouldRetry` and `onRetry` is of type `RetryContext`:
|
|
@@ -516,6 +596,76 @@ kinetex({
|
|
|
516
596
|
|
|
517
597
|
---
|
|
518
598
|
|
|
599
|
+
## Concurrency Limiting
|
|
600
|
+
|
|
601
|
+
A counting semaphore bounding how many requests may be **in flight** at once.
|
|
602
|
+
`rateLimit` cannot do this: a token bucket releases at _dispatch_, so
|
|
603
|
+
`rateLimit: { limit: 100 }` per minute still permits 100 simultaneous sockets.
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
kinetex({
|
|
607
|
+
concurrencyLimit: {
|
|
608
|
+
maxConcurrent: 10, // Permits held at once (default: 10; must be a finite number >= 1)
|
|
609
|
+
queue: true, // Queue the excess vs reject (default: true)
|
|
610
|
+
maxQueue: 100, // Queue depth before rejecting (default: 100)
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
A permit is held for the **whole logical request, retries included**, and
|
|
616
|
+
returned in a `finally` — so a throw, an exhausted retry budget or a `destroy()`
|
|
617
|
+
can never leak one and permanently shrink the pool. On release the permit is
|
|
618
|
+
handed straight to the longest-waiting caller rather than freed and re-taken, so
|
|
619
|
+
`inFlight` never transiently exceeds `maxConcurrent`.
|
|
620
|
+
|
|
621
|
+
When the queue is full (or `queue: false`), the request is rejected with
|
|
622
|
+
`ConcurrencyLimitError`, code `ECONCURRENCY`:
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
import { ConcurrencyLimitError } from "kinetex";
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
await client.get("/slow");
|
|
629
|
+
} catch (err) {
|
|
630
|
+
if (err instanceof ConcurrencyLimitError) {
|
|
631
|
+
console.log(err.code); // "ECONCURRENCY"
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
A request cancelled while queued is a **different** failure and is reported as
|
|
637
|
+
such — `code: "EABORT"` with `isAbort`, the same contract every other abort path
|
|
638
|
+
in the library offers. That holds whichever path the acquire took: an idle pool,
|
|
639
|
+
a saturated pool with queueing on, queueing off, and a full queue all answer
|
|
640
|
+
`EABORT` rather than reporting a cancellation as a capacity failure. An
|
|
641
|
+
already-aborted signal is refused before a permit is taken, so none can leak.
|
|
642
|
+
|
|
643
|
+
`maxQueue` must be a non-negative integer, or `Infinity` for an unbounded queue;
|
|
644
|
+
anything else — including `NaN` — throws a `RangeError` at construction rather
|
|
645
|
+
than leaving the cap unbound. `maxConcurrent` is validated the same way.
|
|
646
|
+
|
|
647
|
+
### Standalone use
|
|
648
|
+
|
|
649
|
+
The limiter is exported on its own, for gating work that is not a request:
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { ConcurrencyLimiter, CONCURRENCY_DEFAULTS } from "kinetex";
|
|
653
|
+
|
|
654
|
+
const limiter = new ConcurrencyLimiter({ maxConcurrent: 4 });
|
|
655
|
+
console.log(limiter.inFlight, limiter.waiting, limiter.highWaterMark);
|
|
656
|
+
|
|
657
|
+
await limiter.acquire(signal);
|
|
658
|
+
try {
|
|
659
|
+
await doWork();
|
|
660
|
+
} finally {
|
|
661
|
+
limiter.release();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
limiter.drain(); // reject everyone still queued (what Kinetex.destroy does)
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
519
669
|
## Timeout
|
|
520
670
|
|
|
521
671
|
Default timeout is 30 seconds. Set to 0 for no timeout:
|
|
@@ -540,6 +690,15 @@ try {
|
|
|
540
690
|
|
|
541
691
|
Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
|
|
542
692
|
|
|
693
|
+
**A merged signal keeps the reason.** `mergeSignals(a, b)` re-aborts with the
|
|
694
|
+
`reason` of whichever source actually aborted, so
|
|
695
|
+
`signal.reason` survives the combination instead of collapsing to a generic
|
|
696
|
+
`AbortError: This operation was aborted` — including when a source was _already_
|
|
697
|
+
aborted before the call, which is the case a shortcut in the "no live signals
|
|
698
|
+
left" branch used to get wrong. Interceptors rely on this: retry and
|
|
699
|
+
deduplication logic reads `existing.reason` off the combined signal to decide
|
|
700
|
+
what actually went wrong.
|
|
701
|
+
|
|
543
702
|
---
|
|
544
703
|
|
|
545
704
|
## Interceptors
|
|
@@ -655,7 +814,14 @@ const suite = createInterceptorSuite({
|
|
|
655
814
|
|
|
656
815
|
## Lifecycle Hooks
|
|
657
816
|
|
|
658
|
-
Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation
|
|
817
|
+
Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation.
|
|
818
|
+
|
|
819
|
+
`onAfterRequest` fires in exactly the window its name describes: after the
|
|
820
|
+
transport has answered and before the response is built, once per attempt. It
|
|
821
|
+
is the only place you can observe that the round trip is over without also
|
|
822
|
+
having to see the parsed response — and it deliberately does **not** fire on a
|
|
823
|
+
request that never left, so a hook that counts what was sent does not count a
|
|
824
|
+
connection that died first.
|
|
659
825
|
|
|
660
826
|
```ts
|
|
661
827
|
kinetex({
|
|
@@ -668,7 +834,7 @@ kinetex({
|
|
|
668
834
|
],
|
|
669
835
|
onAfterRequest: [
|
|
670
836
|
(req, ctx) => {
|
|
671
|
-
/*
|
|
837
|
+
/* the wire round trip is over; the response is not built yet */
|
|
672
838
|
},
|
|
673
839
|
],
|
|
674
840
|
onBeforeResponse: [
|
|
@@ -840,7 +1006,7 @@ interface HookOptions {
|
|
|
840
1006
|
|
|
841
1007
|
## Request Deduplication
|
|
842
1008
|
|
|
843
|
-
Coalesces identical concurrent GET/HEAD requests into a single network call. The dedup key includes a SHA-256 fingerprint of every
|
|
1009
|
+
Coalesces identical concurrent GET/HEAD requests into a single network call. The dedup key includes a SHA-256 fingerprint of **every request header**, so two callers are only ever coalesced when they would have received the same response — different `Authorization`, `Cookie`, API key _or_ a tenant/session header each get their own in-flight slot. Per-request tracing headers (`traceparent`, `tracestate`, `baggage`, `x-request-id`, `x-correlation-id`, `x-trace-id`) are excluded, since every request carries its own and including them would disable coalescing entirely:
|
|
844
1010
|
|
|
845
1011
|
```ts
|
|
846
1012
|
import { CREDENTIAL_HEADERS } from "kinetex/cache";
|
|
@@ -987,7 +1153,7 @@ const client = kinetex({
|
|
|
987
1153
|
// maxBodySizeBytes: 5 * 1024 * 1024, // skip caching larger bodies
|
|
988
1154
|
// defaultTtlMs: 60_000, // when the response has no Cache-Control (max 1 year)
|
|
989
1155
|
// maxAbsoluteAgeMs: 7 * 24 * 60 * 60 * 1000, // hard cap regardless of Cache-Control
|
|
990
|
-
// honorCacheControl: true, // respect no-store / no-cache
|
|
1156
|
+
// honorCacheControl: true, // respect no-store / no-cache / private
|
|
991
1157
|
// cacheMethods: ["GET", "HEAD"],
|
|
992
1158
|
// cacheStatuses: [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
|
|
993
1159
|
storage: new MemoryStorageAdapter(), // omit for plain in-memory
|
|
@@ -1009,6 +1175,11 @@ client.GET("/users").cache({ ttlMs: 5000 }).json();
|
|
|
1009
1175
|
client.GET("/users").noCache().json(); // forceRefresh: true
|
|
1010
1176
|
```
|
|
1011
1177
|
|
|
1178
|
+
`forceRefresh` skips the cache **read** for that one request and nothing else:
|
|
1179
|
+
the fresh response is still written, so a later plain request is served from
|
|
1180
|
+
the cache again. It is a bypass, not a purge — use `cache.clear()` or
|
|
1181
|
+
`invalidateTags()` to drop entries.
|
|
1182
|
+
|
|
1012
1183
|
**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
1184
|
|
|
1014
1185
|
- 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.
|
|
@@ -1113,7 +1284,15 @@ await cache.clear();
|
|
|
1113
1284
|
|
|
1114
1285
|
## Cookie Jar
|
|
1115
1286
|
|
|
1116
|
-
Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching
|
|
1287
|
+
Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching.
|
|
1288
|
+
|
|
1289
|
+
`CookieJar.fromJSON` / `loadCookieJar` are the one public path that ingests
|
|
1290
|
+
externally authored JSON, so they canonicalise `sameSite` on the way in:
|
|
1291
|
+
`"lax"`, `"LAX"` and `"Lax"` all mean `Lax`, and an unrecognised or missing
|
|
1292
|
+
value becomes `Unset`. Without that, a persisted jar could report `count === 1`
|
|
1293
|
+
and send nothing at all, because an unrecognised `SameSite` matches no branch
|
|
1294
|
+
of the retrieval filter. `Unset` still refuses an explicit cross-site request,
|
|
1295
|
+
so a value that cannot be interpreted is never treated as "send everywhere".
|
|
1117
1296
|
|
|
1118
1297
|
```ts
|
|
1119
1298
|
// Auto-managed through the client
|
|
@@ -1579,7 +1758,7 @@ const ws = new WSClient({
|
|
|
1579
1758
|
lowWaterMark: 16384,
|
|
1580
1759
|
maxSendRate: 0, // 0 = unlimited
|
|
1581
1760
|
keepRooms: true,
|
|
1582
|
-
bufferMessages: true,
|
|
1761
|
+
bufferMessages: true, // queue sends made while reconnecting; see below
|
|
1583
1762
|
maxBufferSize: 1000,
|
|
1584
1763
|
rooms: ["prices"],
|
|
1585
1764
|
signal: controller.signal,
|
|
@@ -1642,6 +1821,11 @@ ws.leave("prices");
|
|
|
1642
1821
|
ws.rooms; // readonly WSSubscribedRoom[]
|
|
1643
1822
|
|
|
1644
1823
|
// Backpressure
|
|
1824
|
+
// With `bufferMessages: true` (default) a send made while the socket is not
|
|
1825
|
+
// OPEN is queued, up to `maxBufferSize`, oldest evicted first. With it false
|
|
1826
|
+
// there is nowhere to put that message: it is dropped, and the drop is reported
|
|
1827
|
+
// through `onError` — `send()` returns void, so silence would make a lost
|
|
1828
|
+
// message indistinguishable from a delivered one.
|
|
1645
1829
|
ws.backpressure; // { bufferedBytes, highWaterMark, lowWaterMark, isBackpressured, ... }
|
|
1646
1830
|
await ws.drain(30_000); // Wait until outbound buffer is flushed
|
|
1647
1831
|
await ws.drainAndClose(30_000); // Drain, then close gracefully
|
|
@@ -2004,6 +2188,17 @@ const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
|
|
|
2004
2188
|
isClockSkewError(err); // → boolean
|
|
2005
2189
|
```
|
|
2006
2190
|
|
|
2191
|
+
**`imdsCredentials` reports failure as a `NetworkError`, never a raw
|
|
2192
|
+
`SyntaxError`.** Every way the EC2 metadata service can disappoint you — an
|
|
2193
|
+
unreachable endpoint, a non-200, a body that is not JSON, or a 200 whose JSON
|
|
2194
|
+
is missing `AccessKeyId` / `SecretAccessKey` / `Token` — surfaces as the same
|
|
2195
|
+
error type, so a `chainCredentials` fallback can catch one thing. A body is
|
|
2196
|
+
accepted only if every required field is a non-empty string; `{}` and
|
|
2197
|
+
`{"AccessKeyId": null}` are refused, and the error names _all_ the fields that
|
|
2198
|
+
were absent rather than making you discover them one round trip at a time.
|
|
2199
|
+
Signing with `undefined` keys would otherwise produce a `SignatureDoesNotMatch`
|
|
2200
|
+
from AWS that points at the caller instead of at the metadata service.
|
|
2201
|
+
|
|
2007
2202
|
### Client-Level SigV4
|
|
2008
2203
|
|
|
2009
2204
|
```ts
|
|
@@ -2155,6 +2350,14 @@ Create one per client (do not share it across users). `Kinetex`'s built-in `auth
|
|
|
2155
2350
|
|
|
2156
2351
|
## Structured Logging
|
|
2157
2352
|
|
|
2353
|
+
Body-field redaction applies to every JSON media type, not just
|
|
2354
|
+
`application/json`: `+json` structured suffixes (`application/vnd.api+json`,
|
|
2355
|
+
`application/hal+json`, `application/problem+json`, …), `text/json`, any
|
|
2356
|
+
casing, and any parameters. `allowedBodyTypes` decides whether a body is logged
|
|
2357
|
+
at all; a body that clears that gate has its `bodyFields` redacted whatever
|
|
2358
|
+
its exact media type, so widening `allowedBodyTypes` cannot quietly start
|
|
2359
|
+
leaking `password` fields.
|
|
2360
|
+
|
|
2158
2361
|
```ts
|
|
2159
2362
|
import {
|
|
2160
2363
|
HTTPLogger,
|
|
@@ -2238,7 +2441,7 @@ const har = client.getHAR();
|
|
|
2238
2441
|
// HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
|
|
2239
2442
|
|
|
2240
2443
|
// Each HAREntry contains:
|
|
2241
|
-
// startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
|
|
2444
|
+
// startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize, postData?),
|
|
2242
2445
|
// response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
|
|
2243
2446
|
// timings (send, wait, receive), cache
|
|
2244
2447
|
|
|
@@ -2254,6 +2457,7 @@ HAR logs are routinely exported and shared, so entries are redacted before they
|
|
|
2254
2457
|
- **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
2458
|
- **`Location`** — the redirect target is passed through the same URL redaction.
|
|
2256
2459
|
- **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.
|
|
2460
|
+
- **Request bodies** — recorded as `request.postData` (`{ mimeType, text }`) under the same policy and the same limit, so a HAR viewer shows what was actually sent. A `ReadableStream` or `FormData` body is omitted rather than buffered, since reading it would consume it; `request.bodySize` is `-1` for those, as it always was. For a `json` body the same sensitive names used for headers and query parameters (`password`, `token`, `api_key`, `secret`, …) are replaced with `***REDACTED***` at any nesting depth, since a JSON body is where credentials most often travel and HAR files get shared. Non-JSON bodies keep their text, but the literal values of this request's own credentials — the credential header, the part after its scheme, and sensitive query parameters — are still replaced wherever they appear, which catches a server echoing a token back in an error message.
|
|
2257
2461
|
|
|
2258
2462
|
```ts
|
|
2259
2463
|
// ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
|
|
@@ -2337,8 +2541,114 @@ Pipeline stages in order:
|
|
|
2337
2541
|
|
|
2338
2542
|
---
|
|
2339
2543
|
|
|
2544
|
+
## Private networks, DNS and TLS
|
|
2545
|
+
|
|
2546
|
+
```ts
|
|
2547
|
+
const client = kinetex({
|
|
2548
|
+
baseURL: "https://internal.svc",
|
|
2549
|
+
|
|
2550
|
+
// Trust a private CA alongside the system store. Without this, a service mesh
|
|
2551
|
+
// or enterprise proxy whose certificate a corporate CA issued fails TLS.
|
|
2552
|
+
// Node's transports only — `fetch` exposes no CA option, so a custom `fetch`
|
|
2553
|
+
// or `dispatcher` setup has to carry its own.
|
|
2554
|
+
ca: [readFileSync("./corp-root.pem", "utf8")],
|
|
2555
|
+
|
|
2556
|
+
// Reach localhost, a service mesh or a local dev server. Off by default.
|
|
2557
|
+
allowPrivateNetwork: true,
|
|
2558
|
+
});
|
|
2559
|
+
```
|
|
2560
|
+
|
|
2561
|
+
### How the screen works
|
|
2562
|
+
|
|
2563
|
+
A URL is a name, not an address, so screening the text you typed does not
|
|
2564
|
+
describe what the socket will do. Three layers close that gap:
|
|
2565
|
+
|
|
2566
|
+
1. **Lexical.** `isSafeURL` rejects a literal loopback, private, link-local or
|
|
2567
|
+
otherwise blocked address, on the initial URL and on every redirect hop.
|
|
2568
|
+
2. **Resolution, pinned (Node).** `NodeHTTP2Transport` and the HTTP/1.1 path
|
|
2569
|
+
resolve once and hand the _checked_ address to the socket
|
|
2570
|
+
(`createPinnedLookup`). A name answering with both a public and a private
|
|
2571
|
+
address keeps only the public one; one answering with only private space
|
|
2572
|
+
fails with `EBLOCKEDHOST` rather than connecting.
|
|
2573
|
+
3. **Resolution, pre-flight (`FetchTransport`).** `fetch` exposes no resolver
|
|
2574
|
+
hook — undici owns the connection — so `FetchTransport` resolves before the
|
|
2575
|
+
call and refuses a host whose answers are all blocked. This layer applies
|
|
2576
|
+
only when kinetex is using the runtime's own `fetch`. Supply your own via
|
|
2577
|
+
the `fetch` option and you own resolution end to end: kinetex screens every
|
|
2578
|
+
URL lexically, but it cannot observe which address your dispatcher dials, so
|
|
2579
|
+
checking the system resolver's answer would describe a lookup that never
|
|
2580
|
+
happens. Treat `fetch` the way you would treat a custom `dispatcher` — a
|
|
2581
|
+
decision you have taken responsibility for.
|
|
2582
|
+
|
|
2583
|
+
The second layer closes rebinding completely, because the address that passed
|
|
2584
|
+
the check is the address that is dialled. The third cannot: it is a check
|
|
2585
|
+
followed by a separate resolution inside `fetch`, so a resolver that answers
|
|
2586
|
+
public on the first lookup and private on the second is not caught here. On
|
|
2587
|
+
Node you get the pinned path; on Deno, Bun and edge runtimes this is the
|
|
2588
|
+
residual exposure, and it is worth a runtime-level DNS policy where one is
|
|
2589
|
+
available.
|
|
2590
|
+
|
|
2591
|
+
### What the opt-out really does
|
|
2592
|
+
|
|
2593
|
+
`allowPrivateNetwork: true` is not a narrower exception — it turns the screen
|
|
2594
|
+
off for the initial URL _and_ for every redirect hop, and it is the reason a
|
|
2595
|
+
redirect to `http://127.0.0.1:9/` is followed rather than refused. Set it only
|
|
2596
|
+
where the URLs you request are not influenced by anyone else: local
|
|
2597
|
+
development, a service mesh you control, and the test suite.
|
|
2598
|
+
|
|
2340
2599
|
## Transport Layer
|
|
2341
2600
|
|
|
2601
|
+
### Redirects
|
|
2602
|
+
|
|
2603
|
+
**kinetex follows every redirect itself.** The outgoing request always carries
|
|
2604
|
+
`redirect: "manual"`, and a server-chosen `Location` is resolved, screened and
|
|
2605
|
+
re-dispatched one hop at a time. This is not a tuning knob — it is what makes
|
|
2606
|
+
the per-hop checks below possible at all, because `fetch` following on its own
|
|
2607
|
+
never reports a target back to the caller.
|
|
2608
|
+
|
|
2609
|
+
Every hop is subject to:
|
|
2610
|
+
|
|
2611
|
+
- **SSRF.** `isSafeURL` runs on each `Location` before it is dialled, so a
|
|
2612
|
+
redirect cannot walk the client onto a loopback, private or link-local
|
|
2613
|
+
address. A 302 to `http://127.0.0.1:9/` or to `http://169.254.169.254/`
|
|
2614
|
+
raises `EVALIDATION` ("Unsafe redirect target blocked") rather than opening
|
|
2615
|
+
the socket — the initial request URL gets the same screen.
|
|
2616
|
+
- **`httpsOnly`.** Checked on the target, not just the request you wrote, so an
|
|
2617
|
+
`https:` request cannot be downgraded to cleartext by its response.
|
|
2618
|
+
- **`maxRedirects`.** Enforced per request, default 20. Exhausting it raises
|
|
2619
|
+
`RedirectError` (`EREDIRECT`, "Too many redirects"). `followRedirects: false`
|
|
2620
|
+
— or `maxRedirects: 0` — hands the 3xx back to you instead.
|
|
2621
|
+
- **Loop detection.** A target already visited in this chain is refused with
|
|
2622
|
+
`RedirectError` (`EREDIRECT`, "Redirect loop detected").
|
|
2623
|
+
- **Method downgrade.** Per RFC 7231, 301/302/303 downgrade to `GET` and drop
|
|
2624
|
+
the body; 307/308 preserve both. A 307/308 therefore needs the body a second
|
|
2625
|
+
time, and a `ReadableStream`/`Blob` body is spent after the first attempt — so
|
|
2626
|
+
that hop is refused with `EVALIDATION` rather than silently sent empty. Buffer
|
|
2627
|
+
the body first, or disable redirects for that request.
|
|
2628
|
+
- **Credentials.** `Authorization`, `apikey` headers, `Cookie` and any
|
|
2629
|
+
declared auth are dropped when a hop changes origin, and kept when it does
|
|
2630
|
+
not. Intermediate `Set-Cookie` headers are captured by the jar, so cookies
|
|
2631
|
+
set on a redirect leg are applied to the next one.
|
|
2632
|
+
- **Scheme.** Anything but `http:` / `https:` is rejected as
|
|
2633
|
+
`EVALIDATION`, so `file:`, `data:` and friends cannot be reached.
|
|
2634
|
+
|
|
2635
|
+
`res.redirected` tells you whether a hop was actually taken, and `res.url` is
|
|
2636
|
+
where the request finally landed. An _unfollowed_ 3xx — `followRedirects:
|
|
2637
|
+
false`, or `maxRedirects: 0` — reports `redirected: false` and the original
|
|
2638
|
+
`res.url`, because nothing was followed: that response is the one you have to
|
|
2639
|
+
read `Location` on and act on yourself.
|
|
2640
|
+
|
|
2641
|
+
A redirect failure is a `RedirectError` with code `EREDIRECT` ("Too many
|
|
2642
|
+
redirects", "Redirect loop detected"), and it is **not** retried. A chain is a
|
|
2643
|
+
deterministic answer from the origin, so replaying it would only multiply the
|
|
2644
|
+
requests against a server already looping: `maxRedirects: 3` makes exactly
|
|
2645
|
+
four requests, not one per retry attempt. The SSRF and `httpsOnly` gates stay
|
|
2646
|
+
`EVALIDATION`, as does an unsafe redirect target.
|
|
2647
|
+
|
|
2648
|
+
The built-in transports enforce the same two gates in their own loops
|
|
2649
|
+
(`https:` only, plus `isSafeURL`), so the protection does not depend on going
|
|
2650
|
+
through `Kinetex`.
|
|
2651
|
+
|
|
2342
2652
|
### FetchTransport
|
|
2343
2653
|
|
|
2344
2654
|
Universal fetch-based transport for all runtimes:
|
|
@@ -2388,6 +2698,57 @@ Features:
|
|
|
2388
2698
|
- Iterative redirect following (not recursive)
|
|
2389
2699
|
- Backpressure-aware body writes (awaits `drain` events)
|
|
2390
2700
|
|
|
2701
|
+
**The transport owns the request line.** `:method`, `:path`, `:scheme` and
|
|
2702
|
+
`:authority` are built from the URL you hand to `send()` and cannot be
|
|
2703
|
+
overridden from `request.headers`. A pseudo-header there is refused the same
|
|
2704
|
+
way any other invalid header is: `EVALIDATION` under `strict: true`, otherwise
|
|
2705
|
+
`onDroppedHeader(name, value)` or a `console.warn`. This is not a formality —
|
|
2706
|
+
`":"` is not a token character, so `FetchTransport` has always dropped such a
|
|
2707
|
+
header, and the two transports now answer a single request the same way.
|
|
2708
|
+
|
|
2709
|
+
Header names are validated as tokens and values as field-values, so a
|
|
2710
|
+
`__proto__` header (legal — it is all token characters) is sent as a real
|
|
2711
|
+
header rather than disappearing into an inherited setter.
|
|
2712
|
+
|
|
2713
|
+
### Request Bodies
|
|
2714
|
+
|
|
2715
|
+
`FetchTransport` hands the body to `fetch`; `NodeHTTP2Transport` drives
|
|
2716
|
+
`node:http2` directly. The two therefore serialize differently, and on Node
|
|
2717
|
+
the default transport is the latter — so anything fetch would have encoded has
|
|
2718
|
+
to be encoded by kinetex first. `URLSearchParams`, `Blob` and `FormData` are
|
|
2719
|
+
all covered:
|
|
2720
|
+
|
|
2721
|
+
```ts
|
|
2722
|
+
const form = new FormData();
|
|
2723
|
+
form.append("field", "value");
|
|
2724
|
+
form.append("file", new File([blob], "report.csv", { type: "text/csv" }));
|
|
2725
|
+
|
|
2726
|
+
// → multipart/form-data; boundary=----kinetexFormBoundary<random>
|
|
2727
|
+
const res = await client.POST("/upload").withForm(form).send();
|
|
2728
|
+
```
|
|
2729
|
+
|
|
2730
|
+
**The boundary and the header are generated together.** The boundary is
|
|
2731
|
+
invented _during_ encoding, so a body encoded after the header block was
|
|
2732
|
+
already written names a boundary no header mentions — and a multipart body
|
|
2733
|
+
whose boundary is not announced is unparseable. Both raw Node paths therefore
|
|
2734
|
+
pre-encode the body before building headers, and a `content-type` you set
|
|
2735
|
+
yourself always wins. `encodeMultipart(form, boundary?)` is exported for
|
|
2736
|
+
callers who want the bytes directly; pass a boundary to make the output
|
|
2737
|
+
deterministic.
|
|
2738
|
+
|
|
2739
|
+
A field name containing CR, LF or a double quote is refused with
|
|
2740
|
+
`EVALIDATION` rather than serialized, since a name is interpolated into a
|
|
2741
|
+
`Content-Disposition` header and could otherwise forge extra part headers. A
|
|
2742
|
+
File's `filename` goes into the same quoted-string context but is
|
|
2743
|
+
percent-escaped rather than refused, so ordinary names keep working.
|
|
2744
|
+
|
|
2745
|
+
`maxRequestSize` counts what actually goes on the wire. For a `FormData`
|
|
2746
|
+
that means the form is encoded once and its real byte length is compared —
|
|
2747
|
+
there is no per-part guess, and the encoding is not repeated on the dispatch
|
|
2748
|
+
path. A `ReadableStream` body is the one type that cannot be measured at all,
|
|
2749
|
+
so it is refused instead of bypassing the limit; pass `maxRequestSize: 0` to
|
|
2750
|
+
opt out, or buffer the body first.
|
|
2751
|
+
|
|
2391
2752
|
### Transport Factory
|
|
2392
2753
|
|
|
2393
2754
|
```ts
|
|
@@ -2577,14 +2938,7 @@ import {
|
|
|
2577
2938
|
buildDataURL,
|
|
2578
2939
|
diffURLs,
|
|
2579
2940
|
} from "kinetex/url";
|
|
2580
|
-
import type {
|
|
2581
|
-
ParsedURL,
|
|
2582
|
-
URLBuilderOptions,
|
|
2583
|
-
URLPattern,
|
|
2584
|
-
URLPatternMatch,
|
|
2585
|
-
URLDiff,
|
|
2586
|
-
DataURLParts,
|
|
2587
|
-
} from "kinetex/url";
|
|
2941
|
+
import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
|
|
2588
2942
|
|
|
2589
2943
|
// URL Builder (fluent, immutable — every method returns a new builder)
|
|
2590
2944
|
const builder = URLBuilder.from("https://api.example.com")
|
|
@@ -2999,8 +3353,12 @@ isSafeURL("http://169.254.169.254/latest/meta-data/"); // false
|
|
|
2999
3353
|
isSafeURL("http://[::ffff:127.0.0.1]/"); // false
|
|
3000
3354
|
|
|
3001
3355
|
// Applied to the initial URL AND to every redirect hop, so a public host cannot
|
|
3002
|
-
// bounce a request into the private network.
|
|
3003
|
-
//
|
|
3356
|
+
// bounce a request into the private network.
|
|
3357
|
+
//
|
|
3358
|
+
// These helpers are literal-address checks. Resolution is handled separately by
|
|
3359
|
+
// the transports: the Node ones resolve once and pin the checked address to the
|
|
3360
|
+
// socket, and `FetchTransport` resolves before the call and refuses a host whose
|
|
3361
|
+
// answers are all blocked. See "Private networks and DNS" below.
|
|
3004
3362
|
sanitizeURL("javascript:alert(1)"); // null — invalid or an SSRF risk
|
|
3005
3363
|
sanitizeURL("https://user:pass@api.example.com/x"); // "https://api.example.com/x" (credentials stripped)
|
|
3006
3364
|
|
|
@@ -3023,9 +3381,19 @@ const b64 = uint8ArrayToBase64(uint8);
|
|
|
3023
3381
|
|
|
3024
3382
|
// Object
|
|
3025
3383
|
const clone = deepClone(original);
|
|
3026
|
-
|
|
3384
|
+
// normalizeHeaders keys are lowercased, and a header that appears more than
|
|
3385
|
+
// once keeps every value rather than only the last one.
|
|
3386
|
+
const normalized = normalizeHeaders(rawHeaders);
|
|
3027
3387
|
```
|
|
3028
3388
|
|
|
3389
|
+
**Repeated headers keep every value.** `Set-Cookie` is the one header a
|
|
3390
|
+
`Headers` object yields _separately_ per cookie rather than already joined, so
|
|
3391
|
+
a naive normalisation that assigns as it iterates silently drops every cookie
|
|
3392
|
+
but the last — and the request still looks fine. `normalizeHeaders`
|
|
3393
|
+
accumulates instead, so a response with three `Set-Cookie` lines round-trips
|
|
3394
|
+
back to three, each with its `Expires=Wed, 09 Jun 2021 10:18:14 GMT` intact.
|
|
3395
|
+
`toNodeHeaders` and the `HttpHeaders` type were already correct on this.
|
|
3396
|
+
|
|
3029
3397
|
---
|
|
3030
3398
|
|
|
3031
3399
|
## Error Handling
|
|
@@ -3252,14 +3620,7 @@ import type { Socks5ProxyConfig, Socks5Tunnel, Socks5Target, TcpConnector } from
|
|
|
3252
3620
|
import type { FetchTransportOptions } from "kinetex/core";
|
|
3253
3621
|
import type { OTelTracer, OTelSpan } from "kinetex";
|
|
3254
3622
|
import type { SafeJSONParseOptions, SafeJSONParseResult, ErrorContext } from "kinetex";
|
|
3255
|
-
import type {
|
|
3256
|
-
ParsedURL,
|
|
3257
|
-
URLBuilderOptions,
|
|
3258
|
-
URLPattern,
|
|
3259
|
-
URLPatternMatch,
|
|
3260
|
-
URLDiff,
|
|
3261
|
-
DataURLParts,
|
|
3262
|
-
} from "kinetex/url";
|
|
3623
|
+
import type { ParsedURL, URLPattern, URLPatternMatch, URLDiff, DataURLParts } from "kinetex/url";
|
|
3263
3624
|
```
|
|
3264
3625
|
|
|
3265
3626
|
---
|
|
@@ -3330,26 +3691,26 @@ import { kinetex } from "kinetex/browser";
|
|
|
3330
3691
|
|
|
3331
3692
|
## Runtime Compatibility
|
|
3332
3693
|
|
|
3333
|
-
| Feature | Node
|
|
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) | ✓*
|
|
3339
|
-
| WebSocket (WSClient) |
|
|
3340
|
-
| SOCKS5 proxy | ✓
|
|
3341
|
-
| Blob | ✓
|
|
3342
|
-
| DOMException | ✓
|
|
3343
|
-
| Buffer | ✓
|
|
3344
|
-
| crypto.subtle | ✓
|
|
3345
|
-
| ReadableStream | ✓
|
|
3346
|
-
| URL pattern matching | ✓
|
|
3347
|
-
| Brotli decompression | ✓
|
|
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
|
|
3351
|
-
|
|
3352
|
-
¹ WSClient requires a native `WebSocket` constructor
|
|
3694
|
+
| Feature | Node 22+ | Deno | Bun | Browser | CF Workers | Vercel Edge |
|
|
3695
|
+
| ----------------------------------------- | -------- | ---- | ------------- | ------------- | ------------- | ------------- |
|
|
3696
|
+
| HTTP/1.1 fetch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3697
|
+
| HTTP/2 (fetch, via Alt-Svc/runtime hints) | ✓* | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3698
|
+
| HTTP/2 (NodeHTTP2Transport) | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
|
3699
|
+
| HTTP/3 (detection via Alt-Svc) | ✓* | ✓* | ✓* | experimental | ✓* | ✓* |
|
|
3700
|
+
| WebSocket (WSClient) | ✓ | ✓ | ✓ | ✓ | partial² | ✗³ |
|
|
3701
|
+
| SOCKS5 proxy | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
|
|
3702
|
+
| Blob | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
|
|
3703
|
+
| DOMException | ✓ | ✓ | ✓ | ✓ | ✓ | guarded | guarded |
|
|
3704
|
+
| Buffer | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
|
|
3705
|
+
| crypto.subtle | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3706
|
+
| ReadableStream | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3707
|
+
| URL pattern matching | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3708
|
+
| Brotli decompression | ✓ | ✓ | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough | ✗ passthrough |
|
|
3709
|
+
| Gzip/deflate decompression | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3710
|
+
|
|
3711
|
+
\* 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 any runtime whose `fetch` does not negotiate h2 by default; use `NodeHTTP2Transport` (Node 22+) when you need guaranteed HTTP/2.
|
|
3712
|
+
|
|
3713
|
+
¹ WSClient requires a native `WebSocket` constructor, which every supported Node.js version (22+) provides.
|
|
3353
3714
|
|
|
3354
3715
|
² Cloudflare Workers exposes a `WebSocket` constructor, but outbound client connections depend on runtime support.
|
|
3355
3716
|
|