kinetex 1.1.0 → 1.3.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 +919 -445
- package/dist/browser/kinetex.esm.js +18 -18
- package/dist/browser/kinetex.js +749 -295
- package/dist/browser/kinetex.min.js +18 -18
- package/dist/cjs/aws-sigv4.js +4 -1
- package/dist/cjs/cache.js +52 -14
- package/dist/cjs/circuit-breaker.js +24 -4
- package/dist/cjs/client.js +495 -131
- package/dist/cjs/cookie-parser.js +7 -4
- package/dist/cjs/cookie-store.js +16 -8
- package/dist/cjs/core.js +83 -35
- package/dist/cjs/dedup.js +9 -7
- package/dist/cjs/digest.js +26 -0
- package/dist/cjs/graphql.js +19 -4
- package/dist/cjs/headers.js +64 -8
- package/dist/cjs/interceptors.js +86 -33
- package/dist/cjs/logging.js +1 -1
- package/dist/cjs/pagination.js +14 -6
- package/dist/cjs/progress.js +129 -42
- package/dist/cjs/socks5.js +36 -21
- package/dist/cjs/sse.js +48 -11
- package/dist/cjs/utils.js +24 -12
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +23 -7
- package/dist/esm/aws-sigv4.js +4 -1
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +52 -14
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +24 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +495 -131
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +7 -4
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +16 -8
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +83 -35
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +9 -7
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +26 -0
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +19 -4
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +64 -8
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +86 -33
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/logging.js +1 -1
- package/dist/esm/pagination.js +14 -6
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +129 -42
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/socks5.js +36 -21
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +48 -11
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +24 -12
- 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 +23 -7
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +8 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +29 -12
- package/dist/types/client.d.ts.map +1 -1
- 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 +10 -0
- 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 +14 -0
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +25 -10
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/logging.d.ts +1 -1
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.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 +25 -2
- package/dist/types/types.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 +4 -0
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/cjs/client.js
CHANGED
|
@@ -7,13 +7,19 @@ 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, toRequestId } from "./types.js";
|
|
11
11
|
import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
|
|
12
|
-
import { getAuthFingerprint } from "./cache.js";
|
|
12
|
+
import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
|
|
13
13
|
import { createRateLimitInterceptor } from "./interceptors.js";
|
|
14
14
|
import { SigV4Signer } from "./aws-sigv4.js";
|
|
15
|
-
import {
|
|
15
|
+
import { createDigestAuthorizer } from "./digest.js";
|
|
16
|
+
import { DEFAULT_ACCEPT_ENCODING } from "./core.js";
|
|
16
17
|
import { createTransport, sendWithTimeout, decompressBodyStream, readRawBody, parseBody, RUNTIME, IS_NODE, } from "./core.js";
|
|
18
|
+
/**
|
|
19
|
+
* Hard ceiling on redirect hops followed by the manual redirect follower.
|
|
20
|
+
* Overridable per client / per request with `maxRedirects`.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_MAX_REDIRECTS = 20;
|
|
17
23
|
/** Default retry configuration used when no retry config is provided. */
|
|
18
24
|
const DEFAULT_RETRY = {
|
|
19
25
|
maxRetries: 3,
|
|
@@ -160,9 +166,82 @@ const HAR_REDACT_HEADERS = new Set([
|
|
|
160
166
|
"passwd",
|
|
161
167
|
"secret",
|
|
162
168
|
]);
|
|
169
|
+
/**
|
|
170
|
+
* `meta` key carrying the number of response-interceptor re-sends so a
|
|
171
|
+
* self-retriggering interceptor cannot loop forever.
|
|
172
|
+
*/
|
|
173
|
+
const INTERCEPTOR_RESEND_DEPTH = "__interceptorResendDepth";
|
|
174
|
+
/** Hard cap on consecutive response-interceptor re-sends (digest refresh, etc.). */
|
|
175
|
+
const MAX_INTERCEPTOR_RESENDS = 5;
|
|
176
|
+
/** Query-parameter names whose values are redacted in HAR output. */
|
|
177
|
+
const HAR_REDACT_PARAMS = new Set([
|
|
178
|
+
"api_key",
|
|
179
|
+
"apikey",
|
|
180
|
+
"access_token",
|
|
181
|
+
"refresh_token",
|
|
182
|
+
"id_token",
|
|
183
|
+
"token",
|
|
184
|
+
"auth",
|
|
185
|
+
"authorization",
|
|
186
|
+
"key",
|
|
187
|
+
"secret",
|
|
188
|
+
"password",
|
|
189
|
+
"passwd",
|
|
190
|
+
"signature",
|
|
191
|
+
"sig",
|
|
192
|
+
"x-amz-signature",
|
|
193
|
+
"x-amz-credential",
|
|
194
|
+
"x-amz-security-token",
|
|
195
|
+
"x-goog-signature",
|
|
196
|
+
"sas",
|
|
197
|
+
"session",
|
|
198
|
+
"sessionid",
|
|
199
|
+
"jwt",
|
|
200
|
+
"code",
|
|
201
|
+
]);
|
|
202
|
+
/** Maximum number of body characters recorded in a HAR entry. */
|
|
203
|
+
const HAR_MAX_BODY_CHARS = 8192;
|
|
204
|
+
/** HTML/other content types whose bodies are never recorded in HAR output. */
|
|
205
|
+
function isHARBodySafeToRecord(contentType) {
|
|
206
|
+
if (!contentType)
|
|
207
|
+
return false;
|
|
208
|
+
const ct = contentType.toLowerCase();
|
|
209
|
+
return (ct.includes("json") ||
|
|
210
|
+
ct.includes("xml") ||
|
|
211
|
+
ct.includes("text/plain") ||
|
|
212
|
+
ct.includes("application/javascript"));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Redact sensitive query-parameter values in a URL, preserving everything else
|
|
216
|
+
* (scheme, host, path, parameter names, ordering) so the HAR stays useful.
|
|
217
|
+
*/
|
|
218
|
+
function redactHARUrl(url) {
|
|
219
|
+
try {
|
|
220
|
+
const u = new URL(url);
|
|
221
|
+
let changed = false;
|
|
222
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
223
|
+
if (HAR_REDACT_PARAMS.has(key.toLowerCase())) {
|
|
224
|
+
u.searchParams.set(key, "***REDACTED***");
|
|
225
|
+
changed = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Hash can carry an implicit-access-token (S3, Firebase, share links).
|
|
229
|
+
if (u.hash && (u.hash.includes("token") || u.hash.includes("sig") || u.hash.length > 1)) {
|
|
230
|
+
u.hash = "#***REDACTED***";
|
|
231
|
+
changed = true;
|
|
232
|
+
}
|
|
233
|
+
return changed ? u.toString() : url;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Unparseable URL — fall back to a regex that masks known param names.
|
|
237
|
+
return url.replace(/([?&])(api_key|apikey|access_token|refresh_token|token|secret|password|signature|sig)=([^&#]*)/gi, "$1$2=***REDACTED***");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
163
240
|
/** Redact a single header value for HAR output. */
|
|
164
241
|
function redactHARHeader(name, value) {
|
|
165
|
-
return HAR_REDACT_HEADERS.has(name.toLowerCase())
|
|
242
|
+
return HAR_REDACT_HEADERS.has(name.toLowerCase())
|
|
243
|
+
? { name, value: "***REDACTED***" }
|
|
244
|
+
: { name, value };
|
|
166
245
|
}
|
|
167
246
|
/**
|
|
168
247
|
* O(1) ring-buffer HAR entry recorder.
|
|
@@ -204,10 +283,16 @@ class HARRecorder {
|
|
|
204
283
|
// Browser Resource Timing API — accurate per-request breakdown
|
|
205
284
|
if (typeof performance !== "undefined" &&
|
|
206
285
|
typeof performance.getEntriesByType === "function") {
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
|
|
286
|
+
// getEntriesByName narrows the buffer instead of scanning every resource
|
|
287
|
+
// entry for each recorded request (was O(entries) per request).
|
|
288
|
+
const entries = (typeof performance.getEntriesByName === "function"
|
|
289
|
+
? performance.getEntriesByName(res.url)
|
|
290
|
+
: performance.getEntriesByType("resource").filter((e) => e.name === res.url));
|
|
291
|
+
// Most recent entry for this URL. Entries are startTime-ordered, so the
|
|
292
|
+
// last one is the most recent — and, unlike a name-only match, we also
|
|
293
|
+
// require it to be recent enough to actually belong to this request.
|
|
294
|
+
const entry = entries[entries.length - 1];
|
|
295
|
+
if (entry && entry.requestStart > 0 && Date.now() - entry.startTime < 60_000) {
|
|
211
296
|
sendMs = Math.max(0, entry.responseStart - entry.requestStart);
|
|
212
297
|
receiveMs = Math.max(0, entry.responseEnd - entry.responseStart);
|
|
213
298
|
waitMs = Math.max(0, total - sendMs - receiveMs);
|
|
@@ -224,12 +309,16 @@ class HARRecorder {
|
|
|
224
309
|
time: total,
|
|
225
310
|
request: {
|
|
226
311
|
method: req.method,
|
|
227
|
-
|
|
312
|
+
// Redacted: HAR logs are routinely exported and shared, and a query
|
|
313
|
+
// string is just as leaky as a header (?api_key=, ?access_token=,
|
|
314
|
+
// ?signature=). Previously only headers were redacted, so the full URL
|
|
315
|
+
// and every query value landed in the log verbatim.
|
|
316
|
+
url: redactHARUrl(req.url),
|
|
228
317
|
httpVersion: res.httpVersion,
|
|
229
318
|
headers: Object.entries(req.headers).map(([name, value]) => redactHARHeader(name, value)),
|
|
230
319
|
queryString: (() => {
|
|
231
320
|
try {
|
|
232
|
-
return Array.from(new URL(req.url).searchParams.entries()).map(([name, value]) => ({
|
|
321
|
+
return Array.from(new URL(redactHARUrl(req.url)).searchParams.entries()).map(([name, value]) => ({
|
|
233
322
|
name,
|
|
234
323
|
value,
|
|
235
324
|
}));
|
|
@@ -258,9 +347,14 @@ class HARRecorder {
|
|
|
258
347
|
content: {
|
|
259
348
|
size: res.rawBody?.byteLength ?? 0,
|
|
260
349
|
mimeType: res.headers["content-type"] ?? "application/octet-stream",
|
|
261
|
-
|
|
350
|
+
// Body text is only kept for non-HTML payloads and is truncated:
|
|
351
|
+
// response bodies routinely carry tokens and PII.
|
|
352
|
+
...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
|
|
353
|
+
? { text: res.data.slice(0, HAR_MAX_BODY_CHARS) }
|
|
354
|
+
: {}),
|
|
262
355
|
},
|
|
263
|
-
|
|
356
|
+
// The Location header can itself carry a signed URL — redact it too.
|
|
357
|
+
redirectURL: res.headers["location"] ? redactHARUrl(res.headers["location"]) : "",
|
|
264
358
|
bodySize: res.rawBody?.byteLength ?? 0,
|
|
265
359
|
},
|
|
266
360
|
timings: {
|
|
@@ -379,26 +473,48 @@ async function applyAuth(req, auth) {
|
|
|
379
473
|
// ============================================================================
|
|
380
474
|
// §5 URL BUILDING
|
|
381
475
|
// ============================================================================
|
|
476
|
+
/**
|
|
477
|
+
* Headers the Fetch spec already drops when a redirect crosses origins.
|
|
478
|
+
* Anything credential-bearing outside this set is forwarded by fetch() itself,
|
|
479
|
+
* which is why those requests must be redirected manually.
|
|
480
|
+
* See {@link CROSS_ORIGIN_STRIP_HEADERS}.
|
|
481
|
+
*/
|
|
482
|
+
const FETCH_SPEC_STRIPPED_ON_REDIRECT = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
483
|
+
/**
|
|
484
|
+
* True when the request carries a credential-bearing header that fetch()
|
|
485
|
+
* would forward across a cross-origin redirect. Drives the decision to follow
|
|
486
|
+
* redirects manually even when no cookie jar is configured.
|
|
487
|
+
*
|
|
488
|
+
* Two sources are consulted: the well-known CREDENTIAL_HEADERS names, and a
|
|
489
|
+
* declared `apikey` auth header, whose name is chosen by the application and so
|
|
490
|
+
* cannot be known to the library.
|
|
491
|
+
*/
|
|
492
|
+
function hasForwardedCredentials(headers, auth) {
|
|
493
|
+
if (auth && auth.type === "apikey")
|
|
494
|
+
return true;
|
|
495
|
+
if (!headers)
|
|
496
|
+
return false;
|
|
497
|
+
for (const name of Object.keys(headers)) {
|
|
498
|
+
const lower = name.toLowerCase();
|
|
499
|
+
if (CROSS_ORIGIN_STRIP_HEADERS.has(lower) && !FETCH_SPEC_STRIPPED_ON_REDIRECT.has(lower)) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
382
505
|
/**
|
|
383
506
|
* Headers stripped when a redirect crosses origins (FIX H2).
|
|
384
507
|
* These carry credentials and must never be forwarded to a different origin.
|
|
385
508
|
*/
|
|
509
|
+
// Derived from the single CREDENTIAL_HEADERS list in cache.ts so the strip
|
|
510
|
+
// list, the dedup key and the cache key can never drift apart. (The previous
|
|
511
|
+
// list also carried `www-authenticate`, a RESPONSE header that can never appear
|
|
512
|
+
// on an outgoing request.)
|
|
386
513
|
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",
|
|
514
|
+
...CREDENTIAL_HEADERS,
|
|
515
|
+
// Response-only per RFC 9110, so it can never legitimately appear on an
|
|
516
|
+
// outgoing request — kept in the strip list as defence in depth for callers
|
|
517
|
+
// that copy a full header bag (including response headers) onto a request.
|
|
402
518
|
"www-authenticate",
|
|
403
519
|
]);
|
|
404
520
|
/**
|
|
@@ -693,11 +809,12 @@ function getRetryAfterMs(headers) {
|
|
|
693
809
|
*
|
|
694
810
|
* const client = kinetex({ baseURL: "https://api.example.com" });
|
|
695
811
|
*
|
|
696
|
-
* // Fluent chain
|
|
697
|
-
*
|
|
812
|
+
* // Fluent chain — `get()` returns a Promise, so use the uppercase
|
|
813
|
+
* // `GET()` builder if you want to chain `.json()` onto it.
|
|
814
|
+
* const user = await client.GET("/users/1").json<User>();
|
|
698
815
|
*
|
|
699
|
-
* // Standard send
|
|
700
|
-
* const res = await client.send<User>(
|
|
816
|
+
* // Standard send — `send(url, method, options)`, not an object argument
|
|
817
|
+
* const res = await client.send<User>("/users/1", "GET");
|
|
701
818
|
* ```
|
|
702
819
|
*/
|
|
703
820
|
export class Kinetex {
|
|
@@ -782,6 +899,11 @@ export class Kinetex {
|
|
|
782
899
|
// Digest auth interceptor — handles 401 → parse challenge → retry
|
|
783
900
|
if (config.auth?.type === "digest") {
|
|
784
901
|
const digestConfig = config.auth;
|
|
902
|
+
// Per-client nonce counter. RFC 7616 requires `nc` to strictly increase
|
|
903
|
+
// for every request reusing a nonce; the stateless helper always used
|
|
904
|
+
// 00000001, so any server enforcing replay protection rejected the second
|
|
905
|
+
// authenticated request with 401.
|
|
906
|
+
const digestAuthorizer = createDigestAuthorizer();
|
|
785
907
|
this.interceptors.addResponse(async (ctx) => {
|
|
786
908
|
if (!ctx.response)
|
|
787
909
|
return;
|
|
@@ -793,8 +915,9 @@ export class Kinetex {
|
|
|
793
915
|
if (ctx.request.meta.__digestRetried)
|
|
794
916
|
return;
|
|
795
917
|
const method = ctx.request.method;
|
|
796
|
-
const
|
|
797
|
-
const
|
|
918
|
+
const parsedUrl = new URL(ctx.request.url);
|
|
919
|
+
const uri = parsedUrl.pathname + parsedUrl.search;
|
|
920
|
+
const authHeader = await digestAuthorizer(wwwAuth, digestConfig.username, digestConfig.password, method, uri);
|
|
798
921
|
return {
|
|
799
922
|
...ctx.request,
|
|
800
923
|
headers: { ...ctx.request.headers, authorization: authHeader },
|
|
@@ -893,22 +1016,30 @@ export class Kinetex {
|
|
|
893
1016
|
const errEject = this.useError(async (ctx) => {
|
|
894
1017
|
if (!ctx.error)
|
|
895
1018
|
return;
|
|
1019
|
+
const hookReq = {
|
|
1020
|
+
url: ctx.request.url,
|
|
1021
|
+
method: ctx.request.method,
|
|
1022
|
+
headers: ctx.request.headers,
|
|
1023
|
+
body: ctx.request.body,
|
|
1024
|
+
signal: ctx.request.signal,
|
|
1025
|
+
meta: ctx.request.meta,
|
|
1026
|
+
};
|
|
1027
|
+
// A failed request can still have produced a response: HTTPStatusError
|
|
1028
|
+
// carries the KinetexResponse, and createLoggingHooks' onError reads
|
|
1029
|
+
// `err.response?.status`. Hardcoding null here made every bridged
|
|
1030
|
+
// onError hook see no response, so log entries silently recorded a null
|
|
1031
|
+
// status for 4xx/5xx. Only genuinely response-less errors (network,
|
|
1032
|
+
// timeout, abort) keep null.
|
|
1033
|
+
const errResponse = toHookResponse(ctx.error.response, hookReq);
|
|
896
1034
|
const hookErr = {
|
|
897
1035
|
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,
|
|
1036
|
+
request: hookReq,
|
|
1037
|
+
response: errResponse,
|
|
907
1038
|
attempt: ctx.attempt,
|
|
908
1039
|
};
|
|
909
1040
|
const hookCtx = {
|
|
910
1041
|
request: hookErr.request,
|
|
911
|
-
response:
|
|
1042
|
+
response: errResponse,
|
|
912
1043
|
error: ctx.error,
|
|
913
1044
|
startedAt: ctx.startedAt,
|
|
914
1045
|
attempt: ctx.attempt,
|
|
@@ -1461,7 +1592,14 @@ export class Kinetex {
|
|
|
1461
1592
|
throw new KinetexError(`maxRequestSize cannot be enforced for ReadableStream bodies — pass maxRequestSize: 0 to opt out, or buffer the body first`, "EVALIDATION");
|
|
1462
1593
|
}
|
|
1463
1594
|
else if (options.body && typeof options.body === "object") {
|
|
1464
|
-
|
|
1595
|
+
try {
|
|
1596
|
+
bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
|
|
1597
|
+
}
|
|
1598
|
+
catch (err) {
|
|
1599
|
+
// A circular (or BigInt-containing) body threw a raw TypeError from
|
|
1600
|
+
// inside the size guard, masking the real serialization error.
|
|
1601
|
+
throw new KinetexError(`Cannot measure request body size: ${err instanceof Error ? err.message : String(err)}`, "EVALIDATION");
|
|
1602
|
+
}
|
|
1465
1603
|
}
|
|
1466
1604
|
if (bodySize > maxRequestSize) {
|
|
1467
1605
|
throw new KinetexError(`Request body size ${bodySize} bytes exceeds limit of ${maxRequestSize} bytes`, "EVALIDATION");
|
|
@@ -1469,10 +1607,26 @@ export class Kinetex {
|
|
|
1469
1607
|
}
|
|
1470
1608
|
let req = {
|
|
1471
1609
|
url: fullUrl,
|
|
1472
|
-
method,
|
|
1610
|
+
// Use the normalized method, not the caller's casing: `send(url, "patch")`
|
|
1611
|
+
// passed validation above but used to put the literal string "patch" on
|
|
1612
|
+
// the wire. fetch() only normalizes delete/get/head/options/post/put, so a
|
|
1613
|
+
// lowercase PATCH/CONNECT went out verbatim and servers answered 405.
|
|
1614
|
+
method: normalizedMethod,
|
|
1473
1615
|
headers: mergeHeaders(this.cfg.headers, options.headers),
|
|
1474
|
-
|
|
1616
|
+
// A plain object/array is accepted by the public API (RequestBody) and is
|
|
1617
|
+
// JSON-encoded in the block immediately below, so the request object only
|
|
1618
|
+
// ever holds a real BodyInit by the time this function returns.
|
|
1619
|
+
body: (options.body ?? null),
|
|
1475
1620
|
signal: options.signal ?? null,
|
|
1621
|
+
// Resolved once here so the manual redirect follower sees the same
|
|
1622
|
+
// effective values the caller asked for. Spread (not `??`) because the
|
|
1623
|
+
// project uses exactOptionalPropertyTypes.
|
|
1624
|
+
...(options.followRedirects !== undefined || this.cfg.followRedirects !== undefined
|
|
1625
|
+
? { followRedirects: options.followRedirects ?? this.cfg.followRedirects }
|
|
1626
|
+
: {}),
|
|
1627
|
+
...(options.maxRedirects !== undefined || this.cfg.maxRedirects !== undefined
|
|
1628
|
+
? { maxRedirects: options.maxRedirects ?? this.cfg.maxRedirects }
|
|
1629
|
+
: {}),
|
|
1476
1630
|
meta: { ...options.meta },
|
|
1477
1631
|
httpVersion: options.httpVersion ?? this.cfg.httpVersion ?? "HTTP/2",
|
|
1478
1632
|
};
|
|
@@ -1492,6 +1646,18 @@ export class Kinetex {
|
|
|
1492
1646
|
body: JSON.stringify(req.body),
|
|
1493
1647
|
};
|
|
1494
1648
|
}
|
|
1649
|
+
// A URLSearchParams body is urlencoded by the raw Node transports, which
|
|
1650
|
+
// do not set a content-type the way fetch does. Without this the server
|
|
1651
|
+
// receives the bytes but cannot parse them as a form.
|
|
1652
|
+
if (req.body !== null &&
|
|
1653
|
+
typeof URLSearchParams !== "undefined" &&
|
|
1654
|
+
req.body instanceof URLSearchParams &&
|
|
1655
|
+
!req.headers["content-type"]) {
|
|
1656
|
+
req = {
|
|
1657
|
+
...req,
|
|
1658
|
+
headers: { ...req.headers, "content-type": "application/x-www-form-urlencoded" },
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1495
1661
|
// ── Apply auth ─────────────────────────────────────────────────────────
|
|
1496
1662
|
const auth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
1497
1663
|
if (auth)
|
|
@@ -1512,33 +1678,52 @@ export class Kinetex {
|
|
|
1512
1678
|
// Works with any OpenTelemetry SDK — just call client.setTracer(tracer).
|
|
1513
1679
|
// If no tracer is set we still propagate a randomly-generated trace ID
|
|
1514
1680
|
// when the caller passes options.meta.traceId (useful for manual tracing).
|
|
1681
|
+
// Everything between startSpan() and the dispatch try/catch below can
|
|
1682
|
+
// throw (traceparent building, the circuit-breaker key fn, auth
|
|
1683
|
+
// fingerprinting). Wrap it so a failure still ends the span instead of
|
|
1684
|
+
// abandoning it — an unended span is never exported and never reports the
|
|
1685
|
+
// error, and holds its attributes in the tracer's memory.
|
|
1515
1686
|
let _otelSpan = null;
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1687
|
+
try {
|
|
1688
|
+
if (this._otelTracer) {
|
|
1689
|
+
_otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
|
|
1690
|
+
const { traceparent, traceId, spanId } = buildTraceparent(_otelSpan);
|
|
1691
|
+
_otelSpan.setAttribute("http.request.method", req.method);
|
|
1692
|
+
_otelSpan.setAttribute("url.full", req.url);
|
|
1693
|
+
try {
|
|
1694
|
+
_otelSpan.setAttribute("server.address", new URL(req.url).hostname);
|
|
1695
|
+
}
|
|
1696
|
+
catch {
|
|
1697
|
+
// Skip hostname attribute if URL is invalid
|
|
1698
|
+
}
|
|
1699
|
+
req = {
|
|
1700
|
+
...req,
|
|
1701
|
+
headers: { ...req.headers, traceparent },
|
|
1702
|
+
meta: { ...req.meta, traceId, spanId },
|
|
1703
|
+
};
|
|
1523
1704
|
}
|
|
1524
|
-
|
|
1525
|
-
//
|
|
1705
|
+
else if (req.meta["traceId"] && !req.headers["traceparent"]) {
|
|
1706
|
+
// Manual trace propagation — caller set traceId in meta
|
|
1707
|
+
const traceId = String(req.meta["traceId"]);
|
|
1708
|
+
const spanId = randomHex(16);
|
|
1709
|
+
req = {
|
|
1710
|
+
...req,
|
|
1711
|
+
headers: { ...req.headers, traceparent: `00-${traceId}-${spanId}-01` },
|
|
1712
|
+
meta: { ...req.meta, spanId },
|
|
1713
|
+
};
|
|
1526
1714
|
}
|
|
1527
|
-
req = {
|
|
1528
|
-
...req,
|
|
1529
|
-
headers: { ...req.headers, traceparent },
|
|
1530
|
-
meta: { ...req.meta, traceId, spanId },
|
|
1531
|
-
};
|
|
1532
1715
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1716
|
+
catch (tracingErr) {
|
|
1717
|
+
if (_otelSpan) {
|
|
1718
|
+
_otelSpan.setStatus({
|
|
1719
|
+
code: 2 /* ERROR */,
|
|
1720
|
+
message: tracingErr instanceof Error ? tracingErr.message : String(tracingErr),
|
|
1721
|
+
});
|
|
1722
|
+
if (tracingErr instanceof Error)
|
|
1723
|
+
_otelSpan.recordException(tracingErr);
|
|
1724
|
+
_otelSpan.end();
|
|
1725
|
+
}
|
|
1726
|
+
throw tracingErr;
|
|
1542
1727
|
}
|
|
1543
1728
|
// Determine key for dedup + circuit breaker.
|
|
1544
1729
|
// Uses circuitBreakerKeyFn if configured (e.g. per-method isolation),
|
|
@@ -1570,8 +1755,13 @@ export class Kinetex {
|
|
|
1570
1755
|
// SECURITY: The dedup key includes a fingerprint of auth-sensitive headers
|
|
1571
1756
|
// so requests from different users (different Authorization / Cookie) are
|
|
1572
1757
|
// NEVER coalesced — each user gets their own isolated in-flight slot.
|
|
1573
|
-
|
|
1574
|
-
|
|
1758
|
+
// Fingerprinting hashes the credential headers with SHA-256, so it is only
|
|
1759
|
+
// paid when dedup is actually enabled.
|
|
1760
|
+
let _dedupKey = "";
|
|
1761
|
+
if (this._dedup) {
|
|
1762
|
+
const authFp = await getAuthFingerprint(req.headers ?? {});
|
|
1763
|
+
_dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
|
|
1764
|
+
}
|
|
1575
1765
|
const _dedupedFactory = this._dedup
|
|
1576
1766
|
? () => this._dedup
|
|
1577
1767
|
.execute(req.method, _dedupKey, _execFactory)
|
|
@@ -1616,7 +1806,14 @@ export class Kinetex {
|
|
|
1616
1806
|
attempt++;
|
|
1617
1807
|
// If caller aborted between retries, stop immediately
|
|
1618
1808
|
if (req.signal?.aborted) {
|
|
1619
|
-
throw createAbortError();
|
|
1809
|
+
throw createAbortError(req);
|
|
1810
|
+
}
|
|
1811
|
+
// A ReadableStream / Blob body is not replayable: the first attempt
|
|
1812
|
+
// consumes (and locks) it, so a retry re-wraps an already-locked stream
|
|
1813
|
+
// for upload progress and sends an empty body. Fail loudly on the retry
|
|
1814
|
+
// instead of silently transmitting nothing.
|
|
1815
|
+
if (attempt > 1 && isNonReplayableBody(req.body)) {
|
|
1816
|
+
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
1817
|
}
|
|
1621
1818
|
try {
|
|
1622
1819
|
const res = await this._executeOnce(req, timeout, options, startMs, attempt, wallClockMs, retryCfg);
|
|
@@ -1646,6 +1843,15 @@ export class Kinetex {
|
|
|
1646
1843
|
return res;
|
|
1647
1844
|
}
|
|
1648
1845
|
catch (err) {
|
|
1846
|
+
if (globalThis.__KINETEX_DEBUG_RETRY) {
|
|
1847
|
+
console.log("DBG catch", {
|
|
1848
|
+
attempt,
|
|
1849
|
+
maxRetries: retryCfg === false ? "FALSE" : retryCfg?.maxRetries,
|
|
1850
|
+
methods: retryCfg === false ? "FALSE" : retryCfg?.methods,
|
|
1851
|
+
code: err?.code,
|
|
1852
|
+
method: req.method,
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1649
1855
|
if (retryCfg && attempt <= retryCfg.maxRetries) {
|
|
1650
1856
|
const retryCtx = {
|
|
1651
1857
|
request: req,
|
|
@@ -1686,11 +1892,25 @@ export class Kinetex {
|
|
|
1686
1892
|
// intermediate redirect responses. When a cookie jar is active we must follow
|
|
1687
1893
|
// redirects ourselves one hop at a time so we can capture cookies at each step.
|
|
1688
1894
|
/**
|
|
1689
|
-
* Follow redirects manually, one hop at a time
|
|
1690
|
-
*
|
|
1895
|
+
* Follow redirects manually, one hop at a time.
|
|
1896
|
+
*
|
|
1897
|
+
* Two independent reasons this path exists:
|
|
1898
|
+
* 1. fetch() auto-follows redirects but silently drops Set-Cookie from
|
|
1899
|
+
* intermediary hops, so an active cookie jar must see every hop itself.
|
|
1900
|
+
* 2. Per the Fetch spec, a cross-origin redirect only drops
|
|
1901
|
+
* `authorization` / `cookie` / `proxy-authorization`. Custom credential
|
|
1902
|
+
* headers (apikey, X-Company-Key, ...) are forwarded verbatim, so any
|
|
1903
|
+
* request carrying one must also be followed manually.
|
|
1904
|
+
*
|
|
1905
|
+
* @param jar - Optional cookie jar. When omitted, no cookie header is
|
|
1906
|
+
* rebuilt and intermediate Set-Cookie headers are ignored.
|
|
1691
1907
|
*/
|
|
1692
1908
|
async _sendFollowingRedirects(req, timeout, jar, appliedAuth) {
|
|
1693
|
-
|
|
1909
|
+
// `maxRedirects` / `followRedirects` were documented on KinetexConfig and
|
|
1910
|
+
// SendOptions but never read, so the documented default and the enforced one
|
|
1911
|
+
// had drifted apart. Both are honoured here now.
|
|
1912
|
+
const maxRedirects = Math.max(0, req.maxRedirects ?? DEFAULT_MAX_REDIRECTS);
|
|
1913
|
+
const followRedirects = req.followRedirects !== false && maxRedirects > 0;
|
|
1694
1914
|
let currentReq = { ...req, redirect: "manual" };
|
|
1695
1915
|
const origin0 = (() => {
|
|
1696
1916
|
try {
|
|
@@ -1702,7 +1922,7 @@ export class Kinetex {
|
|
|
1702
1922
|
})();
|
|
1703
1923
|
// Track visited URLs to detect redirect loops
|
|
1704
1924
|
const visited = new Set();
|
|
1705
|
-
for (let hop = 0; hop <=
|
|
1925
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1706
1926
|
// FIX H2 (part 2): re-apply auth on every hop ONLY while we remain on the
|
|
1707
1927
|
// original origin. Once a redirect has crossed origins, credential-bearing
|
|
1708
1928
|
// headers must not be re-injected — otherwise the cross-origin strip in
|
|
@@ -1757,11 +1977,16 @@ export class Kinetex {
|
|
|
1757
1977
|
}
|
|
1758
1978
|
visited.add(raw.url);
|
|
1759
1979
|
// Capture cookies from this redirect hop
|
|
1760
|
-
jar
|
|
1980
|
+
jar?.processResponseHeaders(raw.headers, {
|
|
1761
1981
|
url: raw.url,
|
|
1762
1982
|
});
|
|
1763
|
-
|
|
1764
|
-
|
|
1983
|
+
// `followRedirects: false` (or `maxRedirects: 0`) hands the 3xx back to
|
|
1984
|
+
// the caller instead of chasing it — the same shape fetch() returns for
|
|
1985
|
+
// `redirect: "manual"`.
|
|
1986
|
+
if (!followRedirects)
|
|
1987
|
+
return { ...raw, redirected: true };
|
|
1988
|
+
if (hop === maxRedirects) {
|
|
1989
|
+
throw new KinetexError(`Too many redirects (exceeded ${maxRedirects})`, "ENETWORK", {
|
|
1765
1990
|
request: req,
|
|
1766
1991
|
});
|
|
1767
1992
|
}
|
|
@@ -1786,6 +2011,19 @@ export class Kinetex {
|
|
|
1786
2011
|
if (protocol !== "http:" && protocol !== "https:") {
|
|
1787
2012
|
throw new KinetexError(`Unsafe redirect to ${protocol} detected — only HTTP(S) allowed`, "ENETWORK", { request: req });
|
|
1788
2013
|
}
|
|
2014
|
+
// SSRF GATE (P0): the initial URL is screened by buildURL → isSafeURL,
|
|
2015
|
+
// but a redirect target never went through that check. Without this a
|
|
2016
|
+
// public host could 302 the client straight at link-local/loopback
|
|
2017
|
+
// addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
|
|
2018
|
+
// private-network block list would be bypassable. Re-validate every hop.
|
|
2019
|
+
if (!isSafeURL(nextUrl)) {
|
|
2020
|
+
throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
|
|
2021
|
+
}
|
|
2022
|
+
// httpsOnly must hold for redirect legs too, otherwise a redirect is a
|
|
2023
|
+
// trivial downgrade from https:// to http:// past the pre-flight guard.
|
|
2024
|
+
if (this.cfg.httpsOnly && protocol !== "https:") {
|
|
2025
|
+
throw new KinetexError(`HTTPS-only mode enabled but redirect target uses ${protocol}`, "EVALIDATION", { request: req });
|
|
2026
|
+
}
|
|
1789
2027
|
}
|
|
1790
2028
|
catch (err) {
|
|
1791
2029
|
if (err instanceof KinetexError)
|
|
@@ -1801,30 +2039,50 @@ export class Kinetex {
|
|
|
1801
2039
|
? "GET"
|
|
1802
2040
|
: currentReq.method;
|
|
1803
2041
|
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
2042
|
const nextHeaders = { ...currentReq.headers };
|
|
1807
|
-
if (cookieHeader) {
|
|
1808
|
-
nextHeaders["cookie"] = cookieHeader;
|
|
1809
|
-
}
|
|
1810
|
-
else {
|
|
1811
|
-
delete nextHeaders["cookie"];
|
|
1812
|
-
}
|
|
1813
2043
|
// FIX H2: When the redirect crosses origins, strip credential-bearing
|
|
1814
2044
|
// headers (Authorization, Cookie, proxy auth, API keys) so secrets are
|
|
1815
2045
|
// never forwarded to a different origin (RFC 9110 7.1 semantics).
|
|
1816
|
-
//
|
|
1817
|
-
//
|
|
2046
|
+
//
|
|
2047
|
+
// ORDERING (this must happen BEFORE the cookie header is rebuilt): the
|
|
2048
|
+
// previous order computed the new origin's cookie header first and then
|
|
2049
|
+
// deleted it again in the strip loop, so every cross-origin hop was
|
|
2050
|
+
// sent without the cookies the jar had just scoped for it.
|
|
2051
|
+
let crossOrigin = false;
|
|
1818
2052
|
try {
|
|
1819
|
-
|
|
1820
|
-
for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
|
|
1821
|
-
delete nextHeaders[h];
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
2053
|
+
crossOrigin = new URL(nextUrl).origin !== new URL(currentReq.url).origin;
|
|
1824
2054
|
}
|
|
1825
2055
|
catch {
|
|
1826
2056
|
/* nextUrl was already validated above */
|
|
1827
2057
|
}
|
|
2058
|
+
if (crossOrigin) {
|
|
2059
|
+
for (const h of CROSS_ORIGIN_STRIP_HEADERS) {
|
|
2060
|
+
delete nextHeaders[h];
|
|
2061
|
+
}
|
|
2062
|
+
// An `apikey` auth header name is chosen by the application, so it is
|
|
2063
|
+
// not in the well-known list. It is a credential all the same, and it
|
|
2064
|
+
// was being forwarded verbatim to the new origin.
|
|
2065
|
+
if (appliedAuth && appliedAuth.type === "apikey") {
|
|
2066
|
+
delete nextHeaders[appliedAuth.header.toLowerCase()];
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
// Rebuild the Cookie header for the next hop from the updated jar.
|
|
2070
|
+
// Jar scoping guarantees only cookies that match the NEW origin are
|
|
2071
|
+
// attached, which is exactly the post-strip state we want.
|
|
2072
|
+
if (jar) {
|
|
2073
|
+
const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
|
|
2074
|
+
if (cookieHeader) {
|
|
2075
|
+
nextHeaders["cookie"] = cookieHeader;
|
|
2076
|
+
}
|
|
2077
|
+
else {
|
|
2078
|
+
delete nextHeaders["cookie"];
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
else if (crossOrigin) {
|
|
2082
|
+
// No jar: drop the caller's cookie header with the other credentials
|
|
2083
|
+
// (mirrors what fetch() does for a cross-origin redirect).
|
|
2084
|
+
delete nextHeaders["cookie"];
|
|
2085
|
+
}
|
|
1828
2086
|
currentReq = {
|
|
1829
2087
|
...currentReq,
|
|
1830
2088
|
url: nextUrl,
|
|
@@ -1837,7 +2095,9 @@ export class Kinetex {
|
|
|
1837
2095
|
}
|
|
1838
2096
|
// Not a redirect — return the final raw response as-is.
|
|
1839
2097
|
// _executeOnce will capture its Set-Cookie headers via the normal path.
|
|
1840
|
-
|
|
2098
|
+
// The chain was followed by hand, so report `redirected: true` for hop > 0
|
|
2099
|
+
// to match what fetch() reports under redirect: "follow".
|
|
2100
|
+
return hop > 0 ? { ...raw, redirected: true } : raw;
|
|
1841
2101
|
}
|
|
1842
2102
|
// Unreachable
|
|
1843
2103
|
throw new KinetexError("Redirect loop", "ENETWORK", { request: req });
|
|
@@ -1943,7 +2203,15 @@ export class Kinetex {
|
|
|
1943
2203
|
}
|
|
1944
2204
|
}
|
|
1945
2205
|
finally {
|
|
1946
|
-
|
|
2206
|
+
// Must not be able to skip: if getCache() rejects, the in-flight
|
|
2207
|
+
// marker survives and this key can never revalidate again, so
|
|
2208
|
+
// every future stale hit would be served stale forever.
|
|
2209
|
+
try {
|
|
2210
|
+
(await this.getCache())?.clearSWRInFlight(cacheReq);
|
|
2211
|
+
}
|
|
2212
|
+
catch {
|
|
2213
|
+
/* isolate — never leave the SWR marker stuck */
|
|
2214
|
+
}
|
|
1947
2215
|
}
|
|
1948
2216
|
})();
|
|
1949
2217
|
}
|
|
@@ -2037,20 +2305,26 @@ export class Kinetex {
|
|
|
2037
2305
|
...req,
|
|
2038
2306
|
headers: {
|
|
2039
2307
|
...req.headers,
|
|
2040
|
-
"accept-encoding":
|
|
2308
|
+
"accept-encoding": DEFAULT_ACCEPT_ENCODING,
|
|
2041
2309
|
},
|
|
2042
2310
|
};
|
|
2043
2311
|
}
|
|
2044
2312
|
// ── Dispatch ───────────────────────────────────────────────────────────
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2313
|
+
// Redirects are followed by hand when EITHER:
|
|
2314
|
+
// - a cookie jar is active, so every intermediate Set-Cookie is captured
|
|
2315
|
+
// (fetch() silently drops them), or
|
|
2316
|
+
// - the request carries a credential header that fetch() would NOT strip on
|
|
2317
|
+
// a cross-origin redirect. Per the Fetch spec only `authorization`,
|
|
2318
|
+
// `cookie` and `proxy-authorization` are dropped, so a custom API-key
|
|
2319
|
+
// header would otherwise be forwarded verbatim to a foreign origin.
|
|
2048
2320
|
const dispatchJar = await this.getCookieJar();
|
|
2321
|
+
const effectiveAuth = options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined;
|
|
2322
|
+
const needsManualRedirects = dispatchJar !== null || hasForwardedCredentials(req.headers, effectiveAuth);
|
|
2049
2323
|
this._trace(_traceId, "transport_send", "start", startMs, attempt);
|
|
2050
2324
|
let raw;
|
|
2051
2325
|
try {
|
|
2052
|
-
raw =
|
|
2053
|
-
? await this._sendFollowingRedirects(req, timeout, dispatchJar
|
|
2326
|
+
raw = needsManualRedirects
|
|
2327
|
+
? await this._sendFollowingRedirects(req, timeout, dispatchJar ?? undefined, effectiveAuth)
|
|
2054
2328
|
: await sendWithTimeout(this.transport, req, timeout);
|
|
2055
2329
|
}
|
|
2056
2330
|
catch (err) {
|
|
@@ -2111,9 +2385,18 @@ export class Kinetex {
|
|
|
2111
2385
|
},
|
|
2112
2386
|
...(req.signal !== null ? { signal: req.signal } : {}),
|
|
2113
2387
|
});
|
|
2388
|
+
// Capture the SOURCE in its own binding. `bodyStream` is reassigned to
|
|
2389
|
+
// this wrapper immediately after construction, so closing over it made
|
|
2390
|
+
// `cancel()` cancel *itself*: readRawBody's reader.cancel() (size limit,
|
|
2391
|
+
// abort, read error) re-entered this function, which threw
|
|
2392
|
+
// "Invalid state: ReadableStream is locked" from inside the cancel
|
|
2393
|
+
// algorithm and left that inner promise unhandled (process-level crash on
|
|
2394
|
+
// Node). start() only worked by accident, relying on the async-fn body
|
|
2395
|
+
// running synchronously up to the first await.
|
|
2396
|
+
const source = bodyStream;
|
|
2114
2397
|
bodyStream = new ReadableStream({
|
|
2115
2398
|
async start(controller) {
|
|
2116
|
-
const reader =
|
|
2399
|
+
const reader = source.getReader();
|
|
2117
2400
|
try {
|
|
2118
2401
|
while (true) {
|
|
2119
2402
|
const { done, value } = await reader.read();
|
|
@@ -2133,8 +2416,10 @@ export class Kinetex {
|
|
|
2133
2416
|
reader.releaseLock();
|
|
2134
2417
|
}
|
|
2135
2418
|
},
|
|
2136
|
-
cancel() {
|
|
2137
|
-
|
|
2419
|
+
cancel(reason) {
|
|
2420
|
+
// Forward cancellation to the real source and swallow its failure:
|
|
2421
|
+
// a rejection here is never observed by the cancelling reader.
|
|
2422
|
+
void source.cancel(reason).catch(() => { });
|
|
2138
2423
|
},
|
|
2139
2424
|
});
|
|
2140
2425
|
}
|
|
@@ -2159,9 +2444,9 @@ export class Kinetex {
|
|
|
2159
2444
|
status: raw.status,
|
|
2160
2445
|
statusText: raw.statusText,
|
|
2161
2446
|
headers: raw.headers,
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2447
|
+
// transformResponse is applied below, once `res` exists, so it receives
|
|
2448
|
+
// the real response object instead of the `{}` placeholder it used to get.
|
|
2449
|
+
data,
|
|
2165
2450
|
rawBody,
|
|
2166
2451
|
url: raw.url,
|
|
2167
2452
|
cached: false,
|
|
@@ -2171,6 +2456,14 @@ export class Kinetex {
|
|
|
2171
2456
|
request: req,
|
|
2172
2457
|
attempt,
|
|
2173
2458
|
};
|
|
2459
|
+
// Apply transformResponse now that the response object exists, so the hook
|
|
2460
|
+
// receives the real response (status/headers/url) rather than the empty
|
|
2461
|
+
// placeholder it used to be handed.
|
|
2462
|
+
if (this.cfg.transformResponse) {
|
|
2463
|
+
// `data` is readonly on the public type; the cast is confined to this one
|
|
2464
|
+
// write, immediately after construction.
|
|
2465
|
+
res.data = this.cfg.transformResponse(res.data, res);
|
|
2466
|
+
}
|
|
2174
2467
|
// ── Store in cache ─────────────────────────────────────────────────────
|
|
2175
2468
|
if (options.cache !== false && this.cfg.cache) {
|
|
2176
2469
|
const cache = await this.getCache();
|
|
@@ -2249,6 +2542,16 @@ export class Kinetex {
|
|
|
2249
2542
|
current = result;
|
|
2250
2543
|
}
|
|
2251
2544
|
else if ("url" in result && "method" in result && !("status" in result)) {
|
|
2545
|
+
// Re-sending from a response interceptor used to be unbounded: an
|
|
2546
|
+
// interceptor that always returns a modified request (the classic
|
|
2547
|
+
// token-refresh shape, but also a mis-written one) recursed forever,
|
|
2548
|
+
// each level a fresh _executeOnce with a fresh interceptor context.
|
|
2549
|
+
// The depth therefore travels on request meta, which _executeOnce
|
|
2550
|
+
// carries into the nested call (ctx.store would not survive it).
|
|
2551
|
+
const resendDepth = Number(_req.meta[INTERCEPTOR_RESEND_DEPTH] ?? 0);
|
|
2552
|
+
if (resendDepth >= MAX_INTERCEPTOR_RESENDS) {
|
|
2553
|
+
throw new KinetexError(`Response interceptor re-send limit reached (${MAX_INTERCEPTOR_RESENDS}) — refusing to loop`, "EVALIDATION", { request: _req });
|
|
2554
|
+
}
|
|
2252
2555
|
if (retryCfg && attempt <= retryCfg.maxRetries) {
|
|
2253
2556
|
const retryCtx = {
|
|
2254
2557
|
request: _req,
|
|
@@ -2265,7 +2568,13 @@ export class Kinetex {
|
|
|
2265
2568
|
}, Promise.resolve());
|
|
2266
2569
|
await sleep(delay, _req.signal);
|
|
2267
2570
|
}
|
|
2268
|
-
return this._executeOnce(
|
|
2571
|
+
return this._executeOnce({
|
|
2572
|
+
...result,
|
|
2573
|
+
meta: {
|
|
2574
|
+
...result.meta,
|
|
2575
|
+
[INTERCEPTOR_RESEND_DEPTH]: resendDepth + 1,
|
|
2576
|
+
},
|
|
2577
|
+
}, timeout, options, startMs, attempt + 1, undefined, retryCfg);
|
|
2269
2578
|
}
|
|
2270
2579
|
}
|
|
2271
2580
|
return current;
|
|
@@ -2556,7 +2865,10 @@ export class Kinetex {
|
|
|
2556
2865
|
* Call this when the client is no longer needed to prevent memory leaks.
|
|
2557
2866
|
* @returns A promise that resolves when cleanup is complete.
|
|
2558
2867
|
*/
|
|
2559
|
-
async
|
|
2868
|
+
// Not `async`: nothing here awaits, so the returned promise is resolved
|
|
2869
|
+
// explicitly instead. The signature stays Promise<void> — callers and the
|
|
2870
|
+
// docs `await client.destroy()`.
|
|
2871
|
+
destroy() {
|
|
2560
2872
|
// Close all tracked WebSocket connections
|
|
2561
2873
|
for (const ws of this._wsClients) {
|
|
2562
2874
|
try {
|
|
@@ -2567,14 +2879,10 @@ export class Kinetex {
|
|
|
2567
2879
|
}
|
|
2568
2880
|
}
|
|
2569
2881
|
this._wsClients.clear();
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
catch {
|
|
2575
|
-
/* best-effort */
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2882
|
+
// NOTE: the cache is deliberately NOT cleared here. destroy() releases
|
|
2883
|
+
// resources; it must not purge data, and a user-supplied adapter
|
|
2884
|
+
// (localStorage / Cloudflare KV / Redis) would lose every persisted entry.
|
|
2885
|
+
// Call `client.getCache().then(c => c.clear())` explicitly to empty it.
|
|
2578
2886
|
if (IS_NODE && this.transport && "destroy" in this.transport) {
|
|
2579
2887
|
this.transport.destroy();
|
|
2580
2888
|
}
|
|
@@ -2584,6 +2892,7 @@ export class Kinetex {
|
|
|
2584
2892
|
this._circuitBreakers?.clear?.();
|
|
2585
2893
|
this._otelTracer = null;
|
|
2586
2894
|
this.interceptors.clear();
|
|
2895
|
+
return Promise.resolve();
|
|
2587
2896
|
}
|
|
2588
2897
|
}
|
|
2589
2898
|
// ============================================================================
|
|
@@ -2837,16 +3146,40 @@ export class FluentRequest {
|
|
|
2837
3146
|
// §15 UTILITIES
|
|
2838
3147
|
// ============================================================================
|
|
2839
3148
|
/**
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
3149
|
+
* Convert a KinetexResponse into the lifecycle HookResponse shape, or null
|
|
3150
|
+
* when the request never produced one (network error, timeout, abort).
|
|
3151
|
+
*
|
|
3152
|
+
* Used by the error-hook bridge so onError hooks can read the HTTP status of
|
|
3153
|
+
* a failed request instead of always seeing null.
|
|
2842
3154
|
*/
|
|
2843
|
-
function
|
|
2844
|
-
if (
|
|
2845
|
-
return
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
3155
|
+
function toHookResponse(res, request) {
|
|
3156
|
+
if (!res)
|
|
3157
|
+
return null;
|
|
3158
|
+
return {
|
|
3159
|
+
status: res.status,
|
|
3160
|
+
statusText: res.statusText,
|
|
3161
|
+
headers: res.headers,
|
|
3162
|
+
body: res.rawBody ?? null,
|
|
3163
|
+
request,
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* The abort error raised by the retry loop itself.
|
|
3168
|
+
*
|
|
3169
|
+
* This used to build a bare `DOMException`, which is a real `Error` but not a
|
|
3170
|
+
* `KinetexError`: it carries no `code`, so `err.code === "EABORT"` and
|
|
3171
|
+
* `err.isAbort` were both false here while every other abort path in the
|
|
3172
|
+
* library (see core.ts) raised `EABORT`. Callers documented to see `AbortError`
|
|
3173
|
+
* therefore got a structurally different error depending on whether the signal
|
|
3174
|
+
* fired mid-request or mid-retry-delay. The library's own `AbortError` keeps
|
|
3175
|
+
* `name === "AbortError"`, so name-based checks like `isAbortError` are
|
|
3176
|
+
* unaffected.
|
|
3177
|
+
*
|
|
3178
|
+
* @param request - The request being retried, attached when available.
|
|
3179
|
+
* @returns A KinetexError with code `EABORT`.
|
|
3180
|
+
*/
|
|
3181
|
+
function createAbortError(request) {
|
|
3182
|
+
return new AbortError(request);
|
|
2850
3183
|
}
|
|
2851
3184
|
/**
|
|
2852
3185
|
* Merge two query parameter maps into one.
|
|
@@ -2882,6 +3215,16 @@ function sleep(ms, signal) {
|
|
|
2882
3215
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2883
3216
|
});
|
|
2884
3217
|
}
|
|
3218
|
+
/**
|
|
3219
|
+
* True for request bodies that cannot be sent twice: a ReadableStream is
|
|
3220
|
+
* consumed (and locked) by the first attempt, and a Blob-backed stream is
|
|
3221
|
+
* derived from an already-read handle. Both are fine once, never on retry.
|
|
3222
|
+
*/
|
|
3223
|
+
function isNonReplayableBody(body) {
|
|
3224
|
+
if (body instanceof ReadableStream)
|
|
3225
|
+
return true;
|
|
3226
|
+
return typeof Blob !== "undefined" && body instanceof Blob;
|
|
3227
|
+
}
|
|
2885
3228
|
/** Cross-runtime performance.now() — falls back to Date.now(). */
|
|
2886
3229
|
function perfNow() {
|
|
2887
3230
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
@@ -2942,7 +3285,13 @@ export function createMethodCircuitBreakerKey(req) {
|
|
|
2942
3285
|
export class BatchQueue {
|
|
2943
3286
|
/** The parent Kinetex instance used to send requests. */
|
|
2944
3287
|
_client;
|
|
2945
|
-
/**
|
|
3288
|
+
/**
|
|
3289
|
+
* Maximum number of requests taken out of the queue per flush.
|
|
3290
|
+
* NOTE: this is a batching size, NOT a concurrency limit — every request in a
|
|
3291
|
+
* batch is dispatched immediately and in parallel, and `flush()` drains the
|
|
3292
|
+
* whole queue the same way. Use `maxBatch` to bound how much is dispatched per
|
|
3293
|
+
* tick, and a semaphore or rate limiter to bound actual parallelism.
|
|
3294
|
+
*/
|
|
2946
3295
|
_maxBatch;
|
|
2947
3296
|
/** Milliseconds to wait before flushing an incomplete batch. */
|
|
2948
3297
|
_flushMs;
|
|
@@ -2956,8 +3305,19 @@ export class BatchQueue {
|
|
|
2956
3305
|
*/
|
|
2957
3306
|
constructor(client, options = {}) {
|
|
2958
3307
|
this._client = client;
|
|
2959
|
-
|
|
2960
|
-
|
|
3308
|
+
// maxBatch must be a positive integer: _flushNow() splices exactly
|
|
3309
|
+
// `_maxBatch` items, so 0 (or a negative value) spliced nothing and made
|
|
3310
|
+
// flush() spin forever on a queue it could never drain.
|
|
3311
|
+
const maxBatch = options.maxBatch ?? 100;
|
|
3312
|
+
if (!Number.isInteger(maxBatch) || maxBatch < 1) {
|
|
3313
|
+
throw new RangeError(`BatchQueue maxBatch must be a positive integer (got ${maxBatch})`);
|
|
3314
|
+
}
|
|
3315
|
+
this._maxBatch = maxBatch;
|
|
3316
|
+
const flushMs = options.flushMs ?? 0;
|
|
3317
|
+
if (!Number.isFinite(flushMs) || flushMs < 0) {
|
|
3318
|
+
throw new RangeError(`BatchQueue flushMs must be a non-negative finite number (got ${flushMs})`);
|
|
3319
|
+
}
|
|
3320
|
+
this._flushMs = flushMs;
|
|
2961
3321
|
}
|
|
2962
3322
|
/**
|
|
2963
3323
|
* Enqueue a request. Returns a promise that resolves when the batch
|
|
@@ -3018,7 +3378,11 @@ export class BatchQueue {
|
|
|
3018
3378
|
* Loops until the queue is empty so items beyond maxBatch are not orphaned.
|
|
3019
3379
|
*/
|
|
3020
3380
|
flush() {
|
|
3021
|
-
|
|
3381
|
+
// The constructor guarantees _maxBatch >= 1, so every _flushNow() removes at
|
|
3382
|
+
// least one item. The counter is defence in depth against a future change
|
|
3383
|
+
// reintroducing a zero-progress flush (which would spin forever).
|
|
3384
|
+
let guard = this._queue.length + 1;
|
|
3385
|
+
while (this._queue.length > 0 && guard-- > 0)
|
|
3022
3386
|
this._flushNow();
|
|
3023
3387
|
}
|
|
3024
3388
|
/** How many requests are currently queued (not yet sent). */
|