kinetex 1.4.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 +170 -46
- package/dist/browser/kinetex.esm.js +29 -29
- package/dist/browser/kinetex.js +1143 -791
- package/dist/browser/kinetex.min.js +29 -29
- package/dist/cjs/aws-sigv4.js +7 -14
- package/dist/cjs/cache.js +9 -0
- package/dist/cjs/circuit-breaker.js +8 -1
- package/dist/cjs/client.js +350 -95
- package/dist/cjs/cookie-parser.js +28 -1
- package/dist/cjs/core.js +227 -5
- package/dist/cjs/dedup.js +93 -10
- package/dist/cjs/digest.js +36 -15
- package/dist/cjs/graphql.js +2 -11
- package/dist/cjs/headers.js +24 -1
- package/dist/cjs/interceptors.js +6 -17
- package/dist/cjs/lifecycle.js +3 -19
- package/dist/cjs/logging.js +12 -29
- package/dist/cjs/progress.js +1 -3
- package/dist/cjs/proxy.js +19 -2
- package/dist/cjs/response.js +202 -75
- package/dist/cjs/socks5.js +5 -0
- package/dist/cjs/utils.js +115 -27
- package/dist/cjs/ws.js +43 -2
- package/dist/esm/aws-sigv4.js +7 -14
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +9 -0
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +8 -1
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +350 -95
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +28 -1
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/core.js +227 -5
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +93 -10
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +36 -15
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +2 -11
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +24 -1
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +6 -17
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +3 -19
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +12 -29
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/progress.js +1 -3
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +19 -2
- package/dist/esm/proxy.js.map +1 -1
- package/dist/esm/response.js +202 -75
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +5 -0
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +115 -27
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +43 -2
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +1 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/core.d.ts +113 -0
- 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.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +23 -4
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +24 -38
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts.map +1 -1
- package/dist/types/response.d.ts +42 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/types.d.ts +105 -23
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -11
- 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/esm/client.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
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
10
|
import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
|
|
11
|
-
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
|
|
12
|
-
import {
|
|
11
|
+
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, perfNow, isSensitiveHeader, } from "./utils.js";
|
|
12
|
+
import { CREDENTIAL_HEADERS } from "./cache.js";
|
|
13
13
|
import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
|
|
14
14
|
import { SigV4Signer } from "./aws-sigv4.js";
|
|
15
15
|
import { createDigestAuthorizer } from "./digest.js";
|
|
@@ -142,6 +142,15 @@ function randomHex(len) {
|
|
|
142
142
|
* Mirrors logging.ts DEFAULT_REDACT_HEADERS — HAR logs are routinely exported
|
|
143
143
|
* and shared, so credentials must never appear verbatim.
|
|
144
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;
|
|
145
154
|
const HAR_REDACT_HEADERS = new Set([
|
|
146
155
|
"authorization",
|
|
147
156
|
"proxy-authorization",
|
|
@@ -202,6 +211,92 @@ const HAR_REDACT_PARAMS = new Set([
|
|
|
202
211
|
"jwt",
|
|
203
212
|
"code",
|
|
204
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
|
+
}
|
|
205
300
|
/** Maximum number of body characters recorded in a HAR entry. */
|
|
206
301
|
const HAR_MAX_BODY_CHARS = 8192;
|
|
207
302
|
/** HTML/other content types whose bodies are never recorded in HAR output. */
|
|
@@ -241,10 +336,20 @@ function redactHARUrl(url) {
|
|
|
241
336
|
}
|
|
242
337
|
}
|
|
243
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***";
|
|
244
346
|
function redactHARHeader(name, value) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
};
|
|
248
353
|
}
|
|
249
354
|
/**
|
|
250
355
|
* O(1) ring-buffer HAR entry recorder.
|
|
@@ -277,7 +382,10 @@ function harPostData(req) {
|
|
|
277
382
|
else {
|
|
278
383
|
return {}; // stream / FormData / Blob: not readable without consuming it
|
|
279
384
|
}
|
|
280
|
-
|
|
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) } };
|
|
281
389
|
}
|
|
282
390
|
class HARRecorder {
|
|
283
391
|
/** Ring buffer of entries keyed by monotonic counter. */
|
|
@@ -393,7 +501,12 @@ class HARRecorder {
|
|
|
393
501
|
// Body text is only kept for non-HTML payloads and is truncated:
|
|
394
502
|
// response bodies routinely carry tokens and PII.
|
|
395
503
|
...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
|
|
396
|
-
? {
|
|
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
|
+
}
|
|
397
510
|
: {}),
|
|
398
511
|
},
|
|
399
512
|
// The Location header can itself carry a signed URL — redact it too.
|
|
@@ -559,8 +672,49 @@ function redactUserInfo(url) {
|
|
|
559
672
|
* @returns Fully-qualified URL string.
|
|
560
673
|
* @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
|
|
561
674
|
*/
|
|
562
|
-
function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
675
|
+
function buildURL(base, url, params, allowedSchemes = ["http", "https"], allowPrivateNetwork = false) {
|
|
563
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
|
+
};
|
|
564
718
|
const MAX_URL_LENGTH = 8192;
|
|
565
719
|
let full;
|
|
566
720
|
if (/^https?:\/\//i.test(url)) {
|
|
@@ -588,7 +742,7 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
|
588
742
|
}
|
|
589
743
|
}
|
|
590
744
|
if (!params || Object.keys(params).length === 0) {
|
|
591
|
-
if (!isSafeURL(full, allowedSchemes)) {
|
|
745
|
+
if (!isSafeURL(full, allowedSchemes, { allowPrivateNetwork })) {
|
|
592
746
|
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
593
747
|
}
|
|
594
748
|
return full;
|
|
@@ -597,40 +751,24 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
|
597
751
|
const u = new URL(full);
|
|
598
752
|
// The params branch screens the URL after appending them, so it needs the
|
|
599
753
|
// same scheme list as the no-params branch above.
|
|
600
|
-
if (!isSafeURL(u, allowedSchemes)) {
|
|
754
|
+
if (!isSafeURL(u, allowedSchemes, { allowPrivateNetwork })) {
|
|
601
755
|
throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
602
756
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
paramCount++;
|
|
612
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
613
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
614
|
-
}
|
|
615
|
-
u.searchParams.append(key, String(v));
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
else {
|
|
620
|
-
paramCount++;
|
|
621
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
622
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
623
|
-
}
|
|
624
|
-
u.searchParams.set(key, strValue);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
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
|
+
});
|
|
627
765
|
const result = u.toString();
|
|
628
766
|
// Check URL length limit
|
|
629
767
|
if (result.length > MAX_URL_LENGTH) {
|
|
630
768
|
throw new KinetexError(`URL length ${result.length} bytes exceeds limit of ${MAX_URL_LENGTH} bytes`, "EVALIDATION");
|
|
631
769
|
}
|
|
632
770
|
// Validate the final URL with params
|
|
633
|
-
if (!isSafeURL(result)) {
|
|
771
|
+
if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
|
|
634
772
|
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
635
773
|
}
|
|
636
774
|
return result;
|
|
@@ -646,29 +784,9 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
|
646
784
|
full += "?";
|
|
647
785
|
}
|
|
648
786
|
const paramParts = [];
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
continue;
|
|
653
|
-
if (Array.isArray(value)) {
|
|
654
|
-
for (const v of value) {
|
|
655
|
-
if (v !== null && v !== undefined) {
|
|
656
|
-
paramCount++;
|
|
657
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
658
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
659
|
-
}
|
|
660
|
-
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
else {
|
|
665
|
-
paramCount++;
|
|
666
|
-
if (paramCount > MAX_QUERY_PARAM_COUNT) {
|
|
667
|
-
throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
|
|
668
|
-
}
|
|
669
|
-
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
787
|
+
forEachQueryParam(params ?? {}, (key, value) => {
|
|
788
|
+
paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
789
|
+
});
|
|
672
790
|
const result = full + paramParts.join("&");
|
|
673
791
|
// Check URL length limit
|
|
674
792
|
if (result.length > MAX_URL_LENGTH) {
|
|
@@ -676,7 +794,7 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
|
|
|
676
794
|
}
|
|
677
795
|
// FIX M4: the manual param-concat fallback previously returned WITHOUT a
|
|
678
796
|
// safety check — validate the assembled URL like every other path.
|
|
679
|
-
if (!isSafeURL(result)) {
|
|
797
|
+
if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
|
|
680
798
|
throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
|
|
681
799
|
}
|
|
682
800
|
return result;
|
|
@@ -732,11 +850,42 @@ function mergeHeaders(...sources) {
|
|
|
732
850
|
* @param ctx - Current retry context (error, response, attempt count).
|
|
733
851
|
* @returns Whether a retry should be attempted.
|
|
734
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
|
+
}
|
|
735
875
|
function shouldRetry(cfg, ctx) {
|
|
736
876
|
if (ctx.attempt > cfg.maxRetries)
|
|
737
877
|
return false;
|
|
738
|
-
|
|
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)) {
|
|
739
887
|
return false;
|
|
888
|
+
}
|
|
740
889
|
if (cfg.shouldRetry)
|
|
741
890
|
return cfg.shouldRetry(ctx); // caller handles it
|
|
742
891
|
if (ctx.error) {
|
|
@@ -764,6 +913,10 @@ function shouldRetry(cfg, ctx) {
|
|
|
764
913
|
return false;
|
|
765
914
|
case "EREDIRECT":
|
|
766
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;
|
|
767
920
|
case "EUNKNOWN":
|
|
768
921
|
return false;
|
|
769
922
|
default: {
|
|
@@ -943,6 +1096,10 @@ export class Kinetex {
|
|
|
943
1096
|
...(config.strictHeaders ? { strict: true } : {}),
|
|
944
1097
|
...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
|
|
945
1098
|
...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
|
|
1099
|
+
...(config.allowPrivateNetwork !== undefined
|
|
1100
|
+
? { allowPrivateNetwork: config.allowPrivateNetwork }
|
|
1101
|
+
: {}),
|
|
1102
|
+
...(config.ca !== undefined ? { ca: config.ca } : {}),
|
|
946
1103
|
});
|
|
947
1104
|
// Register config-level interceptors
|
|
948
1105
|
if (config.interceptors) {
|
|
@@ -1046,7 +1203,7 @@ export class Kinetex {
|
|
|
1046
1203
|
status: ctx.response.status,
|
|
1047
1204
|
statusText: ctx.response.statusText,
|
|
1048
1205
|
headers: ctx.response.headers,
|
|
1049
|
-
body: ctx.response.
|
|
1206
|
+
body: ctx.response.body ?? null,
|
|
1050
1207
|
request: {
|
|
1051
1208
|
url: ctx.request.url,
|
|
1052
1209
|
method: ctx.request.method,
|
|
@@ -1443,12 +1600,7 @@ export class Kinetex {
|
|
|
1443
1600
|
// check and threw EVALIDATION on every call, so `client.ws()` could not
|
|
1444
1601
|
// connect to anything. Only the scheme list is widened: the loopback and
|
|
1445
1602
|
// 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
|
-
]);
|
|
1603
|
+
const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, ["http", "https", "ws", "wss"], this.cfg.allowPrivateNetwork);
|
|
1452
1604
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
1453
1605
|
// Apply auth headers manually since WS handshake goes through the browser
|
|
1454
1606
|
// WS API which doesn't use the kinetex transport pipeline.
|
|
@@ -1481,7 +1633,11 @@ export class Kinetex {
|
|
|
1481
1633
|
// Inject cookies from cookie jar into WS handshake
|
|
1482
1634
|
const jar = await this.getCookieJar();
|
|
1483
1635
|
if (jar) {
|
|
1484
|
-
const cookieHeader = jar.getCookieHeader({
|
|
1636
|
+
const cookieHeader = jar.getCookieHeader({
|
|
1637
|
+
url: fullURL,
|
|
1638
|
+
http: false,
|
|
1639
|
+
sameSiteContext: "strict",
|
|
1640
|
+
});
|
|
1485
1641
|
if (cookieHeader)
|
|
1486
1642
|
headers["cookie"] = cookieHeader;
|
|
1487
1643
|
}
|
|
@@ -1490,7 +1646,14 @@ export class Kinetex {
|
|
|
1490
1646
|
const wsUrl = new URL(fullURL);
|
|
1491
1647
|
if (this.cfg.baseURL) {
|
|
1492
1648
|
const baseUrl = new URL(this.cfg.baseURL);
|
|
1493
|
-
|
|
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:";
|
|
1494
1657
|
// A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
|
|
1495
1658
|
// "wss://…" })` then `client.ws("/path")` is the natural spelling, and
|
|
1496
1659
|
// it is what the README's origin-validation section shows. Comparing
|
|
@@ -1549,6 +1712,12 @@ export class Kinetex {
|
|
|
1549
1712
|
this._wsClients.add(client);
|
|
1550
1713
|
await client.connect().catch((err) => {
|
|
1551
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();
|
|
1552
1721
|
throw err;
|
|
1553
1722
|
});
|
|
1554
1723
|
return client;
|
|
@@ -1572,7 +1741,23 @@ export class Kinetex {
|
|
|
1572
1741
|
// ── Resolve timeout ────────────────────────────────────────────────────
|
|
1573
1742
|
const timeoutMs = options.timeout !== undefined ? options.timeout : (this.cfg.timeout ?? 30_000);
|
|
1574
1743
|
// ── Resolve retry ──────────────────────────────────────────────────────
|
|
1575
|
-
|
|
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
|
+
};
|
|
1576
1761
|
// ── Validate HTTP method ────────────────────────────────────────────────
|
|
1577
1762
|
const VALID_METHODS = [
|
|
1578
1763
|
"GET",
|
|
@@ -1590,7 +1775,7 @@ export class Kinetex {
|
|
|
1590
1775
|
throw new KinetexError(`Invalid HTTP method '${method}'. Valid methods: ${VALID_METHODS.join(", ")}`, "EVALIDATION");
|
|
1591
1776
|
}
|
|
1592
1777
|
// ── Build initial request ─────────────────────────────────────────────
|
|
1593
|
-
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
|
|
1778
|
+
const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params), undefined, this.cfg.allowPrivateNetwork === true);
|
|
1594
1779
|
// A client-level `proxy` is honoured by the Node transport, which tunnels
|
|
1595
1780
|
// every connection through it with CONNECT. A per-request `proxy` cannot be:
|
|
1596
1781
|
// the transport pools one connection per origin, so honouring a per-request
|
|
@@ -1762,7 +1947,11 @@ export class Kinetex {
|
|
|
1762
1947
|
// ── Cookie jar (outgoing cookies) ──────────────────────────────────────
|
|
1763
1948
|
const jar = await this.getCookieJar();
|
|
1764
1949
|
if (jar) {
|
|
1765
|
-
const cookieHeader = jar.getCookieHeader({
|
|
1950
|
+
const cookieHeader = jar.getCookieHeader({
|
|
1951
|
+
url: req.url,
|
|
1952
|
+
http: true,
|
|
1953
|
+
sameSiteContext: "strict",
|
|
1954
|
+
});
|
|
1766
1955
|
if (cookieHeader)
|
|
1767
1956
|
req = { ...req, headers: { ...req.headers, cookie: cookieHeader } };
|
|
1768
1957
|
}
|
|
@@ -1778,6 +1967,7 @@ export class Kinetex {
|
|
|
1778
1967
|
// abandoning it — an unended span is never exported and never reports the
|
|
1779
1968
|
// error, and holds its attributes in the tracer's memory.
|
|
1780
1969
|
let _otelSpan = null;
|
|
1970
|
+
let _dedupKey = "";
|
|
1781
1971
|
try {
|
|
1782
1972
|
if (this._otelTracer) {
|
|
1783
1973
|
_otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
|
|
@@ -1806,6 +1996,24 @@ export class Kinetex {
|
|
|
1806
1996
|
meta: { ...req.meta, spanId },
|
|
1807
1997
|
};
|
|
1808
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
|
+
}
|
|
1809
2017
|
}
|
|
1810
2018
|
catch (tracingErr) {
|
|
1811
2019
|
if (_otelSpan) {
|
|
@@ -1846,16 +2054,12 @@ export class Kinetex {
|
|
|
1846
2054
|
const _execFactory = () => this._executeWithRetry(req, retryCfg, timeoutMs, options, startMs, wallClockMs);
|
|
1847
2055
|
// Dedup: coalesce identical concurrent GET/HEAD requests into one network
|
|
1848
2056
|
// call. All callers share the same KinetexResponse object once resolved.
|
|
1849
|
-
// SECURITY:
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
// Fingerprinting hashes the
|
|
1853
|
-
// paid when dedup is actually enabled.
|
|
1854
|
-
|
|
1855
|
-
if (this._dedup) {
|
|
1856
|
-
const authFp = await getAuthFingerprint(req.headers ?? {});
|
|
1857
|
-
_dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
|
|
1858
|
-
}
|
|
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.
|
|
1859
2063
|
const _dedupedFactory = this._dedup
|
|
1860
2064
|
? () => this._dedup
|
|
1861
2065
|
.execute(req.method, _dedupKey, _execFactory)
|
|
@@ -2001,6 +2205,14 @@ export class Kinetex {
|
|
|
2001
2205
|
: shouldRetry(retryCfg, retryCtx);
|
|
2002
2206
|
if (doRetry) {
|
|
2003
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
|
+
}
|
|
2004
2216
|
await retryCfg.onRetry?.(retryCtx, delay);
|
|
2005
2217
|
await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
|
|
2006
2218
|
await p;
|
|
@@ -2070,6 +2282,14 @@ export class Kinetex {
|
|
|
2070
2282
|
: shouldRetry(retryCfg, retryCtx);
|
|
2071
2283
|
if (doRetry) {
|
|
2072
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
|
+
}
|
|
2073
2293
|
await retryCfg.onRetry?.(retryCtx, delay);
|
|
2074
2294
|
await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
|
|
2075
2295
|
await p;
|
|
@@ -2236,7 +2456,9 @@ export class Kinetex {
|
|
|
2236
2456
|
// public host could 302 the client straight at link-local/loopback
|
|
2237
2457
|
// addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
|
|
2238
2458
|
// private-network block list would be bypassable. Re-validate every hop.
|
|
2239
|
-
if (!isSafeURL(nextUrl
|
|
2459
|
+
if (!isSafeURL(nextUrl, undefined, {
|
|
2460
|
+
allowPrivateNetwork: this.cfg.allowPrivateNetwork === true,
|
|
2461
|
+
})) {
|
|
2240
2462
|
throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
|
|
2241
2463
|
}
|
|
2242
2464
|
// httpsOnly must hold for redirect legs too, otherwise a redirect is a
|
|
@@ -2259,6 +2481,15 @@ export class Kinetex {
|
|
|
2259
2481
|
? "GET"
|
|
2260
2482
|
: currentReq.method;
|
|
2261
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
|
+
}
|
|
2262
2493
|
const nextHeaders = { ...currentReq.headers };
|
|
2263
2494
|
// FIX H2: When the redirect crosses origins, strip credential-bearing
|
|
2264
2495
|
// headers (Authorization, Cookie, proxy auth, API keys) so secrets are
|
|
@@ -2290,7 +2521,13 @@ export class Kinetex {
|
|
|
2290
2521
|
// Jar scoping guarantees only cookies that match the NEW origin are
|
|
2291
2522
|
// attached, which is exactly the post-strip state we want.
|
|
2292
2523
|
if (jar) {
|
|
2293
|
-
|
|
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
|
+
});
|
|
2294
2531
|
if (cookieHeader) {
|
|
2295
2532
|
nextHeaders["cookie"] = cookieHeader;
|
|
2296
2533
|
}
|
|
@@ -2356,6 +2593,19 @@ export class Kinetex {
|
|
|
2356
2593
|
}
|
|
2357
2594
|
}
|
|
2358
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
|
+
}
|
|
2359
2609
|
// ── Cache lookup ───────────────────────────────────────────────────────
|
|
2360
2610
|
// `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
|
|
2361
2611
|
// declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
|
|
@@ -2677,7 +2927,11 @@ export class Kinetex {
|
|
|
2677
2927
|
});
|
|
2678
2928
|
}
|
|
2679
2929
|
// ── Read body with size limit ──────────────────────────────────────────
|
|
2680
|
-
|
|
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;
|
|
2681
2935
|
const rawBody = await readRawBody(bodyStream, maxSize, raw.url, req.signal);
|
|
2682
2936
|
// ── Cookie jar (incoming Set-Cookie) ──────────────────────────────────
|
|
2683
2937
|
const jar = await this.getCookieJar();
|
|
@@ -2701,6 +2955,9 @@ export class Kinetex {
|
|
|
2701
2955
|
// the real response object instead of the `{}` placeholder it used to get.
|
|
2702
2956
|
data,
|
|
2703
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,
|
|
2704
2961
|
url: raw.url,
|
|
2705
2962
|
cached: false,
|
|
2706
2963
|
redirected: raw.redirected,
|
|
@@ -2725,9 +2982,9 @@ export class Kinetex {
|
|
|
2725
2982
|
}
|
|
2726
2983
|
}
|
|
2727
2984
|
// ── Logger ─────────────────────────────────────────────────────────────
|
|
2728
|
-
const logger = await this.getLogger();
|
|
2985
|
+
const logger = _logger ?? (await this.getLogger());
|
|
2729
2986
|
if (logger) {
|
|
2730
|
-
const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ??
|
|
2987
|
+
const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? _traceId;
|
|
2731
2988
|
logger.logResponse(requestId, res.status, res.statusText, res.headers, rawBody, attempt, false);
|
|
2732
2989
|
}
|
|
2733
2990
|
// ── HAR recording ──────────────────────────────────────────────────────
|
|
@@ -2888,6 +3145,8 @@ export class Kinetex {
|
|
|
2888
3145
|
status: raw.status,
|
|
2889
3146
|
statusText: raw.statusText,
|
|
2890
3147
|
headers: raw.headers,
|
|
3148
|
+
// Present so the object satisfies both response views at once.
|
|
3149
|
+
body: rawBody,
|
|
2891
3150
|
data,
|
|
2892
3151
|
rawBody,
|
|
2893
3152
|
url: responseURL,
|
|
@@ -3006,7 +3265,7 @@ export class Kinetex {
|
|
|
3006
3265
|
async sse(url, options = {}) {
|
|
3007
3266
|
const { SSEClient } = await import("./sse.js");
|
|
3008
3267
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
3009
|
-
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);
|
|
3010
3269
|
// Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, etc.)
|
|
3011
3270
|
// by providing a pipeline-aware fetch function.
|
|
3012
3271
|
const pipeFetch = async (input, init) => {
|
|
@@ -3047,7 +3306,7 @@ export class Kinetex {
|
|
|
3047
3306
|
async graphql(url, options = {}) {
|
|
3048
3307
|
const { GraphQLClient } = await import("./graphql.js");
|
|
3049
3308
|
const headers = mergeHeaders(this.cfg.headers, options.headers);
|
|
3050
|
-
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);
|
|
3051
3310
|
// Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, OTel, etc.)
|
|
3052
3311
|
const pipeFetch = async (input, init) => {
|
|
3053
3312
|
const reqUrl = typeof input === "string" ? input : input.url;
|
|
@@ -3086,7 +3345,7 @@ export class Kinetex {
|
|
|
3086
3345
|
*/
|
|
3087
3346
|
async paginate(url, options) {
|
|
3088
3347
|
const { createPagePaginator } = await import("./pagination.js");
|
|
3089
|
-
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);
|
|
3090
3349
|
// Route through the full kinetex pipeline
|
|
3091
3350
|
const pipeFetch = async (input, init) => {
|
|
3092
3351
|
const reqUrl = typeof input === "string" ? input : input.url;
|
|
@@ -3508,10 +3767,6 @@ function isNonReplayableBody(body) {
|
|
|
3508
3767
|
return true;
|
|
3509
3768
|
return typeof Blob !== "undefined" && body instanceof Blob;
|
|
3510
3769
|
}
|
|
3511
|
-
/** Cross-runtime performance.now() — falls back to Date.now(). */
|
|
3512
|
-
function perfNow() {
|
|
3513
|
-
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
3514
|
-
}
|
|
3515
3770
|
// ============================================================================
|
|
3516
3771
|
// §16 ENTERPRISE HELPERS
|
|
3517
3772
|
// ============================================================================
|