kinetex 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +14 -1
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
@@ -28,6 +28,8 @@
28
28
  * - No dependencies, no runtime globals beyond Promise/Map/Set
29
29
  */
30
30
  import { getAuthFingerprint } from "./cache.js";
31
+ import { KinetexError } from "./types.js";
32
+ import { parseHTTPDate } from "./headers.js";
31
33
  // ============================================================================
32
34
  // §3 INTERCEPTOR MANAGER
33
35
  // ============================================================================
@@ -357,7 +359,9 @@ export function createRetryInterceptor(config = {}) {
357
359
  const exp = cfg.baseDelayMs * Math.pow(2, attempt - 1);
358
360
  const capped = Math.min(exp, cfg.maxDelayMs);
359
361
  const jitterMs = capped * cfg.jitter * Math.random();
360
- return Math.floor(capped + jitterMs);
362
+ // `maxDelayMs` is documented as a hard maximum, so it has to be applied
363
+ // after jitter too — capping first let jitter push the delay past it.
364
+ return Math.min(Math.floor(capped + jitterMs), cfg.maxDelayMs);
361
365
  }
362
366
  function shouldRetryCtx(ctx) {
363
367
  if (ctx.attempt > cfg.maxRetries)
@@ -378,8 +382,12 @@ export function createRetryInterceptor(config = {}) {
378
382
  return null;
379
383
  if (/^\d+$/.test(ra.trim()))
380
384
  return parseInt(ra, 10) * 1000;
381
- const ms = Date.parse(ra);
382
- if (!isNaN(ms))
385
+ // Only the three HTTP-date formats count. Raw `Date.parse` is lenient to a
386
+ // fault here: it happily accepts "1.5", "-5" and "+5" as ancient dates, so a
387
+ // malformed `Retry-After` resolved to ~0ms and silently cancelled the
388
+ // back-off instead of falling through to the exponential delay.
389
+ const ms = parseHTTPDate(ra);
390
+ if (ms !== null)
383
391
  return Math.max(0, ms - Date.now());
384
392
  return null;
385
393
  }
@@ -618,6 +626,7 @@ export function createLoggingInterceptor(config = {}) {
618
626
  durationMs: type !== "request" ? now() - ctx.startedAt : null,
619
627
  attempt: ctx.attempt,
620
628
  error: ctx.error instanceof Error ? ctx.error.message : null,
629
+ headers: redactHeaders(ctx.request.headers ?? {}),
621
630
  };
622
631
  }
623
632
  const requestInterceptor = (ctx) => {
@@ -637,7 +646,6 @@ export function createLoggingInterceptor(config = {}) {
637
646
  return;
638
647
  cfg.logger(makeEntry("error", ctx));
639
648
  };
640
- void redactHeaders; // used externally; suppress unused warning
641
649
  return { requestInterceptor, responseInterceptor, errorInterceptor };
642
650
  }
