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/dist/esm/client.js
CHANGED
|
@@ -7,13 +7,14 @@ import { ProgressTracker, withUploadProgress } from "./progress.js";
|
|
|
7
7
|
import { DedupMap } from "./dedup.js";
|
|
8
8
|
import { CircuitBreakerRegistry, } from "./circuit-breaker.js";
|
|
9
9
|
import { WSClient } from "./ws.js";
|
|
10
|
-
import { KinetexError, HTTPStatusError, AbortError, toRequestId } from "./types.js";
|
|
10
|
+
import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
|
|
11
11
|
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
|
|
12
12
|
import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
|
|
13
|
-
import { createRateLimitInterceptor } from "./interceptors.js";
|
|
13
|
+
import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
|
|
14
14
|
import { SigV4Signer } from "./aws-sigv4.js";
|
|
15
15
|
import { createDigestAuthorizer } from "./digest.js";
|
|
16
|
-
import {
|
|
16
|
+
import { generateIdempotencyKey, isValidIdempotencyKey, parseRetryAfter } from "./headers.js";
|
|
17
|
+
import { DEFAULT_ACCEPT_ENCODING, encodeMultipart } from "./core.js";
|
|
17
18
|
import { createTransport, sendWithTimeout, decompressBodyStream, readRawBody, parseBody, RUNTIME, IS_NODE, } from "./core.js";
|
|
18
19
|
/**
|
|
19
20
|
* Hard ceiling on redirect hops followed by the manual redirect follower.
|
|
@@ -170,6 +171,8 @@ const HAR_REDACT_HEADERS = new Set([
|
|
|
170
171
|
* `meta` key carrying the number of response-interceptor re-sends so a
|
|
171
172
|
* self-retriggering interceptor cannot loop forever.
|
|
172
173
|
*/
|
|
174
|
+
/** Request-meta key: a clock-skew correction has already been spent. */
|
|
175
|
+
const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
|
|
173
176
|
const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
|
|
174
177
|
/** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
|
|
175
178
|
const MAX_INTERCEPTOR_RESENDS = 5;
|
|
@@ -247,6 +250,35 @@ function redactHARHeader(name, value) {
|
|
|
247
250
|
* O(1) ring-buffer HAR entry recorder.
|
|
248
251
|
* Stores up to `maxEntries` entries, evicting oldest first.
|
|
249
252
|
*/
|
|
253
|
+
/**
|
|
254
|
+
* The `postData` block for a recorded request, or `{}` when there is nothing
|
|
255
|
+
* safe or possible to record.
|
|
256
|
+
*
|
|
257
|
+
* Kept out of `record()` so the recorder's entry literal reads as the HAR it
|
|
258
|
+
* claims to conform to, and so the "may I record this?" decision has one home.
|
|
259
|
+
*/
|
|
260
|
+
function harPostData(req) {
|
|
261
|
+
if (!req.body)
|
|
262
|
+
return {};
|
|
263
|
+
const mimeType = req.headers["content-type"] ?? "";
|
|
264
|
+
// Same policy as the response body: skip anything that is not plain text.
|
|
265
|
+
if (mimeType && !isHARBodySafeToRecord(mimeType))
|
|
266
|
+
return {};
|
|
267
|
+
let text;
|
|
268
|
+
if (typeof req.body === "string") {
|
|
269
|
+
text = req.body;
|
|
270
|
+
}
|
|
271
|
+
else if (req.body instanceof Uint8Array) {
|
|
272
|
+
text = new TextDecoder().decode(req.body);
|
|
273
|
+
}
|
|
274
|
+
else if (req.body instanceof ArrayBuffer) {
|
|
275
|
+
text = new TextDecoder().decode(new Uint8Array(req.body));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
return {}; // stream / FormData / Blob: not readable without consuming it
|
|
279
|
+
}
|
|
280
|
+
return { postData: { mimeType, text: text.slice(0, HAR_MAX_BODY_CHARS) } };
|
|
281
|
+
}
|
|
250
282
|
class HARRecorder {
|
|
251
283
|
/** Ring buffer of entries keyed by monotonic counter. */
|
|
252
284
|
_buf = new Map();
|
|
@@ -338,6 +370,17 @@ class HARRecorder {
|
|
|
338
370
|
return req.body.byteLength;
|
|
339
371
|
return -1; // Unknown (stream, FormData, etc.)
|
|
340
372
|
})(),
|
|
373
|
+
// `postData` is declared on `HAREntry` as "Posted data, if applicable"
|
|
374
|
+
// and was never written, so a HAR exported from a client that POSTs
|
|
375
|
+
// anything shows an empty request body in every viewer — the response
|
|
376
|
+
// body, the query string, the headers and the URL are all there, and
|
|
377
|
+
// the one part that explains what was actually sent is not. The gates
|
|
378
|
+
// are the ones the response side already uses: a body is recorded only
|
|
379
|
+
// when its content type is safe to record, it is truncated to the same
|
|
380
|
+
// limit, and a body that cannot be read without consuming it (a stream,
|
|
381
|
+
// a FormData) is omitted rather than guessed at — which is what the
|
|
382
|
+
// `bodySize: -1` above already admits.
|
|
383
|
+
...harPostData(req),
|
|
341
384
|
},
|
|
342
385
|
response: {
|
|
343
386
|
status: res.status,
|
|
@@ -473,35 +516,6 @@ async function applyAuth(req, auth) {
|
|
|
473
516
|
// ============================================================================
|
|
474
517
|
// §5 URL BUILDING
|
|
475
518
|
// ============================================================================
|
|
476
|
-
/**
|
|
477
|
-
* Headers the Fetch spec already drops when a redirect crosses origins.
|
|
478
|
-
* Anything credential-bearing outside this set is forwarded by fetch() itself,
|
|
479
|
-
* which is why those requests must be redirected manually.
|
|
480
|
-
* See {@link CROSS_ORIGIN_STRIP_HEADERS}.
|
|
481
|
-
*/
|
|
482
|
-
const FETCH_SPEC_STRIPPED_ON_REDIRECT = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
483
|
-
/**
|
|
484
|
-
* True when the request carries a credential-bearing header that fetch()
|
|
485
|
-
* would forward across a cross-origin redirect. Drives the decision to follow
|
|
486
|
-
* redirects manually even when no cookie jar is configured.
|
|
487
|
-
*
|
|
488
|
-
* Two sources are consulted: the well-known CREDENTIAL_HEADERS names, and a
|
|
489
|
-
* declared `apikey` auth header, whose name is chosen by the application and so
|
|
490
|
-
* cannot be known to the library.
|
|
491
|
-
*/
|
|
492
|
-
function hasForwardedCredentials(headers, auth) {
|
|
493
|
-
if (auth && auth.type === "apikey")
|
|
494
|
-
return true;
|
|
495
|
-
if (!headers)
|
|
496
|
-
return false;
|
|
497
|
-
for (const name of Object.keys(headers)) {
|
|
498
|
-
const lower = name.toLowerCase();
|
|
499
|
-
if (CROSS_ORIGIN_STRIP_HEADERS.has(lower) && !FETCH_SPEC_STRIPPED_ON_REDIRECT.has(lower)) {
|
|
500
|
-
return true;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
return false;
|
|
504
|
-
}
|
|
505
519
|
/**
|
|
506
520
|
* Headers stripped when a redirect crosses origins (FIX H2).
|
|
507
521
|
* These carry credentials and must never be forwarded to a different origin.
|
|
@@ -545,7 +559,7 @@ function redactUserInfo(url) {
|
|
|
545
559
|
* @returns Fully-qualified URL string.
|
|
546
560
|
* @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
|
|
547
561
|
*/
|
|
548
|
-
function buildURL(base, url, params) {
|
|
562
|
+
function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
549
563
|
const MAX_QUERY_PARAM_COUNT = 100;
|
|
550
564
|
const MAX_URL_LENGTH = 8192;
|
|
551
565
|
let full;
|
|
@@ -574,13 +588,18 @@ function buildURL(base, url, params) {
|
|
|
574
588
|
}
|
|
575
589
|
}
|
|
576
590
|
if (!params || Object.keys(params).length === 0) {
|
|
577
|
-
if (!isSafeURL(full)) {
|
|
591
|
+
if (!isSafeURL(full, allowedSchemes)) {
|
|
578
592
|
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
579
593
|
}
|
|
580
594
|
return full;
|
|
581
595
|
}
|
|
582
596
|
try {
|
|
583
597
|
const u = new URL(full);
|
|
598
|
+
// The params branch screens the URL after appending them, so it needs the
|
|
599
|
+
// same scheme list as the no-params branch above.
|
|
600
|
+
if (!isSafeURL(u, allowedSchemes)) {
|
|
601
|
+
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
602
|
+
}
|
|
584
603
|
let paramCount = 0;
|
|
585
604
|
for (const [key, value] of Object.entries(params)) {
|
|
586
605
|
if (value === null || value === undefined)
|
|
@@ -775,7 +794,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
|
|
|
775
794
|
return cfg.maxDelayMs;
|
|
776
795
|
}
|
|
777
796
|
const capped = Math.min(exp, cfg.maxDelayMs);
|
|
778
|
-
|
|
797
|
+
const jittered = capped + capped * cfg.jitter * Math.random();
|
|
798
|
+
// `maxDelayMs` is documented as a maximum, so the cap must be re-applied
|
|
799
|
+
// *after* jitter. It was not: base 1000, cap 1200, jitter 1 returned 2000ms,
|
|
800
|
+
// so the option never bounded worst-case latency at all — it bounded only the
|
|
801
|
+
// pre-jitter base. With the default jitter of 0.3 a 30 s cap still allowed
|
|
802
|
+
// 39 s.
|
|
803
|
+
return Math.min(Math.floor(jittered), cfg.maxDelayMs);
|
|
779
804
|
}
|
|
780
805
|
/**
|
|
781
806
|
* Extract and parse the Retry-After header value.
|
|
@@ -787,15 +812,21 @@ function getRetryAfterMs(headers) {
|
|
|
787
812
|
const ra = headers["retry-after"];
|
|
788
813
|
if (!ra)
|
|
789
814
|
return null;
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
815
|
+
// Delegate to the single RFC 7231 §7.1.1 parser instead of a second,
|
|
816
|
+
// looser copy: Date.parse() accepts "-5", "1.5" and "+5" as dates, which
|
|
817
|
+
// used to yield a 0 ms (i.e. "ignore Retry-After") back-off on a 429.
|
|
818
|
+
const parsed = parseRetryAfter(ra);
|
|
819
|
+
if (parsed.delay !== null) {
|
|
794
820
|
const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
|
|
795
|
-
return Math.min(
|
|
821
|
+
return Math.min(parsed.delay, MAX_RETRY_AFTER_SEC) * 1000;
|
|
822
|
+
}
|
|
823
|
+
if (parsed.date) {
|
|
824
|
+
// A date in the past genuinely means "retry now" — 0 is the answer, not a
|
|
825
|
+
// parse failure. Cap at 24 h so a bogus far-future date cannot stall.
|
|
826
|
+
const MAX_RETRY_AFTER_MS = 86_400_000;
|
|
827
|
+
return Math.max(0, Math.min(parsed.date.getTime() - Date.now(), MAX_RETRY_AFTER_MS));
|
|
796
828
|
}
|
|
797
|
-
|
|
798
|
-
return isNaN(ms) ? null : Math.max(0, Math.min(ms - Date.now(), 86_400_000));
|
|
829
|
+
return null;
|
|
799
830
|
}
|
|
800
831
|
// ============================================================================
|
|
801
832
|
// §8 MAIN KINETEX CLASS
|
|
@@ -850,6 +881,14 @@ export class Kinetex {
|
|
|
850
881
|
_circuitBreakerKeyFn = null;
|
|
851
882
|
/** Active WebSocket connections tracked for cleanup on destroy(). */
|
|
852
883
|
_wsClients = new Set();
|
|
884
|
+
/**
|
|
885
|
+
* Optional bulkhead bounding in-flight requests. `null` when
|
|
886
|
+
* `concurrencyLimit` is not configured, which keeps the hot path free of a
|
|
887
|
+
* limiter check.
|
|
888
|
+
*/
|
|
889
|
+
_concurrencyLimiter;
|
|
890
|
+
/** SigV4 signer kept so a clock-skew correction survives across retries. */
|
|
891
|
+
_awsSigner = null;
|
|
853
892
|
/**
|
|
854
893
|
* @param config - Global client configuration.
|
|
855
894
|
*/
|
|
@@ -868,10 +907,17 @@ export class Kinetex {
|
|
|
868
907
|
const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
|
|
869
908
|
this.interceptors.addRequest(rlInterceptor);
|
|
870
909
|
}
|
|
910
|
+
// Concurrency limiter (bulkhead). Held as an object rather than a request
|
|
911
|
+
// interceptor because a permit must survive until the request settles,
|
|
912
|
+
// and interceptors cannot wrap the downstream call.
|
|
913
|
+
this._concurrencyLimiter = config.concurrencyLimit
|
|
914
|
+
? new ConcurrencyLimiter(config.concurrencyLimit)
|
|
915
|
+
: null;
|
|
871
916
|
// AWS SigV4 request signing — registered synchronously (static import).
|
|
872
917
|
// Active immediately; no race between first request and interceptor registration.
|
|
873
918
|
if (config.awsSigning) {
|
|
874
919
|
const signer = new SigV4Signer(config.awsSigning);
|
|
920
|
+
this._awsSigner = signer;
|
|
875
921
|
this.interceptors.addRequest(async (ctx) => {
|
|
876
922
|
const req = ctx.request;
|
|
877
923
|
let signableBody = null;
|
|
@@ -889,7 +935,15 @@ export class Kinetex {
|
|
|
889
935
|
});
|
|
890
936
|
}
|
|
891
937
|
// Transport — pass strictHeaders option through to FetchTransport
|
|
892
|
-
this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
|
|
938
|
+
this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
|
|
939
|
+
// The HTTP/2 session pool was configurable on the transport but the
|
|
940
|
+
// client always passed `undefined` here, so `sessionPool` on the client
|
|
941
|
+
// config could not tune it.
|
|
942
|
+
config.sessionPool, {
|
|
943
|
+
...(config.strictHeaders ? { strict: true } : {}),
|
|
944
|
+
...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
|
|
945
|
+
...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
|
|
946
|
+
});
|
|
893
947
|
// Register config-level interceptors
|
|
894
948
|
if (config.interceptors) {
|
|
895
949
|
config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
|
|
@@ -1293,20 +1347,19 @@ export class Kinetex {
|
|
|
1293
1347
|
/**
|
|
1294
1348
|
* Returns deduplication metrics.
|
|
1295
1349
|
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1350
|
+
* The full `DedupMap.getStats()` snapshot, so a caller has to reach for one
|
|
1351
|
+
* shape rather than two. The wrapper used to expose only `hits`, `misses`
|
|
1352
|
+
* and `inFlightCount` — which meant the hit rate, the only figure anyone
|
|
1353
|
+
* actually wants from a dedup map, was unavailable on the client even
|
|
1354
|
+
* though the map computed it.
|
|
1355
|
+
*
|
|
1356
|
+
* @returns `{ hits, misses, totalRequests, hitRate, inFlightCount, trackedKeys }`,
|
|
1357
|
+
* or `null` if dedup is not enabled.
|
|
1298
1358
|
*/
|
|
1299
1359
|
get dedupMetrics() {
|
|
1300
1360
|
if (!this._dedup)
|
|
1301
1361
|
return null;
|
|
1302
|
-
return
|
|
1303
|
-
/** Number of requests that shared an in-flight or windowed response. */
|
|
1304
|
-
hits: this._dedup.hits,
|
|
1305
|
-
/** Number of requests that triggered a real network call. */
|
|
1306
|
-
misses: this._dedup.misses,
|
|
1307
|
-
/** Number of currently in-flight requests. */
|
|
1308
|
-
inFlightCount: this._dedup.inFlightCount,
|
|
1309
|
-
};
|
|
1362
|
+
return this._dedup.getStats();
|
|
1310
1363
|
}
|
|
1311
1364
|
// ── §8.5d Circuit Breaker ────────────────────────────────────────────────
|
|
1312
1365
|
/**
|
|
@@ -1382,7 +1435,20 @@ export class Kinetex {
|
|
|
1382
1435
|
* ```
|
|
1383
1436
|
*/
|
|
1384
1437
|
async ws(url, options = {}) {
|
|
1385
|
-
|
|
1438
|
+
// `ws://` / `wss://` have to be allowed here, and only here. The default
|
|
1439
|
+
// `["http", "https"]` is what stops an ordinary HTTP request from being
|
|
1440
|
+
// pointed at a WebSocket scheme, and every other buildURL caller keeps it.
|
|
1441
|
+
// Without this opt-in the documented `client.ws("wss://…")` form — the one
|
|
1442
|
+
// in the README and in this method's own JSDoc — failed the SSRF scheme
|
|
1443
|
+
// check and threw EVALIDATION on every call, so `client.ws()` could not
|
|
1444
|
+
// connect to anything. Only the scheme list is widened: the loopback and
|
|
1445
|
+
// private-range checks still apply to WebSocket URLs.
|
|
1446
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, [
|
|
1447
|
+
"http",
|
|
1448
|
+
"https",
|
|
1449
|
+
"ws",
|
|
1450
|
+
"wss",
|
|
1451
|
+
]);
|
|
1386
1452
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
1387
1453
|
// Apply auth headers manually since WS handshake goes through the browser
|
|
1388
1454
|
// WS API which doesn't use the kinetex transport pipeline.
|
|
@@ -1425,7 +1491,12 @@ export class Kinetex {
|
|
|
1425
1491
|
if (this.cfg.baseURL) {
|
|
1426
1492
|
const baseUrl = new URL(this.cfg.baseURL);
|
|
1427
1493
|
const wsIsSecure = wsUrl.protocol === "wss:";
|
|
1428
|
-
|
|
1494
|
+
// A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
|
|
1495
|
+
// "wss://…" })` then `client.ws("/path")` is the natural spelling, and
|
|
1496
|
+
// it is what the README's origin-validation section shows. Comparing
|
|
1497
|
+
// `wss:` only against `https:` rejected that pairing outright, so a
|
|
1498
|
+
// client configured with a WebSocket baseURL could never open a socket.
|
|
1499
|
+
const httpIsSecure = baseUrl.protocol === "https:" || baseUrl.protocol === "wss:";
|
|
1429
1500
|
if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
|
|
1430
1501
|
throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
|
|
1431
1502
|
}
|
|
@@ -1520,15 +1591,17 @@ export class Kinetex {
|
|
|
1520
1591
|
}
|
|
1521
1592
|
// ── Build initial request ─────────────────────────────────────────────
|
|
1522
1593
|
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
"
|
|
1594
|
+
// A client-level `proxy` is honoured by the Node transport, which tunnels
|
|
1595
|
+
// every connection through it with CONNECT. A per-request `proxy` cannot be:
|
|
1596
|
+
// the transport pools one connection per origin, so honouring a per-request
|
|
1597
|
+
// proxy would mean tearing the pool down mid-flight. Reject it with an
|
|
1598
|
+
// accurate reason rather than silently ignoring it.
|
|
1599
|
+
const perRequestProxy = options.proxy;
|
|
1600
|
+
if (perRequestProxy) {
|
|
1601
|
+
throw new KinetexError("A per-request `proxy` is not supported because the transport pools one connection " +
|
|
1602
|
+
"per origin — set `proxy` on the client instead, or use a custom `fetch` with a " +
|
|
1603
|
+
"proxy agent for per-request routing. SOCKS5 requires createSocks5Tunnel() from " +
|
|
1604
|
+
"kinetex/socks5.", "EVALIDATION");
|
|
1532
1605
|
}
|
|
1533
1606
|
// Enforce HTTPS-only if configured
|
|
1534
1607
|
if (this.cfg.httpsOnly) {
|
|
@@ -1544,6 +1617,14 @@ export class Kinetex {
|
|
|
1544
1617
|
throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
|
|
1545
1618
|
}
|
|
1546
1619
|
}
|
|
1620
|
+
// A multipart body has no size until it is encoded, and the encoding is
|
|
1621
|
+
// the same call the dispatch path makes below, so it is done once here and
|
|
1622
|
+
// the result reused. The guard used to estimate it with a flat 76 bytes per
|
|
1623
|
+
// part, which is less than the framing `encodeMultipart` actually writes
|
|
1624
|
+
// for its 70-character generated boundary: a form sized to exactly the
|
|
1625
|
+
// estimate passed this check and then went out roughly 45 bytes per part
|
|
1626
|
+
// over the limit the caller had set.
|
|
1627
|
+
let preEncodedForm;
|
|
1547
1628
|
// Enforce request size limit if configured
|
|
1548
1629
|
const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
|
|
1549
1630
|
if (maxRequestSize > 0 && options.body) {
|
|
@@ -1571,19 +1652,13 @@ export class Kinetex {
|
|
|
1571
1652
|
bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
|
|
1572
1653
|
}
|
|
1573
1654
|
else if (options.body instanceof FormData) {
|
|
1574
|
-
// FIX (H4):
|
|
1575
|
-
//
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
}
|
|
1582
|
-
else {
|
|
1583
|
-
bodySize += value.size;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
bodySize += boundaryOverhead; // final boundary
|
|
1655
|
+
// FIX (H4): the old branch skipped FormData entirely, which allowed
|
|
1656
|
+
// unbounded uploads past the configured limit; it then replaced the
|
|
1657
|
+
// skip with an estimate whose per-part constant was smaller than the
|
|
1658
|
+
// framing it stood in for. The exact bytes are the only honest
|
|
1659
|
+
// answer, and they are needed a few lines below anyway.
|
|
1660
|
+
preEncodedForm = await encodeMultipart(options.body);
|
|
1661
|
+
bodySize = preEncodedForm.bytes.byteLength;
|
|
1587
1662
|
}
|
|
1588
1663
|
else if (options.body instanceof ReadableStream) {
|
|
1589
1664
|
// FIX (H4): a stream's size cannot be known without consuming it —
|
|
@@ -1630,6 +1705,25 @@ export class Kinetex {
|
|
|
1630
1705
|
meta: { ...options.meta },
|
|
1631
1706
|
httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
|
|
1632
1707
|
};
|
|
1708
|
+
// A `FormData` body is encoded here rather than handed to the transport,
|
|
1709
|
+
// because the encoding and the `Content-Type` that describes it have to be
|
|
1710
|
+
// produced together. The raw Node transports bypass fetch, and
|
|
1711
|
+
// `serializeRawBody` — the function written so they would not send an empty
|
|
1712
|
+
// body — covered `URLSearchParams` and `Blob` but not `FormData`, so on the
|
|
1713
|
+
// default transport on Node a form upload went out with no body and no
|
|
1714
|
+
// `Content-Type` and the server recorded an empty form. The response was an
|
|
1715
|
+
// ordinary 200, so nothing downstream could tell.
|
|
1716
|
+
if (req.body !== null && req.body instanceof FormData && !req.headers["content-type"]) {
|
|
1717
|
+
const encoded = preEncodedForm ?? (await encodeMultipart(req.body));
|
|
1718
|
+
req = {
|
|
1719
|
+
...req,
|
|
1720
|
+
headers: {
|
|
1721
|
+
...req.headers,
|
|
1722
|
+
"content-type": `multipart/form-data; boundary=${encoded.boundary}`,
|
|
1723
|
+
},
|
|
1724
|
+
body: encoded.bytes,
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1633
1727
|
// Default Content-Type for JSON bodies
|
|
1634
1728
|
if (req.body !== null &&
|
|
1635
1729
|
typeof req.body === "object" &&
|
|
@@ -1801,6 +1895,82 @@ export class Kinetex {
|
|
|
1801
1895
|
* and retries on failure per the retry config.
|
|
1802
1896
|
*/
|
|
1803
1897
|
async _executeWithRetry(req, retryCfg, timeout, options, startMs, wallClockMs) {
|
|
1898
|
+
// A permit is held for the whole logical request — including every retry
|
|
1899
|
+
// attempt — and released in `finally`, so a throw or an exhausted retry
|
|
1900
|
+
// budget can never leak one and permanently shrink the pool.
|
|
1901
|
+
const run = async () => {
|
|
1902
|
+
if (!this._concurrencyLimiter) {
|
|
1903
|
+
return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
|
|
1904
|
+
}
|
|
1905
|
+
await this._concurrencyLimiter.acquire(req.signal);
|
|
1906
|
+
try {
|
|
1907
|
+
return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
|
|
1908
|
+
}
|
|
1909
|
+
finally {
|
|
1910
|
+
this._concurrencyLimiter.release();
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
// Metrics measure the whole logical request, retries included, and are
|
|
1914
|
+
// emitted from `finally` so failures and aborts are counted too. The
|
|
1915
|
+
// no-tracer case is not short-circuited here: `_recordMetrics` returns
|
|
1916
|
+
// immediately when telemetry is off, and this client then pays nothing
|
|
1917
|
+
// beyond the call it already makes on every request.
|
|
1918
|
+
const metricsStart = Date.now();
|
|
1919
|
+
let status;
|
|
1920
|
+
let errorCode;
|
|
1921
|
+
try {
|
|
1922
|
+
const res = await run();
|
|
1923
|
+
status = res.status;
|
|
1924
|
+
return res;
|
|
1925
|
+
}
|
|
1926
|
+
catch (err) {
|
|
1927
|
+
errorCode = err.code;
|
|
1928
|
+
throw err;
|
|
1929
|
+
}
|
|
1930
|
+
finally {
|
|
1931
|
+
this._recordMetrics(req, status, errorCode, Date.now() - metricsStart);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Emit request metrics to the configured tracer, if it supports them.
|
|
1936
|
+
*
|
|
1937
|
+
* Failures here are swallowed on purpose: telemetry must never be able to
|
|
1938
|
+
* fail a request that otherwise succeeded.
|
|
1939
|
+
*
|
|
1940
|
+
* @param req - The originating request.
|
|
1941
|
+
* @param status - Final HTTP status, or undefined if the request threw.
|
|
1942
|
+
* @param errorCode - `KinetexError` code, or undefined on success.
|
|
1943
|
+
* @param durationMs - Wall-clock duration of the logical request.
|
|
1944
|
+
*/
|
|
1945
|
+
_recordMetrics(req, status, errorCode, durationMs) {
|
|
1946
|
+
const tracer = this._otelTracer;
|
|
1947
|
+
if (!tracer)
|
|
1948
|
+
return;
|
|
1949
|
+
// `req.url` is always an absolute, already-parsed URL by the time a
|
|
1950
|
+
// request reaches here: `buildURL()` constructs it and the SSRF safety
|
|
1951
|
+
// check parses it again before dispatch, so there is nothing to guard.
|
|
1952
|
+
const attributes = {
|
|
1953
|
+
"http.request.method": req.method,
|
|
1954
|
+
"server.address": new URL(req.url).hostname,
|
|
1955
|
+
};
|
|
1956
|
+
if (status !== undefined)
|
|
1957
|
+
attributes["http.response.status_code"] = status;
|
|
1958
|
+
if (errorCode !== undefined)
|
|
1959
|
+
attributes["error.type"] = errorCode;
|
|
1960
|
+
try {
|
|
1961
|
+
// Seconds, per OTel semantic conventions for http.client.request.duration.
|
|
1962
|
+
tracer.recordHistogram?.("http.client.request.duration", durationMs / 1000, attributes);
|
|
1963
|
+
tracer.incrementCounter?.("http.client.request.count", 1, attributes);
|
|
1964
|
+
if (errorCode !== undefined) {
|
|
1965
|
+
tracer.incrementCounter?.("http.client.error.count", 1, attributes);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
catch {
|
|
1969
|
+
// Swallowed — see the doc comment.
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
/** Retry loop body. See {@link _executeWithRetry} for the concurrency gate. */
|
|
1973
|
+
async _executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs) {
|
|
1804
1974
|
let attempt = 0;
|
|
1805
1975
|
while (true) {
|
|
1806
1976
|
attempt++;
|
|
@@ -1852,20 +2022,59 @@ export class Kinetex {
|
|
|
1852
2022
|
method: req.method,
|
|
1853
2023
|
});
|
|
1854
2024
|
}
|
|
1855
|
-
|
|
2025
|
+
// Clock-skew correction. `SigV4Signer.handleClockSkewError` existed and
|
|
2026
|
+
// `detectClockSkew` was a public export, but nothing in the library
|
|
2027
|
+
// ever called them: a client with a wrong clock got a 403
|
|
2028
|
+
// `RequestTimeTooSkewed`, retried if 403 happened to be in `statuses`,
|
|
2029
|
+
// and re-signed the identical wrong timestamp every attempt. Correcting
|
|
2030
|
+
// here is bounded to one attempt per logical request, so a server that
|
|
2031
|
+
// keeps reporting a different time cannot spin.
|
|
2032
|
+
let clockSkewCorrected = false;
|
|
2033
|
+
const skewResponse = err.response;
|
|
2034
|
+
if (this._awsSigner && skewResponse && err.code === "EHTTPSTATUS") {
|
|
2035
|
+
// `data` is the parsed body: a string for text, a Uint8Array for any
|
|
2036
|
+
// other content type (which is what an XML error body arrives as),
|
|
2037
|
+
// and an object only for JSON.
|
|
2038
|
+
const raw = skewResponse.data;
|
|
2039
|
+
const body = typeof raw === "string"
|
|
2040
|
+
? raw
|
|
2041
|
+
: raw instanceof Uint8Array
|
|
2042
|
+
? new TextDecoder().decode(raw)
|
|
2043
|
+
: raw instanceof ArrayBuffer
|
|
2044
|
+
? new TextDecoder().decode(new Uint8Array(raw))
|
|
2045
|
+
: JSON.stringify(raw ?? "");
|
|
2046
|
+
if (this._awsSigner.handleClockSkewError(skewResponse.status, body, skewResponse.headers)) {
|
|
2047
|
+
clockSkewCorrected = !req.meta[AWS_SKEW_CORRECTED];
|
|
2048
|
+
if (clockSkewCorrected)
|
|
2049
|
+
req.meta[AWS_SKEW_CORRECTED] = true;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
if (retryCfg && (attempt <= retryCfg.maxRetries || clockSkewCorrected)) {
|
|
2053
|
+
// A thrown HTTPStatusError already carries the full response. This
|
|
2054
|
+
// path used to hand hooks a context with `response: null` and a hard
|
|
2055
|
+
// `null` Retry-After, so the *default* case — throwOnError: true,
|
|
2056
|
+
// which is what every 429/503 goes through — ignored the server's
|
|
2057
|
+
// Retry-After entirely and never fired lifecycle onRetry hooks.
|
|
2058
|
+
const errResponse = skewResponse ?? null;
|
|
1856
2059
|
const retryCtx = {
|
|
1857
2060
|
request: req,
|
|
1858
|
-
response:
|
|
2061
|
+
response: errResponse,
|
|
1859
2062
|
error: err,
|
|
1860
2063
|
attempt,
|
|
1861
2064
|
maxRetries: retryCfg.maxRetries,
|
|
1862
2065
|
};
|
|
1863
|
-
const doRetry =
|
|
1864
|
-
?
|
|
1865
|
-
: shouldRetry
|
|
2066
|
+
const doRetry = clockSkewCorrected
|
|
2067
|
+
? true
|
|
2068
|
+
: retryCfg.shouldRetry
|
|
2069
|
+
? await retryCfg.shouldRetry(retryCtx)
|
|
2070
|
+
: shouldRetry(retryCfg, retryCtx);
|
|
1866
2071
|
if (doRetry) {
|
|
1867
|
-
const delay = computeRetryDelay(retryCfg, attempt, null);
|
|
2072
|
+
const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
|
|
1868
2073
|
await retryCfg.onRetry?.(retryCtx, delay);
|
|
2074
|
+
await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
|
|
2075
|
+
await p;
|
|
2076
|
+
await fn(retryCtx);
|
|
2077
|
+
}, Promise.resolve());
|
|
1869
2078
|
await sleep(delay, req.signal);
|
|
1870
2079
|
continue;
|
|
1871
2080
|
}
|
|
@@ -1971,9 +2180,7 @@ export class Kinetex {
|
|
|
1971
2180
|
if (isRedirect) {
|
|
1972
2181
|
// Check for redirect loops
|
|
1973
2182
|
if (visited.has(raw.url)) {
|
|
1974
|
-
throw new
|
|
1975
|
-
request: req,
|
|
1976
|
-
});
|
|
2183
|
+
throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
|
|
1977
2184
|
}
|
|
1978
2185
|
visited.add(raw.url);
|
|
1979
2186
|
// Capture cookies from this redirect hop
|
|
@@ -1982,13 +2189,26 @@ export class Kinetex {
|
|
|
1982
2189
|
});
|
|
1983
2190
|
// `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
|
|
1984
2191
|
// the caller instead of chasing it — the same shape fetch() returns for
|
|
1985
|
-
// `redirect: "manual"`.
|
|
2192
|
+
// `redirect: "manual"`. It reported `redirected: true`, which is the one
|
|
2193
|
+
// value `redirected` must never take: no hop was taken, `res.url` is
|
|
2194
|
+
// still the request's own URL, and the whole point of the option is that
|
|
2195
|
+
// the caller now has to read `Location` and decide for itself. A caller
|
|
2196
|
+
// branching on `if (res.redirected)` to detect a cross-origin bounce was
|
|
2197
|
+
// told it had already been redirected to a URL it never requested.
|
|
1986
2198
|
if (!followRedirects)
|
|
1987
|
-
return { ...raw, redirected:
|
|
2199
|
+
return { ...raw, redirected: false };
|
|
1988
2200
|
if (hop === maxRedirects) {
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2201
|
+
// `EREDIRECT`, not `ENETWORK`. A redirect chain that has run out of
|
|
2202
|
+
// hops is a deterministic answer from the origin: the same request
|
|
2203
|
+
// produces the same chain. As an `ENETWORK` it fell into
|
|
2204
|
+
// `shouldRetry`'s network-error case and the whole chain was replayed
|
|
2205
|
+
// once per attempt — 16 requests against a server already looping,
|
|
2206
|
+
// under a `maxRedirects: 3` the caller had set, and after the whole
|
|
2207
|
+
// backoff schedule before the error they asked for finally arrived.
|
|
2208
|
+
// `shouldRetry` already had a non-retryable `EREDIRECT` case, and
|
|
2209
|
+
// `RedirectError` was already exported and documented; nothing
|
|
2210
|
+
// constructed it.
|
|
2211
|
+
throw new RedirectError(`Too many redirects (exceeded ${maxRedirects})`, req);
|
|
1992
2212
|
}
|
|
1993
2213
|
// Drain the redirect body (usually empty, but must be cancelled)
|
|
1994
2214
|
if (raw.body) {
|
|
@@ -2100,7 +2320,7 @@ export class Kinetex {
|
|
|
2100
2320
|
return hop > 0 ? { ...raw, redirected: true } : raw;
|
|
2101
2321
|
}
|
|
2102
2322
|
// Unreachable
|
|
2103
|
-
throw new
|
|
2323
|
+
throw new RedirectError("Redirect loop", req);
|
|
2104
2324
|
}
|
|
2105
2325
|
// ── §8.8 Single attempt ──────────────────────────────────────────────────
|
|
2106
2326
|
/**
|
|
@@ -2137,9 +2357,16 @@ export class Kinetex {
|
|
|
2137
2357
|
}
|
|
2138
2358
|
this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
|
|
2139
2359
|
// ── Cache lookup ───────────────────────────────────────────────────────
|
|
2360
|
+
// `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
|
|
2361
|
+
// declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
|
|
2362
|
+
// exactly as if no option had been passed, so a warm entry was served and
|
|
2363
|
+
// the fluent method documented as "Force a fresh fetch, bypassing any cached
|
|
2364
|
+
// response" did not fetch. The fix is to skip the read for this request —
|
|
2365
|
+
// the write still happens, which is what "refresh" means.
|
|
2366
|
+
const forceRefresh = options.cache !== false && options.cache?.forceRefresh === true;
|
|
2140
2367
|
if (options.cache !== false && this.cfg.cache) {
|
|
2141
2368
|
const cache = await this.getCache();
|
|
2142
|
-
if (cache) {
|
|
2369
|
+
if (cache && !forceRefresh) {
|
|
2143
2370
|
const cacheReq = { url: req.url, method: req.method, headers: req.headers };
|
|
2144
2371
|
const hit = await cache.get(cacheReq);
|
|
2145
2372
|
if (hit && !hit.stale) {
|
|
@@ -2310,22 +2537,34 @@ export class Kinetex {
|
|
|
2310
2537
|
};
|
|
2311
2538
|
}
|
|
2312
2539
|
// ── Dispatch ───────────────────────────────────────────────────────────
|
|
2313
|
-
// Redirects are followed by hand
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2540
|
+
// Redirects are ALWAYS followed by hand, and that is not a preference.
|
|
2541
|
+
//
|
|
2542
|
+
// `_sendFollowingRedirects` is the only place kinetex checks a redirect
|
|
2543
|
+
// *target*: the SSRF gate (`isSafeURL` on every hop), the `httpsOnly`
|
|
2544
|
+
// policy, the redirect-loop detector, the `maxRedirects` cap, and the
|
|
2545
|
+
// RFC 7231 method downgrade for 301/302/303. It also exists to capture
|
|
2546
|
+
// intermediate Set-Cookie and to strip credentials across origins, which is
|
|
2547
|
+
// why it used to be entered only when a cookie jar or a forwarded credential
|
|
2548
|
+
// header happened to be configured.
|
|
2549
|
+
//
|
|
2550
|
+
// An ordinary GET is neither. So an ordinary GET's redirects were chased by
|
|
2551
|
+
// the transport instead, and every check above was skipped: the HTTP/2
|
|
2552
|
+
// transport's own loop resolved any `Location` and dialled it, and
|
|
2553
|
+
// `FetchTransport` handed fetch `redirect: "follow"`, which does the same.
|
|
2554
|
+
// A 302 to `http://127.0.0.1:9/` opened the socket (`ECONNREFUSED` came
|
|
2555
|
+
// back from the loopback port, which is the point — nothing refused the
|
|
2556
|
+
// connection first), and a 302 to `http://169.254.169.254/` was answered by
|
|
2557
|
+
// the cloud metadata service. `httpsOnly: true` changed nothing on either
|
|
2558
|
+
// path; both reported the same opaque `Protocol error`.
|
|
2559
|
+
//
|
|
2560
|
+
// The two reasons the follower was originally introduced are reasons it is
|
|
2561
|
+
// *necessary*, not an exhaustive list of when it applies.
|
|
2320
2562
|
const dispatchJar = await this.getCookieJar();
|
|
2321
2563
|
const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
2322
|
-
const needsManualRedirects = dispatchJar !== null || hasForwardedCredentials(req.headers, effectiveAuth);
|
|
2323
2564
|
this._trace(_traceId, "transport_send", "start", startMs, attempt);
|
|
2324
2565
|
let raw;
|
|
2325
2566
|
try {
|
|
2326
|
-
raw =
|
|
2327
|
-
? await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth)
|
|
2328
|
-
: await sendWithTimeout(this.transport, req, timeout);
|
|
2567
|
+
raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
|
|
2329
2568
|
}
|
|
2330
2569
|
catch (err) {
|
|
2331
2570
|
// Cancel the progress-tracking ReadableStream to release the underlying
|
|
@@ -2343,6 +2582,20 @@ export class Kinetex {
|
|
|
2343
2582
|
throw err;
|
|
2344
2583
|
}
|
|
2345
2584
|
this._trace(_traceId, "transport_send", "end", startMs, attempt);
|
|
2585
|
+
// ── Lifecycle: after request ───────────────────────────────────────────
|
|
2586
|
+
// "After the request is sent (before response is processed)" — the window
|
|
2587
|
+
// between the transport answering and the response being built, and the
|
|
2588
|
+
// only place a caller can observe that the wire round trip is over without
|
|
2589
|
+
// also having to see the parsed response. It was declared on `LifecycleHooks`
|
|
2590
|
+
// and documented twice in the README, and never invoked: a caller who
|
|
2591
|
+
// registered it got silence, on every code path, for every status.
|
|
2592
|
+
//
|
|
2593
|
+
// Deliberately *not* fired on the throw above: the request was not sent.
|
|
2594
|
+
if (this.cfg.hooks?.onAfterRequest) {
|
|
2595
|
+
for (const fn of this.cfg.hooks.onAfterRequest) {
|
|
2596
|
+
await fn(req, this._hookCtx(ctx));
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2346
2599
|
// ── Handle 304 Not Modified ────────────────────────────────────────────
|
|
2347
2600
|
if (raw.status === 304) {
|
|
2348
2601
|
const cache = await this.getCache();
|
|
@@ -2886,6 +3139,8 @@ export class Kinetex {
|
|
|
2886
3139
|
if (IS_NODE && this.transport && "destroy" in this.transport) {
|
|
2887
3140
|
this.transport.destroy();
|
|
2888
3141
|
}
|
|
3142
|
+
// Reject anyone still parked on the queue — it can never drain now.
|
|
3143
|
+
this._concurrencyLimiter?.drain();
|
|
2889
3144
|
this._cookieJar = null;
|
|
2890
3145
|
this._logger = null;
|
|
2891
3146
|
this._dedup?.clear();
|
|
@@ -2940,6 +3195,34 @@ export class FluentRequest {
|
|
|
2940
3195
|
};
|
|
2941
3196
|
return this;
|
|
2942
3197
|
}
|
|
3198
|
+
/**
|
|
3199
|
+
* Attach an `Idempotency-Key`, generating one when none is given.
|
|
3200
|
+
*
|
|
3201
|
+
* Lets a retried `POST` be recognised as the same logical operation by the
|
|
3202
|
+
* server instead of creating a duplicate. Because the header is set on the
|
|
3203
|
+
* request options — not regenerated per attempt — every retry of this
|
|
3204
|
+
* request carries the same key, which is the entire point.
|
|
3205
|
+
*
|
|
3206
|
+
* @param value - An explicit key, or `undefined` to generate a v4 UUID.
|
|
3207
|
+
* @throws {TypeError} If `value` is not a valid key (see `isValidIdempotencyKey`).
|
|
3208
|
+
*
|
|
3209
|
+
* @example
|
|
3210
|
+
* ```ts
|
|
3211
|
+
* await client.POST("/charges").withJSON(body).idempotencyKey().json();
|
|
3212
|
+
* ```
|
|
3213
|
+
*/
|
|
3214
|
+
idempotencyKey(value) {
|
|
3215
|
+
const key = value === undefined ? generateIdempotencyKey() : value;
|
|
3216
|
+
if (!isValidIdempotencyKey(key)) {
|
|
3217
|
+
throw new TypeError(`idempotencyKey: ${typeof key === "string" ? JSON.stringify(key) : typeof key} is not a ` +
|
|
3218
|
+
"valid Idempotency-Key — expected 1-255 visible ASCII characters");
|
|
3219
|
+
}
|
|
3220
|
+
this._options.headers = {
|
|
3221
|
+
...this._options.headers,
|
|
3222
|
+
"idempotency-key": key,
|
|
3223
|
+
};
|
|
3224
|
+
return this;
|
|
3225
|
+
}
|
|
2943
3226
|
/** Merge a headers map. */
|
|
2944
3227
|
headers(headers) {
|
|
2945
3228
|
this._options.headers = {
|