kinetex 1.2.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 +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- 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 +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- 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 +169 -16
- 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 +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- 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 +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- 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/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- 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 +98 -23
- 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 +109 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +31 -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 +62 -29
- 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 +23 -4
- 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 +139 -5
- 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/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/esm/client.js
CHANGED
|
@@ -7,13 +7,20 @@ 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, 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
|
-
import { getAuthFingerprint } from "./cache.js";
|
|
13
|
-
import { createRateLimitInterceptor } from "./interceptors.js";
|
|
12
|
+
import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
|
|
13
|
+
import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
|
|
14
14
|
import { SigV4Signer } from "./aws-sigv4.js";
|
|
15
|
-
import {
|
|
15
|
+
import { createDigestAuthorizer } from "./digest.js";
|
|
16
|
+
import { generateIdempotencyKey, isValidIdempotencyKey, parseRetryAfter } from "./headers.js";
|
|
17
|
+
import { DEFAULT_ACCEPT_ENCODING, encodeMultipart } from "./core.js";
|
|
16
18
|
import { createTransport, sendWithTimeout, decompressBodyStream, readRawBody, parseBody, RUNTIME, IS_NODE, } from "./core.js";
|
|
19
|
+
/**
|
|
20
|
+
* Hard ceiling on redirect hops followed by the manual redirect follower.
|
|
21
|
+
* Overridable per client / per request with `maxRedirects`.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_MAX_REDIRECTS = 20;
|
|
17
24
|
/** Default retry configuration used when no retry config is provided. */
|
|
18
25
|
const DEFAULT_RETRY = {
|
|
19
26
|
maxRetries: 3,
|
|
@@ -160,14 +167,118 @@ const HAR_REDACT_HEADERS = new Set([
|
|
|
160
167
|
"passwd",
|
|
161
168
|
"secret",
|
|
162
169
|
]);
|
|
170
|
+
/**
|
|
171
|
+
* `meta` key carrying the number of response-interceptor re-sends so a
|
|
172
|
+
* self-retriggering interceptor cannot loop forever.
|
|
173
|
+
*/
|
|
174
|
+
/** Request-meta key: a clock-skew correction has already been spent. */
|
|
175
|
+
const AWS_SKEW_CORRECTED = "__awsSkewCorrected";
|
|
176
|
+
const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
|
|
177
|
+
/** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
|
|
178
|
+
const MAX_INTERCEPTOR_RESENDS = 5;
|
|
179
|
+
/** Query-parameter names whose values are redacted in HAR output. */
|
|
180
|
+
const HAR_REDACT_PARAMS = new Set([
|
|
181
|
+
"api_key",
|
|
182
|
+
"apikey",
|
|
183
|
+
"access_token",
|
|
184
|
+
"refresh_token",
|
|
185
|
+
"id_token",
|
|
186
|
+
"token",
|
|
187
|
+
"auth",
|
|
188
|
+
"authorization",
|
|
189
|
+
"key",
|
|
190
|
+
"secret",
|
|
191
|
+
"password",
|
|
192
|
+
"passwd",
|
|
193
|
+
"signature",
|
|
194
|
+
"sig",
|
|
195
|
+
"x-amz-signature",
|
|
196
|
+
"x-amz-credential",
|
|
197
|
+
"x-amz-security-token",
|
|
198
|
+
"x-goog-signature",
|
|
199
|
+
"sas",
|
|
200
|
+
"session",
|
|
201
|
+
"sessionid",
|
|
202
|
+
"jwt",
|
|
203
|
+
"code",
|
|
204
|
+
]);
|
|
205
|
+
/** Maximum number of body characters recorded in a HAR entry. */
|
|
206
|
+
const HAR_MAX_BODY_CHARS = 8192;
|
|
207
|
+
/** HTML/other content types whose bodies are never recorded in HAR output. */
|
|
208
|
+
function isHARBodySafeToRecord(contentType) {
|
|
209
|
+
if (!contentType)
|
|
210
|
+
return false;
|
|
211
|
+
const ct = contentType.toLowerCase();
|
|
212
|
+
return (ct.includes("json") ||
|
|
213
|
+
ct.includes("xml") ||
|
|
214
|
+
ct.includes("text/plain") ||
|
|
215
|
+
ct.includes("application/javascript"));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Redact sensitive query-parameter values in a URL, preserving everything else
|
|
219
|
+
* (scheme, host, path, parameter names, ordering) so the HAR stays useful.
|
|
220
|
+
*/
|
|
221
|
+
function redactHARUrl(url) {
|
|
222
|
+
try {
|
|
223
|
+
const u = new URL(url);
|
|
224
|
+
let changed = false;
|
|
225
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
226
|
+
if (HAR_REDACT_PARAMS.has(key.toLowerCase())) {
|
|
227
|
+
u.searchParams.set(key, "***REDACTED***");
|
|
228
|
+
changed = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Hash can carry an implicit-access-token (S3, Firebase, share links).
|
|
232
|
+
if (u.hash && (u.hash.includes("token") || u.hash.includes("sig") || u.hash.length > 1)) {
|
|
233
|
+
u.hash = "#***REDACTED***";
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
236
|
+
return changed ? u.toString() : url;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Unparseable URL — fall back to a regex that masks known param names.
|
|
240
|
+
return url.replace(/([?&])(api_key|apikey|access_token|refresh_token|token|secret|password|signature|sig)=([^&#]*)/gi, "$1$2=***REDACTED***");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
163
243
|
/** Redact a single header value for HAR output. */
|
|
164
244
|
function redactHARHeader(name, value) {
|
|
165
|
-
return HAR_REDACT_HEADERS.has(name.toLowerCase())
|
|
245
|
+
return HAR_REDACT_HEADERS.has(name.toLowerCase())
|
|
246
|
+
? { name, value: "***REDACTED***" }
|
|
247
|
+
: { name, value };
|
|
166
248
|
}
|
|
167
249
|
/**
|
|
168
250
|
* O(1) ring-buffer HAR entry recorder.
|
|
169
251
|
* Stores up to `maxEntries` entries, evicting oldest first.
|
|
170
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
|
+
}
|
|
171
282
|
class HARRecorder {
|
|
172
283
|
/** Ring buffer of entries keyed by monotonic counter. */
|
|
173
284
|
_buf = new Map();
|
|
@@ -204,10 +315,16 @@ class HARRecorder {
|
|
|
204
315
|
// Browser Resource Timing API — accurate per-request breakdown
|
|
205
316
|
if (typeof performance !== "undefined" &&
|
|
206
317
|
typeof performance.getEntriesByType === "function") {
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
|
|
318
|
+
// getEntriesByName narrows the buffer instead of scanning every resource
|
|
319
|
+
// entry for each recorded request (was O(entries) per request).
|
|
320
|
+
const entries = (typeof performance.getEntriesByName === "function"
|
|
321
|
+
? performance.getEntriesByName(res.url)
|
|
322
|
+
: performance.getEntriesByType("resource").filter((e) => e.name === res.url));
|
|
323
|
+
// Most recent entry for this URL. Entries are startTime-ordered, so the
|
|
324
|
+
// last one is the most recent — and, unlike a name-only match, we also
|
|
325
|
+
// require it to be recent enough to actually belong to this request.
|
|
326
|
+
const entry = entries[entries.length - 1];
|
|
327
|
+
if (entry && entry.requestStart > 0 && Date.now() - entry.startTime < 60_000) {
|
|
211
328
|
sendMs = Math.max(0, entry.responseStart - entry.requestStart);
|
|
212
329
|
receiveMs = Math.max(0, entry.responseEnd - entry.responseStart);
|
|
213
330
|
waitMs = Math.max(0, total - sendMs - receiveMs);
|
|
@@ -224,12 +341,16 @@ class HARRecorder {
|
|
|
224
341
|
time: total,
|
|
225
342
|
request: {
|
|
226
343
|
method: req.method,
|
|
227
|
-
|
|
344
|
+
// Redacted: HAR logs are routinely exported and shared, and a query
|
|
345
|
+
// string is just as leaky as a header (?api_key=, ?access_token=,
|
|
346
|
+
// ?signature=). Previously only headers were redacted, so the full URL
|
|
347
|
+
// and every query value landed in the log verbatim.
|
|
348
|
+
url: redactHARUrl(req.url),
|
|
228
349
|
httpVersion: res.httpVersion,
|
|
229
350
|
headers: Object.entries(req.headers).map(([name, value]) => redactHARHeader(name, value)),
|
|
230
351
|
queryString: (() => {
|
|
231
352
|
try {
|
|
232
|
-
return Array.from(new URL(req.url).searchParams.entries()).map(([name, value]) => ({
|
|
353
|
+
return Array.from(new URL(redactHARUrl(req.url)).searchParams.entries()).map(([name, value]) => ({
|
|
233
354
|
name,
|
|
234
355
|
value,
|
|
235
356
|
}));
|
|
@@ -249,6 +370,17 @@ class HARRecorder {
|
|
|
249
370
|
return req.body.byteLength;
|
|
250
371
|
return -1; // Unknown (stream, FormData, etc.)
|
|
251
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),
|
|
252
384
|
},
|
|
253
385
|
response: {
|
|
254
386
|
status: res.status,
|
|
@@ -258,9 +390,14 @@ class HARRecorder {
|
|
|
258
390
|
content: {
|
|
259
391
|
size: res.rawBody?.byteLength ?? 0,
|
|
260
392
|
mimeType: res.headers["content-type"] ?? "application/octet-stream",
|
|
261
|
-
|
|
393
|
+
// Body text is only kept for non-HTML payloads and is truncated:
|
|
394
|
+
// response bodies routinely carry tokens and PII.
|
|
395
|
+
...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
|
|
396
|
+
? { text: res.data.slice(0, HAR_MAX_BODY_CHARS) }
|
|
397
|
+
: {}),
|
|
262
398
|
},
|
|
263
|
-
|
|
399
|
+
// The Location header can itself carry a signed URL — redact it too.
|
|
400
|
+
redirectURL: res.headers["location"] ? redactHARUrl(res.headers["location"]) : "",
|
|
264
401
|
bodySize: res.rawBody?.byteLength ?? 0,
|
|
265
402
|
},
|
|
266
403
|
timings: {
|
|
@@ -383,22 +520,15 @@ async function applyAuth(req, auth) {
|
|
|
383
520
|
* Headers stripped when a redirect crosses origins (FIX H2).
|
|
384
521
|
* These carry credentials and must never be forwarded to a different origin.
|
|
385
522
|
*/
|
|
523
|
+
// Derived from the single CREDENTIAL_HEADERS list in cache.ts so the strip
|
|
524
|
+
// list, the dedup key and the cache key can never drift apart. (The previous
|
|
525
|
+
// list also carried `www-authenticate`, a RESPONSE header that can never appear
|
|
526
|
+
// on an outgoing request.)
|
|
386
527
|
const CROSS_ORIGIN_STRIP_HEADERS = new Set([
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
"x-auth-token",
|
|
392
|
-
"x-access-token",
|
|
393
|
-
"x-refresh-token",
|
|
394
|
-
"x-csrf-token",
|
|
395
|
-
"x-session-id",
|
|
396
|
-
"x-session-token",
|
|
397
|
-
"x-secret",
|
|
398
|
-
"x-secret-key",
|
|
399
|
-
"x-private-key",
|
|
400
|
-
"api-key",
|
|
401
|
-
"apikey",
|
|
528
|
+
...CREDENTIAL_HEADERS,
|
|
529
|
+
// Response-only per RFC 9110, so it can never legitimately appear on an
|
|
530
|
+
// outgoing request — kept in the strip list as defence in depth for callers
|
|
531
|
+
// that copy a full header bag (including response headers) onto a request.
|
|
402
532
|
"www-authenticate",
|
|
403
533
|
]);
|
|
404
534
|
/**
|
|
@@ -429,7 +559,7 @@ function redactUserInfo(url) {
|
|
|
429
559
|
* @returns Fully-qualified URL string.
|
|
430
560
|
* @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
|
|
431
561
|
*/
|
|
432
|
-
function buildURL(base, url, params) {
|
|
562
|
+
function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
433
563
|
const MAX_QUERY_PARAM_COUNT = 100;
|
|
434
564
|
const MAX_URL_LENGTH = 8192;
|
|
435
565
|
let full;
|
|
@@ -458,13 +588,18 @@ function buildURL(base, url, params) {
|
|
|
458
588
|
}
|
|
459
589
|
}
|
|
460
590
|
if (!params || Object.keys(params).length === 0) {
|
|
461
|
-
if (!isSafeURL(full)) {
|
|
591
|
+
if (!isSafeURL(full, allowedSchemes)) {
|
|
462
592
|
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
463
593
|
}
|
|
464
594
|
return full;
|
|
465
595
|
}
|
|
466
596
|
try {
|
|
467
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
|
+
}
|
|
468
603
|
let paramCount = 0;
|
|
469
604
|
for (const [key, value] of Object.entries(params)) {
|
|
470
605
|
if (value === null || value === undefined)
|
|
@@ -659,7 +794,13 @@ function computeRetryDelay(cfg, attempt, retryAfterMs) {
|
|
|
659
794
|
return cfg.maxDelayMs;
|
|
660
795
|
}
|
|
661
796
|
const capped = Math.min(exp, cfg.maxDelayMs);
|
|
662
|
-
|
|
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);
|
|
663
804
|
}
|
|
664
805
|
/**
|
|
665
806
|
* Extract and parse the Retry-After header value.
|
|
@@ -671,15 +812,21 @@ function getRetryAfterMs(headers) {
|
|
|
671
812
|
const ra = headers["retry-after"];
|
|
672
813
|
if (!ra)
|
|
673
814
|
return null;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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) {
|
|
678
820
|
const MAX_RETRY_AFTER_SEC = 86_400; // 24 hours
|
|
679
|
-
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));
|
|
680
828
|
}
|
|
681
|
-
|
|
682
|
-
return isNaN(ms) ? null : Math.max(0, Math.min(ms - Date.now(), 86_400_000));
|
|
829
|
+
return null;
|
|
683
830
|
}
|
|
684
831
|
// ============================================================================
|
|
685
832
|
// §8 MAIN KINETEX CLASS
|
|
@@ -693,11 +840,12 @@ function getRetryAfterMs(headers) {
|
|
|
693
840
|
*
|
|
694
841
|
* const client = kinetex({ baseURL: "https://api.example.com" });
|
|
695
842
|
*
|
|
696
|
-
* // Fluent chain
|
|
697
|
-
*
|
|
843
|
+
* // Fluent chain — `get()` returns a Promise, so use the uppercase
|
|
844
|
+
* // `GET()` builder if you want to chain `.json()` onto it.
|
|
845
|
+
* const user = await client.GET("/users/1").json<User>();
|
|
698
846
|
*
|
|
699
|
-
* // Standard send
|
|
700
|
-
* const res = await client.send<User>(
|
|
847
|
+
* // Standard send — `send(url, method, options)`, not an object argument
|
|
848
|
+
* const res = await client.send<User>("/users/1", "GET");
|
|
701
849
|
* ```
|
|
702
850
|
*/
|
|
703
851
|
export class Kinetex {
|
|
@@ -733,6 +881,14 @@ export class Kinetex {
|
|
|
733
881
|
_circuitBreakerKeyFn = null;
|
|
734
882
|
/** Active WebSocket connections tracked for cleanup on destroy(). */
|
|
735
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;
|
|
736
892
|
/**
|
|
737
893
|
* @param config - Global client configuration.
|
|
738
894
|
*/
|
|
@@ -751,10 +907,17 @@ export class Kinetex {
|
|
|
751
907
|
const rlInterceptor = createRateLimitInterceptor(config.rateLimit);
|
|
752
908
|
this.interceptors.addRequest(rlInterceptor);
|
|
753
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;
|
|
754
916
|
// AWS SigV4 request signing — registered synchronously (static import).
|
|
755
917
|
// Active immediately; no race between first request and interceptor registration.
|
|
756
918
|
if (config.awsSigning) {
|
|
757
919
|
const signer = new SigV4Signer(config.awsSigning);
|
|
920
|
+
this._awsSigner = signer;
|
|
758
921
|
this.interceptors.addRequest(async (ctx) => {
|
|
759
922
|
const req = ctx.request;
|
|
760
923
|
let signableBody = null;
|
|
@@ -772,7 +935,15 @@ export class Kinetex {
|
|
|
772
935
|
});
|
|
773
936
|
}
|
|
774
937
|
// Transport — pass strictHeaders option through to FetchTransport
|
|
775
|
-
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
|
+
});
|
|
776
947
|
// Register config-level interceptors
|
|
777
948
|
if (config.interceptors) {
|
|
778
949
|
config.interceptors.request?.forEach((fn) => this.interceptors.addRequest(fn));
|
|
@@ -782,6 +953,11 @@ export class Kinetex {
|
|
|
782
953
|
// Digest auth interceptor — handles 401 → parse challenge → retry
|
|
783
954
|
if (config.auth?.type === "digest") {
|
|
784
955
|
const digestConfig = config.auth;
|
|
956
|
+
// Per-client nonce counter. RFC 7616 requires `nc` to strictly increase
|
|
957
|
+
// for every request reusing a nonce; the stateless helper always used
|
|
958
|
+
// 00000001, so any server enforcing replay protection rejected the second
|
|
959
|
+
// authenticated request with 401.
|
|
960
|
+
const digestAuthorizer = createDigestAuthorizer();
|
|
785
961
|
this.interceptors.addResponse(async (ctx) => {
|
|
786
962
|
if (!ctx.response)
|
|
787
963
|
return;
|
|
@@ -793,8 +969,9 @@ export class Kinetex {
|
|
|
793
969
|
if (ctx.request.meta.__digestRetried)
|
|
794
970
|
return;
|
|
795
971
|
const method = ctx.request.method;
|
|
796
|
-
const
|
|
797
|
-
const
|
|
972
|
+
const parsedUrl = new URL(ctx.request.url);
|
|
973
|
+
const uri = parsedUrl.pathname + parsedUrl.search;
|
|
974
|
+
const authHeader = await digestAuthorizer(wwwAuth, digestConfig.username, digestConfig.password, method, uri);
|
|
798
975
|
return {
|
|
799
976
|
...ctx.request,
|
|
800
977
|
headers: { ...ctx.request.headers, authorization: authHeader },
|
|
@@ -893,22 +1070,30 @@ export class Kinetex {
|
|
|
893
1070
|
const errEject = this.useError(async (ctx) => {
|
|
894
1071
|
if (!ctx.error)
|
|
895
1072
|
return;
|
|
1073
|
+
const hookReq = {
|
|
1074
|
+
url: ctx.request.url,
|
|
1075
|
+
method: ctx.request.method,
|
|
1076
|
+
headers: ctx.request.headers,
|
|
1077
|
+
body: ctx.request.body,
|
|
1078
|
+
signal: ctx.request.signal,
|
|
1079
|
+
meta: ctx.request.meta,
|
|
1080
|
+
};
|
|
1081
|
+
// A failed request can still have produced a response: HTTPStatusError
|
|
1082
|
+
// carries the KinetexResponse, and createLoggingHooks' onError reads
|
|
1083
|
+
// `err.response?.status`. Hardcoding null here made every bridged
|
|
1084
|
+
// onError hook see no response, so log entries silently recorded a null
|
|
1085
|
+
// status for 4xx/5xx. Only genuinely response-less errors (network,
|
|
1086
|
+
// timeout, abort) keep null.
|
|
1087
|
+
const errResponse = toHookResponse(ctx.error.response, hookReq);
|
|
896
1088
|
const hookErr = {
|
|
897
1089
|
error: ctx.error,
|
|
898
|
-
request:
|
|
899
|
-
|
|
900
|
-
method: ctx.request.method,
|
|
901
|
-
headers: ctx.request.headers,
|
|
902
|
-
body: ctx.request.body,
|
|
903
|
-
signal: ctx.request.signal,
|
|
904
|
-
meta: ctx.request.meta,
|
|
905
|
-
},
|
|
906
|
-
response: null,
|
|
1090
|
+
request: hookReq,
|
|
1091
|
+
response: errResponse,
|
|
907
1092
|
attempt: ctx.attempt,
|
|
908
1093
|
};
|
|
909
1094
|
const hookCtx = {
|
|
910
1095
|
request: hookErr.request,
|
|
911
|
-
response:
|
|
1096
|
+
response: errResponse,
|
|
912
1097
|
error: ctx.error,
|
|
913
1098
|
startedAt: ctx.startedAt,
|
|
914
1099
|
attempt: ctx.attempt,
|
|
@@ -1162,20 +1347,19 @@ export class Kinetex {
|
|
|
1162
1347
|
/**
|
|
1163
1348
|
* Returns deduplication metrics.
|
|
1164
1349
|
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
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.
|
|
1167
1358
|
*/
|
|
1168
1359
|
get dedupMetrics() {
|
|
1169
1360
|
if (!this._dedup)
|
|
1170
1361
|
return null;
|
|
1171
|
-
return
|
|
1172
|
-
/** Number of requests that shared an in-flight or windowed response. */
|
|
1173
|
-
hits: this._dedup.hits,
|
|
1174
|
-
/** Number of requests that triggered a real network call. */
|
|
1175
|
-
misses: this._dedup.misses,
|
|
1176
|
-
/** Number of currently in-flight requests. */
|
|
1177
|
-
inFlightCount: this._dedup.inFlightCount,
|
|
1178
|
-
};
|
|
1362
|
+
return this._dedup.getStats();
|
|
1179
1363
|
}
|
|
1180
1364
|
// ── §8.5d Circuit Breaker ────────────────────────────────────────────────
|
|
1181
1365
|
/**
|
|
@@ -1251,7 +1435,20 @@ export class Kinetex {
|
|
|
1251
1435
|
* ```
|
|
1252
1436
|
*/
|
|
1253
1437
|
async ws(url, options = {}) {
|
|
1254
|
-
|
|
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
|
+
]);
|
|
1255
1452
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
1256
1453
|
// Apply auth headers manually since WS handshake goes through the browser
|
|
1257
1454
|
// WS API which doesn't use the kinetex transport pipeline.
|
|
@@ -1294,7 +1491,12 @@ export class Kinetex {
|
|
|
1294
1491
|
if (this.cfg.baseURL) {
|
|
1295
1492
|
const baseUrl = new URL(this.cfg.baseURL);
|
|
1296
1493
|
const wsIsSecure = wsUrl.protocol === "wss:";
|
|
1297
|
-
|
|
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:";
|
|
1298
1500
|
if (wsIsSecure !== httpIsSecure || wsUrl.host !== baseUrl.host) {
|
|
1299
1501
|
throw new KinetexError(`WebSocket origin ${wsUrl.origin} does not match baseURL origin ${baseUrl.origin}`, "EVALIDATION");
|
|
1300
1502
|
}
|
|
@@ -1389,15 +1591,17 @@ export class Kinetex {
|
|
|
1389
1591
|
}
|
|
1390
1592
|
// ── Build initial request ─────────────────────────────────────────────
|
|
1391
1593
|
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
"
|
|
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");
|
|
1401
1605
|
}
|
|
1402
1606
|
// Enforce HTTPS-only if configured
|
|
1403
1607
|
if (this.cfg.httpsOnly) {
|
|
@@ -1413,6 +1617,14 @@ export class Kinetex {
|
|
|
1413
1617
|
throw new KinetexError(`Invalid URL: ${err}`, "EVALIDATION");
|
|
1414
1618
|
}
|
|
1415
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;
|
|
1416
1628
|
// Enforce request size limit if configured
|
|
1417
1629
|
const maxRequestSize = options.maxRequestSize ?? this.cfg.maxRequestSize ?? 0;
|
|
1418
1630
|
if (maxRequestSize > 0 && options.body) {
|
|
@@ -1440,19 +1652,13 @@ export class Kinetex {
|
|
|
1440
1652
|
bodySize = new TextEncoder().encode(options.body.toString()).byteLength;
|
|
1441
1653
|
}
|
|
1442
1654
|
else if (options.body instanceof FormData) {
|
|
1443
|
-
// FIX (H4):
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
}
|
|
1451
|
-
else {
|
|
1452
|
-
bodySize += value.size;
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
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;
|
|
1456
1662
|
}
|
|
1457
1663
|
else if (options.body instanceof ReadableStream) {
|
|
1458
1664
|
// FIX (H4): a stream's size cannot be known without consuming it —
|
|
@@ -1461,7 +1667,14 @@ export class Kinetex {
|
|
|
1461
1667
|
throw new KinetexError(`maxRequestSize cannot be enforced for ReadableStream bodies — pass maxRequestSize: 0 to opt out, or buffer the body first`, "EVALIDATION");
|
|
1462
1668
|
}
|
|
1463
1669
|
else if (options.body && typeof options.body === "object") {
|
|
1464
|
-
|
|
1670
|
+
try {
|
|
1671
|
+
bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
|
|
1672
|
+
}
|
|
1673
|
+
catch (err) {
|
|
1674
|
+
// A circular (or BigInt-containing) body threw a raw TypeError from
|
|
1675
|
+
// inside the size guard, masking the real serialization error.
|
|
1676
|
+
throw new KinetexError(`Cannot measure request body size: ${err instanceof Error ? err.message : String(err)}`, "EVALIDATION");
|
|
1677
|
+
}
|
|
1465
1678
|
}
|
|
1466
1679
|
if (bodySize > maxRequestSize) {
|
|
1467
1680
|
throw new KinetexError(`Request body size ${bodySize} bytes exceeds limit of ${maxRequestSize} bytes`, "EVALIDATION");
|
|
@@ -1469,13 +1682,48 @@ export class Kinetex {
|
|
|
1469
1682
|
}
|
|
1470
1683
|
let req = {
|
|
1471
1684
|
url: fullUrl,
|
|
1472
|
-
method,
|
|
1685
|
+
// Use the normalized method, not the caller's casing: `send(url, "patch")`
|
|
1686
|
+
// passed validation above but used to put the literal string "patch" on
|
|
1687
|
+
// the wire. fetch() only normalizes delete/get/head/options/post/put, so a
|
|
1688
|
+
// lowercase PATCH/CONNECT went out verbatim and servers answered 405.
|
|
1689
|
+
method: normalizedMethod,
|
|
1473
1690
|
headers: mergeHeaders(this.cfg.headers, options.headers),
|
|
1474
|
-
|
|
1691
|
+
// A plain object/array is accepted by the public API (RequestBody) and is
|
|
1692
|
+
// JSON-encoded in the block immediately below, so the request object only
|
|
1693
|
+
// ever holds a real BodyInit by the time this function returns.
|
|
1694
|
+
body: (options.body ?? null),
|
|
1475
1695
|
signal: options.signal ?? null,
|
|
1696
|
+
// Resolved once here so the manual redirect follower sees the same
|
|
1697
|
+
// effective values the caller asked for. Spread (not `??`) because the
|
|
1698
|
+
// project uses exactOptionalPropertyTypes.
|
|
1699
|
+
...(options.followRedirects !== undefined || this.cfg.followRedirects !== undefined
|
|
1700
|
+
? { followRedirects: options.followRedirects ?? this.cfg.followRedirects }
|
|
1701
|
+
: {}),
|
|
1702
|
+
...(options.maxRedirects !== undefined || this.cfg.maxRedirects !== undefined
|
|
1703
|
+
? { maxRedirects: options.maxRedirects ?? this.cfg.maxRedirects }
|
|
1704
|
+
: {}),
|
|
1476
1705
|
meta: { ...options.meta },
|
|
1477
1706
|
httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
|
|
1478
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
|
+
}
|
|
1479
1727
|
// Default Content-Type for JSON bodies
|
|
1480
1728
|
if (req.body !== null &&
|
|
1481
1729
|
typeof req.body === "object" &&
|
|
@@ -1492,6 +1740,18 @@ export class Kinetex {
|
|
|
1492
1740
|
body: JSON.stringify(req.body),
|
|
1493
1741
|
};
|
|
1494
1742
|
}
|
|
1743
|
+
// A URLSearchParams body is urlencoded by the raw Node transports, which
|
|
1744
|
+
// do not set a content-type the way fetch does. Without this the server
|
|
1745
|
+
// receives the bytes but cannot parse them as a form.
|
|
1746
|
+
if (req.body !== null &&
|
|
1747
|
+
typeof URLSearchParams !== "undefined" &&
|
|
1748
|
+
req.body instanceof URLSearchParams &&
|
|
1749
|
+
!req.headers["content-type"]) {
|
|
1750
|
+
req = {
|
|
1751
|
+
...req,
|
|
1752
|
+
headers: { ...req.headers, "content-type": "application/x-www-form-urlencoded" },
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1495
1755
|
// ── Apply auth ─────────────────────────────────────────────────────────
|
|
1496
1756
|
const auth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
1497
1757
|
if (auth)
|
|
@@ -1512,33 +1772,52 @@ export class Kinetex {
|
|
|
1512
1772
|
// Works with any OpenTelemetry SDK — just call client.setTracer(tracer).
|
|
1513
1773
|
// If no tracer is set we still propagate a randomly-generated trace ID
|
|
1514
1774
|
// when the caller passes options.meta.traceId (useful for manual tracing).
|
|
1775
|
+
// Everything between startSpan() and the dispatch try/catch below can
|
|
1776
|
+
// throw (traceparent building, the circuit-breaker key fn, auth
|
|
1777
|
+
// fingerprinting). Wrap it so a failure still ends the span instead of
|
|
1778
|
+
// abandoning it — an unended span is never exported and never reports the
|
|
1779
|
+
// error, and holds its attributes in the tracer's memory.
|
|
1515
1780
|
let _otelSpan = null;
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1781
|
+
try {
|
|
1782
|
+
if (this._otelTracer) {
|
|
1783
|
+
_otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
|
|
1784
|
+
const { traceparent, traceId, spanId } = buildTraceparent(_otelSpan);
|
|
1785
|
+
_otelSpan.setAttribute("http.request.method", req.method);
|
|
1786
|
+
_otelSpan.setAttribute("url.full", req.url);
|
|
1787
|
+
try {
|
|
1788
|
+
_otelSpan.setAttribute("server.address", new URL(req.url).hostname);
|
|
1789
|
+
}
|
|
1790
|
+
catch {
|
|
1791
|
+
// Skip hostname attribute if URL is invalid
|
|
1792
|
+
}
|
|
1793
|
+
req = {
|
|
1794
|
+
...req,
|
|
1795
|
+
headers: { ...req.headers, traceparent },
|
|
1796
|
+
meta: { ...req.meta, traceId, spanId },
|
|
1797
|
+
};
|
|
1523
1798
|
}
|
|
1524
|
-
|
|
1525
|
-
//
|
|
1799
|
+
else if (req.meta["traceId"] && !req.headers["traceparent"]) {
|
|
1800
|
+
// Manual trace propagation — caller set traceId in meta
|
|
1801
|
+
const traceId = String(req.meta["traceId"]);
|
|
1802
|
+
const spanId = randomHex(16);
|
|
1803
|
+
req = {
|
|
1804
|
+
...req,
|
|
1805
|
+
headers: { ...req.headers, traceparent: `00-${traceId}-${spanId}-01` },
|
|
1806
|
+
meta: { ...req.meta, spanId },
|
|
1807
|
+
};
|
|
1526
1808
|
}
|
|
1527
|
-
req = {
|
|
1528
|
-
...req,
|
|
1529
|
-
headers: { ...req.headers, traceparent },
|
|
1530
|
-
meta: { ...req.meta, traceId, spanId },
|
|
1531
|
-
};
|
|
1532
1809
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1810
|
+
catch (tracingErr) {
|
|
1811
|
+
if (_otelSpan) {
|
|
1812
|
+
_otelSpan.setStatus({
|
|
1813
|
+
code: 2 /* ERROR */,
|
|
1814
|
+
message: tracingErr instanceof Error ? tracingErr.message : String(tracingErr),
|
|
1815
|
+
});
|
|
1816
|
+
if (tracingErr instanceof Error)
|
|
1817
|
+
_otelSpan.recordException(tracingErr);
|
|
1818
|
+
_otelSpan.end();
|
|
1819
|
+
}
|
|
1820
|
+
throw tracingErr;
|
|
1542
1821
|
}
|
|
1543
1822
|
// Determine key for dedup + circuit breaker.
|
|
1544
1823
|
// Uses circuitBreakerKeyFn if configured (e.g. per-method isolation),
|
|
@@ -1570,8 +1849,13 @@ export class Kinetex {
|
|
|
1570
1849
|
// SECURITY: The dedup key includes a fingerprint of auth-sensitive headers
|
|
1571
1850
|
// so requests from different users (different Authorization / Cookie) are
|
|
1572
1851
|
// NEVER coalesced — each user gets their own isolated in-flight slot.
|
|
1573
|
-
|
|
1574
|
-
|
|
1852
|
+
// Fingerprinting hashes the credential headers with SHA-256, so it is only
|
|
1853
|
+
// paid when dedup is actually enabled.
|
|
1854
|
+
let _dedupKey = "";
|
|
1855
|
+
if (this._dedup) {
|
|
1856
|
+
const authFp = await getAuthFingerprint(req.headers ?? {});
|
|
1857
|
+
_dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
|
|
1858
|
+
}
|
|
1575
1859
|
const _dedupedFactory = this._dedup
|
|
1576
1860
|
? () => this._dedup
|
|
1577
1861
|
.execute(req.method, _dedupKey, _execFactory)
|
|
@@ -1611,12 +1895,95 @@ export class Kinetex {
|
|
|
1611
1895
|
* and retries on failure per the retry config.
|
|
1612
1896
|
*/
|
|
1613
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) {
|
|
1614
1974
|
let attempt = 0;
|
|
1615
1975
|
while (true) {
|
|
1616
1976
|
attempt++;
|
|
1617
1977
|
// If caller aborted between retries, stop immediately
|
|
1618
1978
|
if (req.signal?.aborted) {
|
|
1619
|
-
throw createAbortError();
|
|
1979
|
+
throw createAbortError(req);
|
|
1980
|
+
}
|
|
1981
|
+
// A ReadableStream / Blob body is not replayable: the first attempt
|
|
1982
|
+
// consumes (and locks) it, so a retry re-wraps an already-locked stream
|
|
1983
|
+
// for upload progress and sends an empty body. Fail loudly on the retry
|
|
1984
|
+
// instead of silently transmitting nothing.
|
|
1985
|
+
if (attempt > 1 && isNonReplayableBody(req.body)) {
|
|
1986
|
+
throw new KinetexError("Cannot retry a request whose body is a stream or Blob — the body was consumed by the first attempt. Buffer it first, or disable retry for this request.", "EVALIDATION", { request: req });
|
|
1620
1987
|
}
|
|
1621
1988
|
try {
|
|
1622
1989
|
const res = await this._executeOnce(req, timeout, options, startMs, attempt, wallClockMs, retryCfg);
|
|
@@ -1646,20 +2013,68 @@ export class Kinetex {
|
|
|
1646
2013
|
return res;
|
|
1647
2014
|
}
|
|
1648
2015
|
catch (err) {
|
|
1649
|
-
if (
|
|
2016
|
+
if (globalThis.__KINETEX_DEBUG_RETRY) {
|
|
2017
|
+
console.log("DBG catch", {
|
|
2018
|
+
attempt,
|
|
2019
|
+
maxRetries: retryCfg === false ? "FALSE" : retryCfg?.maxRetries,
|
|
2020
|
+
methods: retryCfg === false ? "FALSE" : retryCfg?.methods,
|
|
2021
|
+
code: err?.code,
|
|
2022
|
+
method: req.method,
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
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;
|
|
1650
2059
|
const retryCtx = {
|
|
1651
2060
|
request: req,
|
|
1652
|
-
response:
|
|
2061
|
+
response: errResponse,
|
|
1653
2062
|
error: err,
|
|
1654
2063
|
attempt,
|
|
1655
2064
|
maxRetries: retryCfg.maxRetries,
|
|
1656
2065
|
};
|
|
1657
|
-
const doRetry =
|
|
1658
|
-
?
|
|
1659
|
-
: shouldRetry
|
|
2066
|
+
const doRetry = clockSkewCorrected
|
|
2067
|
+
? true
|
|
2068
|
+
: retryCfg.shouldRetry
|
|
2069
|
+
? await retryCfg.shouldRetry(retryCtx)
|
|
2070
|
+
: shouldRetry(retryCfg, retryCtx);
|
|
1660
2071
|
if (doRetry) {
|
|
1661
|
-
const delay = computeRetryDelay(retryCfg, attempt, null);
|
|
2072
|
+
const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
|
|
1662
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());
|
|
1663
2078
|
await sleep(delay, req.signal);
|
|
1664
2079
|
continue;
|
|
1665
2080
|
}
|
|
@@ -1686,11 +2101,25 @@ export class Kinetex {
|
|
|
1686
2101
|
// intermediate redirect responses. When a cookie jar is active we must follow
|
|
1687
2102
|
// redirects ourselves one hop at a time so we can capture cookies at each step.
|
|
1688
2103
|
/**
|
|
1689
|
-
* Follow redirects manually, one hop at a time
|
|
1690
|
-
*
|
|
2104
|
+
* Follow redirects manually, one hop at a time.
|
|
2105
|
+
*
|
|
2106
|
+
* Two independent reasons this path exists:
|
|
2107
|
+
* 1. fetch() auto-follows redirects but silently drops Set-Cookie from
|
|
2108
|
+
* intermediary hops, so an active cookie jar must see every hop itself.
|
|
2109
|
+
* 2. Per the Fetch spec, a cross-origin redirect only drops
|
|
2110
|
+
* `authorization` / `cookie` / `proxy-authorization`. Custom credential
|
|
2111
|
+
* headers (apikey, X-Company-Key, ...) are forwarded verbatim, so any
|
|
2112
|
+
* request carrying one must also be followed manually.
|
|
2113
|
+
*
|
|
2114
|
+
* @param jar - Optional cookie jar. When omitted, no cookie header is
|
|
2115
|
+
* rebuilt and intermediate Set-Cookie headers are ignored.
|
|
1691
2116
|
*/
|
|
1692
2117
|
async _sendFollowingRedirects(req, timeout, jar, appliedAuth) {
|
|
1693
|
-
|
|
2118
|
+
// `maxRedirects` / `followRedirects` were documented on KinetexConfig and
|
|
2119
|
+
// SendOptions but never read, so the documented default and the enforced one
|
|
2120
|
+
// had drifted apart. Both are honoured here now.
|
|
2121
|
+
const maxRedirects = Math.max(0, req.maxRedirects ?? DEFAULT_MAX_REDIRECTS);
|
|
2122
|
+
const followRedirects = req.followRedirects !== false && maxRedirects > 0;
|
|
1694
2123
|
let currentReq = { ...req, redirect: "manual" };
|
|
1695
2124
|
const origin0 = (() => {
|
|
1696
2125
|
try {
|
|
@@ -1702,7 +2131,7 @@ export class Kinetex {
|
|
|
1702
2131
|
})();
|
|
1703
2132
|
// Track visited URLs to detect redirect loops
|
|
1704
2133
|
const visited = new Set();
|
|
1705
|
-
for (let hop = 0; hop <=
|
|
2134
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1706
2135
|
// FIX H2 (part 2): re-apply auth on every hop ONLY while we remain on the
|
|
1707
2136
|
// original origin. Once a redirect has crossed origins, credential-bearing
|
|
1708
2137
|
// headers must not be re-injected — otherwise the cross-origin strip in
|
|
@@ -1751,19 +2180,35 @@ export class Kinetex {
|
|
|
1751
2180
|
if (isRedirect) {
|
|
1752
2181
|
// Check for redirect loops
|
|
1753
2182
|
if (visited.has(raw.url)) {
|
|
1754
|
-
throw new
|
|
1755
|
-
request: req,
|
|
1756
|
-
});
|
|
2183
|
+
throw new RedirectError(`Redirect loop detected: ${raw.url}`, req);
|
|
1757
2184
|
}
|
|
1758
2185
|
visited.add(raw.url);
|
|
1759
2186
|
// Capture cookies from this redirect hop
|
|
1760
|
-
jar
|
|
2187
|
+
jar?.processResponseHeaders(raw.headers, {
|
|
1761
2188
|
url: raw.url,
|
|
1762
2189
|
});
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
2190
|
+
// `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
|
|
2191
|
+
// the caller instead of chasing it — the same shape fetch() returns for
|
|
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.
|
|
2198
|
+
if (!followRedirects)
|
|
2199
|
+
return { ...raw, redirected: false };
|
|
2200
|
+
if (hop === maxRedirects) {
|
|
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);
|
|
1767
2212
|
}
|
|
1768
2213
|
// Drain the redirect body (usually empty, but must be cancelled)
|
|
1769
2214
|
if (raw.body) {
|
|
@@ -1786,6 +2231,19 @@ export class Kinetex {
|
|
|
1786
2231
|
if (protocol !== "http:" && protocol !== "https:") {
|
|
1787
2232
|
throw new KinetexError(`Unsafe redirect to ${protocol} detected — only HTTP(S) allowed`, "ENETWORK", { request: req });
|
|
1788
2233
|
}
|
|
2234
|
+
// SSRF GATE (P0): the initial URL is screened by buildURL → isSafeURL,
|
|
2235
|
+
// but a redirect target never went through that check. Without this a
|
|
2236
|
+
// public host could 302 the client straight at link-local/loopback
|
|
2237
|
+
// addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
|
|
2238
|
+
// private-network block list would be bypassable. Re-validate every hop.
|
|
2239
|
+
if (!isSafeURL(nextUrl)) {
|
|
2240
|
+
throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
|
|
2241
|
+
}
|
|
2242
|
+
// httpsOnly must hold for redirect legs too, otherwise a redirect is a
|
|
2243
|
+
// trivial downgrade from https:// to http:// past the pre-flight guard.
|
|
2244
|
+
if (this.cfg.httpsOnly && protocol !== "https:") {
|
|
2245
|
+
throw new KinetexError(`HTTPS-only mode enabled but redirect target uses ${protocol}`, "EVALIDATION", { request: req });
|
|
2246
|
+
}
|
|
1789
2247
|
}
|
|
1790
2248
|
catch (err) {
|
|
1791
2249
|
if (err instanceof KinetexError)
|
|
@@ -1801,30 +2259,50 @@ export class Kinetex {
|
|
|
1801
2259
|
? "GET"
|
|
1802
2260
|
: currentReq.method;
|
|
1803
2261
|
const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
|
|
1804
|
-
// Rebuild Cookie header for the next hop using the updated jar
|
|
1805
|
-
const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
|
|
1806
2262
|
const nextHeaders = { ...currentReq.headers };
|
|
1807
|
-
if (cookieHeader) {
|
|
1808
|
-
nextHeaders["cookie"] = cookieHeader;
|
|
1809
|
-
}
|
|
1810
|
-
else {
|
|
1811
|
-
delete nextHeaders["cookie"];
|
|
1812
|
-
}
|
|
1813
2263
|
// FIX H2: When the redirect crosses origins, strip credential-bearing
|
|
1814
2264
|
// headers (Authorization, Cookie, proxy auth, API keys) so secrets are
|
|
1815
2265
|
// never forwarded to a different origin (RFC 9110 7.1 semantics).
|
|
1816
|
-
//
|
|
1817
|
-
//
|
|
2266
|
+
//
|
|
2267
|
+
// ORDERING (this must happen BEFORE the cookie header is rebuilt): the
|
|
2268
|
+
// previous order computed the new origin's cookie header first and then
|
|
2269
|
+
// deleted it again in the strip loop, so every cross-origin hop was
|
|
2270
|
+
// sent without the cookies the jar had just scoped for it.
|
|
2271
|
+
let crossOrigin = false;
|
|
1818
2272
|
try {
|
|
1819
|
-
|
|
1820
|
-
for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
|
|
1821
|
-
delete nextHeaders[h];
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
2273
|
+
crossOrigin = new URL(nextUrl).origin !== new URL(currentReq.url).origin;
|
|
1824
2274
|
}
|
|
1825
2275
|
catch {
|
|
1826
2276
|
/* nextUrl was already validated above */
|
|
1827
2277
|
}
|
|
2278
|
+
if (crossOrigin) {
|
|
2279
|
+
for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
|
|
2280
|
+
delete nextHeaders[h];
|
|
2281
|
+
}
|
|
2282
|
+
// An `apikey` auth header name is chosen by the application, so it is
|
|
2283
|
+
// not in the well-known list. It is a credential all the same, and it
|
|
2284
|
+
// was being forwarded verbatim to the new origin.
|
|
2285
|
+
if (appliedAuth && appliedAuth.type === "apikey") {
|
|
2286
|
+
delete nextHeaders[appliedAuth.header.toLowerCase()];
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
// Rebuild the Cookie header for the next hop from the updated jar.
|
|
2290
|
+
// Jar scoping guarantees only cookies that match the NEW origin are
|
|
2291
|
+
// attached, which is exactly the post-strip state we want.
|
|
2292
|
+
if (jar) {
|
|
2293
|
+
const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
|
|
2294
|
+
if (cookieHeader) {
|
|
2295
|
+
nextHeaders["cookie"] = cookieHeader;
|
|
2296
|
+
}
|
|
2297
|
+
else {
|
|
2298
|
+
delete nextHeaders["cookie"];
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
else if (crossOrigin) {
|
|
2302
|
+
// No jar: drop the caller's cookie header with the other credentials
|
|
2303
|
+
// (mirrors what fetch() does for a cross-origin redirect).
|
|
2304
|
+
delete nextHeaders["cookie"];
|
|
2305
|
+
}
|
|
1828
2306
|
currentReq = {
|
|
1829
2307
|
...currentReq,
|
|
1830
2308
|
url: nextUrl,
|
|
@@ -1837,10 +2315,12 @@ export class Kinetex {
|
|
|
1837
2315
|
}
|
|
1838
2316
|
// Not a redirect — return the final raw response as-is.
|
|
1839
2317
|
// _executeOnce will capture its Set-Cookie headers via the normal path.
|
|
1840
|
-
|
|
2318
|
+
// The chain was followed by hand, so report `redirected: true` for hop > 0
|
|
2319
|
+
// to match what fetch() reports under redirect: "follow".
|
|
2320
|
+
return hop > 0 ? { ...raw, redirected: true } : raw;
|
|
1841
2321
|
}
|
|
1842
2322
|
// Unreachable
|
|
1843
|
-
throw new
|
|
2323
|
+
throw new RedirectError("Redirect loop", req);
|
|
1844
2324
|
}
|
|
1845
2325
|
// ── §8.8 Single attempt ──────────────────────────────────────────────────
|
|
1846
2326
|
/**
|
|
@@ -1877,9 +2357,16 @@ export class Kinetex {
|
|
|
1877
2357
|
}
|
|
1878
2358
|
this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
|
|
1879
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;
|
|
1880
2367
|
if (options.cache !== false && this.cfg.cache) {
|
|
1881
2368
|
const cache = await this.getCache();
|
|
1882
|
-
if (cache) {
|
|
2369
|
+
if (cache && !forceRefresh) {
|
|
1883
2370
|
const cacheReq = { url: req.url, method: req.method, headers: req.headers };
|
|
1884
2371
|
const hit = await cache.get(cacheReq);
|
|
1885
2372
|
if (hit && !hit.stale) {
|
|
@@ -1943,7 +2430,15 @@ export class Kinetex {
|
|
|
1943
2430
|
}
|
|
1944
2431
|
}
|
|
1945
2432
|
finally {
|
|
1946
|
-
|
|
2433
|
+
// Must not be able to skip: if getCache() rejects, the in-flight
|
|
2434
|
+
// marker survives and this key can never revalidate again, so
|
|
2435
|
+
// every future stale hit would be served stale forever.
|
|
2436
|
+
try {
|
|
2437
|
+
(await this.getCache())?.clearSWRInFlight(cacheReq);
|
|
2438
|
+
}
|
|
2439
|
+
catch {
|
|
2440
|
+
/* isolate — never leave the SWR marker stuck */
|
|
2441
|
+
}
|
|
1947
2442
|
}
|
|
1948
2443
|
})();
|
|
1949
2444
|
}
|
|
@@ -2037,21 +2532,39 @@ export class Kinetex {
|
|
|
2037
2532
|
...req,
|
|
2038
2533
|
headers: {
|
|
2039
2534
|
...req.headers,
|
|
2040
|
-
"accept-encoding":
|
|
2535
|
+
"accept-encoding": DEFAULT_ACCEPT_ENCODING,
|
|
2041
2536
|
},
|
|
2042
2537
|
};
|
|
2043
2538
|
}
|
|
2044
2539
|
// ── Dispatch ───────────────────────────────────────────────────────────
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
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.
|
|
2048
2562
|
const dispatchJar = await this.getCookieJar();
|
|
2563
|
+
const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
2049
2564
|
this._trace(_traceId, "transport_send", "start", startMs, attempt);
|
|
2050
2565
|
let raw;
|
|
2051
2566
|
try {
|
|
2052
|
-
raw = dispatchJar
|
|
2053
|
-
? await this._sendFollowingRedirects(req, timeout, dispatchJar, options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined)
|
|
2054
|
-
: await sendWithTimeout(this.transport, req, timeout);
|
|
2567
|
+
raw = await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth);
|
|
2055
2568
|
}
|
|
2056
2569
|
catch (err) {
|
|
2057
2570
|
// Cancel the progress-tracking ReadableStream to release the underlying
|
|
@@ -2069,6 +2582,20 @@ export class Kinetex {
|
|
|
2069
2582
|
throw err;
|
|
2070
2583
|
}
|
|
2071
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
|
+
}
|
|
2072
2599
|
// ── Handle 304 Not Modified ────────────────────────────────────────────
|
|
2073
2600
|
if (raw.status === 304) {
|
|
2074
2601
|
const cache = await this.getCache();
|
|
@@ -2111,9 +2638,18 @@ export class Kinetex {
|
|
|
2111
2638
|
},
|
|
2112
2639
|
...(req.signal !== null ? { signal: req.signal } : {}),
|
|
2113
2640
|
});
|
|
2641
|
+
// Capture the SOURCE in its own binding. `bodyStream` is reassigned to
|
|
2642
|
+
// this wrapper immediately after construction, so closing over it made
|
|
2643
|
+
// `cancel()` cancel *itself*: readRawBody's reader.cancel() (size limit,
|
|
2644
|
+
// abort, read error) re-entered this function, which threw
|
|
2645
|
+
// "Invalid state: ReadableStream is locked" from inside the cancel
|
|
2646
|
+
// algorithm and left that inner promise unhandled (process-level crash on
|
|
2647
|
+
// Node). start() only worked by accident, relying on the async-fn body
|
|
2648
|
+
// running synchronously up to the first await.
|
|
2649
|
+
const source = bodyStream;
|
|
2114
2650
|
bodyStream = new ReadableStream({
|
|
2115
2651
|
async start(controller) {
|
|
2116
|
-
const reader =
|
|
2652
|
+
const reader = source.getReader();
|
|
2117
2653
|
try {
|
|
2118
2654
|
while (true) {
|
|
2119
2655
|
const { done, value } = await reader.read();
|
|
@@ -2133,8 +2669,10 @@ export class Kinetex {
|
|
|
2133
2669
|
reader.releaseLock();
|
|
2134
2670
|
}
|
|
2135
2671
|
},
|
|
2136
|
-
cancel() {
|
|
2137
|
-
|
|
2672
|
+
cancel(reason) {
|
|
2673
|
+
// Forward cancellation to the real source and swallow its failure:
|
|
2674
|
+
// a rejection here is never observed by the cancelling reader.
|
|
2675
|
+
void source.cancel(reason).catch(() => { });
|
|
2138
2676
|
},
|
|
2139
2677
|
});
|
|
2140
2678
|
}
|
|
@@ -2159,9 +2697,9 @@ export class Kinetex {
|
|
|
2159
2697
|
status: raw.status,
|
|
2160
2698
|
statusText: raw.statusText,
|
|
2161
2699
|
headers: raw.headers,
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2700
|
+
// transformResponse is applied below, once `res` exists, so it receives
|
|
2701
|
+
// the real response object instead of the `{}` placeholder it used to get.
|
|
2702
|
+
data,
|
|
2165
2703
|
rawBody,
|
|
2166
2704
|
url: raw.url,
|
|
2167
2705
|
cached: false,
|
|
@@ -2171,6 +2709,14 @@ export class Kinetex {
|
|
|
2171
2709
|
request: req,
|
|
2172
2710
|
attempt,
|
|
2173
2711
|
};
|
|
2712
|
+
// Apply transformResponse now that the response object exists, so the hook
|
|
2713
|
+
// receives the real response (status/headers/url) rather than the empty
|
|
2714
|
+
// placeholder it used to be handed.
|
|
2715
|
+
if (this.cfg.transformResponse) {
|
|
2716
|
+
// `data` is readonly on the public type; the cast is confined to this one
|
|
2717
|
+
// write, immediately after construction.
|
|
2718
|
+
res.data = this.cfg.transformResponse(res.data, res);
|
|
2719
|
+
}
|
|
2174
2720
|
// ── Store in cache ─────────────────────────────────────────────────────
|
|
2175
2721
|
if (options.cache !== false && this.cfg.cache) {
|
|
2176
2722
|
const cache = await this.getCache();
|
|
@@ -2249,6 +2795,16 @@ export class Kinetex {
|
|
|
2249
2795
|
current = result;
|
|
2250
2796
|
}
|
|
2251
2797
|
else if ("url" in result && "method" in result && !("status" in result)) {
|
|
2798
|
+
// Re-sending from a response interceptor used to be unbounded: an
|
|
2799
|
+
// interceptor that always returns a modified request (the classic
|
|
2800
|
+
// token-refresh shape, but also a mis-written one) recursed forever,
|
|
2801
|
+
// each level a fresh _executeOnce with a fresh interceptor context.
|
|
2802
|
+
// The depth therefore travels on request meta, which _executeOnce
|
|
2803
|
+
// carries into the nested call (ctx.store would not survive it).
|
|
2804
|
+
const resendDepth = Number(_req.meta[INTERCEPTOR_RESEND_DEPTH] ?? 0);
|
|
2805
|
+
if (resendDepth >= MAX_INTERCEPTOR_RESENDS) {
|
|
2806
|
+
throw new KinetexError(`Response interceptor re-send limit reached (${MAX_INTERCEPTOR_RESENDS}) — refusing to loop`, "EVALIDATION", { request: _req });
|
|
2807
|
+
}
|
|
2252
2808
|
if (retryCfg && attempt <= retryCfg.maxRetries) {
|
|
2253
2809
|
const retryCtx = {
|
|
2254
2810
|
request: _req,
|
|
@@ -2265,7 +2821,13 @@ export class Kinetex {
|
|
|
2265
2821
|
}, Promise.resolve());
|
|
2266
2822
|
await sleep(delay, _req.signal);
|
|
2267
2823
|
}
|
|
2268
|
-
return this._executeOnce(
|
|
2824
|
+
return this._executeOnce({
|
|
2825
|
+
...result,
|
|
2826
|
+
meta: {
|
|
2827
|
+
...result.meta,
|
|
2828
|
+
[INTERCEPTOR_RESEND_DEPTH]: resendDepth + 1,
|
|
2829
|
+
},
|
|
2830
|
+
}, timeout, options, startMs, attempt + 1, undefined, retryCfg);
|
|
2269
2831
|
}
|
|
2270
2832
|
}
|
|
2271
2833
|
return current;
|
|
@@ -2556,7 +3118,10 @@ export class Kinetex {
|
|
|
2556
3118
|
* Call this when the client is no longer needed to prevent memory leaks.
|
|
2557
3119
|
* @returns A promise that resolves when cleanup is complete.
|
|
2558
3120
|
*/
|
|
2559
|
-
async
|
|
3121
|
+
// Not `async`: nothing here awaits, so the returned promise is resolved
|
|
3122
|
+
// explicitly instead. The signature stays Promise<void> — callers and the
|
|
3123
|
+
// docs `await client.destroy()`.
|
|
3124
|
+
destroy() {
|
|
2560
3125
|
// Close all tracked WebSocket connections
|
|
2561
3126
|
for (const ws of this._wsClients) {
|
|
2562
3127
|
try {
|
|
@@ -2567,23 +3132,22 @@ export class Kinetex {
|
|
|
2567
3132
|
}
|
|
2568
3133
|
}
|
|
2569
3134
|
this._wsClients.clear();
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
catch {
|
|
2575
|
-
/* best-effort */
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
3135
|
+
// NOTE: the cache is deliberately NOT cleared here. destroy() releases
|
|
3136
|
+
// resources; it must not purge data, and a user-supplied adapter
|
|
3137
|
+
// (localStorage / Cloudflare KV / Redis) would lose every persisted entry.
|
|
3138
|
+
// Call `client.getCache().then(c => c.clear())` explicitly to empty it.
|
|
2578
3139
|
if (IS_NODE && this.transport && "destroy" in this.transport) {
|
|
2579
3140
|
this.transport.destroy();
|
|
2580
3141
|
}
|
|
3142
|
+
// Reject anyone still parked on the queue — it can never drain now.
|
|
3143
|
+
this._concurrencyLimiter?.drain();
|
|
2581
3144
|
this._cookieJar = null;
|
|
2582
3145
|
this._logger = null;
|
|
2583
3146
|
this._dedup?.clear();
|
|
2584
3147
|
this._circuitBreakers?.clear?.();
|
|
2585
3148
|
this._otelTracer = null;
|
|
2586
3149
|
this.interceptors.clear();
|
|
3150
|
+
return Promise.resolve();
|
|
2587
3151
|
}
|
|
2588
3152
|
}
|
|
2589
3153
|
// ============================================================================
|
|
@@ -2631,6 +3195,34 @@ export class FluentRequest {
|
|
|
2631
3195
|
};
|
|
2632
3196
|
return this;
|
|
2633
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
|
+
}
|
|
2634
3226
|
/** Merge a headers map. */
|
|
2635
3227
|
headers(headers) {
|
|
2636
3228
|
this._options.headers = {
|
|
@@ -2837,16 +3429,40 @@ export class FluentRequest {
|
|
|
2837
3429
|
// §15 UTILITIES
|
|
2838
3430
|
// ============================================================================
|
|
2839
3431
|
/**
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
3432
|
+
* Convert a KinetexResponse into the lifecycle HookResponse shape, or null
|
|
3433
|
+
* when the request never produced one (network error, timeout, abort).
|
|
3434
|
+
*
|
|
3435
|
+
* Used by the error-hook bridge so onError hooks can read the HTTP status of
|
|
3436
|
+
* a failed request instead of always seeing null.
|
|
3437
|
+
*/
|
|
3438
|
+
function toHookResponse(res, request) {
|
|
3439
|
+
if (!res)
|
|
3440
|
+
return null;
|
|
3441
|
+
return {
|
|
3442
|
+
status: res.status,
|
|
3443
|
+
statusText: res.statusText,
|
|
3444
|
+
headers: res.headers,
|
|
3445
|
+
body: res.rawBody ?? null,
|
|
3446
|
+
request,
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* The abort error raised by the retry loop itself.
|
|
3451
|
+
*
|
|
3452
|
+
* This used to build a bare `DOMException`, which is a real `Error` but not a
|
|
3453
|
+
* `KinetexError`: it carries no `code`, so `err.code === "EABORT"` and
|
|
3454
|
+
* `err.isAbort` were both false here while every other abort path in the
|
|
3455
|
+
* library (see core.ts) raised `EABORT`. Callers documented to see `AbortError`
|
|
3456
|
+
* therefore got a structurally different error depending on whether the signal
|
|
3457
|
+
* fired mid-request or mid-retry-delay. The library's own `AbortError` keeps
|
|
3458
|
+
* `name === "AbortError"`, so name-based checks like `isAbortError` are
|
|
3459
|
+
* unaffected.
|
|
3460
|
+
*
|
|
3461
|
+
* @param request - The request being retried, attached when available.
|
|
3462
|
+
* @returns A KinetexError with code `EABORT`.
|
|
2842
3463
|
*/
|
|
2843
|
-
function createAbortError() {
|
|
2844
|
-
|
|
2845
|
-
return new DOMException("Aborted", "AbortError");
|
|
2846
|
-
}
|
|
2847
|
-
const err = new Error("Aborted");
|
|
2848
|
-
err.name = "AbortError";
|
|
2849
|
-
return err;
|
|
3464
|
+
function createAbortError(request) {
|
|
3465
|
+
return new AbortError(request);
|
|
2850
3466
|
}
|
|
2851
3467
|
/**
|
|
2852
3468
|
* Merge two query parameter maps into one.
|
|
@@ -2882,6 +3498,16 @@ function sleep(ms, signal) {
|
|
|
2882
3498
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2883
3499
|
});
|
|
2884
3500
|
}
|
|
3501
|
+
/**
|
|
3502
|
+
* True for request bodies that cannot be sent twice: a ReadableStream is
|
|
3503
|
+
* consumed (and locked) by the first attempt, and a Blob-backed stream is
|
|
3504
|
+
* derived from an already-read handle. Both are fine once, never on retry.
|
|
3505
|
+
*/
|
|
3506
|
+
function isNonReplayableBody(body) {
|
|
3507
|
+
if (body instanceof ReadableStream)
|
|
3508
|
+
return true;
|
|
3509
|
+
return typeof Blob !== "undefined" && body instanceof Blob;
|
|
3510
|
+
}
|
|
2885
3511
|
/** Cross-runtime performance.now() — falls back to Date.now(). */
|
|
2886
3512
|
function perfNow() {
|
|
2887
3513
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
@@ -2942,7 +3568,13 @@ export function createMethodCircuitBreakerKey(req) {
|
|
|
2942
3568
|
export class BatchQueue {
|
|
2943
3569
|
/** The parent Kinetex instance used to send requests. */
|
|
2944
3570
|
_client;
|
|
2945
|
-
/**
|
|
3571
|
+
/**
|
|
3572
|
+
* Maximum number of requests taken out of the queue per flush.
|
|
3573
|
+
* NOTE: this is a batching size, NOT a concurrency limit — every request in a
|
|
3574
|
+
* batch is dispatched immediately and in parallel, and `flush()` drains the
|
|
3575
|
+
* whole queue the same way. Use `maxBatch` to bound how much is dispatched per
|
|
3576
|
+
* tick, and a semaphore or rate limiter to bound actual parallelism.
|
|
3577
|
+
*/
|
|
2946
3578
|
_maxBatch;
|
|
2947
3579
|
/** Milliseconds to wait before flushing an incomplete batch. */
|
|
2948
3580
|
_flushMs;
|
|
@@ -2956,8 +3588,19 @@ export class BatchQueue {
|
|
|
2956
3588
|
*/
|
|
2957
3589
|
constructor(client, options = {}) {
|
|
2958
3590
|
this._client = client;
|
|
2959
|
-
|
|
2960
|
-
|
|
3591
|
+
// maxBatch must be a positive integer: _flushNow() splices exactly
|
|
3592
|
+
// `_maxBatch` items, so 0 (or a negative value) spliced nothing and made
|
|
3593
|
+
// flush() spin forever on a queue it could never drain.
|
|
3594
|
+
const maxBatch = options.maxBatch ?? 100;
|
|
3595
|
+
if (!Number.isInteger(maxBatch) || maxBatch < 1) {
|
|
3596
|
+
throw new RangeError(`BatchQueue maxBatch must be a positive integer (got ${maxBatch})`);
|
|
3597
|
+
}
|
|
3598
|
+
this._maxBatch = maxBatch;
|
|
3599
|
+
const flushMs = options.flushMs ?? 0;
|
|
3600
|
+
if (!Number.isFinite(flushMs) || flushMs < 0) {
|
|
3601
|
+
throw new RangeError(`BatchQueue flushMs must be a non-negative finite number (got ${flushMs})`);
|
|
3602
|
+
}
|
|
3603
|
+
this._flushMs = flushMs;
|
|
2961
3604
|
}
|
|
2962
3605
|
/**
|
|
2963
3606
|
* Enqueue a request. Returns a promise that resolves when the batch
|
|
@@ -3018,7 +3661,11 @@ export class BatchQueue {
|
|
|
3018
3661
|
* Loops until the queue is empty so items beyond maxBatch are not orphaned.
|
|
3019
3662
|
*/
|
|
3020
3663
|
flush() {
|
|
3021
|
-
|
|
3664
|
+
// The constructor guarantees _maxBatch >= 1, so every _flushNow() removes at
|
|
3665
|
+
// least one item. The counter is defence in depth against a future change
|
|
3666
|
+
// reintroducing a zero-progress flush (which would spin forever).
|
|
3667
|
+
let guard = this._queue.length + 1;
|
|
3668
|
+
while (this._queue.length > 0 && guard-- > 0)
|
|
3022
3669
|
this._flushNow();
|
|
3023
3670
|
}
|
|
3024
3671
|
/** How many requests are currently queued (not yet sent). */
|