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/dist/cjs/client.js
CHANGED
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
* retry, auth, cache, cookie jar, HAR, logging, progress, and SSE/GraphQL/Pagination.
|
|
5
5
|
*/
|
|
6
6
|
import { ProgressTracker, withUploadProgress } from "./progress.js";
|
|
7
|
-
import { DedupMap } from "./dedup.js";
|
|
7
|
+
import { DedupMap, dedupKeyFrom, identityFingerprint } 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";
|
|
11
|
-
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
|
|
12
|
-
import {
|
|
13
|
-
import { createRateLimitInterceptor } from "./interceptors.js";
|
|
10
|
+
import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
|
|
11
|
+
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, perfNow, isSensitiveHeader, } from "./utils.js";
|
|
12
|
+
import { CREDENTIAL_HEADERS } from "./cache.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.
|
|
@@ -141,6 +142,15 @@ function randomHex(len) {
|
|
|
141
142
|
* Mirrors logging.ts DEFAULT_REDACT_HEADERS — HAR logs are routinely exported
|
|
142
143
|
* and shared, so credentials must never appear verbatim.
|
|
143
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Ceiling on a buffered response body when the caller sets no limit.
|
|
147
|
+
*
|
|
148
|
+
* Roughly the largest JSON document that is still sane to hand to
|
|
149
|
+
* `JSON.parse`, and far below the point where a single response can take a
|
|
150
|
+
* process down. Not a security boundary on its own — it is the difference
|
|
151
|
+
* between one bad response being a failed request and being an OOM kill.
|
|
152
|
+
*/
|
|
153
|
+
const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
|
|
144
154
|
const HAR_REDACT_HEADERS = new Set([
|
|
145
155
|
"authorization",
|
|
146
156
|
"proxy-authorization",
|
|
@@ -170,6 +180,8 @@ const HAR_REDACT_HEADERS = new Set([
|
|
|
170
180
|
* `meta` key carrying the number of response-interceptor re-sends so a
|
|
171
181
|
* self-retriggering interceptor cannot loop forever.
|
|
172
182
|
*/
|
|
183
|
+
/** Request-meta key: a clock-skew correction has already been spent. */
|
|
184
|
+
const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
|
|
173
185
|
const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
|
|
174
186
|
/** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
|
|
175
187
|
const MAX_INTERCEPTOR_RESENDS = 5;
|
|
@@ -199,6 +211,92 @@ const HAR_REDACT_PARAMS = new Set([
|
|
|
199
211
|
"jwt",
|
|
200
212
|
"code",
|
|
201
213
|
]);
|
|
214
|
+
/**
|
|
215
|
+
* Body field names whose values are redacted from a recorded HAR body.
|
|
216
|
+
*
|
|
217
|
+
* The same names already redacted from HAR headers and query parameters.
|
|
218
|
+
* Headers were covered and query parameters were covered, but a JSON request
|
|
219
|
+
* body was recorded verbatim: `{"password":"..."}` went into the export in
|
|
220
|
+
* clear text while `{"password":"..."}` in a header was masked. HAR files get
|
|
221
|
+
* attached to bug reports and handed to support teams, so a body is the most
|
|
222
|
+
* likely place for a credential to travel. Nested objects and arrays are
|
|
223
|
+
* walked to any depth.
|
|
224
|
+
*/
|
|
225
|
+
/**
|
|
226
|
+
* Replace the literal values this request's own credentials take, anywhere in
|
|
227
|
+
* a recorded body.
|
|
228
|
+
*
|
|
229
|
+
* Field-name redaction cannot help a `text/plain` body, where there are no
|
|
230
|
+
* field names — and a server echoing a token back is the common case (an
|
|
231
|
+
* error body quoting the offending header, a "session invalid" message).
|
|
232
|
+
* Matching the exact values kinetex already knows are sensitive is precise: no
|
|
233
|
+
* pattern matching, no false positives on ordinary text.
|
|
234
|
+
*/
|
|
235
|
+
function scrubKnownSecrets(text, req) {
|
|
236
|
+
const secrets = [];
|
|
237
|
+
// Credential headers are usually scheme-prefixed — `Bearer <token>` — and a
|
|
238
|
+
// body that echoes the token back echoes only the token, not the scheme.
|
|
239
|
+
// Scrubbing the whole header value would therefore match nothing.
|
|
240
|
+
const add = (v) => {
|
|
241
|
+
if (typeof v === "string" && v.length >= 8)
|
|
242
|
+
secrets.push(v);
|
|
243
|
+
};
|
|
244
|
+
for (const [name, value] of Object.entries(req.headers ?? {})) {
|
|
245
|
+
if (isSensitiveHeader(name, HAR_REDACT_HEADERS) && typeof value === "string") {
|
|
246
|
+
add(value);
|
|
247
|
+
const sp = value.indexOf(" ");
|
|
248
|
+
if (sp !== -1)
|
|
249
|
+
add(value.slice(sp + 1));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let query;
|
|
253
|
+
try {
|
|
254
|
+
query = new URL(req.url).searchParams;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
query = new URLSearchParams();
|
|
258
|
+
}
|
|
259
|
+
for (const [key, value] of query.entries()) {
|
|
260
|
+
if (HAR_REDACT_PARAMS.has(key.toLowerCase()) &&
|
|
261
|
+
typeof value === "string" &&
|
|
262
|
+
value.length >= 8) {
|
|
263
|
+
secrets.push(value);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let out = text;
|
|
267
|
+
for (const secret of secrets)
|
|
268
|
+
out = out.split(secret).join(HAR_REDACT_MASK);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
function redactHARBody(text, mimeType) {
|
|
272
|
+
if (!/json/i.test(mimeType))
|
|
273
|
+
return text;
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(text);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return text; // Not valid JSON despite the header; nothing safe to rewrite.
|
|
280
|
+
}
|
|
281
|
+
const walk = (node) => {
|
|
282
|
+
if (Array.isArray(node))
|
|
283
|
+
return node.map(walk);
|
|
284
|
+
if (node !== null && typeof node === "object") {
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const [key, value] of Object.entries(node)) {
|
|
287
|
+
out[key] = HAR_REDACT_PARAMS.has(key.toLowerCase()) ? HAR_REDACT_MASK : walk(value);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
return node;
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
return JSON.stringify(walk(parsed));
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return text;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
202
300
|
/** Maximum number of body characters recorded in a HAR entry. */
|
|
203
301
|
const HAR_MAX_BODY_CHARS = 8192;
|
|
204
302
|
/** HTML/other content types whose bodies are never recorded in HAR output. */
|
|
@@ -238,15 +336,57 @@ function redactHARUrl(url) {
|
|
|
238
336
|
}
|
|
239
337
|
}
|
|
240
338
|
/** Redact a single header value for HAR output. */
|
|
339
|
+
/**
|
|
340
|
+
* HAR entries carry their own mask ("***REDACTED***"), distinct from the
|
|
341
|
+
* logger's "***" and the logging interceptor's "**REDACTED**". A recorded HAR
|
|
342
|
+
* is a file a user reads and shares, so the string is part of its output; the
|
|
343
|
+
* shared helper takes it as an argument rather than the three being merged.
|
|
344
|
+
*/
|
|
345
|
+
const HAR_REDACT_MASK = "***REDACTED***";
|
|
241
346
|
function redactHARHeader(name, value) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
347
|
+
// A HAR stores headers as `{name, value}` pairs rather than a record, so it
|
|
348
|
+
// uses the shared case-insensitive predicate rather than the record helper.
|
|
349
|
+
return {
|
|
350
|
+
name,
|
|
351
|
+
value: isSensitiveHeader(name, HAR_REDACT_HEADERS) ? HAR_REDACT_MASK : value,
|
|
352
|
+
};
|
|
245
353
|
}
|
|
246
354
|
/**
|
|
247
355
|
* O(1) ring-buffer HAR entry recorder.
|
|
248
356
|
* Stores up to `maxEntries` entries, evicting oldest first.
|
|
249
357
|
*/
|
|
358
|
+
/**
|
|
359
|
+
* The `postData` block for a recorded request, or `{}` when there is nothing
|
|
360
|
+
* safe or possible to record.
|
|
361
|
+
*
|
|
362
|
+
* Kept out of `record()` so the recorder's entry literal reads as the HAR it
|
|
363
|
+
* claims to conform to, and so the "may I record this?" decision has one home.
|
|
364
|
+
*/
|
|
365
|
+
function harPostData(req) {
|
|
366
|
+
if (!req.body)
|
|
367
|
+
return {};
|
|
368
|
+
const mimeType = req.headers["content-type"] ?? "";
|
|
369
|
+
// Same policy as the response body: skip anything that is not plain text.
|
|
370
|
+
if (mimeType && !isHARBodySafeToRecord(mimeType))
|
|
371
|
+
return {};
|
|
372
|
+
let text;
|
|
373
|
+
if (typeof req.body === "string") {
|
|
374
|
+
text = req.body;
|
|
375
|
+
}
|
|
376
|
+
else if (req.body instanceof Uint8Array) {
|
|
377
|
+
text = new TextDecoder().decode(req.body);
|
|
378
|
+
}
|
|
379
|
+
else if (req.body instanceof ArrayBuffer) {
|
|
380
|
+
text = new TextDecoder().decode(new Uint8Array(req.body));
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
return {}; // stream / FormData / Blob: not readable without consuming it
|
|
384
|
+
}
|
|
385
|
+
// Redact before truncating: truncating first could cut a redaction in half
|
|
386
|
+
// and leave the head of a secret in the export.
|
|
387
|
+
const redacted = scrubKnownSecrets(redactHARBody(text, mimeType), req);
|
|
388
|
+
return { postData: { mimeType, text: redacted.slice(0, HAR_MAX_BODY_CHARS) } };
|
|
389
|
+
}
|
|
250
390
|
class HARRecorder {
|
|
251
391
|
/** Ring buffer of entries keyed by monotonic counter. */
|
|
252
392
|
_buf = new Map();
|
|
@@ -338,6 +478,17 @@ class HARRecorder {
|
|
|
338
478
|
return req.body.byteLength;
|
|
339
479
|
return -1; // Unknown (stream, FormData, etc.)
|
|
340
480
|
})(),
|
|
481
|
+
// `postData` is declared on `HAREntry` as "Posted data, if applicable"
|
|
482
|
+
// and was never written, so a HAR exported from a client that POSTs
|
|
483
|
+
// anything shows an empty request body in every viewer — the response
|
|
484
|
+
// body, the query string, the headers and the URL are all there, and
|
|
485
|
+
// the one part that explains what was actually sent is not. The gates
|
|
486
|
+
// are the ones the response side already uses: a body is recorded only
|
|
487
|
+
// when its content type is safe to record, it is truncated to the same
|
|
488
|
+
// limit, and a body that cannot be read without consuming it (a stream,
|
|
489
|
+
// a FormData) is omitted rather than guessed at — which is what the
|
|
490
|
+
// `bodySize: -1` above already admits.
|
|
491
|
+
...harPostData(req),
|
|
341
492
|
},
|
|
342
493
|
response: {
|
|
343
494
|
status: res.status,
|
|
@@ -350,7 +501,12 @@ class HARRecorder {
|
|
|
350
501
|
// Body text is only kept for non-HTML payloads and is truncated:
|
|
351
502
|
// response bodies routinely carry tokens and PII.
|
|
352
503
|
...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
|
|
353
|
-
? {
|
|
504
|
+
? {
|
|
505
|
+
// Same reason as the request side: a `text/plain` body has no
|
|
506
|
+
// field names to redact by, and a server that echoes the token
|
|
507
|
+
// back would otherwise put it in the export verbatim.
|
|
508
|
+
text: scrubKnownSecrets(res.data, req).slice(0, HAR_MAX_BODY_CHARS),
|
|
509
|
+
}
|
|
354
510
|
: {}),
|
|
355
511
|
},
|
|
356
512
|
// The Location header can itself carry a signed URL — redact it too.
|
|
@@ -473,35 +629,6 @@ async function applyAuth(req, auth) {
|
|
|
473
629
|
// ============================================================================
|
|
474
630
|
// §5 URL BUILDING
|
|
475
631
|
// ============================================================================
|
|
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
632
|
/**
|
|
506
633
|
* Headers stripped when a redirect crosses origins (FIX H2).
|
|
507
634
|
* These carry credentials and must never be forwarded to a different origin.
|
|
@@ -545,8 +672,49 @@ function redactUserInfo(url) {
|
|
|
545
672
|
* @returns Fully-qualified URL string.
|
|
546
673
|
* @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
|
|
547
674
|
*/
|
|
548
|
-
function buildURL(base, url, params) {
|
|
675
|
+
function buildURL(base, url, params, allowedSchemes = ["http", "https"], allowPrivateNetwork = false) {
|
|
549
676
|
const MAX_QUERY_PARAM_COUNT = 100;
|
|
677
|
+
/**
|
|
678
|
+
* Walk query params once, enforcing the count limit, handing each surviving
|
|
679
|
+
* key/value pair to `emit`.
|
|
680
|
+
*
|
|
681
|
+
* `null` and `undefined` are dropped, and an array expands to one entry per
|
|
682
|
+
* non-null element. Both URL-assembly paths below did exactly this and each
|
|
683
|
+
* carried its own copy of the limit check — four copies of the same `throw`,
|
|
684
|
+
* inside two loops — so the two paths could drift and the cap could end up
|
|
685
|
+
* enforced in one spelling of the query but not the other.
|
|
686
|
+
*
|
|
687
|
+
* Encoding stays with the caller, and so does the array/scalar distinction:
|
|
688
|
+
* the primary path must `append` array elements (so `?a=1&a=2` survives) but
|
|
689
|
+
* `set` a scalar (so it replaces a value the URL already carried). The
|
|
690
|
+
* fallback appends to a list it is building and does not care. That
|
|
691
|
+
* difference is observable, so `emit` is told which case it is in.
|
|
692
|
+
*/
|
|
693
|
+
const forEachQueryParam = (params, emit) => {
|
|
694
|
+
let paramCount = 0;
|
|
695
|
+
const count = () => {
|
|
696
|
+
paramCount++;
|
|
697
|
+
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
698
|
+
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
for (const [key, value] of Object.entries(params)) {
|
|
702
|
+
if (value === null || value === undefined)
|
|
703
|
+
continue;
|
|
704
|
+
if (Array.isArray(value)) {
|
|
705
|
+
for (const v of value) {
|
|
706
|
+
if (v === null || v === undefined)
|
|
707
|
+
continue;
|
|
708
|
+
count();
|
|
709
|
+
emit(key, String(v), true);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
count();
|
|
714
|
+
emit(key, String(value), false);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
550
718
|
const MAX_URL_LENGTH = 8192;
|
|
551
719
|
let full;
|
|
552
720
|
if (/^https?:\/\//i.test(url)) {
|
|
@@ -574,44 +742,33 @@ function buildURL(base, url, params) {
|
|
|
574
742
|
}
|
|
575
743
|
}
|
|
576
744
|
if (!params || Object.keys(params).length === 0) {
|
|
577
|
-
if (!isSafeURL(full)) {
|
|
745
|
+
if (!isSafeURL(full, allowedSchemes, { allowPrivateNetwork })) {
|
|
578
746
|
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
579
747
|
}
|
|
580
748
|
return full;
|
|
581
749
|
}
|
|
582
750
|
try {
|
|
583
751
|
const u = new URL(full);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const strValue = String(value);
|
|
589
|
-
if (Array.isArray(value)) {
|
|
590
|
-
for (const v of value) {
|
|
591
|
-
if (v !== null && v !== undefined) {
|
|
592
|
-
paramCount++;
|
|
593
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
594
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
595
|
-
}
|
|
596
|
-
u.searchParams.append(key, String(v));
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
else {
|
|
601
|
-
paramCount++;
|
|
602
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
603
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
604
|
-
}
|
|
605
|
-
u.searchParams.set(key, strValue);
|
|
606
|
-
}
|
|
752
|
+
// The params branch screens the URL after appending them, so it needs the
|
|
753
|
+
// same scheme list as the no-params branch above.
|
|
754
|
+
if (!isSafeURL(u, allowedSchemes, { allowPrivateNetwork })) {
|
|
755
|
+
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
607
756
|
}
|
|
757
|
+
// An array element appends, so `?a=1&a=2` survives; a scalar sets, so it
|
|
758
|
+
// replaces a value the URL already carried under that name.
|
|
759
|
+
forEachQueryParam(params, (key, value, fromArray) => {
|
|
760
|
+
if (fromArray)
|
|
761
|
+
u.searchParams.append(key, value);
|
|
762
|
+
else
|
|
763
|
+
u.searchParams.set(key, value);
|
|
764
|
+
});
|
|
608
765
|
const result = u.toString();
|
|
609
766
|
// Check URL length limit
|
|
610
767
|
if (result.length > MAX_URL_LENGTH) {
|
|
611
768
|
throw new KinetexError(`URL length ${result.length} bytes exceeds limit of ${MAX_URL_LENGTH} bytes`, "EVALIDATION");
|
|
612
769
|
}
|
|
613
770
|
// Validate the final URL with params
|
|
614
|
-
if (!isSafeURL(result)) {
|
|
771
|
+
if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
|
|
615
772
|
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
616
773
|
}
|
|
617
774
|
return result;
|
|
@@ -627,29 +784,9 @@ function buildURL(base, url, params) {
|
|
|
627
784
|
full += "?";
|
|
628
785
|
}
|
|
629
786
|
const paramParts = [];
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
continue;
|
|
634
|
-
if (Array.isArray(value)) {
|
|
635
|
-
for (const v of value) {
|
|
636
|
-
if (v !== null && v !== undefined) {
|
|
637
|
-
paramCount++;
|
|
638
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
639
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
640
|
-
}
|
|
641
|
-
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
else {
|
|
646
|
-
paramCount++;
|
|
647
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
648
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
649
|
-
}
|
|
650
|
-
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
787
|
+
forEachQueryParam(params ?? {}, (key, value) => {
|
|
788
|
+
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
789
|
+
});
|
|
653
790
|
const result = full + paramParts.join("&");
|
|
654
791
|
// Check URL length limit
|
|
655
792
|
if (result.length > MAX_URL_LENGTH) {
|
|
@@ -657,7 +794,7 @@ function buildURL(base, url, params) {
|
|
|
657
794
|
}
|
|
658
795
|
// FIX M4: the manual param-concat fallback previously returned WITHOUT a
|
|
659
796
|
// safety check — validate the assembled URL like every other path.
|
|
660
|
-
if (!isSafeURL(result)) {
|
|
797
|
+
if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
|
|
661
798
|
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
662
799
|
}
|
|
663
800
|
return result;
|
|
@@ -713,11 +850,42 @@ function mergeHeaders(...sources) {
|
|
|
713
850
|
* @param ctx - Current retry context (error, response, attempt count).
|
|
714
851
|
* @returns Whether a retry should be attempted.
|
|
715
852
|
*/
|
|
853
|
+
/**
|
|
854
|
+
* A request body rendered for a log entry, or `null` when it cannot be.
|
|
855
|
+
*
|
|
856
|
+
* A `ReadableStream` or a `FormData` is deliberately not consumed to log it:
|
|
857
|
+
* reading either would consume the payload the request is about to send, so
|
|
858
|
+
* logging would break the request it is describing.
|
|
859
|
+
*/
|
|
860
|
+
function loggableBody(body) {
|
|
861
|
+
if (body === null || body === undefined)
|
|
862
|
+
return null;
|
|
863
|
+
if (typeof body === "string")
|
|
864
|
+
return body;
|
|
865
|
+
if (body instanceof Uint8Array)
|
|
866
|
+
return body;
|
|
867
|
+
try {
|
|
868
|
+
return JSON.stringify(body);
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
// Cyclic, or otherwise not serialisable. The request still has to go out.
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
716
875
|
function shouldRetry(cfg, ctx) {
|
|
717
876
|
if (ctx.attempt > cfg.maxRetries)
|
|
718
877
|
return false;
|
|
719
|
-
|
|
878
|
+
// `Idempotency-Key` is a declaration that this exact request may be
|
|
879
|
+
// repeated: the whole point of the header is that the server collapses
|
|
880
|
+
// duplicates. Without this, POST is never retried, so a caller who attached
|
|
881
|
+
// a key — which kinetex generates for exactly this purpose — still lost the
|
|
882
|
+
// request to the first transient 503, and the changelog's claim that "every
|
|
883
|
+
// retry reuses it" described retries that could never happen. A caller opts
|
|
884
|
+
// in by sending the header, which is the same contract the header carries.
|
|
885
|
+
const hasIdempotencyKey = Object.keys(ctx.request.headers ?? {}).some((name) => name.toLowerCase() === "idempotency-key" && ctx.request.headers[name] !== "");
|
|
886
|
+
if (!hasIdempotencyKey && !cfg.methods.includes(ctx.request.method)) {
|
|
720
887
|
return false;
|
|
888
|
+
}
|
|
721
889
|
if (cfg.shouldRetry)
|
|
722
890
|
return cfg.shouldRetry(ctx); // caller handles it
|
|
723
891
|
if (ctx.error) {
|
|
@@ -745,6 +913,10 @@ function shouldRetry(cfg, ctx) {
|
|
|
745
913
|
return false;
|
|
746
914
|
case "EREDIRECT":
|
|
747
915
|
return false;
|
|
916
|
+
// A blocked host is a permanent refusal, not a transport hiccup; retrying
|
|
917
|
+
// resolves the same name to the same blocked address.
|
|
918
|
+
case "EBLOCKEDHOST":
|
|
919
|
+
return false;
|
|
748
920
|
case "EUNKNOWN":
|
|
749
921
|
return false;
|
|
750
922
|
default: {
|
|
@@ -775,7 +947,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
|
|
|
775
947
|
return cfg.maxDelayMs;
|
|
776
948
|
}
|
|
777
949
|
const capped = Math.min(exp, cfg.maxDelayMs);
|
|
778
|
-
|
|
950
|
+
const jittered = capped + capped * cfg.jitter * Math.random();
|
|
951
|
+
// `maxDelayMs` is documented as a maximum, so the cap must be re-applied
|
|
952
|
+
// *after* jitter. It was not: base 1000, cap 1200, jitter 1 returned 2000ms,
|
|
953
|
+
// so the option never bounded worst-case latency at all — it bounded only the
|
|
954
|
+
// pre-jitter base. With the default jitter of 0.3 a 30 s cap still allowed
|
|
955
|
+
// 39 s.
|
|
956
|
+
return Math.min(Math.floor(jittered), cfg.maxDelayMs);
|
|
779
957
|
}
|
|
780
958
|
/**
|
|
781
959
|
* Extract and parse the Retry-After header value.
|
|
@@ -787,15 +965,21 @@ function getRetryAfterMs(headers) {
|
|
|
787
965
|
const ra = headers["retry-after"];
|
|
788
966
|
if (!ra)
|
|
789
967
|
return null;
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
968
|
+
// Delegate to the single RFC 7231 §7.1.1 parser instead of a second,
|
|
969
|
+
// looser copy: Date.parse() accepts "-5", "1.5" and "+5" as dates, which
|
|
970
|
+
// used to yield a 0 ms (i.e. "ignore Retry-After") back-off on a 429.
|
|
971
|
+
const parsed = parseRetryAfter(ra);
|
|
972
|
+
if (parsed.delay !== null) {
|
|
794
973
|
const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
|
|
795
|
-
return Math.min(
|
|
974
|
+
return Math.min(parsed.delay, MAX_RETRY_AFTER_SEC) * 1000;
|
|
796
975
|
}
|
|
797
|
-
|
|
798
|
-
|
|
976
|
+
if (parsed.date) {
|
|
977
|
+
// A date in the past genuinely means "retry now" — 0 is the answer, not a
|
|
978
|
+
// parse failure. Cap at 24 h so a bogus far-future date cannot stall.
|
|
979
|
+
const MAX_RETRY_AFTER_MS = 86_400_000;
|
|
980
|
+
return Math.max(0, Math.min(parsed.date.getTime() - Date.now(), MAX_RETRY_AFTER_MS));
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
799
983
|
}
|
|
800
984
|
// ============================================================================
|
|
801
985
|
// §8 MAIN KINETEX CLASS
|
|
@@ -850,6 +1034,14 @@ export class Kinetex {
|
|
|
850
1034
|
_circuitBreakerKeyFn = null;
|
|
851
1035
|
/** Active WebSocket connections tracked for cleanup on destroy(). */
|
|
852
1036
|
_wsClients = new Set();
|
|
1037
|
+
/**
|
|
1038
|
+
* Optional bulkhead bounding in-flight requests. `null` when
|
|
1039
|
+
* `concurrencyLimit` is not configured, which keeps the hot path free of a
|
|
1040
|
+
* limiter check.
|
|
1041
|
+
*/
|
|
1042
|
+
_concurrencyLimiter;
|
|
1043
|
+
/** SigV4 signer kept so a clock-skew correction survives across retries. */
|
|
1044
|
+
_awsSigner = null;
|
|
853
1045
|
/**
|
|
854
1046
|
* @param config - Global client configuration.
|
|
855
1047
|
*/
|
|
@@ -868,10 +1060,17 @@ export class Kinetex {
|
|
|
868
1060
|
const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
|
|
869
1061
|
this.interceptors.addRequest(rlInterceptor);
|
|
870
1062
|
}
|
|
1063
|
+
// Concurrency limiter (bulkhead). Held as an object rather than a request
|
|
1064
|
+
// interceptor because a permit must survive until the request settles,
|
|
1065
|
+
// and interceptors cannot wrap the downstream call.
|
|
1066
|
+
this._concurrencyLimiter = config.concurrencyLimit
|
|
1067
|
+
? new ConcurrencyLimiter(config.concurrencyLimit)
|
|
1068
|
+
: null;
|
|
871
1069
|
// AWS SigV4 request signing — registered synchronously (static import).
|
|
872
1070
|
// Active immediately; no race between first request and interceptor registration.
|
|
873
1071
|
if (config.awsSigning) {
|
|
874
1072
|
const signer = new SigV4Signer(config.awsSigning);
|
|
1073
|
+
this._awsSigner = signer;
|
|
875
1074
|
this.interceptors.addRequest(async (ctx) => {
|
|
876
1075
|
const req = ctx.request;
|
|
877
1076
|
let signableBody = null;
|
|
@@ -889,7 +1088,19 @@ export class Kinetex {
|
|
|
889
1088
|
});
|
|
890
1089
|
}
|
|
891
1090
|
// Transport — pass strictHeaders option through to FetchTransport
|
|
892
|
-
this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
|
|
1091
|
+
this.transport = createTransport(config.fetch, config.httpVersion !== "HTTP/1.1",
|
|
1092
|
+
// The HTTP/2 session pool was configurable on the transport but the
|
|
1093
|
+
// client always passed `undefined` here, so `sessionPool` on the client
|
|
1094
|
+
// config could not tune it.
|
|
1095
|
+
config.sessionPool, {
|
|
1096
|
+
...(config.strictHeaders ? { strict: true } : {}),
|
|
1097
|
+
...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
|
|
1098
|
+
...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
|
|
1099
|
+
...(config.allowPrivateNetwork !== undefined
|
|
1100
|
+
? { allowPrivateNetwork: config.allowPrivateNetwork }
|
|
1101
|
+
: {}),
|
|
1102
|
+
...(config.ca !== undefined ? { ca: config.ca } : {}),
|
|
1103
|
+
});
|
|
893
1104
|
// Register config-level interceptors
|
|
894
1105
|
if (config.interceptors) {
|
|
895
1106
|
config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
|
|
@@ -992,7 +1203,7 @@ export class Kinetex {
|
|
|
992
1203
|
status: ctx.response.status,
|
|
993
1204
|
statusText: ctx.response.statusText,
|
|
994
1205
|
headers: ctx.response.headers,
|
|
995
|
-
body: ctx.response.
|
|
1206
|
+
body: ctx.response.body ?? null,
|
|
996
1207
|
request: {
|
|
997
1208
|
url: ctx.request.url,
|
|
998
1209
|
method: ctx.request.method,
|
|
@@ -1293,20 +1504,19 @@ export class Kinetex {
|
|
|
1293
1504
|
/**
|
|
1294
1505
|
* Returns deduplication metrics.
|
|
1295
1506
|
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1507
|
+
* The full `DedupMap.getStats()` snapshot, so a caller has to reach for one
|
|
1508
|
+
* shape rather than two. The wrapper used to expose only `hits`, `misses`
|
|
1509
|
+
* and `inFlightCount` — which meant the hit rate, the only figure anyone
|
|
1510
|
+
* actually wants from a dedup map, was unavailable on the client even
|
|
1511
|
+
* though the map computed it.
|
|
1512
|
+
*
|
|
1513
|
+
* @returns `{ hits, misses, totalRequests, hitRate, inFlightCount, trackedKeys }`,
|
|
1514
|
+
* or `null` if dedup is not enabled.
|
|
1298
1515
|
*/
|
|
1299
1516
|
get dedupMetrics() {
|
|
1300
1517
|
if (!this._dedup)
|
|
1301
1518
|
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
|
-
};
|
|
1519
|
+
return this._dedup.getStats();
|
|
1310
1520
|
}
|
|
1311
1521
|
// ── §8.5d Circuit Breaker ────────────────────────────────────────────────
|
|
1312
1522
|
/**
|
|
@@ -1382,7 +1592,15 @@ export class Kinetex {
|
|
|
1382
1592
|
* ```
|
|
1383
1593
|
*/
|
|
1384
1594
|
async ws(url, options = {}) {
|
|
1385
|
-
|
|
1595
|
+
// `ws://` / `wss://` have to be allowed here, and only here. The default
|
|
1596
|
+
// `["http", "https"]` is what stops an ordinary HTTP request from being
|
|
1597
|
+
// pointed at a WebSocket scheme, and every other buildURL caller keeps it.
|
|
1598
|
+
// Without this opt-in the documented `client.ws("wss://…")` form — the one
|
|
1599
|
+
// in the README and in this method's own JSDoc — failed the SSRF scheme
|
|
1600
|
+
// check and threw EVALIDATION on every call, so `client.ws()` could not
|
|
1601
|
+
// connect to anything. Only the scheme list is widened: the loopback and
|
|
1602
|
+
// private-range checks still apply to WebSocket URLs.
|
|
1603
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, ["http", "https", "ws", "wss"], this.cfg.allowPrivateNetwork);
|
|
1386
1604
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
1387
1605
|
// Apply auth headers manually since WS handshake goes through the browser
|
|
1388
1606
|
// WS API which doesn't use the kinetex transport pipeline.
|
|
@@ -1415,7 +1633,11 @@ export class Kinetex {
|
|
|
1415
1633
|
// Inject cookies from cookie jar into WS handshake
|
|
1416
1634
|
const jar = await this.getCookieJar();
|
|
1417
1635
|
if (jar) {
|
|
1418
|
-
const cookieHeader = jar.getCookieHeader({
|
|
1636
|
+
const cookieHeader = jar.getCookieHeader({
|
|
1637
|
+
url: fullURL,
|
|
1638
|
+
http: false,
|
|
1639
|
+
sameSiteContext: "strict",
|
|
1640
|
+
});
|
|
1419
1641
|
if (cookieHeader)
|
|
1420
1642
|
headers["cookie"] = cookieHeader;
|
|
1421
1643
|
}
|
|
@@ -1424,8 +1646,20 @@ export class Kinetex {
|
|
|
1424
1646
|
const wsUrl = new URL(fullURL);
|
|
1425
1647
|
if (this.cfg.baseURL) {
|
|
1426
1648
|
const baseUrl = new URL(this.cfg.baseURL);
|
|
1427
|
-
|
|
1428
|
-
|
|
1649
|
+
// `https:` counts as secure here, not just `wss:`. `fullURL` comes
|
|
1650
|
+
// from `buildURL` against the same baseURL, so it carries the baseURL's
|
|
1651
|
+
// own scheme — `https:` — and the WebSocket upgrade happens later, in
|
|
1652
|
+
// the runtime's `WebSocket`. Testing for `wss:` alone therefore made
|
|
1653
|
+
// `wsIsSecure` false for every https baseURL while `httpIsSecure` was
|
|
1654
|
+
// true, so `client.ws()` rejected its own configuration and could not
|
|
1655
|
+
// open a socket at all. The error even printed two identical origins.
|
|
1656
|
+
const wsIsSecure = wsUrl.protocol === "wss:" || wsUrl.protocol === "https:";
|
|
1657
|
+
// A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
|
|
1658
|
+
// "wss://…" })` then `client.ws("/path")` is the natural spelling, and
|
|
1659
|
+
// it is what the README's origin-validation section shows. Comparing
|
|
1660
|
+
// `wss:` only against `https:` rejected that pairing outright, so a
|
|
1661
|
+
// client configured with a WebSocket baseURL could never open a socket.
|
|
1662
|
+
const httpIsSecure = baseUrl.protocol === "https:" || baseUrl.protocol === "wss:";
|
|
1429
1663
|
if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
|
|
1430
1664
|
throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
|
|
1431
1665
|
}
|
|
@@ -1478,6 +1712,12 @@ export class Kinetex {
|
|
|
1478
1712
|
this._wsClients.add(client);
|
|
1479
1713
|
await client.connect().catch((err) => {
|
|
1480
1714
|
this._wsClients.delete(client);
|
|
1715
|
+
// Destroy, not just deregister. A handshake that fails before `open`
|
|
1716
|
+
// rejects with the socket still referenced by the WSClient — its
|
|
1717
|
+
// handlers, its buffer and the underlying handle — so dropping the
|
|
1718
|
+
// registry entry alone left it alive and holding a socket for the life of
|
|
1719
|
+
// the process. A caller that retried on failure leaked one per attempt.
|
|
1720
|
+
client.destroy();
|
|
1481
1721
|
throw err;
|
|
1482
1722
|
});
|
|
1483
1723
|
return client;
|
|
@@ -1501,7 +1741,23 @@ export class Kinetex {
|
|
|
1501
1741
|
// ── Resolve timeout ────────────────────────────────────────────────────
|
|
1502
1742
|
const timeoutMs = options.timeout !== undefined ? options.timeout : (this.cfg.timeout ?? 30_000);
|
|
1503
1743
|
// ── Resolve retry ──────────────────────────────────────────────────────
|
|
1504
|
-
|
|
1744
|
+
// `totalTimeoutMs` defaults to the request timeout, so `timeout` bounds the
|
|
1745
|
+
// whole logical request rather than each attempt. It applied per attempt
|
|
1746
|
+
// only, while the backoff between attempts was bounded by nothing: with
|
|
1747
|
+
// `timeout: 1_000`, five retries and a growing `baseDelayMs`, an instantly
|
|
1748
|
+
// failing endpoint held the caller for ~37s. Anyone who reads `timeout` as
|
|
1749
|
+
// "this call must not take longer than this" — most people — was wrong.
|
|
1750
|
+
// `timeout: 0` (no timeout) likewise yields no budget, and an explicit
|
|
1751
|
+
// `totalTimeoutMs: 0` opts back out.
|
|
1752
|
+
const retryCfg = options.retry === false
|
|
1753
|
+
? false
|
|
1754
|
+
: {
|
|
1755
|
+
...this.retryConfig,
|
|
1756
|
+
...(options.retry ?? {}),
|
|
1757
|
+
...(options.retry?.totalTimeoutMs !== undefined
|
|
1758
|
+
? {}
|
|
1759
|
+
: { totalTimeoutMs: options.totalTimeoutMs ?? this.cfg.totalTimeoutMs ?? timeoutMs }),
|
|
1760
|
+
};
|
|
1505
1761
|
// ── Validate HTTP method ────────────────────────────────────────────────
|
|
1506
1762
|
const VALID_METHODS = [
|
|
1507
1763
|
"GET",
|
|
@@ -1519,16 +1775,18 @@ export class Kinetex {
|
|
|
1519
1775
|
throw new KinetexError(`Invalid HTTP method '${method}'. Valid methods: ${VALID_METHODS.join(", ")}`, "EVALIDATION");
|
|
1520
1776
|
}
|
|
1521
1777
|
// ── Build initial request ─────────────────────────────────────────────
|
|
1522
|
-
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
"
|
|
1778
|
+
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params), undefined, this.cfg.allowPrivateNetwork === true);
|
|
1779
|
+
// A client-level `proxy` is honoured by the Node transport, which tunnels
|
|
1780
|
+
// every connection through it with CONNECT. A per-request `proxy` cannot be:
|
|
1781
|
+
// the transport pools one connection per origin, so honouring a per-request
|
|
1782
|
+
// proxy would mean tearing the pool down mid-flight. Reject it with an
|
|
1783
|
+
// accurate reason rather than silently ignoring it.
|
|
1784
|
+
const perRequestProxy = options.proxy;
|
|
1785
|
+
if (perRequestProxy) {
|
|
1786
|
+
throw new KinetexError("A per-request `proxy` is not supported because the transport pools one connection " +
|
|
1787
|
+
"per origin — set `proxy` on the client instead, or use a custom `fetch` with a " +
|
|
1788
|
+
"proxy agent for per-request routing. SOCKS5 requires createSocks5Tunnel() from " +
|
|
1789
|
+
"kinetex/socks5.", "EVALIDATION");
|
|
1532
1790
|
}
|
|
1533
1791
|
// Enforce HTTPS-only if configured
|
|
1534
1792
|
if (this.cfg.httpsOnly) {
|
|
@@ -1544,6 +1802,14 @@ export class Kinetex {
|
|
|
1544
1802
|
throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
|
|
1545
1803
|
}
|
|
1546
1804
|
}
|
|
1805
|
+
// A multipart body has no size until it is encoded, and the encoding is
|
|
1806
|
+
// the same call the dispatch path makes below, so it is done once here and
|
|
1807
|
+
// the result reused. The guard used to estimate it with a flat 76 bytes per
|
|
1808
|
+
// part, which is less than the framing `encodeMultipart` actually writes
|
|
1809
|
+
// for its 70-character generated boundary: a form sized to exactly the
|
|
1810
|
+
// estimate passed this check and then went out roughly 45 bytes per part
|
|
1811
|
+
// over the limit the caller had set.
|
|
1812
|
+
let preEncodedForm;
|
|
1547
1813
|
// Enforce request size limit if configured
|
|
1548
1814
|
const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
|
|
1549
1815
|
if (maxRequestSize > 0 && options.body) {
|
|
@@ -1571,19 +1837,13 @@ export class Kinetex {
|
|
|
1571
1837
|
bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
|
|
1572
1838
|
}
|
|
1573
1839
|
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
|
|
1840
|
+
// FIX (H4): the old branch skipped FormData entirely, which allowed
|
|
1841
|
+
// unbounded uploads past the configured limit; it then replaced the
|
|
1842
|
+
// skip with an estimate whose per-part constant was smaller than the
|
|
1843
|
+
// framing it stood in for. The exact bytes are the only honest
|
|
1844
|
+
// answer, and they are needed a few lines below anyway.
|
|
1845
|
+
preEncodedForm = await encodeMultipart(options.body);
|
|
1846
|
+
bodySize = preEncodedForm.bytes.byteLength;
|
|
1587
1847
|
}
|
|
1588
1848
|
else if (options.body instanceof ReadableStream) {
|
|
1589
1849
|
// FIX (H4): a stream's size cannot be known without consuming it —
|
|
@@ -1630,6 +1890,25 @@ export class Kinetex {
|
|
|
1630
1890
|
meta: { ...options.meta },
|
|
1631
1891
|
httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
|
|
1632
1892
|
};
|
|
1893
|
+
// A `FormData` body is encoded here rather than handed to the transport,
|
|
1894
|
+
// because the encoding and the `Content-Type` that describes it have to be
|
|
1895
|
+
// produced together. The raw Node transports bypass fetch, and
|
|
1896
|
+
// `serializeRawBody` — the function written so they would not send an empty
|
|
1897
|
+
// body — covered `URLSearchParams` and `Blob` but not `FormData`, so on the
|
|
1898
|
+
// default transport on Node a form upload went out with no body and no
|
|
1899
|
+
// `Content-Type` and the server recorded an empty form. The response was an
|
|
1900
|
+
// ordinary 200, so nothing downstream could tell.
|
|
1901
|
+
if (req.body !== null && req.body instanceof FormData && !req.headers["content-type"]) {
|
|
1902
|
+
const encoded = preEncodedForm ?? (await encodeMultipart(req.body));
|
|
1903
|
+
req = {
|
|
1904
|
+
...req,
|
|
1905
|
+
headers: {
|
|
1906
|
+
...req.headers,
|
|
1907
|
+
"content-type": `multipart/form-data; boundary=${encoded.boundary}`,
|
|
1908
|
+
},
|
|
1909
|
+
body: encoded.bytes,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1633
1912
|
// Default Content-Type for JSON bodies
|
|
1634
1913
|
if (req.body !== null &&
|
|
1635
1914
|
typeof req.body === "object" &&
|
|
@@ -1668,7 +1947,11 @@ export class Kinetex {
|
|
|
1668
1947
|
// ── Cookie jar (outgoing cookies) ──────────────────────────────────────
|
|
1669
1948
|
const jar = await this.getCookieJar();
|
|
1670
1949
|
if (jar) {
|
|
1671
|
-
const cookieHeader = jar.getCookieHeader({
|
|
1950
|
+
const cookieHeader = jar.getCookieHeader({
|
|
1951
|
+
url: req.url,
|
|
1952
|
+
http: true,
|
|
1953
|
+
sameSiteContext: "strict",
|
|
1954
|
+
});
|
|
1672
1955
|
if (cookieHeader)
|
|
1673
1956
|
req = { ...req, headers: { ...req.headers, cookie: cookieHeader } };
|
|
1674
1957
|
}
|
|
@@ -1684,6 +1967,7 @@ export class Kinetex {
|
|
|
1684
1967
|
// abandoning it — an unended span is never exported and never reports the
|
|
1685
1968
|
// error, and holds its attributes in the tracer's memory.
|
|
1686
1969
|
let _otelSpan = null;
|
|
1970
|
+
let _dedupKey = "";
|
|
1687
1971
|
try {
|
|
1688
1972
|
if (this._otelTracer) {
|
|
1689
1973
|
_otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
|
|
@@ -1712,6 +1996,24 @@ export class Kinetex {
|
|
|
1712
1996
|
meta: { ...req.meta, spanId },
|
|
1713
1997
|
};
|
|
1714
1998
|
}
|
|
1999
|
+
// Dedup key. Computed inside this try, not below it, so that a failure
|
|
2000
|
+
// here ends the span instead of abandoning it. The auth fingerprint needs
|
|
2001
|
+
// Web Crypto, so it can throw on a runtime without `crypto.subtle` — and
|
|
2002
|
+
// because it is an `await`, the throw resumes here, outside the span's
|
|
2003
|
+
// lifecycle when this block sat below the catch. The span was then never
|
|
2004
|
+
// ended, never exported, and held its attributes in the tracer's memory.
|
|
2005
|
+
if (this._dedup) {
|
|
2006
|
+
// Fingerprint *every* header, not just the credential ones. Keying on
|
|
2007
|
+
// `Authorization`/`Cookie` alone is what the comment above promised,
|
|
2008
|
+
// and it held — for the two headers it covered. It did not cover the
|
|
2009
|
+
// header a multi-tenant gateway actually partitions on: two concurrent
|
|
2010
|
+
// GETs to one URL differing only in `X-Tenant` shared a single response,
|
|
2011
|
+
// so one tenant was handed another's data. Hashing keeps the values out
|
|
2012
|
+
// of the key itself, and the trace-id headers are excluded so they
|
|
2013
|
+
// cannot silently disable coalescing.
|
|
2014
|
+
const identity = await identityFingerprint(req.headers);
|
|
2015
|
+
_dedupKey = dedupKeyFrom(req.method, req.url, identity);
|
|
2016
|
+
}
|
|
1715
2017
|
}
|
|
1716
2018
|
catch (tracingErr) {
|
|
1717
2019
|
if (_otelSpan) {
|
|
@@ -1752,16 +2054,12 @@ export class Kinetex {
|
|
|
1752
2054
|
const _execFactory = () => this._executeWithRetry(req, retryCfg, timeoutMs, options, startMs, wallClockMs);
|
|
1753
2055
|
// Dedup: coalesce identical concurrent GET/HEAD requests into one network
|
|
1754
2056
|
// call. All callers share the same KinetexResponse object once resolved.
|
|
1755
|
-
// SECURITY:
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
// Fingerprinting hashes the
|
|
1759
|
-
// paid when dedup is actually enabled.
|
|
1760
|
-
|
|
1761
|
-
if (this._dedup) {
|
|
1762
|
-
const authFp = await getAuthFingerprint(req.headers ?? {});
|
|
1763
|
-
_dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
|
|
1764
|
-
}
|
|
2057
|
+
// SECURITY: the dedup key fingerprints every request header, so two
|
|
2058
|
+
// requests are only ever coalesced when they would have received the same
|
|
2059
|
+
// response — including when they differ only by tenant or session header.
|
|
2060
|
+
// Fingerprinting hashes the header set with SHA-256, so credentials never
|
|
2061
|
+
// sit in the key, and it is only paid when dedup is actually enabled. The
|
|
2062
|
+
// key is computed inside the tracing try above, where a throw ends the span.
|
|
1765
2063
|
const _dedupedFactory = this._dedup
|
|
1766
2064
|
? () => this._dedup
|
|
1767
2065
|
.execute(req.method, _dedupKey, _execFactory)
|
|
@@ -1801,6 +2099,82 @@ export class Kinetex {
|
|
|
1801
2099
|
* and retries on failure per the retry config.
|
|
1802
2100
|
*/
|
|
1803
2101
|
async _executeWithRetry(req, retryCfg, timeout, options, startMs, wallClockMs) {
|
|
2102
|
+
// A permit is held for the whole logical request — including every retry
|
|
2103
|
+
// attempt — and released in `finally`, so a throw or an exhausted retry
|
|
2104
|
+
// budget can never leak one and permanently shrink the pool.
|
|
2105
|
+
const run = async () => {
|
|
2106
|
+
if (!this._concurrencyLimiter) {
|
|
2107
|
+
return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
|
|
2108
|
+
}
|
|
2109
|
+
await this._concurrencyLimiter.acquire(req.signal);
|
|
2110
|
+
try {
|
|
2111
|
+
return await this._executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs);
|
|
2112
|
+
}
|
|
2113
|
+
finally {
|
|
2114
|
+
this._concurrencyLimiter.release();
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
// Metrics measure the whole logical request, retries included, and are
|
|
2118
|
+
// emitted from `finally` so failures and aborts are counted too. The
|
|
2119
|
+
// no-tracer case is not short-circuited here: `_recordMetrics` returns
|
|
2120
|
+
// immediately when telemetry is off, and this client then pays nothing
|
|
2121
|
+
// beyond the call it already makes on every request.
|
|
2122
|
+
const metricsStart = Date.now();
|
|
2123
|
+
let status;
|
|
2124
|
+
let errorCode;
|
|
2125
|
+
try {
|
|
2126
|
+
const res = await run();
|
|
2127
|
+
status = res.status;
|
|
2128
|
+
return res;
|
|
2129
|
+
}
|
|
2130
|
+
catch (err) {
|
|
2131
|
+
errorCode = err.code;
|
|
2132
|
+
throw err;
|
|
2133
|
+
}
|
|
2134
|
+
finally {
|
|
2135
|
+
this._recordMetrics(req, status, errorCode, Date.now() - metricsStart);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Emit request metrics to the configured tracer, if it supports them.
|
|
2140
|
+
*
|
|
2141
|
+
* Failures here are swallowed on purpose: telemetry must never be able to
|
|
2142
|
+
* fail a request that otherwise succeeded.
|
|
2143
|
+
*
|
|
2144
|
+
* @param req - The originating request.
|
|
2145
|
+
* @param status - Final HTTP status, or undefined if the request threw.
|
|
2146
|
+
* @param errorCode - `KinetexError` code, or undefined on success.
|
|
2147
|
+
* @param durationMs - Wall-clock duration of the logical request.
|
|
2148
|
+
*/
|
|
2149
|
+
_recordMetrics(req, status, errorCode, durationMs) {
|
|
2150
|
+
const tracer = this._otelTracer;
|
|
2151
|
+
if (!tracer)
|
|
2152
|
+
return;
|
|
2153
|
+
// `req.url` is always an absolute, already-parsed URL by the time a
|
|
2154
|
+
// request reaches here: `buildURL()` constructs it and the SSRF safety
|
|
2155
|
+
// check parses it again before dispatch, so there is nothing to guard.
|
|
2156
|
+
const attributes = {
|
|
2157
|
+
"http.request.method": req.method,
|
|
2158
|
+
"server.address": new URL(req.url).hostname,
|
|
2159
|
+
};
|
|
2160
|
+
if (status !== undefined)
|
|
2161
|
+
attributes["http.response.status_code"] = status;
|
|
2162
|
+
if (errorCode !== undefined)
|
|
2163
|
+
attributes["error.type"] = errorCode;
|
|
2164
|
+
try {
|
|
2165
|
+
// Seconds, per OTel semantic conventions for http.client.request.duration.
|
|
2166
|
+
tracer.recordHistogram?.("http.client.request.duration", durationMs / 1000, attributes);
|
|
2167
|
+
tracer.incrementCounter?.("http.client.request.count", 1, attributes);
|
|
2168
|
+
if (errorCode !== undefined) {
|
|
2169
|
+
tracer.incrementCounter?.("http.client.error.count", 1, attributes);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
catch {
|
|
2173
|
+
// Swallowed — see the doc comment.
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
/** Retry loop body. See {@link _executeWithRetry} for the concurrency gate. */
|
|
2177
|
+
async _executeWithRetryInner(req, retryCfg, timeout, options, startMs, wallClockMs) {
|
|
1804
2178
|
let attempt = 0;
|
|
1805
2179
|
while (true) {
|
|
1806
2180
|
attempt++;
|
|
@@ -1831,6 +2205,14 @@ export class Kinetex {
|
|
|
1831
2205
|
: shouldRetry(retryCfg, retryCtx);
|
|
1832
2206
|
if (doRetry) {
|
|
1833
2207
|
const delay = computeRetryDelay(retryCfg, attempt, getRetryAfterMs(res.headers));
|
|
2208
|
+
// Total budget: a backoff that would run past it is not worth
|
|
2209
|
+
// sleeping through. The caller gets the response they already have
|
|
2210
|
+
// (a retry-worthy status) instead of a manufactured timeout, which
|
|
2211
|
+
// keeps the real reason visible.
|
|
2212
|
+
if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
|
|
2213
|
+
if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs)
|
|
2214
|
+
return res;
|
|
2215
|
+
}
|
|
1834
2216
|
await retryCfg.onRetry?.(retryCtx, delay);
|
|
1835
2217
|
await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
|
|
1836
2218
|
await p;
|
|
@@ -1852,20 +2234,67 @@ export class Kinetex {
|
|
|
1852
2234
|
method: req.method,
|
|
1853
2235
|
});
|
|
1854
2236
|
}
|
|
1855
|
-
|
|
2237
|
+
// Clock-skew correction. `SigV4Signer.handleClockSkewError` existed and
|
|
2238
|
+
// `detectClockSkew` was a public export, but nothing in the library
|
|
2239
|
+
// ever called them: a client with a wrong clock got a 403
|
|
2240
|
+
// `RequestTimeTooSkewed`, retried if 403 happened to be in `statuses`,
|
|
2241
|
+
// and re-signed the identical wrong timestamp every attempt. Correcting
|
|
2242
|
+
// here is bounded to one attempt per logical request, so a server that
|
|
2243
|
+
// keeps reporting a different time cannot spin.
|
|
2244
|
+
let clockSkewCorrected = false;
|
|
2245
|
+
const skewResponse = err.response;
|
|
2246
|
+
if (this._awsSigner && skewResponse && err.code === "EHTTPSTATUS") {
|
|
2247
|
+
// `data` is the parsed body: a string for text, a Uint8Array for any
|
|
2248
|
+
// other content type (which is what an XML error body arrives as),
|
|
2249
|
+
// and an object only for JSON.
|
|
2250
|
+
const raw = skewResponse.data;
|
|
2251
|
+
const body = typeof raw === "string"
|
|
2252
|
+
? raw
|
|
2253
|
+
: raw instanceof Uint8Array
|
|
2254
|
+
? new TextDecoder().decode(raw)
|
|
2255
|
+
: raw instanceof ArrayBuffer
|
|
2256
|
+
? new TextDecoder().decode(new Uint8Array(raw))
|
|
2257
|
+
: JSON.stringify(raw ?? "");
|
|
2258
|
+
if (this._awsSigner.handleClockSkewError(skewResponse.status, body, skewResponse.headers)) {
|
|
2259
|
+
clockSkewCorrected = !req.meta[AWS_SKEW_CORRECTED];
|
|
2260
|
+
if (clockSkewCorrected)
|
|
2261
|
+
req.meta[AWS_SKEW_CORRECTED] = true;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
if (retryCfg && (attempt <= retryCfg.maxRetries || clockSkewCorrected)) {
|
|
2265
|
+
// A thrown HTTPStatusError already carries the full response. This
|
|
2266
|
+
// path used to hand hooks a context with `response: null` and a hard
|
|
2267
|
+
// `null` Retry-After, so the *default* case — throwOnError: true,
|
|
2268
|
+
// which is what every 429/503 goes through — ignored the server's
|
|
2269
|
+
// Retry-After entirely and never fired lifecycle onRetry hooks.
|
|
2270
|
+
const errResponse = skewResponse ?? null;
|
|
1856
2271
|
const retryCtx = {
|
|
1857
2272
|
request: req,
|
|
1858
|
-
response:
|
|
2273
|
+
response: errResponse,
|
|
1859
2274
|
error: err,
|
|
1860
2275
|
attempt,
|
|
1861
2276
|
maxRetries: retryCfg.maxRetries,
|
|
1862
2277
|
};
|
|
1863
|
-
const doRetry =
|
|
1864
|
-
?
|
|
1865
|
-
: shouldRetry
|
|
2278
|
+
const doRetry = clockSkewCorrected
|
|
2279
|
+
? true
|
|
2280
|
+
: retryCfg.shouldRetry
|
|
2281
|
+
? await retryCfg.shouldRetry(retryCtx)
|
|
2282
|
+
: shouldRetry(retryCfg, retryCtx);
|
|
1866
2283
|
if (doRetry) {
|
|
1867
|
-
const delay = computeRetryDelay(retryCfg, attempt, null);
|
|
2284
|
+
const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
|
|
2285
|
+
// Total budget, as above: do not sleep past the caller's ceiling.
|
|
2286
|
+
// Here there is no response to fall back on, so the original error
|
|
2287
|
+
// stands and is rethrown below.
|
|
2288
|
+
if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
|
|
2289
|
+
if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs) {
|
|
2290
|
+
throw err;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
1868
2293
|
await retryCfg.onRetry?.(retryCtx, delay);
|
|
2294
|
+
await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
|
|
2295
|
+
await p;
|
|
2296
|
+
await fn(retryCtx);
|
|
2297
|
+
}, Promise.resolve());
|
|
1869
2298
|
await sleep(delay, req.signal);
|
|
1870
2299
|
continue;
|
|
1871
2300
|
}
|
|
@@ -1971,9 +2400,7 @@ export class Kinetex {
|
|
|
1971
2400
|
if (isRedirect) {
|
|
1972
2401
|
// Check for redirect loops
|
|
1973
2402
|
if (visited.has(raw.url)) {
|
|
1974
|
-
throw new
|
|
1975
|
-
request: req,
|
|
1976
|
-
});
|
|
2403
|
+
throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
|
|
1977
2404
|
}
|
|
1978
2405
|
visited.add(raw.url);
|
|
1979
2406
|
// Capture cookies from this redirect hop
|
|
@@ -1982,13 +2409,26 @@ export class Kinetex {
|
|
|
1982
2409
|
});
|
|
1983
2410
|
// `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
|
|
1984
2411
|
// the caller instead of chasing it — the same shape fetch() returns for
|
|
1985
|
-
// `redirect: "manual"`.
|
|
2412
|
+
// `redirect: "manual"`. It reported `redirected: true`, which is the one
|
|
2413
|
+
// value `redirected` must never take: no hop was taken, `res.url` is
|
|
2414
|
+
// still the request's own URL, and the whole point of the option is that
|
|
2415
|
+
// the caller now has to read `Location` and decide for itself. A caller
|
|
2416
|
+
// branching on `if (res.redirected)` to detect a cross-origin bounce was
|
|
2417
|
+
// told it had already been redirected to a URL it never requested.
|
|
1986
2418
|
if (!followRedirects)
|
|
1987
|
-
return { ...raw, redirected:
|
|
2419
|
+
return { ...raw, redirected: false };
|
|
1988
2420
|
if (hop === maxRedirects) {
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2421
|
+
// `EREDIRECT`, not `ENETWORK`. A redirect chain that has run out of
|
|
2422
|
+
// hops is a deterministic answer from the origin: the same request
|
|
2423
|
+
// produces the same chain. As an `ENETWORK` it fell into
|
|
2424
|
+
// `shouldRetry`'s network-error case and the whole chain was replayed
|
|
2425
|
+
// once per attempt — 16 requests against a server already looping,
|
|
2426
|
+
// under a `maxRedirects: 3` the caller had set, and after the whole
|
|
2427
|
+
// backoff schedule before the error they asked for finally arrived.
|
|
2428
|
+
// `shouldRetry` already had a non-retryable `EREDIRECT` case, and
|
|
2429
|
+
// `RedirectError` was already exported and documented; nothing
|
|
2430
|
+
// constructed it.
|
|
2431
|
+
throw new RedirectError(`Too many redirects (exceeded ${maxRedirects})`, req);
|
|
1992
2432
|
}
|
|
1993
2433
|
// Drain the redirect body (usually empty, but must be cancelled)
|
|
1994
2434
|
if (raw.body) {
|
|
@@ -2016,7 +2456,9 @@ export class Kinetex {
|
|
|
2016
2456
|
// public host could 302 the client straight at link-local/loopback
|
|
2017
2457
|
// addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
|
|
2018
2458
|
// private-network block list would be bypassable. Re-validate every hop.
|
|
2019
|
-
if (!isSafeURL(nextUrl
|
|
2459
|
+
if (!isSafeURL(nextUrl, undefined, {
|
|
2460
|
+
allowPrivateNetwork: this.cfg.allowPrivateNetwork === true,
|
|
2461
|
+
})) {
|
|
2020
2462
|
throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
|
|
2021
2463
|
}
|
|
2022
2464
|
// httpsOnly must hold for redirect legs too, otherwise a redirect is a
|
|
@@ -2039,6 +2481,15 @@ export class Kinetex {
|
|
|
2039
2481
|
? "GET"
|
|
2040
2482
|
: currentReq.method;
|
|
2041
2483
|
const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
|
|
2484
|
+
// A 307/308 keeps the method and the body, so the hop needs the body
|
|
2485
|
+
// again — and a stream or Blob body is spent: the first attempt consumed
|
|
2486
|
+
// and locked it. The retry loop already refuses this case with a clear
|
|
2487
|
+
// EVALIDATION, but the follower had no equivalent check, so it handed
|
|
2488
|
+
// the dead stream to the transport and the caller got an opaque ENETWORK
|
|
2489
|
+
// "Response body object should not be disturbed or locked".
|
|
2490
|
+
if (nextBody !== null && isNonReplayableBody(nextBody)) {
|
|
2491
|
+
throw new KinetexError("Cannot follow this redirect: a 307/308 preserves the method and body, and this request's body is a stream or Blob that the first attempt already consumed. Buffer it first, or disable redirects for this request.", "EVALIDATION", { request: currentReq });
|
|
2492
|
+
}
|
|
2042
2493
|
const nextHeaders = { ...currentReq.headers };
|
|
2043
2494
|
// FIX H2: When the redirect crosses origins, strip credential-bearing
|
|
2044
2495
|
// headers (Authorization, Cookie, proxy auth, API keys) so secrets are
|
|
@@ -2070,7 +2521,13 @@ export class Kinetex {
|
|
|
2070
2521
|
// Jar scoping guarantees only cookies that match the NEW origin are
|
|
2071
2522
|
// attached, which is exactly the post-strip state we want.
|
|
2072
2523
|
if (jar) {
|
|
2073
|
-
|
|
2524
|
+
// Same context as the initial hop: a redirect followed to an origin the
|
|
2525
|
+
// client chose is still a same-site request for cookie purposes.
|
|
2526
|
+
const cookieHeader = jar.getCookieHeader({
|
|
2527
|
+
url: nextUrl,
|
|
2528
|
+
http: true,
|
|
2529
|
+
sameSiteContext: "strict",
|
|
2530
|
+
});
|
|
2074
2531
|
if (cookieHeader) {
|
|
2075
2532
|
nextHeaders["cookie"] = cookieHeader;
|
|
2076
2533
|
}
|
|
@@ -2100,7 +2557,7 @@ export class Kinetex {
|
|
|
2100
2557
|
return hop > 0 ? { ...raw, redirected: true } : raw;
|
|
2101
2558
|
}
|
|
2102
2559
|
// Unreachable
|
|
2103
|
-
throw new
|
|
2560
|
+
throw new RedirectError("Redirect loop", req);
|
|
2104
2561
|
}
|
|
2105
2562
|
// ── §8.8 Single attempt ──────────────────────────────────────────────────
|
|
2106
2563
|
/**
|
|
@@ -2136,10 +2593,30 @@ export class Kinetex {
|
|
|
2136
2593
|
}
|
|
2137
2594
|
}
|
|
2138
2595
|
this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
|
|
2596
|
+
// ── Logger: request ────────────────────────────────────────────────────
|
|
2597
|
+
// `HTTPLogger.logRequest` was fully implemented — it registers the id so
|
|
2598
|
+
// the matching `logResponse` can recover the method, URL and duration —
|
|
2599
|
+
// and the client never called it. Nothing had ever been registered, so
|
|
2600
|
+
// every response entry came back with `url: ""`, `method: "GET"`,
|
|
2601
|
+
// `durationMs: 0` and `requestId: "unknown"`: the request line was simply
|
|
2602
|
+
// missing, and a structured log that cannot name the request it describes
|
|
2603
|
+
// is not much use for diagnosing one. Registered here, before dispatch, so
|
|
2604
|
+
// it covers the same span the response entry closes.
|
|
2605
|
+
const _logger = await this.getLogger();
|
|
2606
|
+
if (_logger) {
|
|
2607
|
+
_logger.logRequest(_traceId, req.method, req.url, req.headers, loggableBody(req.body), attempt);
|
|
2608
|
+
}
|
|
2139
2609
|
// ── Cache lookup ───────────────────────────────────────────────────────
|
|
2610
|
+
// `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
|
|
2611
|
+
// declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
|
|
2612
|
+
// exactly as if no option had been passed, so a warm entry was served and
|
|
2613
|
+
// the fluent method documented as "Force a fresh fetch, bypassing any cached
|
|
2614
|
+
// response" did not fetch. The fix is to skip the read for this request —
|
|
2615
|
+
// the write still happens, which is what "refresh" means.
|
|
2616
|
+
const forceRefresh = options.cache !== false && options.cache?.forceRefresh === true;
|
|
2140
2617
|
if (options.cache !== false && this.cfg.cache) {
|
|
2141
2618
|
const cache = await this.getCache();
|
|
2142
|
-
if (cache) {
|
|
2619
|
+
if (cache && !forceRefresh) {
|
|
2143
2620
|
const cacheReq = { url: req.url, method: req.method, headers: req.headers };
|
|
2144
2621
|
const hit = await cache.get(cacheReq);
|
|
2145
2622
|
if (hit && !hit.stale) {
|
|
@@ -2310,22 +2787,34 @@ export class Kinetex {
|
|
|
2310
2787
|
};
|
|
2311
2788
|
}
|
|
2312
2789
|
// ── Dispatch ───────────────────────────────────────────────────────────
|
|
2313
|
-
// Redirects are followed by hand
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2790
|
+
// Redirects are ALWAYS followed by hand, and that is not a preference.
|
|
2791
|
+
//
|
|
2792
|
+
// `_sendFollowingRedirects` is the only place kinetex checks a redirect
|
|
2793
|
+
// *target*: the SSRF gate (`isSafeURL` on every hop), the `httpsOnly`
|
|
2794
|
+
// policy, the redirect-loop detector, the `maxRedirects` cap, and the
|
|
2795
|
+
// RFC 7231 method downgrade for 301/302/303. It also exists to capture
|
|
2796
|
+
// intermediate Set-Cookie and to strip credentials across origins, which is
|
|
2797
|
+
// why it used to be entered only when a cookie jar or a forwarded credential
|
|
2798
|
+
// header happened to be configured.
|
|
2799
|
+
//
|
|
2800
|
+
// An ordinary GET is neither. So an ordinary GET's redirects were chased by
|
|
2801
|
+
// the transport instead, and every check above was skipped: the HTTP/2
|
|
2802
|
+
// transport's own loop resolved any `Location` and dialled it, and
|
|
2803
|
+
// `FetchTransport` handed fetch `redirect: "follow"`, which does the same.
|
|
2804
|
+
// A 302 to `http://127.0.0.1:9/` opened the socket (`ECONNREFUSED` came
|
|
2805
|
+
// back from the loopback port, which is the point — nothing refused the
|
|
2806
|
+
// connection first), and a 302 to `http://169.254.169.254/` was answered by
|
|
2807
|
+
// the cloud metadata service. `httpsOnly: true` changed nothing on either
|
|
2808
|
+
// path; both reported the same opaque `Protocol error`.
|
|
2809
|
+
//
|
|
2810
|
+
// The two reasons the follower was originally introduced are reasons it is
|
|
2811
|
+
// *necessary*, not an exhaustive list of when it applies.
|
|
2320
2812
|
const dispatchJar = await this.getCookieJar();
|
|
2321
2813
|
const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
2322
|
-
const needsManualRedirects = dispatchJar !== null || hasForwardedCredentials(req.headers, effectiveAuth);
|
|
2323
2814
|
this._trace(_traceId, "transport_send", "start", startMs, attempt);
|
|
2324
2815
|
let raw;
|
|
2325
2816
|
try {
|
|
2326
|
-
raw =
|
|
2327
|
-
? await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth)
|
|
2328
|
-
: await sendWithTimeout(this.transport, req, timeout);
|
|
2817
|
+
raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
|
|
2329
2818
|
}
|
|
2330
2819
|
catch (err) {
|
|
2331
2820
|
// Cancel the progress-tracking ReadableStream to release the underlying
|
|
@@ -2343,6 +2832,20 @@ export class Kinetex {
|
|
|
2343
2832
|
throw err;
|
|
2344
2833
|
}
|
|
2345
2834
|
this._trace(_traceId, "transport_send", "end", startMs, attempt);
|
|
2835
|
+
// ── Lifecycle: after request ───────────────────────────────────────────
|
|
2836
|
+
// "After the request is sent (before response is processed)" — the window
|
|
2837
|
+
// between the transport answering and the response being built, and the
|
|
2838
|
+
// only place a caller can observe that the wire round trip is over without
|
|
2839
|
+
// also having to see the parsed response. It was declared on `LifecycleHooks`
|
|
2840
|
+
// and documented twice in the README, and never invoked: a caller who
|
|
2841
|
+
// registered it got silence, on every code path, for every status.
|
|
2842
|
+
//
|
|
2843
|
+
// Deliberately *not* fired on the throw above: the request was not sent.
|
|
2844
|
+
if (this.cfg.hooks?.onAfterRequest) {
|
|
2845
|
+
for (const fn of this.cfg.hooks.onAfterRequest) {
|
|
2846
|
+
await fn(req, this._hookCtx(ctx));
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2346
2849
|
// ── Handle 304 Not Modified ────────────────────────────────────────────
|
|
2347
2850
|
if (raw.status === 304) {
|
|
2348
2851
|
const cache = await this.getCache();
|
|
@@ -2424,7 +2927,11 @@ export class Kinetex {
|
|
|
2424
2927
|
});
|
|
2425
2928
|
}
|
|
2426
2929
|
// ── Read body with size limit ──────────────────────────────────────────
|
|
2427
|
-
|
|
2930
|
+
// 100 MB by default rather than unbounded: without a cap a hostile or
|
|
2931
|
+
// simply broken server can drive this process out of memory, because the
|
|
2932
|
+
// body is read in full before the caller sees a byte of it. An explicit
|
|
2933
|
+
// `0` opts back out, which is what that value has always meant.
|
|
2934
|
+
const maxSize = options.maxResponseSize ?? this.cfg.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE;
|
|
2428
2935
|
const rawBody = await readRawBody(bodyStream, maxSize, raw.url, req.signal);
|
|
2429
2936
|
// ── Cookie jar (incoming Set-Cookie) ──────────────────────────────────
|
|
2430
2937
|
const jar = await this.getCookieJar();
|
|
@@ -2448,6 +2955,9 @@ export class Kinetex {
|
|
|
2448
2955
|
// the real response object instead of the `{}` placeholder it used to get.
|
|
2449
2956
|
data,
|
|
2450
2957
|
rawBody,
|
|
2958
|
+
// The same bytes in the shape an interceptor sees, so one response
|
|
2959
|
+
// object satisfies both the public and the pipeline views.
|
|
2960
|
+
body: rawBody,
|
|
2451
2961
|
url: raw.url,
|
|
2452
2962
|
cached: false,
|
|
2453
2963
|
redirected: raw.redirected,
|
|
@@ -2472,9 +2982,9 @@ export class Kinetex {
|
|
|
2472
2982
|
}
|
|
2473
2983
|
}
|
|
2474
2984
|
// ── Logger ─────────────────────────────────────────────────────────────
|
|
2475
|
-
const logger = await this.getLogger();
|
|
2985
|
+
const logger = _logger ?? (await this.getLogger());
|
|
2476
2986
|
if (logger) {
|
|
2477
|
-
const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ??
|
|
2987
|
+
const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? _traceId;
|
|
2478
2988
|
logger.logResponse(requestId, res.status, res.statusText, res.headers, rawBody, attempt, false);
|
|
2479
2989
|
}
|
|
2480
2990
|
// ── HAR recording ──────────────────────────────────────────────────────
|
|
@@ -2635,6 +3145,8 @@ export class Kinetex {
|
|
|
2635
3145
|
status: raw.status,
|
|
2636
3146
|
statusText: raw.statusText,
|
|
2637
3147
|
headers: raw.headers,
|
|
3148
|
+
// Present so the object satisfies both response views at once.
|
|
3149
|
+
body: rawBody,
|
|
2638
3150
|
data,
|
|
2639
3151
|
rawBody,
|
|
2640
3152
|
url: responseURL,
|
|
@@ -2753,7 +3265,7 @@ export class Kinetex {
|
|
|
2753
3265
|
async sse(url, options = {}) {
|
|
2754
3266
|
const { SSEClient } = await import("./sse.js");
|
|
2755
3267
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
2756
|
-
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
|
|
3268
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
|
|
2757
3269
|
// Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, etc.)
|
|
2758
3270
|
// by providing a pipeline-aware fetch function.
|
|
2759
3271
|
const pipeFetch = async (input, init) => {
|
|
@@ -2794,7 +3306,7 @@ export class Kinetex {
|
|
|
2794
3306
|
async graphql(url, options = {}) {
|
|
2795
3307
|
const { GraphQLClient } = await import("./graphql.js");
|
|
2796
3308
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
2797
|
-
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
|
|
3309
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
|
|
2798
3310
|
// Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, OTel, etc.)
|
|
2799
3311
|
const pipeFetch = async (input, init) => {
|
|
2800
3312
|
const reqUrl = typeof input === "string" ? input : input.url;
|
|
@@ -2833,7 +3345,7 @@ export class Kinetex {
|
|
|
2833
3345
|
*/
|
|
2834
3346
|
async paginate(url, options) {
|
|
2835
3347
|
const { createPagePaginator } = await import("./pagination.js");
|
|
2836
|
-
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
|
|
3348
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
|
|
2837
3349
|
// Route through the full kinetex pipeline
|
|
2838
3350
|
const pipeFetch = async (input, init) => {
|
|
2839
3351
|
const reqUrl = typeof input === "string" ? input : input.url;
|
|
@@ -2886,6 +3398,8 @@ export class Kinetex {
|
|
|
2886
3398
|
if (IS_NODE && this.transport && "destroy" in this.transport) {
|
|
2887
3399
|
this.transport.destroy();
|
|
2888
3400
|
}
|
|
3401
|
+
// Reject anyone still parked on the queue — it can never drain now.
|
|
3402
|
+
this._concurrencyLimiter?.drain();
|
|
2889
3403
|
this._cookieJar = null;
|
|
2890
3404
|
this._logger = null;
|
|
2891
3405
|
this._dedup?.clear();
|
|
@@ -2940,6 +3454,34 @@ export class FluentRequest {
|
|
|
2940
3454
|
};
|
|
2941
3455
|
return this;
|
|
2942
3456
|
}
|
|
3457
|
+
/**
|
|
3458
|
+
* Attach an `Idempotency-Key`, generating one when none is given.
|
|
3459
|
+
*
|
|
3460
|
+
* Lets a retried `POST` be recognised as the same logical operation by the
|
|
3461
|
+
* server instead of creating a duplicate. Because the header is set on the
|
|
3462
|
+
* request options — not regenerated per attempt — every retry of this
|
|
3463
|
+
* request carries the same key, which is the entire point.
|
|
3464
|
+
*
|
|
3465
|
+
* @param value - An explicit key, or `undefined` to generate a v4 UUID.
|
|
3466
|
+
* @throws {TypeError} If `value` is not a valid key (see `isValidIdempotencyKey`).
|
|
3467
|
+
*
|
|
3468
|
+
* @example
|
|
3469
|
+
* ```ts
|
|
3470
|
+
* await client.POST("/charges").withJSON(body).idempotencyKey().json();
|
|
3471
|
+
* ```
|
|
3472
|
+
*/
|
|
3473
|
+
idempotencyKey(value) {
|
|
3474
|
+
const key = value === undefined ? generateIdempotencyKey() : value;
|
|
3475
|
+
if (!isValidIdempotencyKey(key)) {
|
|
3476
|
+
throw new TypeError(`idempotencyKey: ${typeof key === "string" ? JSON.stringify(key) : typeof key} is not a ` +
|
|
3477
|
+
"valid Idempotency-Key — expected 1-255 visible ASCII characters");
|
|
3478
|
+
}
|
|
3479
|
+
this._options.headers = {
|
|
3480
|
+
...this._options.headers,
|
|
3481
|
+
"idempotency-key": key,
|
|
3482
|
+
};
|
|
3483
|
+
return this;
|
|
3484
|
+
}
|
|
2943
3485
|
/** Merge a headers map. */
|
|
2944
3486
|
headers(headers) {
|
|
2945
3487
|
this._options.headers = {
|
|
@@ -3225,10 +3767,6 @@ function isNonReplayableBody(body) {
|
|
|
3225
3767
|
return true;
|
|
3226
3768
|
return typeof Blob !== "undefined" && body instanceof Blob;
|
|
3227
3769
|
}
|
|
3228
|
-
/** Cross-runtime performance.now() — falls back to Date.now(). */
|
|
3229
|
-
function perfNow() {
|
|
3230
|
-
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
3231
|
-
}
|
|
3232
3770
|
// ============================================================================
|
|
3233
3771
|
// §16 ENTERPRISE HELPERS
|
|
3234
3772
|
// ============================================================================
|