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.
Files changed (95) hide show
  1. package/README.md +919 -445
  2. package/dist/browser/kinetex.esm.js +18 -18
  3. package/dist/browser/kinetex.js +749 -295
  4. package/dist/browser/kinetex.min.js +18 -18
  5. package/dist/cjs/aws-sigv4.js +4 -1
  6. package/dist/cjs/cache.js +52 -14
  7. package/dist/cjs/circuit-breaker.js +24 -4
  8. package/dist/cjs/client.js +495 -131
  9. package/dist/cjs/cookie-parser.js +7 -4
  10. package/dist/cjs/cookie-store.js +16 -8
  11. package/dist/cjs/core.js +83 -35
  12. package/dist/cjs/dedup.js +9 -7
  13. package/dist/cjs/digest.js +26 -0
  14. package/dist/cjs/graphql.js +19 -4
  15. package/dist/cjs/headers.js +64 -8
  16. package/dist/cjs/interceptors.js +86 -33
  17. package/dist/cjs/logging.js +1 -1
  18. package/dist/cjs/pagination.js +14 -6
  19. package/dist/cjs/progress.js +129 -42
  20. package/dist/cjs/socks5.js +36 -21
  21. package/dist/cjs/sse.js +48 -11
  22. package/dist/cjs/utils.js +24 -12
  23. package/dist/cjs/worker.js +6 -6
  24. package/dist/cjs/ws.js +23 -7
  25. package/dist/esm/aws-sigv4.js +4 -1
  26. package/dist/esm/aws-sigv4.js.map +1 -1
  27. package/dist/esm/cache.js +52 -14
  28. package/dist/esm/cache.js.map +1 -1
  29. package/dist/esm/circuit-breaker.js +24 -4
  30. package/dist/esm/circuit-breaker.js.map +1 -1
  31. package/dist/esm/client.js +495 -131
  32. package/dist/esm/client.js.map +1 -1
  33. package/dist/esm/cookie-parser.js +7 -4
  34. package/dist/esm/cookie-parser.js.map +1 -1
  35. package/dist/esm/cookie-store.js +16 -8
  36. package/dist/esm/cookie-store.js.map +1 -1
  37. package/dist/esm/core.js +83 -35
  38. package/dist/esm/core.js.map +1 -1
  39. package/dist/esm/dedup.js +9 -7
  40. package/dist/esm/dedup.js.map +1 -1
  41. package/dist/esm/digest.js +26 -0
  42. package/dist/esm/digest.js.map +1 -1
  43. package/dist/esm/graphql.js +19 -4
  44. package/dist/esm/graphql.js.map +1 -1
  45. package/dist/esm/headers.js +64 -8
  46. package/dist/esm/headers.js.map +1 -1
  47. package/dist/esm/interceptors.js +86 -33
  48. package/dist/esm/interceptors.js.map +1 -1
  49. package/dist/esm/logging.js +1 -1
  50. package/dist/esm/pagination.js +14 -6
  51. package/dist/esm/pagination.js.map +1 -1
  52. package/dist/esm/progress.js +129 -42
  53. package/dist/esm/progress.js.map +1 -1
  54. package/dist/esm/socks5.js +36 -21
  55. package/dist/esm/socks5.js.map +1 -1
  56. package/dist/esm/sse.js +48 -11
  57. package/dist/esm/sse.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/utils.js +24 -12
  60. package/dist/esm/utils.js.map +1 -1
  61. package/dist/esm/worker.js +6 -6
  62. package/dist/esm/worker.js.map +1 -1
  63. package/dist/esm/ws.js +23 -7
  64. package/dist/esm/ws.js.map +1 -1
  65. package/dist/types/aws-sigv4.d.ts.map +1 -1
  66. package/dist/types/cache.d.ts +8 -1
  67. package/dist/types/cache.d.ts.map +1 -1
  68. package/dist/types/circuit-breaker.d.ts.map +1 -1
  69. package/dist/types/client.d.ts +29 -12
  70. package/dist/types/client.d.ts.map +1 -1
  71. package/dist/types/cookie-parser.d.ts.map +1 -1
  72. package/dist/types/cookie-store.d.ts.map +1 -1
  73. package/dist/types/core.d.ts +10 -0
  74. package/dist/types/core.d.ts.map +1 -1
  75. package/dist/types/dedup.d.ts +0 -7
  76. package/dist/types/dedup.d.ts.map +1 -1
  77. package/dist/types/digest.d.ts +14 -0
  78. package/dist/types/digest.d.ts.map +1 -1
  79. package/dist/types/graphql.d.ts.map +1 -1
  80. package/dist/types/headers.d.ts +25 -10
  81. package/dist/types/headers.d.ts.map +1 -1
  82. package/dist/types/interceptors.d.ts.map +1 -1
  83. package/dist/types/logging.d.ts +1 -1
  84. package/dist/types/pagination.d.ts.map +1 -1
  85. package/dist/types/progress.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/sse.d.ts.map +1 -1
  88. package/dist/types/types.d.ts +25 -2
  89. package/dist/types/types.d.ts.map +1 -1
  90. package/dist/types/utils.d.ts.map +1 -1
  91. package/dist/types/worker.d.ts +6 -6
  92. package/dist/types/worker.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +4 -0
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +4 -4
@@ -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 { createDigestAuthorization } from "./digest.js";
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()) ? { name, value: "***REDACTED***" } : { name, value };
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
- const entries = performance.getEntriesByType("resource");
208
- // Find the most recent entry matching this URL
209
- const entry = entries.filter((e) => e.name === res.url).pop();
210
- if (entry && entry.requestStart > 0) {
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
- url: req.url,
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
- ...(typeof res.data === "string" ? { text: res.data } : {}),
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
- redirectURL: res.headers["location"] ?? "",
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
- "authorization",
388
- "cookie",
389
- "proxy-authorization",
390
- "x-api-key",
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
- * const user = await client.get("/users/1").json<User>();
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>({ url: "/users/1", method: "GET" });
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 uri = new URL(ctx.request.url).pathname + new URL(ctx.request.url).search;
797
- const authHeader = await createDigestAuthorization(wwwAuth, digestConfig.username, digestConfig.password, method, uri);
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
- url: ctx.request.url,
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: null,
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
- bodySize = new TextEncoder().encode(JSON.stringify(options.body)).byteLength;
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
- body: options.body ?? null,
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
- if (this._otelTracer) {
1517
- _otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
1518
- const { traceparent, traceId, spanId } = buildTraceparent(_otelSpan);
1519
- _otelSpan.setAttribute("http.request.method", req.method);
1520
- _otelSpan.setAttribute("url.full", req.url);
1521
- try {
1522
- _otelSpan.setAttribute("server.address", new URL(req.url).hostname);
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
- catch {
1525
- // Skip hostname attribute if URL is invalid
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
- else if (req.meta["traceId"] && !req.headers["traceparent"]) {
1534
- // Manual trace propagation — caller set traceId in meta
1535
- const traceId = String(req.meta["traceId"]);
1536
- const spanId = randomHex(16);
1537
- req = {
1538
- ...req,
1539
- headers: { ...req.headers, traceparent: `00-${traceId}-${spanId}-01` },
1540
- meta: { ...req.meta, spanId },
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
- const authFp = await getAuthFingerprint(req.headers ?? {});
1574
- const _dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
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, to capture Set-Cookie headers.
1690
- * fetch() auto-follows redirects but silently drops Set-Cookie from intermediary hops.
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
- const MAX_REDIRECTS = 20;
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 <= MAX_REDIRECTS; 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.processResponseHeaders(raw.headers, {
1980
+ jar?.processResponseHeaders(raw.headers, {
1761
1981
  url: raw.url,
1762
1982
  });
1763
- if (hop === MAX_REDIRECTS) {
1764
- throw new KinetexError(`Too many redirects (exceeded ${MAX_REDIRECTS})`, "ENETWORK", {
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
- // Cookies for the new origin are re-established by the jar lookup above;
1817
- // jar scoping guarantees only same-site cookies apply.
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
- if (new URL(nextUrl).origin !== new URL(currentReq.url).origin) {
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
- return raw;
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
- (await this.getCache())?.clearSWRInFlight(cacheReq);
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": "gzip, deflate, br",
2308
+ "accept-encoding": DEFAULT_ACCEPT_ENCODING,
2041
2309
  },
2042
2310
  };
2043
2311
  }
2044
2312
  // ── Dispatch ───────────────────────────────────────────────────────────
2045
- // When a cookie jar is active we must follow redirects manually so we can
2046
- // capture Set-Cookie headers from every intermediate hop — fetch() drops
2047
- // them silently when auto-following.
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 = dispatchJar
2053
- ? await this._sendFollowingRedirects(req, timeout, dispatchJar, options.auth !== false ? (options.auth ?? this.cfg.auth) : undefined)
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 = bodyStream.getReader();
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
- bodyStream?.cancel();
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
- data: this.cfg.transformResponse
2163
- ? this.cfg.transformResponse(data, {})
2164
- : data,
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(result, timeout, options, startMs, attempt + 1, undefined, retryCfg);
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 destroy() {
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
- if (this._cache) {
2571
- try {
2572
- await this._cache.clear();
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
- * Create an AbortError that is compatible across runtimes.
2841
- * Uses DOMException where available (browser/Deno), falls back to plain Error.
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 createAbortError() {
2844
- if (typeof DOMException !== "undefined") {
2845
- return new DOMException("Aborted", "AbortError");
2846
- }
2847
- const err = new Error("Aborted");
2848
- err.name = "AbortError";
2849
- return err;
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
- /** Maximum number of requests to flush at once. */
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
- this._maxBatch = options.maxBatch ?? 100;
2960
- this._flushMs = options.flushMs ?? 0;
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
- while (this._queue.length > 0)
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). */