kinetex 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -9
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +2545 -550
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +133 -19
- package/dist/cjs/cache.js +49 -7
- package/dist/cjs/circuit-breaker.js +45 -3
- package/dist/cjs/client.js +387 -104
- package/dist/cjs/cookie-parser.js +103 -5
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +465 -66
- package/dist/cjs/dedup.js +49 -11
- package/dist/cjs/digest.js +160 -24
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +303 -45
- package/dist/cjs/interceptors.js +221 -7
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +168 -15
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +177 -27
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +131 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/ws.js +19 -10
- package/dist/esm/aws-sigv4.js +133 -19
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +49 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +45 -3
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +387 -104
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +103 -5
- 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 +465 -66
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +49 -11
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +160 -24
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +303 -45
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +221 -7
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +168 -15
- 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 +177 -27
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +131 -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 +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +19 -10
- 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 +14 -1
- 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 +103 -25
- package/dist/types/core.d.ts.map +1 -1
- 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 +45 -27
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +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 +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +114 -3
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ Works in **Node.js 18+**, **Deno**, **Bun**, **browsers**, **Cloudflare Workers*
|
|
|
39
39
|
- [Authentication](#authentication)
|
|
40
40
|
- [Retry](#retry)
|
|
41
41
|
- [Rate Limiting](#rate-limiting)
|
|
42
|
+
- [Concurrency Limiting](#concurrency-limiting)
|
|
42
43
|
- [Timeout](#timeout)
|
|
43
44
|
- [Interceptors](#interceptors)
|
|
44
45
|
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
@@ -150,6 +151,9 @@ const client = kinetex({
|
|
|
150
151
|
throwOnError: true, // Throw on 4xx/5xx (default: true)
|
|
151
152
|
followRedirects: true, // Follow redirects (default: true; false returns the 3xx as-is)
|
|
152
153
|
maxRedirects: 20, // Max redirect hops (default: 20; 0 disables following)
|
|
154
|
+
// kinetex follows every hop itself, so each `Location` is screened
|
|
155
|
+
// (see "Redirects" under Transport Layer) — the transport is never
|
|
156
|
+
// asked to follow, whatever this is set to
|
|
153
157
|
httpsOnly: false, // Reject non-HTTPS URLs
|
|
154
158
|
maxResponseSize: 10_000_000, // Response body size limit (0 = no limit)
|
|
155
159
|
maxRequestSize: 10_000_000, // Request body size limit (0 = no limit)
|
|
@@ -171,10 +175,15 @@ const client = kinetex({
|
|
|
171
175
|
// ── Rate Limit ──
|
|
172
176
|
rateLimit: { limit: 100, windowMs: 60_000, queue: true, maxQueue: 100 },
|
|
173
177
|
|
|
178
|
+
// ── Concurrency Limit ──
|
|
179
|
+
// Bounds requests *in flight*, which rateLimit cannot: a token bucket
|
|
180
|
+
// releases at dispatch, so 100/min still permits 100 simultaneous sockets.
|
|
181
|
+
concurrencyLimit: { maxConcurrent: 10, queue: true, maxQueue: 100 },
|
|
182
|
+
|
|
174
183
|
// ── Proxy ──
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
184
|
+
// HTTP(S) CONNECT proxies work on Node, on both built-in transports.
|
|
185
|
+
// SOCKS5 URLs still throw, pointing at kinetex/socks5.
|
|
186
|
+
// proxy: { url: "http://127.0.0.1:8080" },
|
|
178
187
|
// proxy: { url: "socks5://127.0.0.1:1080" }, // → throws with guidance
|
|
179
188
|
|
|
180
189
|
// ── Cache ──
|
|
@@ -516,6 +525,76 @@ kinetex({
|
|
|
516
525
|
|
|
517
526
|
---
|
|
518
527
|
|
|
528
|
+
## Concurrency Limiting
|
|
529
|
+
|
|
530
|
+
A counting semaphore bounding how many requests may be **in flight** at once.
|
|
531
|
+
`rateLimit` cannot do this: a token bucket releases at _dispatch_, so
|
|
532
|
+
`rateLimit: { limit: 100 }` per minute still permits 100 simultaneous sockets.
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
kinetex({
|
|
536
|
+
concurrencyLimit: {
|
|
537
|
+
maxConcurrent: 10, // Permits held at once (default: 10; must be a finite number >= 1)
|
|
538
|
+
queue: true, // Queue the excess vs reject (default: true)
|
|
539
|
+
maxQueue: 100, // Queue depth before rejecting (default: 100)
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
A permit is held for the **whole logical request, retries included**, and
|
|
545
|
+
returned in a `finally` — so a throw, an exhausted retry budget or a `destroy()`
|
|
546
|
+
can never leak one and permanently shrink the pool. On release the permit is
|
|
547
|
+
handed straight to the longest-waiting caller rather than freed and re-taken, so
|
|
548
|
+
`inFlight` never transiently exceeds `maxConcurrent`.
|
|
549
|
+
|
|
550
|
+
When the queue is full (or `queue: false`), the request is rejected with
|
|
551
|
+
`ConcurrencyLimitError`, code `ECONCURRENCY`:
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
import { ConcurrencyLimitError } from "kinetex";
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
await client.get("/slow");
|
|
558
|
+
} catch (err) {
|
|
559
|
+
if (err instanceof ConcurrencyLimitError) {
|
|
560
|
+
console.log(err.code); // "ECONCURRENCY"
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
A request cancelled while queued is a **different** failure and is reported as
|
|
566
|
+
such — `code: "EABORT"` with `isAbort`, the same contract every other abort path
|
|
567
|
+
in the library offers. That holds whichever path the acquire took: an idle pool,
|
|
568
|
+
a saturated pool with queueing on, queueing off, and a full queue all answer
|
|
569
|
+
`EABORT` rather than reporting a cancellation as a capacity failure. An
|
|
570
|
+
already-aborted signal is refused before a permit is taken, so none can leak.
|
|
571
|
+
|
|
572
|
+
`maxQueue` must be a non-negative integer, or `Infinity` for an unbounded queue;
|
|
573
|
+
anything else — including `NaN` — throws a `RangeError` at construction rather
|
|
574
|
+
than leaving the cap unbound. `maxConcurrent` is validated the same way.
|
|
575
|
+
|
|
576
|
+
### Standalone use
|
|
577
|
+
|
|
578
|
+
The limiter is exported on its own, for gating work that is not a request:
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
import { ConcurrencyLimiter, CONCURRENCY_DEFAULTS } from "kinetex";
|
|
582
|
+
|
|
583
|
+
const limiter = new ConcurrencyLimiter({ maxConcurrent: 4 });
|
|
584
|
+
console.log(limiter.inFlight, limiter.waiting, limiter.highWaterMark);
|
|
585
|
+
|
|
586
|
+
await limiter.acquire(signal);
|
|
587
|
+
try {
|
|
588
|
+
await doWork();
|
|
589
|
+
} finally {
|
|
590
|
+
limiter.release();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
limiter.drain(); // reject everyone still queued (what Kinetex.destroy does)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
519
598
|
## Timeout
|
|
520
599
|
|
|
521
600
|
Default timeout is 30 seconds. Set to 0 for no timeout:
|
|
@@ -540,6 +619,15 @@ try {
|
|
|
540
619
|
|
|
541
620
|
Internally uses `sendWithTimeout(transport, request, timeoutMs)` which races the transport promise against a timeout promise using `AbortController` and `mergeSignals`.
|
|
542
621
|
|
|
622
|
+
**A merged signal keeps the reason.** `mergeSignals(a, b)` re-aborts with the
|
|
623
|
+
`reason` of whichever source actually aborted, so
|
|
624
|
+
`signal.reason` survives the combination instead of collapsing to a generic
|
|
625
|
+
`AbortError: This operation was aborted` — including when a source was _already_
|
|
626
|
+
aborted before the call, which is the case a shortcut in the "no live signals
|
|
627
|
+
left" branch used to get wrong. Interceptors rely on this: retry and
|
|
628
|
+
deduplication logic reads `existing.reason` off the combined signal to decide
|
|
629
|
+
what actually went wrong.
|
|
630
|
+
|
|
543
631
|
---
|
|
544
632
|
|
|
545
633
|
## Interceptors
|
|
@@ -655,7 +743,14 @@ const suite = createInterceptorSuite({
|
|
|
655
743
|
|
|
656
744
|
## Lifecycle Hooks
|
|
657
745
|
|
|
658
|
-
Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation
|
|
746
|
+
Hooks are higher-level callbacks for specific lifecycle stages, configured at client creation.
|
|
747
|
+
|
|
748
|
+
`onAfterRequest` fires in exactly the window its name describes: after the
|
|
749
|
+
transport has answered and before the response is built, once per attempt. It
|
|
750
|
+
is the only place you can observe that the round trip is over without also
|
|
751
|
+
having to see the parsed response — and it deliberately does **not** fire on a
|
|
752
|
+
request that never left, so a hook that counts what was sent does not count a
|
|
753
|
+
connection that died first.
|
|
659
754
|
|
|
660
755
|
```ts
|
|
661
756
|
kinetex({
|
|
@@ -668,7 +763,7 @@ kinetex({
|
|
|
668
763
|
],
|
|
669
764
|
onAfterRequest: [
|
|
670
765
|
(req, ctx) => {
|
|
671
|
-
/*
|
|
766
|
+
/* the wire round trip is over; the response is not built yet */
|
|
672
767
|
},
|
|
673
768
|
],
|
|
674
769
|
onBeforeResponse: [
|
|
@@ -1009,6 +1104,11 @@ client.GET("/users").cache({ ttlMs: 5000 }).json();
|
|
|
1009
1104
|
client.GET("/users").noCache().json(); // forceRefresh: true
|
|
1010
1105
|
```
|
|
1011
1106
|
|
|
1107
|
+
`forceRefresh` skips the cache **read** for that one request and nothing else:
|
|
1108
|
+
the fresh response is still written, so a later plain request is served from
|
|
1109
|
+
the cache again. It is a bypass, not a purge — use `cache.clear()` or
|
|
1110
|
+
`invalidateTags()` to drop entries.
|
|
1111
|
+
|
|
1012
1112
|
**Stale-while-revalidate needs no config flag.** There is no `swr` or `swrTtlMs` option: the SWR window is taken from the response's `Cache-Control: stale-while-revalidate=N`, and within that window kinetex serves the stale copy immediately and revalidates in the background. Two consequences worth knowing:
|
|
1013
1113
|
|
|
1014
1114
|
- A per-request `cache.ttlMs` override pins the SWR window to 0 for that request. If you want SWR, let the server's `Cache-Control` decide the lifetime.
|
|
@@ -1113,7 +1213,15 @@ await cache.clear();
|
|
|
1113
1213
|
|
|
1114
1214
|
## Cookie Jar
|
|
1115
1215
|
|
|
1116
|
-
Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching
|
|
1216
|
+
Full RFC 6265 cookie storage and management with SameSite, HttpOnly, Secure, domain/path matching.
|
|
1217
|
+
|
|
1218
|
+
`CookieJar.fromJSON` / `loadCookieJar` are the one public path that ingests
|
|
1219
|
+
externally authored JSON, so they canonicalise `sameSite` on the way in:
|
|
1220
|
+
`"lax"`, `"LAX"` and `"Lax"` all mean `Lax`, and an unrecognised or missing
|
|
1221
|
+
value becomes `Unset`. Without that, a persisted jar could report `count === 1`
|
|
1222
|
+
and send nothing at all, because an unrecognised `SameSite` matches no branch
|
|
1223
|
+
of the retrieval filter. `Unset` still refuses an explicit cross-site request,
|
|
1224
|
+
so a value that cannot be interpreted is never treated as "send everywhere".
|
|
1117
1225
|
|
|
1118
1226
|
```ts
|
|
1119
1227
|
// Auto-managed through the client
|
|
@@ -2004,6 +2112,17 @@ const skewMs = await detectClockSkew("https://sts.amazonaws.com", credentials);
|
|
|
2004
2112
|
isClockSkewError(err); // → boolean
|
|
2005
2113
|
```
|
|
2006
2114
|
|
|
2115
|
+
**`imdsCredentials` reports failure as a `NetworkError`, never a raw
|
|
2116
|
+
`SyntaxError`.** Every way the EC2 metadata service can disappoint you — an
|
|
2117
|
+
unreachable endpoint, a non-200, a body that is not JSON, or a 200 whose JSON
|
|
2118
|
+
is missing `AccessKeyId` / `SecretAccessKey` / `Token` — surfaces as the same
|
|
2119
|
+
error type, so a `chainCredentials` fallback can catch one thing. A body is
|
|
2120
|
+
accepted only if every required field is a non-empty string; `{}` and
|
|
2121
|
+
`{"AccessKeyId": null}` are refused, and the error names _all_ the fields that
|
|
2122
|
+
were absent rather than making you discover them one round trip at a time.
|
|
2123
|
+
Signing with `undefined` keys would otherwise produce a `SignatureDoesNotMatch`
|
|
2124
|
+
from AWS that points at the caller instead of at the metadata service.
|
|
2125
|
+
|
|
2007
2126
|
### Client-Level SigV4
|
|
2008
2127
|
|
|
2009
2128
|
```ts
|
|
@@ -2155,6 +2274,14 @@ Create one per client (do not share it across users). `Kinetex`'s built-in `auth
|
|
|
2155
2274
|
|
|
2156
2275
|
## Structured Logging
|
|
2157
2276
|
|
|
2277
|
+
Body-field redaction applies to every JSON media type, not just
|
|
2278
|
+
`application/json`: `+json` structured suffixes (`application/vnd.api+json`,
|
|
2279
|
+
`application/hal+json`, `application/problem+json`, …), `text/json`, any
|
|
2280
|
+
casing, and any parameters. `allowedBodyTypes` decides whether a body is logged
|
|
2281
|
+
at all; a body that clears that gate has its `bodyFields` redacted whatever
|
|
2282
|
+
its exact media type, so widening `allowedBodyTypes` cannot quietly start
|
|
2283
|
+
leaking `password` fields.
|
|
2284
|
+
|
|
2158
2285
|
```ts
|
|
2159
2286
|
import {
|
|
2160
2287
|
HTTPLogger,
|
|
@@ -2238,7 +2365,7 @@ const har = client.getHAR();
|
|
|
2238
2365
|
// HARLog { version: "1.2", creator: { name: "kinetex", version: "1.0.0" }, entries: [...] }
|
|
2239
2366
|
|
|
2240
2367
|
// Each HAREntry contains:
|
|
2241
|
-
// startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize),
|
|
2368
|
+
// startedDateTime, time, request (method, url, httpVersion, headers, queryString, bodySize, postData?),
|
|
2242
2369
|
// response (status, statusText, httpVersion, headers, content, redirectURL, bodySize),
|
|
2243
2370
|
// timings (send, wait, receive), cache
|
|
2244
2371
|
|
|
@@ -2254,6 +2381,7 @@ HAR logs are routinely exported and shared, so entries are redacted before they
|
|
|
2254
2381
|
- **URLs** — sensitive query parameters (`api_key`, `access_token`, `signature`, `password`, `code`, `sas`, …) and the fragment are masked, in both `request.url` and `request.queryString[]`. Non-sensitive parameters and the rest of the URL are preserved so the log stays useful.
|
|
2255
2382
|
- **`Location`** — the redirect target is passed through the same URL redaction.
|
|
2256
2383
|
- **Bodies** — response text is recorded only for `json`/`xml`/`text/plain`/`javascript` content types and truncated to 8 KiB; HTML and binary bodies are never recorded.
|
|
2384
|
+
- **Request bodies** — recorded as `request.postData` (`{ mimeType, text }`) under the same policy and the same limit, so a HAR viewer shows what was actually sent. A `ReadableStream` or `FormData` body is omitted rather than buffered, since reading it would consume it; `request.bodySize` is `-1` for those, as it always was.
|
|
2257
2385
|
|
|
2258
2386
|
```ts
|
|
2259
2387
|
// ?api_key=SUPERSECRET&page=2 → https://api.example.com/v1/items?api_key=***REDACTED***&page=2
|
|
@@ -2339,6 +2467,54 @@ Pipeline stages in order:
|
|
|
2339
2467
|
|
|
2340
2468
|
## Transport Layer
|
|
2341
2469
|
|
|
2470
|
+
### Redirects
|
|
2471
|
+
|
|
2472
|
+
**kinetex follows every redirect itself.** The outgoing request always carries
|
|
2473
|
+
`redirect: "manual"`, and a server-chosen `Location` is resolved, screened and
|
|
2474
|
+
re-dispatched one hop at a time. This is not a tuning knob — it is what makes
|
|
2475
|
+
the per-hop checks below possible at all, because `fetch` following on its own
|
|
2476
|
+
never reports a target back to the caller.
|
|
2477
|
+
|
|
2478
|
+
Every hop is subject to:
|
|
2479
|
+
|
|
2480
|
+
- **SSRF.** `isSafeURL` runs on each `Location` before it is dialled, so a
|
|
2481
|
+
redirect cannot walk the client onto a loopback, private or link-local
|
|
2482
|
+
address. A 302 to `http://127.0.0.1:9/` or to `http://169.254.169.254/`
|
|
2483
|
+
raises `EVALIDATION` ("Unsafe redirect target blocked") rather than opening
|
|
2484
|
+
the socket — the initial request URL gets the same screen.
|
|
2485
|
+
- **`httpsOnly`.** Checked on the target, not just the request you wrote, so an
|
|
2486
|
+
`https:` request cannot be downgraded to cleartext by its response.
|
|
2487
|
+
- **`maxRedirects`.** Enforced per request, default 20. Exhausting it raises
|
|
2488
|
+
`RedirectError` (`EREDIRECT`, "Too many redirects"). `followRedirects: false`
|
|
2489
|
+
— or `maxRedirects: 0` — hands the 3xx back to you instead.
|
|
2490
|
+
- **Loop detection.** A target already visited in this chain is refused with
|
|
2491
|
+
`RedirectError` (`EREDIRECT`, "Redirect loop detected").
|
|
2492
|
+
- **Method downgrade.** Per RFC 7231, 301/302/303 downgrade to `GET` and drop
|
|
2493
|
+
the body; 307/308 preserve both.
|
|
2494
|
+
- **Credentials.** `Authorization`, `apikey` headers, `Cookie` and any
|
|
2495
|
+
declared auth are dropped when a hop changes origin, and kept when it does
|
|
2496
|
+
not. Intermediate `Set-Cookie` headers are captured by the jar, so cookies
|
|
2497
|
+
set on a redirect leg are applied to the next one.
|
|
2498
|
+
- **Scheme.** Anything but `http:` / `https:` is rejected as
|
|
2499
|
+
`EVALIDATION`, so `file:`, `data:` and friends cannot be reached.
|
|
2500
|
+
|
|
2501
|
+
`res.redirected` tells you whether a hop was actually taken, and `res.url` is
|
|
2502
|
+
where the request finally landed. An _unfollowed_ 3xx — `followRedirects:
|
|
2503
|
+
false`, or `maxRedirects: 0` — reports `redirected: false` and the original
|
|
2504
|
+
`res.url`, because nothing was followed: that response is the one you have to
|
|
2505
|
+
read `Location` on and act on yourself.
|
|
2506
|
+
|
|
2507
|
+
A redirect failure is a `RedirectError` with code `EREDIRECT` ("Too many
|
|
2508
|
+
redirects", "Redirect loop detected"), and it is **not** retried. A chain is a
|
|
2509
|
+
deterministic answer from the origin, so replaying it would only multiply the
|
|
2510
|
+
requests against a server already looping: `maxRedirects: 3` makes exactly
|
|
2511
|
+
four requests, not one per retry attempt. The SSRF and `httpsOnly` gates stay
|
|
2512
|
+
`EVALIDATION`, as does an unsafe redirect target.
|
|
2513
|
+
|
|
2514
|
+
The built-in transports enforce the same two gates in their own loops
|
|
2515
|
+
(`https:` only, plus `isSafeURL`), so the protection does not depend on going
|
|
2516
|
+
through `Kinetex`.
|
|
2517
|
+
|
|
2342
2518
|
### FetchTransport
|
|
2343
2519
|
|
|
2344
2520
|
Universal fetch-based transport for all runtimes:
|
|
@@ -2388,6 +2564,57 @@ Features:
|
|
|
2388
2564
|
- Iterative redirect following (not recursive)
|
|
2389
2565
|
- Backpressure-aware body writes (awaits `drain` events)
|
|
2390
2566
|
|
|
2567
|
+
**The transport owns the request line.** `:method`, `:path`, `:scheme` and
|
|
2568
|
+
`:authority` are built from the URL you hand to `send()` and cannot be
|
|
2569
|
+
overridden from `request.headers`. A pseudo-header there is refused the same
|
|
2570
|
+
way any other invalid header is: `EVALIDATION` under `strict: true`, otherwise
|
|
2571
|
+
`onDroppedHeader(name, value)` or a `console.warn`. This is not a formality —
|
|
2572
|
+
`":"` is not a token character, so `FetchTransport` has always dropped such a
|
|
2573
|
+
header, and the two transports now answer a single request the same way.
|
|
2574
|
+
|
|
2575
|
+
Header names are validated as tokens and values as field-values, so a
|
|
2576
|
+
`__proto__` header (legal — it is all token characters) is sent as a real
|
|
2577
|
+
header rather than disappearing into an inherited setter.
|
|
2578
|
+
|
|
2579
|
+
### Request Bodies
|
|
2580
|
+
|
|
2581
|
+
`FetchTransport` hands the body to `fetch`; `NodeHTTP2Transport` drives
|
|
2582
|
+
`node:http2` directly. The two therefore serialize differently, and on Node
|
|
2583
|
+
the default transport is the latter — so anything fetch would have encoded has
|
|
2584
|
+
to be encoded by kinetex first. `URLSearchParams`, `Blob` and `FormData` are
|
|
2585
|
+
all covered:
|
|
2586
|
+
|
|
2587
|
+
```ts
|
|
2588
|
+
const form = new FormData();
|
|
2589
|
+
form.append("field", "value");
|
|
2590
|
+
form.append("file", new File([blob], "report.csv", { type: "text/csv" }));
|
|
2591
|
+
|
|
2592
|
+
// → multipart/form-data; boundary=----kinetexFormBoundary<random>
|
|
2593
|
+
const res = await client.POST("/upload").withForm(form).send();
|
|
2594
|
+
```
|
|
2595
|
+
|
|
2596
|
+
**The boundary and the header are generated together.** The boundary is
|
|
2597
|
+
invented _during_ encoding, so a body encoded after the header block was
|
|
2598
|
+
already written names a boundary no header mentions — and a multipart body
|
|
2599
|
+
whose boundary is not announced is unparseable. Both raw Node paths therefore
|
|
2600
|
+
pre-encode the body before building headers, and a `content-type` you set
|
|
2601
|
+
yourself always wins. `encodeMultipart(form, boundary?)` is exported for
|
|
2602
|
+
callers who want the bytes directly; pass a boundary to make the output
|
|
2603
|
+
deterministic.
|
|
2604
|
+
|
|
2605
|
+
A field name containing CR, LF or a double quote is refused with
|
|
2606
|
+
`EVALIDATION` rather than serialized, since a name is interpolated into a
|
|
2607
|
+
`Content-Disposition` header and could otherwise forge extra part headers. A
|
|
2608
|
+
File's `filename` goes into the same quoted-string context but is
|
|
2609
|
+
percent-escaped rather than refused, so ordinary names keep working.
|
|
2610
|
+
|
|
2611
|
+
`maxRequestSize` counts what actually goes on the wire. For a `FormData`
|
|
2612
|
+
that means the form is encoded once and its real byte length is compared —
|
|
2613
|
+
there is no per-part guess, and the encoding is not repeated on the dispatch
|
|
2614
|
+
path. A `ReadableStream` body is the one type that cannot be measured at all,
|
|
2615
|
+
so it is refused instead of bypassing the limit; pass `maxRequestSize: 0` to
|
|
2616
|
+
opt out, or buffer the body first.
|
|
2617
|
+
|
|
2391
2618
|
### Transport Factory
|
|
2392
2619
|
|
|
2393
2620
|
```ts
|
|
@@ -3023,8 +3250,18 @@ const b64 = uint8ArrayToBase64(uint8);
|
|
|
3023
3250
|
|
|
3024
3251
|
// Object
|
|
3025
3252
|
const clone = deepClone(original);
|
|
3026
|
-
|
|
3027
|
-
|
|
3253
|
+
// normalizeHeaders keys are lowercased, and a header that appears more than
|
|
3254
|
+
// once keeps every value rather than only the last one.
|
|
3255
|
+
const normalized = normalizeHeaders(rawHeaders);
|
|
3256
|
+
```
|
|
3257
|
+
|
|
3258
|
+
**Repeated headers keep every value.** `Set-Cookie` is the one header a
|
|
3259
|
+
`Headers` object yields _separately_ per cookie rather than already joined, so
|
|
3260
|
+
a naive normalisation that assigns as it iterates silently drops every cookie
|
|
3261
|
+
but the last — and the request still looks fine. `normalizeHeaders`
|
|
3262
|
+
accumulates instead, so a response with three `Set-Cookie` lines round-trips
|
|
3263
|
+
back to three, each with its `Expires=Wed, 09 Jun 2021 10:18:14 GMT` intact.
|
|
3264
|
+
`toNodeHeaders` and the `HttpHeaders` type were already correct on this.
|
|
3028
3265
|
|
|
3029
3266
|
---
|
|
3030
3267
|
|