kinetex 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +170 -46
  2. package/dist/browser/kinetex.esm.js +29 -29
  3. package/dist/browser/kinetex.js +1143 -791
  4. package/dist/browser/kinetex.min.js +29 -29
  5. package/dist/cjs/aws-sigv4.js +7 -14
  6. package/dist/cjs/cache.js +9 -0
  7. package/dist/cjs/circuit-breaker.js +8 -1
  8. package/dist/cjs/client.js +350 -95
  9. package/dist/cjs/cookie-parser.js +28 -1
  10. package/dist/cjs/core.js +227 -5
  11. package/dist/cjs/dedup.js +93 -10
  12. package/dist/cjs/digest.js +36 -15
  13. package/dist/cjs/graphql.js +2 -11
  14. package/dist/cjs/headers.js +24 -1
  15. package/dist/cjs/interceptors.js +6 -17
  16. package/dist/cjs/lifecycle.js +3 -19
  17. package/dist/cjs/logging.js +12 -29
  18. package/dist/cjs/progress.js +1 -3
  19. package/dist/cjs/proxy.js +19 -2
  20. package/dist/cjs/response.js +202 -75
  21. package/dist/cjs/socks5.js +5 -0
  22. package/dist/cjs/utils.js +115 -27
  23. package/dist/cjs/ws.js +43 -2
  24. package/dist/esm/aws-sigv4.js +7 -14
  25. package/dist/esm/aws-sigv4.js.map +1 -1
  26. package/dist/esm/cache.js +9 -0
  27. package/dist/esm/cache.js.map +1 -1
  28. package/dist/esm/circuit-breaker.js +8 -1
  29. package/dist/esm/circuit-breaker.js.map +1 -1
  30. package/dist/esm/client.js +350 -95
  31. package/dist/esm/client.js.map +1 -1
  32. package/dist/esm/cookie-parser.js +28 -1
  33. package/dist/esm/cookie-parser.js.map +1 -1
  34. package/dist/esm/core.js +227 -5
  35. package/dist/esm/core.js.map +1 -1
  36. package/dist/esm/dedup.js +93 -10
  37. package/dist/esm/dedup.js.map +1 -1
  38. package/dist/esm/digest.js +36 -15
  39. package/dist/esm/digest.js.map +1 -1
  40. package/dist/esm/graphql.js +2 -11
  41. package/dist/esm/graphql.js.map +1 -1
  42. package/dist/esm/headers.js +24 -1
  43. package/dist/esm/headers.js.map +1 -1
  44. package/dist/esm/interceptors.js +6 -17
  45. package/dist/esm/interceptors.js.map +1 -1
  46. package/dist/esm/lifecycle.js +3 -19
  47. package/dist/esm/lifecycle.js.map +1 -1
  48. package/dist/esm/logging.js +12 -29
  49. package/dist/esm/logging.js.map +1 -1
  50. package/dist/esm/progress.js +1 -3
  51. package/dist/esm/progress.js.map +1 -1
  52. package/dist/esm/proxy.js +19 -2
  53. package/dist/esm/proxy.js.map +1 -1
  54. package/dist/esm/response.js +202 -75
  55. package/dist/esm/response.js.map +1 -1
  56. package/dist/esm/socks5.js +5 -0
  57. package/dist/esm/socks5.js.map +1 -1
  58. package/dist/esm/types.js.map +1 -1
  59. package/dist/esm/url.js.map +1 -1
  60. package/dist/esm/utils.js +115 -27
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/ws.js +43 -2
  63. package/dist/esm/ws.js.map +1 -1
  64. package/dist/types/aws-sigv4.d.ts.map +1 -1
  65. package/dist/types/cache.d.ts.map +1 -1
  66. package/dist/types/circuit-breaker.d.ts +1 -1
  67. package/dist/types/circuit-breaker.d.ts.map +1 -1
  68. package/dist/types/client.d.ts.map +1 -1
  69. package/dist/types/cookie-parser.d.ts.map +1 -1
  70. package/dist/types/core.d.ts +113 -0
  71. package/dist/types/core.d.ts.map +1 -1
  72. package/dist/types/dedup.d.ts +38 -3
  73. package/dist/types/dedup.d.ts.map +1 -1
  74. package/dist/types/digest.d.ts.map +1 -1
  75. package/dist/types/graphql.d.ts.map +1 -1
  76. package/dist/types/headers.d.ts +23 -4
  77. package/dist/types/headers.d.ts.map +1 -1
  78. package/dist/types/interceptors.d.ts +24 -38
  79. package/dist/types/interceptors.d.ts.map +1 -1
  80. package/dist/types/lifecycle.d.ts.map +1 -1
  81. package/dist/types/logging.d.ts.map +1 -1
  82. package/dist/types/progress.d.ts.map +1 -1
  83. package/dist/types/proxy.d.ts.map +1 -1
  84. package/dist/types/response.d.ts +42 -1
  85. package/dist/types/response.d.ts.map +1 -1
  86. package/dist/types/socks5.d.ts.map +1 -1
  87. package/dist/types/types.d.ts +105 -23
  88. package/dist/types/types.d.ts.map +1 -1
  89. package/dist/types/url.d.ts +0 -11
  90. package/dist/types/url.d.ts.map +1 -1
  91. package/dist/types/utils.d.ts +69 -1
  92. package/dist/types/utils.d.ts.map +1 -1
  93. package/dist/types/ws.d.ts +0 -1
  94. package/dist/types/ws.d.ts.map +1 -1
  95. package/package.json +10 -4