643
651
  const CACHE_DEFAULTS = {
@@ -863,6 +871,19 @@ const RATE_LIMIT_DEFAULTS = {
863
871
  */
864
872
  export function createRateLimitInterceptor(config = {}) {
865
873
  const cfg = { ...RATE_LIMIT_DEFAULTS, ...config };
874
+ // A non-positive `limit` (or a non-positive `windowMs`) has no valid token-bucket
875
+ // state: the refill interval `windowMs / limit` becomes `Infinity` or `NaN`,
876
+ // which `setInterval` turns into a ~1ms busy-poll, and every request either
877
+ // throws or queues forever. Reject it up front rather than degrading silently.
878
+ if (!Number.isFinite(cfg.limit) || cfg.limit < 1) {
879
+ throw new RangeError(`rateLimit.limit must be a finite number >= 1, got ${cfg.limit}`);
880
+ }
881
+ if (!Number.isFinite(cfg.windowMs) || cfg.windowMs < 1) {
882
+ throw new RangeError(`rateLimit.windowMs must be a finite number >= 1, got ${cfg.windowMs}`);
883
+ }
884
+ if (!Number.isFinite(cfg.maxQueue) || cfg.maxQueue < 0) {
885
+ throw new RangeError(`rateLimit.maxQueue must be a finite number >= 0, got ${cfg.maxQueue}`);
886
+ }
866
887
  let tokens = cfg.limit;
867
888
  let lastRefill = now();
868
889
  const pending = [];
@@ -919,6 +940,191 @@ export class RateLimitError extends Error {
919
940
  this.name = "RateLimitError";
920
941
  }
921
942
  }
943
+ /** Defaults for {@link ConcurrencyLimitConfig}. */
944
+ export const CONCURRENCY_DEFAULTS = {
945
+ maxConcurrent: 10,
946
+ queue: true,
947
+ maxQueue: 100,
948
+ };
949
+ /**
950
+ * Raised when the concurrency limit is reached and the request cannot be
951
+ * queued (or queueing is disabled).
952
+ *
953
+ * Distinct from {@link RateLimitError}: a rate limit bounds requests per unit
954
+ * of _time_, this bounds requests in flight.
955
+ */
956
+ export class ConcurrencyLimitError extends Error {
957
+ /** Machine-readable error code identifying this as a concurrency error */
958
+ code = "ECONCURRENCY";
959
+ constructor(message) {
960
+ super(message);
961
+ this.name = "ConcurrencyLimitError";
962
+ }
963
+ }
964
+ /**
965
+ * Detach a waiter's abort listener.
966
+ *
967
+ * Nulling `onAbort` alone is not enough: a caller-supplied signal usually
968
+ * outlives a single request, so the `{ once: true }` listener would stay
969
+ * attached forever and accumulate one closure per queued request.
970
+ */
971
+ function detachAbortListener(waiter) {
972
+ if (waiter.onAbort && waiter.signal) {
973
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
974
+ }
975
+ waiter.onAbort = null;
976
+ waiter.signal = null;
977
+ }
978
+ /**
979
+ * A counting semaphore bounding how many requests may be in flight at once.
980
+ *
981
+ * The rate limiter is a token bucket: it releases a token at _dispatch_, so
982
+ * `limit: 100` per minute still permits 100 simultaneous sockets. This bounds
983
+ * concurrency instead — a permit is held for the whole request (including its
984
+ * retries) and returned only when it settles.
985
+ *
986
+ * Permits are handed directly to the next waiter on release, so releasing
987
+ * never transiently overshoots {@link ConcurrencyLimitConfig.maxConcurrent}.
988
+ *
989
+ * @example
990
+ * ```ts
991
+ * const limiter = new ConcurrencyLimiter({ maxConcurrent: 4 });
992
+ * await limiter.acquire();
993
+ * try {
994
+ * await doWork();
995
+ * } finally {
996
+ * limiter.release();
997
+ * }
998
+ * ```
999
+ */
1000
+ export class ConcurrencyLimiter {
1001
+ cfg;
1002
+ active = 0;
1003
+ peak = 0;
1004
+ waiters = [];
1005
+ /**
1006
+ * @param config - Partial configuration; omitted fields use {@link CONCURRENCY_DEFAULTS}.
1007
+ * @throws {RangeError} If `maxConcurrent` is not a finite number >= 1, or
1008
+ * `maxQueue` is neither a non-negative integer nor `Infinity`.
1009
+ */
1010
+ constructor(config = {}) {
1011
+ this.cfg = { ...CONCURRENCY_DEFAULTS, ...config };
1012
+ if (!Number.isFinite(this.cfg.maxConcurrent) || this.cfg.maxConcurrent < 1) {
1013
+ throw new RangeError(`concurrencyLimit.maxConcurrent must be a finite number >= 1, received ${String(this.cfg.maxConcurrent)}`);
1014
+ }
1015
+ // The cap is tested as `waiters.length >= maxQueue`, so `NaN` makes every
1016
+ // comparison false and the queue stops bounding anything: each request then
1017
+ // parks a waiter holding its promise, signal and closures, with nothing
1018
+ // ever rejected. A negative value is the opposite failure — the first
1019
+ // overflow is refused, so a configured depth of -1 silently means 0.
1020
+ if (this.cfg.maxQueue !== Number.POSITIVE_INFINITY &&
1021
+ (!Number.isInteger(this.cfg.maxQueue) || this.cfg.maxQueue < 0)) {
1022
+ throw new RangeError(`concurrencyLimit.maxQueue must be a non-negative integer or Infinity, received ${String(this.cfg.maxQueue)}`);
1023
+ }
1024
+ }
1025
+ /** Number of permits currently held. */
1026
+ get inFlight() {
1027
+ return this.active;
1028
+ }
1029
+ /** Number of requests currently waiting for a permit. */
1030
+ get waiting() {
1031
+ return this.waiters.length;
1032
+ }
1033
+ /** Highest concurrent in-flight count observed. */
1034
+ get highWaterMark() {
1035
+ return this.peak;
1036
+ }
1037
+ /**
1038
+ * Take a permit, waiting in the queue if the limit is saturated.
1039
+ *
1040
+ * @param signal - Optional abort signal; aborting while queued removes the
1041
+ * waiter and rejects without ever consuming a permit.
1042
+ * @throws {ConcurrencyLimitError} If queueing is disabled or the queue is full.
1043
+ * @throws {KinetexError} With code `EABORT` if `signal` aborts while queued —
1044
+ * the same contract every other abort path in the library offers, so
1045
+ * `err.code === "EABORT"` and `err.isAbort` work here too.
1046
+ */
1047
+ acquire(signal) {
1048
+ // Checked before anything else, so the answer does not depend on whether a
1049
+ // permit happened to be free. This used to sit only inside the enqueue
1050
+ // path, which gave the same call three different answers: on an idle pool
1051
+ // the fast path granted the permit and dropped the abort entirely; with
1052
+ // queueing disabled or the queue full, the abort was reported as a queue
1053
+ // overflow, so a caller branching on `err.code === "EABORT"` treated a
1054
+ // cancelled request as a capacity failure.
1055
+ if (signal?.aborted) {
1056
+ // Refused before a permit is taken, so none can leak.
1057
+ return Promise.reject(new KinetexError("Concurrency acquire aborted", "EABORT"));
1058
+ }
1059
+ if (this.active < this.cfg.maxConcurrent) {
1060
+ this.active++;
1061
+ if (this.active > this.peak)
1062
+ this.peak = this.active;
1063
+ return Promise.resolve();
1064
+ }
1065
+ if (!this.cfg.queue) {
1066
+ return Promise.reject(new ConcurrencyLimitError(`Concurrency limit reached (${this.cfg.maxConcurrent} in flight) and queueing is disabled`));
1067
+ }
1068
+ if (this.waiters.length >= this.cfg.maxQueue) {
1069
+ return Promise.reject(new ConcurrencyLimitError(`Concurrency queue is full (${this.cfg.maxQueue} waiting)`));
1070
+ }
1071
+ return new Promise((resolve, reject) => {
1072
+ const waiter = { resolve, reject, onAbort: null, signal: null };
1073
+ if (signal) {
1074
+ if (signal.aborted) {
1075
+ // Redundant with the check at the top of `acquire`, and kept as
1076
+ // defence in depth: nothing awaits between the two, but an `abort`
1077
+ // event never fires on an already-aborted signal, so if this branch
1078
+ // were ever the only check the abort would be lost silently.
1079
+ reject(new KinetexError("Concurrency acquire aborted", "EABORT"));
1080
+ return;
1081
+ }
1082
+ waiter.onAbort = () => {
1083
+ const idx = this.waiters.indexOf(waiter);
1084
+ if (idx !== -1)
1085
+ this.waiters.splice(idx, 1);
1086
+ reject(new KinetexError("Concurrency acquire aborted", "EABORT"));
1087
+ };
1088
+ waiter.signal = signal;
1089
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
1090
+ }
1091
+ this.waiters.push(waiter);
1092
+ });
1093
+ }
1094
+ /**
1095
+ * Return a permit taken by {@link acquire}.
1096
+ *
1097
+ * The permit is handed to the longest-waiting caller, so `inFlight` never
1098
+ * exceeds `maxConcurrent`. Releasing with nothing held is a no-op.
1099
+ */
1100
+ release() {
1101
+ if (this.active === 0)
1102
+ return;
1103
+ const next = this.waiters.shift();
1104
+ if (!next) {
1105
+ this.active--;
1106
+ return;
1107
+ }
1108
+ // Transfer the permit: `active` is intentionally left unchanged.
1109
+ detachAbortListener(next);
1110
+ next.resolve();
1111
+ }
1112
+ /**
1113
+ * Drop every queued waiter, rejecting them with `reason`.
1114
+ *
1115
+ * Used by {@link Kinetex.destroy} so a shutdown cannot leave callers parked
1116
+ * on a queue that will never drain.
1117
+ *
1118
+ * @param reason - Error to reject waiters with.
1119
+ */
1120
+ drain(reason = new ConcurrencyLimitError("Client destroyed")) {
1121
+ const pending = this.waiters.splice(0, this.waiters.length);
1122
+ for (const w of pending) {
1123
+ detachAbortListener(w);
1124
+ w.reject(reason);
1125
+ }
1126
+ }
1127
+ }
922
1128
  /**
923
1129
  * Create a HAR (HTTP Archive) recording interceptor.
924
1130
  * Captures request/response pairs for export as a HAR log.
@@ -950,7 +1156,11 @@ export function createHARInterceptor() {
950
1156
  ctx.response.headers["Content-Type"] ??
951
1157
  "application/octet-stream";
952
1158
  const body = ctx.response.body;
953
- const bSize = body instanceof Uint8Array ? body.byteLength : typeof body === "string" ? body.length : 0;
1159
+ // Byte length, not `.length`: a non-ASCII response body was under-reported in
1160
+ // both `content.size` and `bodySize` by one byte per accented character and by
1161
+ // three bytes per astral character. `computeBodySize` returns -1 for a body it
1162
+ // cannot size, which HAR reports as 0 here (unchanged for those types).
1163
+ const bSize = Math.max(0, computeBodySize(body));
954
1164
  entries.push({
955
1165
  startedDateTime: new Date(Date.now() - total).toISOString(),
956
1166
  time: total,
@@ -1080,8 +1290,12 @@ function sleep(ms) {
1080
1290
  export function computeBodySize(body) {
1081
1291
  if (!body)
1082
1292
  return 0;
1083
- if (typeof body === "string")
1084
- return body.length;
1293
+ if (typeof body === "string") {
1294
+ // UTF-8 byte length, not `.length` (UTF-16 code units). HAR `bodySize` and the
1295
+ // metrics byte counters are byte counts, and a non-ASCII body was under-reported
1296
+ // by 2 bytes per astral character and 1 per Latin-1 accented character.
1297
+ return new TextEncoder().encode(body).length;
1298
+ }
1085
1299
  if (body instanceof Uint8Array)
1086
1300
  return body.byteLength;
1087
1301
  if (body instanceof ArrayBuffer)
@@ -30,7 +30,6 @@
30
30
  * Use when you just need pub/sub notification.
31
31
  */
32
32
  export class HookEmitter {
33
- // deno-lint-ignore ban-types
34
33
  listeners = new Map();
35
34
  /** Register a persistent event listener. */
36
35
  on(event, listener) {
@@ -59,9 +58,19 @@ export class HookEmitter {
59
58
  const list = this.listeners.get(event);
60
59
  if (!list || list.length === 0)
61
60
  return;
62
- const toRemove = new Set();
63
- for (let i = 0; i < list.length; i++) {
64
- const listener = list[i];
61
+ // Iterate a snapshot, and remove `once` listeners by identity rather than
62
+ // by index into the live array. Two things went wrong without that. A
63
+ // listener that registered another listener mid-emit had the new one
64
+ // called by the *same* emit, because the loop walks the live array and
65
+ // re-checks its length. And a listener that called `off()` mid-emit had
66
+ // its removal undone: the trailing write-back rebuilt the list from the
67
+ // snapshot taken before the emit, re-adding whatever it had just removed.
68
+ const snapshot = [...list];
69
+ // The same element type as `list`, inferred rather than restated: the
70
+ // bare `Function` type this used to name provides no type safety at all,
71
+ // since it is every function and every class.
72
+ const invokedOnce = new Set();
73
+ for (const listener of snapshot) {
65
74
  try {
66
75
  await listener.fn(data);
67
76
  }
@@ -69,11 +78,11 @@ export class HookEmitter {
69
78
  /* isolate listener errors */
70
79
  }
71
80
  if (listener.once)
72
- toRemove.add(i);
81
+ invokedOnce.add(listener);
73
82
  }
74
- if (toRemove.size > 0) {
75
- const remaining = list.filter((_, i) => !toRemove.has(i));
76
- this.listeners.set(event, remaining);
83
+ if (invokedOnce.size > 0) {
84
+ const current = this.listeners.get(event) ?? [];
85
+ this.listeners.set(event, current.filter((l) => !invokedOnce.has(l)));
77
86
  }
78
87
  }
79
88
  /** Remove all listeners for an event (or all events if omitted). */
@@ -324,17 +333,25 @@ export class HookRegistry {
324
333
  * If any hook returns a HookResponse, it is treated as recovery and returned.
325
334
  */
326
335
  async runOnError(err, ctx) {
336
+ let recovered = null;
327
337
  for (const hook of sortHooks(this.onError)) {
328
338
  if (!this._shouldRun(hook, ctx))
329
339
  continue;
330
340
  const result = await this._safeRun(hook, () => hook.fn(err, ctx));
331
341
  this._maybeEject(hook, this.onError);
342
+ // First recovery wins; later hooks are not consulted.
332
343
  if (result && typeof result === "object" && "status" in result) {
333
- return result;
344
+ recovered = result;
345
+ break;
334
346
  }
335
347
  }
348
+ // The emitter is the notification channel, not the recovery channel, so it
349
+ // fires on every error. This line used to sit after the loop behind a plain
350
+ // `return`, so a recovered error — the one an operator most wants to hear
351
+ // about, since the caller never sees it — was the only kind an
352
+ // `emitter.on("error", ...)` subscriber was never told about.
336
353
  await this.emitter.emit("error", err);
337
- return null;
354
+ return recovered;
338
355
  }
339
356
  /** Execute all on-retry hooks. */
340
357
  async runOnRetry(evt, ctx) {
@@ -370,12 +387,7 @@ export class HookRegistry {
370
387
  for (const hook of sortHooks(this.onUploadProgress)) {
371
388
  if (!this._shouldRun(hook, ctx))
372
389
  continue;
373
- try {
374
- hook.fn(evt, ctx);
375
- }
376
- catch {
377
- /* isolate */
378
- }
390
+ this._safeRunSync(hook, () => hook.fn(evt, ctx));
379
391
  this._maybeEject(hook, this.onUploadProgress);
380
392
  }
381
393
  this.emitter.emit("upload:progress", evt);
@@ -385,12 +397,7 @@ export class HookRegistry {
385
397
  for (const hook of sortHooks(this.onDownloadProgress)) {
386
398
  if (!this._shouldRun(hook, ctx))
387
399
  continue;
388
- try {
389
- hook.fn(evt, ctx);
390
- }
391
- catch {
392
- /* isolate */
393
- }
400
+ this._safeRunSync(hook, () => hook.fn(evt, ctx));
394
401
  this._maybeEject(hook, this.onDownloadProgress);
395
402
  }
396
403
  this.emitter.emit("download:progress", evt);
@@ -398,25 +405,26 @@ export class HookRegistry {
398
405
  /** Execute all on-cancel hooks. */
399
406
  runOnCancel(evt, ctx) {
400
407
  for (const hook of sortHooks(this.onCancel)) {
401
- try {
402
- hook.fn(evt, ctx);
403
- }
404
- catch {
405
- /* isolate */
406
- }
408
+ if (!this._shouldRun(hook, ctx))
409
+ continue;
410
+ this._safeRunSync(hook, () => hook.fn(evt, ctx));
407
411
  this._maybeEject(hook, this.onCancel);
408
412
  }
409
413
  this.emitter.emit("cancel", evt);
410
414
  }
411
- /** Execute all on-connection hooks. */
412
- runOnConnection(evt) {
415
+ /**
416
+ * Execute all on-connection hooks.
417
+ *
418
+ * `ctx` is required rather than optional: a hook registered with a
419
+ * `condition` can only be evaluated against a context, and making the
420
+ * parameter optional left the condition silently unevaluated for every caller
421
+ * that omitted it — which is a wrong answer rather than a compile error.
422
+ */
423
+ runOnConnection(evt, ctx) {
413
424
  for (const hook of sortHooks(this.onConnection)) {
414
- try {
415
- hook.fn(evt);
416
- }
417
- catch {
418
- /* isolate */
419
- }
425
+ if (!this._shouldRun(hook, ctx))
426
+ continue;
427
+ this._safeRunSync(hook, () => hook.fn(evt));
420
428
  this._maybeEject(hook, this.onConnection);
421
429
  }
422
430
  this.emitter.emit("connection", evt);
@@ -433,8 +441,19 @@ export class HookRegistry {
433
441
  fn = () => {
434
442
  if (!this._shouldRun(hook, ctx))
435
443
  return next();
436
- const result = hook.fn(ctx, next);
444
+ // `safe` is honoured here too. It used to be ignored, and ignored in
445
+ // the *other* direction: a `safe: true` around hook that threw took the
446
+ // whole request down. On failure the dispatch is still run, because an
447
+ // around hook that throws before calling `next` has no response to
448
+ // return and the pipeline cannot continue without one.
449
+ let failed = false;
450
+ const result = this._safeRunSync(hook, () => {
451
+ failed = true;
452
+ return hook.fn(ctx, next);
453
+ });
437
454
  this._maybeEject(hook, this.aroundHooks);
455
+ if (failed)
456
+ return next();
438
457
  return result;
439
458
  };
440
459
  }
@@ -468,6 +487,26 @@ export class HookRegistry {
468
487
  return undefined;
469
488
  }
470
489
  }
490
+ /**
491
+ * Synchronous counterpart of {@link _safeRun}, used by the phases whose hook
492
+ * signature is synchronous (progress, cancel, connection) and by around hooks.
493
+ *
494
+ * These phases used a bare `try {} catch {}`, which ignored `safe` in both
495
+ * directions: `safe: false` — the documented default, "critical hooks that
496
+ * must propagate errors" — was swallowed, and `safe: true` was the only thing
497
+ * that produced the log line the other phases write.
498
+ */
499
+ _safeRunSync(hook, fn) {
500
+ try {
501
+ return fn();
502
+ }
503
+ catch (err) {
504
+ if (!hook.safe)
505
+ throw err;
506
+ console.error(`[lifecycle] Hook "${hook.id}" threw:`, err);
507
+ return undefined;
508
+ }
509
+ }
471
510
  _maybeEject(hook, list) {
472
511
  if (!hook.once)
473
512
  return;
@@ -597,8 +636,14 @@ export function withBaseURL(base) {
597
636
  return (req) => {
598
637
  if (/^https?:\/\//i.test(req.url))
599
638
  return;
600
- const slash = base.endsWith("/") || req.url.startsWith("/") ? "" : "/";
601
- return { ...req, url: `${base}${slash}${req.url}` };
639
+ // Join with exactly one slash. The old test asked "does either side already
640
+ // carry a slash, in which case add none" — which is wrong precisely when
641
+ // *both* do: `baseURL: "https://api.test/"` (the most common spelling) with
642
+ // the path `"/users"` produced "https://api.test//users". Trimming both sides
643
+ // and adding one slash is the only rule that is right in all four cases.
644
+ const baseTrimmed = base.replace(/\/+$/, "");
645
+ const pathTrimmed = req.url.replace(/^\/+/, "");
646
+ return { ...req, url: `${baseTrimmed}/${pathTrimmed}` };
602
647
  };
603
648
  }
604
649
  /**
@@ -761,7 +806,11 @@ export function composeAround(...hooks) {
761
806
  */
762
807
  export function createLoggingHooks(options = {}) {
763
808
  const log = options.logger ?? ((msg, data) => console.log(msg, JSON.stringify(data)));
764
- const redact = new Set((options.redactHeaders ?? ["authorization", "cookie"]).map((h) => h.toLowerCase()));
809
+ // The default list was `authorization` and `cookie` only, while
810
+ // `afterResponse` logs *response* headers — so `Set-Cookie` was written in
811
+ // cleartext by default, on a hook whose entire purpose is to log. The
812
+ // interceptors' own logging defaults already redact all four.
813
+ const redact = new Set((options.redactHeaders ?? ["authorization", "cookie", "set-cookie", "proxy-authorization"]).map((h) => h.toLowerCase()));
765
814
  function safeHeaders(h) {
766
815
  const out = {};
767
816
  for (const [k, v] of Object.entries(h))