@@ -4,12 +4,12 @@
4
4
  * retry, auth, cache, cookie jar, HAR, logging, progress, and SSE/GraphQL/Pagination.
5
5
  */
6
6
  import { ProgressTracker, withUploadProgress } from "./progress.js";
7
- import { DedupMap } from "./dedup.js";
7
+ import { DedupMap, dedupKeyFrom, identityFingerprint } from "./dedup.js";
8
8
  import { CircuitBreakerRegistry, } from "./circuit-breaker.js";
9
9
  import { WSClient } from "./ws.js";
10
10
  import { KinetexError, HTTPStatusError, AbortError, RedirectError, toRequestId } from "./types.js";
11
- import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, } from "./utils.js";
12
- import { getAuthFingerprint, CREDENTIAL_HEADERS } from "./cache.js";
11
+ import { isValidHeaderName, isValidHeaderValue, isSafeURL, uint8ArrayToBase64, randomBytes, perfNow, isSensitiveHeader, } from "./utils.js";
12
+ import { CREDENTIAL_HEADERS } from "./cache.js";
13
13
  import { createRateLimitInterceptor, ConcurrencyLimiter } from "./interceptors.js";
14
14
  import { SigV4Signer } from "./aws-sigv4.js";
15
15
  import { createDigestAuthorizer } from "./digest.js";
@@ -142,6 +142,15 @@ function randomHex(len) {
142
142
  * Mirrors logging.ts DEFAULT_REDACT_HEADERS — HAR logs are routinely exported
143
143
  * and shared, so credentials must never appear verbatim.
144
144
  */
145
+ /**
146
+ * Ceiling on a buffered response body when the caller sets no limit.
147
+ *
148
+ * Roughly the largest JSON document that is still sane to hand to
149
+ * `JSON.parse`, and far below the point where a single response can take a
150
+ * process down. Not a security boundary on its own — it is the difference
151
+ * between one bad response being a failed request and being an OOM kill.
152
+ */
153
+ const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
145
154
  const HAR_REDACT_HEADERS = new Set([
146
155
  "authorization",
147
156
  "proxy-authorization",
@@ -202,6 +211,92 @@ const HAR_REDACT_PARAMS = new Set([
202
211
  "jwt",
203
212
  "code",
204
213
  ]);
214
+ /**
215
+ * Body field names whose values are redacted from a recorded HAR body.
216
+ *
217
+ * The same names already redacted from HAR headers and query parameters.
218
+ * Headers were covered and query parameters were covered, but a JSON request
219
+ * body was recorded verbatim: `{"password":"..."}` went into the export in
220
+ * clear text while `{"password":"..."}` in a header was masked. HAR files get
221
+ * attached to bug reports and handed to support teams, so a body is the most
222
+ * likely place for a credential to travel. Nested objects and arrays are
223
+ * walked to any depth.
224
+ */
225
+ /**
226
+ * Replace the literal values this request's own credentials take, anywhere in
227
+ * a recorded body.
228
+ *
229
+ * Field-name redaction cannot help a `text/plain` body, where there are no
230
+ * field names — and a server echoing a token back is the common case (an
231
+ * error body quoting the offending header, a "session invalid" message).
232
+ * Matching the exact values kinetex already knows are sensitive is precise: no
233
+ * pattern matching, no false positives on ordinary text.
234
+ */
235
+ function scrubKnownSecrets(text, req) {
236
+ const secrets = [];
237
+ // Credential headers are usually scheme-prefixed — `Bearer <token>` — and a
238
+ // body that echoes the token back echoes only the token, not the scheme.
239
+ // Scrubbing the whole header value would therefore match nothing.
240
+ const add = (v) => {
241
+ if (typeof v === "string" && v.length >= 8)
242
+ secrets.push(v);
243
+ };
244
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
245
+ if (isSensitiveHeader(name, HAR_REDACT_HEADERS) && typeof value === "string") {
246
+ add(value);
247
+ const sp = value.indexOf(" ");
248
+ if (sp !== -1)
249
+ add(value.slice(sp + 1));
250
+ }
251
+ }
252
+ let query;
253
+ try {
254
+ query = new URL(req.url).searchParams;
255
+ }
256
+ catch {
257
+ query = new URLSearchParams();
258
+ }
259
+ for (const [key, value] of query.entries()) {
260
+ if (HAR_REDACT_PARAMS.has(key.toLowerCase()) &&
261
+ typeof value === "string" &&
262
+ value.length >= 8) {
263
+ secrets.push(value);
264
+ }
265
+ }
266
+ let out = text;
267
+ for (const secret of secrets)
268
+ out = out.split(secret).join(HAR_REDACT_MASK);
269
+ return out;
270
+ }
271
+ function redactHARBody(text, mimeType) {
272
+ if (!/json/i.test(mimeType))
273
+ return text;
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(text);
277
+ }
278
+ catch {
279
+ return text; // Not valid JSON despite the header; nothing safe to rewrite.
280
+ }
281
+ const walk = (node) => {
282
+ if (Array.isArray(node))
283
+ return node.map(walk);
284
+ if (node !== null && typeof node === "object") {
285
+ const out = {};
286
+ for (const [key, value] of Object.entries(node)) {
287
+ out[key] = HAR_REDACT_PARAMS.has(key.toLowerCase()) ? HAR_REDACT_MASK : walk(value);
288
+ }
289
+ return out;
290
+ }
291
+ return node;
292
+ };
293
+ try {
294
+ return JSON.stringify(walk(parsed));
295
+ }
296
+ catch {
297
+ return text;
298
+ }
299
+ }
205
300
  /** Maximum number of body characters recorded in a HAR entry. */
206
301
  const HAR_MAX_BODY_CHARS = 8192;
207
302
  /** HTML/other content types whose bodies are never recorded in HAR output. */
@@ -241,10 +336,20 @@ function redactHARUrl(url) {
241
336
  }
242
337
  }
243
338
  /** Redact a single header value for HAR output. */
339
+ /**
340
+ * HAR entries carry their own mask ("***REDACTED***"), distinct from the
341
+ * logger's "***" and the logging interceptor's "**REDACTED**". A recorded HAR
342
+ * is a file a user reads and shares, so the string is part of its output; the
343
+ * shared helper takes it as an argument rather than the three being merged.
344
+ */
345
+ const HAR_REDACT_MASK = "***REDACTED***";
244
346
  function redactHARHeader(name, value) {
245
- return HAR_REDACT_HEADERS.has(name.toLowerCase())
246
- ? { name, value: "***REDACTED***" }
247
- : { name, value };
347
+ // A HAR stores headers as `{name, value}` pairs rather than a record, so it
348
+ // uses the shared case-insensitive predicate rather than the record helper.
349
+ return {
350
+ name,
351
+ value: isSensitiveHeader(name, HAR_REDACT_HEADERS) ? HAR_REDACT_MASK : value,
352
+ };
248
353
  }
249
354
  /**
250
355
  * O(1) ring-buffer HAR entry recorder.
@@ -277,7 +382,10 @@ function harPostData(req) {
277
382
  else {
278
383
  return {}; // stream / FormData / Blob: not readable without consuming it
279
384
  }
280
- return { postData: { mimeType, text: text.slice(0, HAR_MAX_BODY_CHARS) } };
385
+ // Redact before truncating: truncating first could cut a redaction in half
386
+ // and leave the head of a secret in the export.
387
+ const redacted = scrubKnownSecrets(redactHARBody(text, mimeType), req);
388
+ return { postData: { mimeType, text: redacted.slice(0, HAR_MAX_BODY_CHARS) } };
281
389
  }
282
390
  class HARRecorder {
283
391
  /** Ring buffer of entries keyed by monotonic counter. */
@@ -393,7 +501,12 @@ class HARRecorder {
393
501
  // Body text is only kept for non-HTML payloads and is truncated:
394
502
  // response bodies routinely carry tokens and PII.
395
503
  ...(typeof res.data === "string" && isHARBodySafeToRecord(res.headers["content-type"])
396
- ? { text: res.data.slice(0, HAR_MAX_BODY_CHARS) }
504
+ ? {
505
+ // Same reason as the request side: a `text/plain` body has no
506
+ // field names to redact by, and a server that echoes the token
507
+ // back would otherwise put it in the export verbatim.
508
+ text: scrubKnownSecrets(res.data, req).slice(0, HAR_MAX_BODY_CHARS),
509
+ }
397
510
  : {}),
398
511
  },
399
512
  // The Location header can itself carry a signed URL — redact it too.
@@ -559,8 +672,49 @@ function redactUserInfo(url) {
559
672
  * @returns Fully-qualified URL string.
560
673
  * @throws {KinetexError} EVALIDATION — if URL is unsafe, params exceed limits, or URL too long.
561
674
  */
562
- function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
675
+ function buildURL(base, url, params, allowedSchemes = ["http", "https"], allowPrivateNetwork = false) {
563
676
  const MAX_QUERY_PARAM_COUNT = 100;
677
+ /**
678
+ * Walk query params once, enforcing the count limit, handing each surviving
679
+ * key/value pair to `emit`.
680
+ *
681
+ * `null` and `undefined` are dropped, and an array expands to one entry per
682
+ * non-null element. Both URL-assembly paths below did exactly this and each
683
+ * carried its own copy of the limit check — four copies of the same `throw`,
684
+ * inside two loops — so the two paths could drift and the cap could end up
685
+ * enforced in one spelling of the query but not the other.
686
+ *
687
+ * Encoding stays with the caller, and so does the array/scalar distinction:
688
+ * the primary path must `append` array elements (so `?a=1&a=2` survives) but
689
+ * `set` a scalar (so it replaces a value the URL already carried). The
690
+ * fallback appends to a list it is building and does not care. That
691
+ * difference is observable, so `emit` is told which case it is in.
692
+ */
693
+ const forEachQueryParam = (params, emit) => {
694
+ let paramCount = 0;
695
+ const count = () => {
696
+ paramCount++;
697
+ if (paramCount > MAX_QUERY_PARAM_COUNT) {
698
+ throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
699
+ }
700
+ };
701
+ for (const [key, value] of Object.entries(params)) {
702
+ if (value === null || value === undefined)
703
+ continue;
704
+ if (Array.isArray(value)) {
705
+ for (const v of value) {
706
+ if (v === null || v === undefined)
707
+ continue;
708
+ count();
709
+ emit(key, String(v), true);
710
+ }
711
+ }
712
+ else {
713
+ count();
714
+ emit(key, String(value), false);
715
+ }
716
+ }
717
+ };
564
718
  const MAX_URL_LENGTH = 8192;
565
719
  let full;
566
720
  if (/^https?:\/\//i.test(url)) {
@@ -588,7 +742,7 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
588
742
  }
589
743
  }
590
744
  if (!params || Object.keys(params).length === 0) {
591
- if (!isSafeURL(full, allowedSchemes)) {
745
+ if (!isSafeURL(full, allowedSchemes, { allowPrivateNetwork })) {
592
746
  throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
593
747
  }
594
748
  return full;
@@ -597,40 +751,24 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
597
751
  const u = new URL(full);
598
752
  // The params branch screens the URL after appending them, so it needs the
599
753
  // same scheme list as the no-params branch above.
600
- if (!isSafeURL(u, allowedSchemes)) {
754
+ if (!isSafeURL(u, allowedSchemes, { allowPrivateNetwork })) {
601
755
  throw new KinetexError(`URL "${redactUserInfo(full)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
602
756
  }
603
- let paramCount = 0;
604
- for (const [key, value] of Object.entries(params)) {
605
- if (value === null || value === undefined)
606
- continue;
607
- const strValue = String(value);
608
- if (Array.isArray(value)) {
609
- for (const v of value) {
610
- if (v !== null && v !== undefined) {
611
- paramCount++;
612
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
613
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
614
- }
615
- u.searchParams.append(key, String(v));
616
- }
617
- }
618
- }
619
- else {
620
- paramCount++;
621
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
622
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
623
- }
624
- u.searchParams.set(key, strValue);
625
- }
626
- }
757
+ // An array element appends, so `?a=1&a=2` survives; a scalar sets, so it
758
+ // replaces a value the URL already carried under that name.
759
+ forEachQueryParam(params, (key, value, fromArray) => {
760
+ if (fromArray)
761
+ u.searchParams.append(key, value);
762
+ else
763
+ u.searchParams.set(key, value);
764
+ });
627
765
  const result = u.toString();
628
766
  // Check URL length limit
629
767
  if (result.length > MAX_URL_LENGTH) {
630
768
  throw new KinetexError(`URL length ${result.length} bytes exceeds limit of ${MAX_URL_LENGTH} bytes`, "EVALIDATION");
631
769
  }
632
770
  // Validate the final URL with params
633
- if (!isSafeURL(result)) {
771
+ if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
634
772
  throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
635
773
  }
636
774
  return result;
@@ -646,29 +784,9 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
646
784
  full += "?";
647
785
  }
648
786
  const paramParts = [];
649
- let paramCount = 0;
650
- for (const [key, value] of Object.entries(params ?? {})) {
651
- if (value === null || value === undefined)
652
- continue;
653
- if (Array.isArray(value)) {
654
- for (const v of value) {
655
- if (v !== null && v !== undefined) {
656
- paramCount++;
657
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
658
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
659
- }
660
- paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
661
- }
662
- }
663
- }
664
- else {
665
- paramCount++;
666
- if (paramCount > MAX_QUERY_PARAM_COUNT) {
667
- throw new KinetexError(`Query parameter count ${paramCount} exceeds limit of ${MAX_QUERY_PARAM_COUNT}`, "EVALIDATION");
668
- }
669
- paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
670
- }
671
- }
787
+ forEachQueryParam(params ?? {}, (key, value) => {
788
+ paramParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
789
+ });
672
790
  const result = full + paramParts.join("&");
673
791
  // Check URL length limit
674
792
  if (result.length > MAX_URL_LENGTH) {
@@ -676,7 +794,7 @@ function buildURL(base, url, params, allowedSchemes = ["http", "https"]) {
676
794
  }
677
795
  // FIX M4: the manual param-concat fallback previously returned WITHOUT a
678
796
  // safety check — validate the assembled URL like every other path.
679
- if (!isSafeURL(result)) {
797
+ if (!isSafeURL(result, allowedSchemes, { allowPrivateNetwork })) {
680
798
  throw new KinetexError(`URL "${redactUserInfo(result)}" failed safety check — blocked private/loopback address or forbidden scheme`, "EVALIDATION");
681
799
  }
682
800
  return result;
@@ -732,11 +850,42 @@ function mergeHeaders(...sources) {
732
850
  * @param ctx - Current retry context (error, response, attempt count).
733
851
  * @returns Whether a retry should be attempted.
734
852
  */
853
+ /**
854
+ * A request body rendered for a log entry, or `null` when it cannot be.
855
+ *
856
+ * A `ReadableStream` or a `FormData` is deliberately not consumed to log it:
857
+ * reading either would consume the payload the request is about to send, so
858
+ * logging would break the request it is describing.
859
+ */
860
+ function loggableBody(body) {
861
+ if (body === null || body === undefined)
862
+ return null;
863
+ if (typeof body === "string")
864
+ return body;
865
+ if (body instanceof Uint8Array)
866
+ return body;
867
+ try {
868
+ return JSON.stringify(body);
869
+ }
870
+ catch {
871
+ // Cyclic, or otherwise not serialisable. The request still has to go out.
872
+ return null;
873
+ }
874
+ }
735
875
  function shouldRetry(cfg, ctx) {
736
876
  if (ctx.attempt > cfg.maxRetries)
737
877
  return false;
738
- if (!cfg.methods.includes(ctx.request.method))
878
+ // `Idempotency-Key` is a declaration that this exact request may be
879
+ // repeated: the whole point of the header is that the server collapses
880
+ // duplicates. Without this, POST is never retried, so a caller who attached
881
+ // a key — which kinetex generates for exactly this purpose — still lost the
882
+ // request to the first transient 503, and the changelog's claim that "every
883
+ // retry reuses it" described retries that could never happen. A caller opts
884
+ // in by sending the header, which is the same contract the header carries.
885
+ const hasIdempotencyKey = Object.keys(ctx.request.headers ?? {}).some((name) => name.toLowerCase() === "idempotency-key" && ctx.request.headers[name] !== "");
886
+ if (!hasIdempotencyKey && !cfg.methods.includes(ctx.request.method)) {
739
887
  return false;
888
+ }
740
889
  if (cfg.shouldRetry)
741
890
  return cfg.shouldRetry(ctx); // caller handles it
742
891
  if (ctx.error) {
@@ -764,6 +913,10 @@ function shouldRetry(cfg, ctx) {
764
913
  return false;
765
914
  case "EREDIRECT":
766
915
  return false;
916
+ // A blocked host is a permanent refusal, not a transport hiccup; retrying
917
+ // resolves the same name to the same blocked address.
918
+ case "EBLOCKEDHOST":
919
+ return false;
767
920
  case "EUNKNOWN":
768
921
  return false;
769
922
  default: {
@@ -943,6 +1096,10 @@ export class Kinetex {
943
1096
  ...(config.strictHeaders ? { strict: true } : {}),
944
1097
  ...(config.dispatcher !== undefined ? { dispatcher: config.dispatcher } : {}),
945
1098
  ...(config.proxy !== undefined ? { proxy: config.proxy } : {}),
1099
+ ...(config.allowPrivateNetwork !== undefined
1100
+ ? { allowPrivateNetwork: config.allowPrivateNetwork }
1101
+ : {}),
1102
+ ...(config.ca !== undefined ? { ca: config.ca } : {}),
946
1103
  });
947
1104
  // Register config-level interceptors
948
1105
  if (config.interceptors) {
@@ -1046,7 +1203,7 @@ export class Kinetex {
1046
1203
  status: ctx.response.status,
1047
1204
  statusText: ctx.response.statusText,
1048
1205
  headers: ctx.response.headers,
1049
- body: ctx.response.rawBody ?? null,
1206
+ body: ctx.response.body ?? null,
1050
1207
  request: {
1051
1208
  url: ctx.request.url,
1052
1209
  method: ctx.request.method,
@@ -1443,12 +1600,7 @@ export class Kinetex {
1443
1600
  // check and threw EVALIDATION on every call, so `client.ws()` could not
1444
1601
  // connect to anything. Only the scheme list is widened: the loopback and
1445
1602
  // private-range checks still apply to WebSocket URLs.
1446
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, [
1447
- "http",
1448
- "https",
1449
- "ws",
1450
- "wss",
1451
- ]);
1603
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, ["http", "https", "ws", "wss"], this.cfg.allowPrivateNetwork);
1452
1604
  const headers = mergeHeaders(this.cfg.headers, options.headers);
1453
1605
  // Apply auth headers manually since WS handshake goes through the browser
1454
1606
  // WS API which doesn't use the kinetex transport pipeline.
@@ -1481,7 +1633,11 @@ export class Kinetex {
1481
1633
  // Inject cookies from cookie jar into WS handshake
1482
1634
  const jar = await this.getCookieJar();
1483
1635
  if (jar) {
1484
- const cookieHeader = jar.getCookieHeader({ url: fullURL, http: false });
1636
+ const cookieHeader = jar.getCookieHeader({
1637
+ url: fullURL,
1638
+ http: false,
1639
+ sameSiteContext: "strict",
1640
+ });
1485
1641
  if (cookieHeader)
1486
1642
  headers["cookie"] = cookieHeader;
1487
1643
  }
@@ -1490,7 +1646,14 @@ export class Kinetex {
1490
1646
  const wsUrl = new URL(fullURL);
1491
1647
  if (this.cfg.baseURL) {
1492
1648
  const baseUrl = new URL(this.cfg.baseURL);
1493
- const wsIsSecure = wsUrl.protocol === "wss:";
1649
+ // `https:` counts as secure here, not just `wss:`. `fullURL` comes
1650
+ // from `buildURL` against the same baseURL, so it carries the baseURL's
1651
+ // own scheme — `https:` — and the WebSocket upgrade happens later, in
1652
+ // the runtime's `WebSocket`. Testing for `wss:` alone therefore made
1653
+ // `wsIsSecure` false for every https baseURL while `httpIsSecure` was
1654
+ // true, so `client.ws()` rejected its own configuration and could not
1655
+ // open a socket at all. The error even printed two identical origins.
1656
+ const wsIsSecure = wsUrl.protocol === "wss:" || wsUrl.protocol === "https:";
1494
1657
  // A baseURL may itself be a WebSocket URL — `kinetex({ baseURL:
1495
1658
  // "wss://…" })` then `client.ws("/path")` is the natural spelling, and
1496
1659
  // it is what the README's origin-validation section shows. Comparing
@@ -1549,6 +1712,12 @@ export class Kinetex {
1549
1712
  this._wsClients.add(client);
1550
1713
  await client.connect().catch((err) => {
1551
1714
  this._wsClients.delete(client);
1715
+ // Destroy, not just deregister. A handshake that fails before `open`
1716
+ // rejects with the socket still referenced by the WSClient — its
1717
+ // handlers, its buffer and the underlying handle — so dropping the
1718
+ // registry entry alone left it alive and holding a socket for the life of
1719
+ // the process. A caller that retried on failure leaked one per attempt.
1720
+ client.destroy();
1552
1721
  throw err;
1553
1722
  });
1554
1723
  return client;
@@ -1572,7 +1741,23 @@ export class Kinetex {
1572
1741
  // ── Resolve timeout ────────────────────────────────────────────────────
1573
1742
  const timeoutMs = options.timeout !== undefined ? options.timeout : (this.cfg.timeout ?? 30_000);
1574
1743
  // ── Resolve retry ──────────────────────────────────────────────────────
1575
- const retryCfg = options.retry === false ? false : { ...this.retryConfig, ...(options.retry ?? {}) };
1744
+ // `totalTimeoutMs` defaults to the request timeout, so `timeout` bounds the
1745
+ // whole logical request rather than each attempt. It applied per attempt
1746
+ // only, while the backoff between attempts was bounded by nothing: with
1747
+ // `timeout: 1_000`, five retries and a growing `baseDelayMs`, an instantly
1748
+ // failing endpoint held the caller for ~37s. Anyone who reads `timeout` as
1749
+ // "this call must not take longer than this" — most people — was wrong.
1750
+ // `timeout: 0` (no timeout) likewise yields no budget, and an explicit
1751
+ // `totalTimeoutMs: 0` opts back out.
1752
+ const retryCfg = options.retry === false
1753
+ ? false
1754
+ : {
1755
+ ...this.retryConfig,
1756
+ ...(options.retry ?? {}),
1757
+ ...(options.retry?.totalTimeoutMs !== undefined
1758
+ ? {}
1759
+ : { totalTimeoutMs: options.totalTimeoutMs ?? this.cfg.totalTimeoutMs ?? timeoutMs }),
1760
+ };
1576
1761
  // ── Validate HTTP method ────────────────────────────────────────────────
1577
1762
  const VALID_METHODS = [
1578
1763
  "GET",
@@ -1590,7 +1775,7 @@ export class Kinetex {
1590
1775
  throw new KinetexError(`Invalid HTTP method '${method}'. Valid methods: ${VALID_METHODS.join(", ")}`, "EVALIDATION");
1591
1776
  }
1592
1777
  // ── Build initial request ─────────────────────────────────────────────
1593
- const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params));
1778
+ const fullUrl = buildURL(options.baseURL ?? this.cfg.baseURL, url, mergeParams(this.cfg.params, options.params), undefined, this.cfg.allowPrivateNetwork === true);
1594
1779
  // A client-level `proxy` is honoured by the Node transport, which tunnels
1595
1780
  // every connection through it with CONNECT. A per-request `proxy` cannot be:
1596
1781
  // the transport pools one connection per origin, so honouring a per-request
@@ -1762,7 +1947,11 @@ export class Kinetex {
1762
1947
  // ── Cookie jar (outgoing cookies) ──────────────────────────────────────
1763
1948
  const jar = await this.getCookieJar();
1764
1949
  if (jar) {
1765
- const cookieHeader = jar.getCookieHeader({ url: req.url, http: true });
1950
+ const cookieHeader = jar.getCookieHeader({
1951
+ url: req.url,
1952
+ http: true,
1953
+ sameSiteContext: "strict",
1954
+ });
1766
1955
  if (cookieHeader)
1767
1956
  req = { ...req, headers: { ...req.headers, cookie: cookieHeader } };
1768
1957
  }
@@ -1778,6 +1967,7 @@ export class Kinetex {
1778
1967
  // abandoning it — an unended span is never exported and never reports the
1779
1968
  // error, and holds its attributes in the tracer's memory.
1780
1969
  let _otelSpan = null;
1970
+ let _dedupKey = "";
1781
1971
  try {
1782
1972
  if (this._otelTracer) {
1783
1973
  _otelSpan = this._otelTracer.startSpan(`HTTP ${req.method}`, { kind: 3 /* CLIENT */ });
@@ -1806,6 +1996,24 @@ export class Kinetex {
1806
1996
  meta: { ...req.meta, spanId },
1807
1997
  };
1808
1998
  }
1999
+ // Dedup key. Computed inside this try, not below it, so that a failure
2000
+ // here ends the span instead of abandoning it. The auth fingerprint needs
2001
+ // Web Crypto, so it can throw on a runtime without `crypto.subtle` — and
2002
+ // because it is an `await`, the throw resumes here, outside the span's
2003
+ // lifecycle when this block sat below the catch. The span was then never
2004
+ // ended, never exported, and held its attributes in the tracer's memory.
2005
+ if (this._dedup) {
2006
+ // Fingerprint *every* header, not just the credential ones. Keying on
2007
+ // `Authorization`/`Cookie` alone is what the comment above promised,
2008
+ // and it held — for the two headers it covered. It did not cover the
2009
+ // header a multi-tenant gateway actually partitions on: two concurrent
2010
+ // GETs to one URL differing only in `X-Tenant` shared a single response,
2011
+ // so one tenant was handed another's data. Hashing keeps the values out
2012
+ // of the key itself, and the trace-id headers are excluded so they
2013
+ // cannot silently disable coalescing.
2014
+ const identity = await identityFingerprint(req.headers);
2015
+ _dedupKey = dedupKeyFrom(req.method, req.url, identity);
2016
+ }
1809
2017
  }
1810
2018
  catch (tracingErr) {
1811
2019
  if (_otelSpan) {
@@ -1846,16 +2054,12 @@ export class Kinetex {
1846
2054
  const _execFactory = () => this._executeWithRetry(req, retryCfg, timeoutMs, options, startMs, wallClockMs);
1847
2055
  // Dedup: coalesce identical concurrent GET/HEAD requests into one network
1848
2056
  // call. All callers share the same KinetexResponse object once resolved.
1849
- // SECURITY: The dedup key includes a fingerprint of auth-sensitive headers
1850
- // so requests from different users (different Authorization / Cookie) are
1851
- // NEVER coalesced — each user gets their own isolated in-flight slot.
1852
- // Fingerprinting hashes the credential headers with SHA-256, so it is only
1853
- // paid when dedup is actually enabled.
1854
- let _dedupKey = "";
1855
- if (this._dedup) {
1856
- const authFp = await getAuthFingerprint(req.headers ?? {});
1857
- _dedupKey = `${req.method}:${req.url}${authFp ? ":" + authFp : ""}`;
1858
- }
2057
+ // SECURITY: the dedup key fingerprints every request header, so two
2058
+ // requests are only ever coalesced when they would have received the same
2059
+ // response — including when they differ only by tenant or session header.
2060
+ // Fingerprinting hashes the header set with SHA-256, so credentials never
2061
+ // sit in the key, and it is only paid when dedup is actually enabled. The
2062
+ // key is computed inside the tracing try above, where a throw ends the span.
1859
2063
  const _dedupedFactory = this._dedup
1860
2064
  ? () => this._dedup
1861
2065
  .execute(req.method, _dedupKey, _execFactory)
@@ -2001,6 +2205,14 @@ export class Kinetex {
2001
2205
  : shouldRetry(retryCfg, retryCtx);
2002
2206
  if (doRetry) {
2003
2207
  const delay = computeRetryDelay(retryCfg, attempt, getRetryAfterMs(res.headers));
2208
+ // Total budget: a backoff that would run past it is not worth
2209
+ // sleeping through. The caller gets the response they already have
2210
+ // (a retry-worthy status) instead of a manufactured timeout, which
2211
+ // keeps the real reason visible.
2212
+ if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
2213
+ if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs)
2214
+ return res;
2215
+ }
2004
2216
  await retryCfg.onRetry?.(retryCtx, delay);
2005
2217
  await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
2006
2218
  await p;
@@ -2070,6 +2282,14 @@ export class Kinetex {
2070
2282
  : shouldRetry(retryCfg, retryCtx);
2071
2283
  if (doRetry) {
2072
2284
  const delay = computeRetryDelay(retryCfg, attempt, errResponse ? getRetryAfterMs(errResponse.headers) : null);
2285
+ // Total budget, as above: do not sleep past the caller's ceiling.
2286
+ // Here there is no response to fall back on, so the original error
2287
+ // stands and is rethrown below.
2288
+ if (retryCfg.totalTimeoutMs !== undefined && retryCfg.totalTimeoutMs > 0) {
2289
+ if (perfNow() - startMs + delay >= retryCfg.totalTimeoutMs) {
2290
+ throw err;
2291
+ }
2292
+ }
2073
2293
  await retryCfg.onRetry?.(retryCtx, delay);
2074
2294
  await this.cfg.hooks?.onRetry?.reduce(async (p, fn) => {
2075
2295
  await p;
@@ -2236,7 +2456,9 @@ export class Kinetex {
2236
2456
  // public host could 302 the client straight at link-local/loopback
2237
2457
  // addresses (169.254.169.254, 127.0.0.1, 10/8, ::1, …) and the whole
2238
2458
  // private-network block list would be bypassable. Re-validate every hop.
2239
- if (!isSafeURL(nextUrl)) {
2459
+ if (!isSafeURL(nextUrl, undefined, {
2460
+ allowPrivateNetwork: this.cfg.allowPrivateNetwork === true,
2461
+ })) {
2240
2462
  throw new KinetexError(`Unsafe redirect target blocked: ${redactUserInfo(location)}`, "EVALIDATION", { request: req });
2241
2463
  }
2242
2464
  // httpsOnly must hold for redirect legs too, otherwise a redirect is a
@@ -2259,6 +2481,15 @@ export class Kinetex {
2259
2481
  ? "GET"
2260
2482
  : currentReq.method;
2261
2483
  const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
2484
+ // A 307/308 keeps the method and the body, so the hop needs the body
2485
+ // again — and a stream or Blob body is spent: the first attempt consumed
2486
+ // and locked it. The retry loop already refuses this case with a clear
2487
+ // EVALIDATION, but the follower had no equivalent check, so it handed
2488
+ // the dead stream to the transport and the caller got an opaque ENETWORK
2489
+ // "Response body object should not be disturbed or locked".
2490
+ if (nextBody !== null && isNonReplayableBody(nextBody)) {
2491
+ throw new KinetexError("Cannot follow this redirect: a 307/308 preserves the method and body, and this request's body is a stream or Blob that the first attempt already consumed. Buffer it first, or disable redirects for this request.", "EVALIDATION", { request: currentReq });
2492
+ }
2262
2493
  const nextHeaders = { ...currentReq.headers };
2263
2494
  // FIX H2: When the redirect crosses origins, strip credential-bearing
2264
2495
  // headers (Authorization, Cookie, proxy auth, API keys) so secrets are
@@ -2290,7 +2521,13 @@ export class Kinetex {
2290
2521
  // Jar scoping guarantees only cookies that match the NEW origin are
2291
2522
  // attached, which is exactly the post-strip state we want.
2292
2523
  if (jar) {
2293
- const cookieHeader = jar.getCookieHeader({ url: nextUrl, http: true });
2524
+ // Same context as the initial hop: a redirect followed to an origin the
2525
+ // client chose is still a same-site request for cookie purposes.
2526
+ const cookieHeader = jar.getCookieHeader({
2527
+ url: nextUrl,
2528
+ http: true,
2529
+ sameSiteContext: "strict",
2530
+ });
2294
2531
  if (cookieHeader) {
2295
2532
  nextHeaders["cookie"] = cookieHeader;
2296
2533
  }
@@ -2356,6 +2593,19 @@ export class Kinetex {
2356
2593
  }
2357
2594
  }
2358
2595
  this._trace(_traceId, "lifecycle_before", "end", startMs, attempt);
2596
+ // ── Logger: request ────────────────────────────────────────────────────
2597
+ // `HTTPLogger.logRequest` was fully implemented — it registers the id so
2598
+ // the matching `logResponse` can recover the method, URL and duration —
2599
+ // and the client never called it. Nothing had ever been registered, so
2600
+ // every response entry came back with `url: ""`, `method: "GET"`,
2601
+ // `durationMs: 0` and `requestId: "unknown"`: the request line was simply
2602
+ // missing, and a structured log that cannot name the request it describes
2603
+ // is not much use for diagnosing one. Registered here, before dispatch, so
2604
+ // it covers the same span the response entry closes.
2605
+ const _logger = await this.getLogger();
2606
+ if (_logger) {
2607
+ _logger.logRequest(_traceId, req.method, req.url, req.headers, loggableBody(req.body), attempt);
2608
+ }
2359
2609
  // ── Cache lookup ───────────────────────────────────────────────────────
2360
2610
  // `noCache()` sets `cache: { forceRefresh: true }`, and `forceRefresh` was
2361
2611
  // declared on `CacheRequestConfig` and read *nowhere*: the lookup below ran
@@ -2677,7 +2927,11 @@ export class Kinetex {
2677
2927
  });
2678
2928
  }
2679
2929
  // ── Read body with size limit ──────────────────────────────────────────
2680
- const maxSize = options.maxResponseSize ?? this.cfg.maxResponseSize ?? 0;
2930
+ // 100 MB by default rather than unbounded: without a cap a hostile or
2931
+ // simply broken server can drive this process out of memory, because the
2932
+ // body is read in full before the caller sees a byte of it. An explicit
2933
+ // `0` opts back out, which is what that value has always meant.
2934
+ const maxSize = options.maxResponseSize ?? this.cfg.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE;
2681
2935
  const rawBody = await readRawBody(bodyStream, maxSize, raw.url, req.signal);
2682
2936
  // ── Cookie jar (incoming Set-Cookie) ──────────────────────────────────
2683
2937
  const jar = await this.getCookieJar();
@@ -2701,6 +2955,9 @@ export class Kinetex {
2701
2955
  // the real response object instead of the `{}` placeholder it used to get.
2702
2956
  data,
2703
2957
  rawBody,
2958
+ // The same bytes in the shape an interceptor sees, so one response
2959
+ // object satisfies both the public and the pipeline views.
2960
+ body: rawBody,
2704
2961
  url: raw.url,
2705
2962
  cached: false,
2706
2963
  redirected: raw.redirected,
@@ -2725,9 +2982,9 @@ export class Kinetex {
2725
2982
  }
2726
2983
  }
2727
2984
  // ── Logger ─────────────────────────────────────────────────────────────
2728
- const logger = await this.getLogger();
2985
+ const logger = _logger ?? (await this.getLogger());
2729
2986
  if (logger) {
2730
- const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? "unknown";
2987
+ const requestId = ctx.store.get("requestId") ?? req.meta["requestId"] ?? _traceId;
2731
2988
  logger.logResponse(requestId, res.status, res.statusText, res.headers, rawBody, attempt, false);
2732
2989
  }
2733
2990
  // ── HAR recording ──────────────────────────────────────────────────────
@@ -2888,6 +3145,8 @@ export class Kinetex {
2888
3145
  status: raw.status,
2889
3146
  statusText: raw.statusText,
2890
3147
  headers: raw.headers,
3148
+ // Present so the object satisfies both response views at once.
3149
+ body: rawBody,
2891
3150
  data,
2892
3151
  rawBody,
2893
3152
  url: responseURL,
@@ -3006,7 +3265,7 @@ export class Kinetex {
3006
3265
  async sse(url, options = {}) {
3007
3266
  const { SSEClient } = await import("./sse.js");
3008
3267
  const headers = mergeHeaders(this.cfg.headers, options.headers);
3009
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3268
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
3010
3269
  // Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, etc.)
3011
3270
  // by providing a pipeline-aware fetch function.
3012
3271
  const pipeFetch = async (input, init) => {
@@ -3047,7 +3306,7 @@ export class Kinetex {
3047
3306
  async graphql(url, options = {}) {
3048
3307
  const { GraphQLClient } = await import("./graphql.js");
3049
3308
  const headers = mergeHeaders(this.cfg.headers, options.headers);
3050
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3309
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
3051
3310
  // Route through the full kinetex pipeline (auth, interceptors, rate-limit, CB, OTel, etc.)
3052
3311
  const pipeFetch = async (input, init) => {
3053
3312
  const reqUrl = typeof input === "string" ? input : input.url;
@@ -3086,7 +3345,7 @@ export class Kinetex {
3086
3345
  */
3087
3346
  async paginate(url, options) {
3088
3347
  const { createPagePaginator } = await import("./pagination.js");
3089
- const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params);
3348
+ const fullURL = buildURL(this.cfg.baseURL, url, this.cfg.params, undefined, this.cfg.allowPrivateNetwork);
3090
3349
  // Route through the full kinetex pipeline
3091
3350
  const pipeFetch = async (input, init) => {
3092
3351
  const reqUrl = typeof input === "string" ? input : input.url;
@@ -3508,10 +3767,6 @@ function isNonReplayableBody(body) {
3508
3767
  return true;
3509
3768
  return typeof Blob !== "undefined" && body instanceof Blob;
3510
3769
  }
3511
- /** Cross-runtime performance.now() — falls back to Date.now(). */
3512
- function perfNow() {
3513
- return typeof performance !== "undefined" ? performance.now() : Date.now();
3514
- }
3515
3770
  // ============================================================================
3516
3771
  // §16 ENTERPRISE HELPERS
3517
3772
  // ============================================================================