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
@@ -27,6 +27,7 @@
27
27
  * - Metrics interceptor (timing, status buckets, error rates)
28
28
  * - No dependencies, no runtime globals beyond Promise/Map/Set
29
29
  */
30
+ import { getAuthFingerprint } from "./cache.js";
30
31
  // ============================================================================
31
32
  // §3 INTERCEPTOR MANAGER
32
33
  // ============================================================================
@@ -34,8 +35,18 @@ let _idSeq = 0;
34
35
  function nextId() {
35
36
  return `interceptor_${++_idSeq}`;
36
37
  }
37
- function sortByPriority(arr) {
38
- return [...arr].sort((a, b) => a.priority - b.priority);
38
+ /**
39
+ * Insert keeping ascending priority order. Sorting at registration (once)
40
+ * instead of on every request avoids copying the whole array three times per
41
+ * request, and Array#sort is stable so equal priorities keep registration
42
+ * order — the same result the old per-request sort produced.
43
+ */
44
+ function insertByPriority(arr, entry) {
45
+ const at = arr.findIndex((x) => x.priority > entry.priority);
46
+ if (at === -1)
47
+ arr.push(entry);
48
+ else
49
+ arr.splice(at, 0, entry);
39
50
  }
40
51
  /**
41
52
  * Manages registration, ejection, and pipeline execution of interceptors.
@@ -57,7 +68,7 @@ export class InterceptorManager {
57
68
  */
58
69
  useRequest(fn, opts = {}) {
59
70
  const id = opts.id ?? nextId();
60
- this.requestInterceptors.push({
71
+ insertByPriority(this.requestInterceptors, {
61
72
  id,
62
73
  priority: opts.priority ?? 0,
63
74
  once: opts.once ?? false,
@@ -75,7 +86,7 @@ export class InterceptorManager {
75
86
  */
76
87
  useResponse(fn, opts = {}) {
77
88
  const id = opts.id ?? nextId();
78
- this.responseInterceptors.push({
89
+ insertByPriority(this.responseInterceptors, {
79
90
  id,
80
91
  priority: opts.priority ?? 0,
81
92
  once: opts.once ?? false,
@@ -93,7 +104,7 @@ export class InterceptorManager {
93
104
  */
94
105
  useError(fn, opts = {}) {
95
106
  const id = opts.id ?? nextId();
96
- this.errorInterceptors.push({
107
+ insertByPriority(this.errorInterceptors, {
97
108
  id,
98
109
  priority: opts.priority ?? 0,
99
110
  once: opts.once ?? false,
@@ -178,7 +189,7 @@ export class InterceptorManager {
178
189
  // Collect IDs to eject after iteration (avoids modifying array during for-of)
179
190
  const toEject = new Set();
180
191
  // ── Request phase ──────────────────────────────────────────────────────
181
- for (const interceptor of sortByPriority(this.requestInterceptors)) {
192
+ for (const interceptor of this.requestInterceptors) {
182
193
  if (ctx.aborted)
183
194
  break;
184
195
  if (interceptor.condition && !interceptor.condition(ctx))
@@ -225,7 +236,7 @@ export class InterceptorManager {
225
236
  async _runResponsePhase(ctx, dispatcher) {
226
237
  // Collect IDs to eject after iteration
227
238
  const toEject = new Set();
228
- for (const interceptor of sortByPriority(this.responseInterceptors)) {
239
+ for (const interceptor of this.responseInterceptors) {
229
240
  if (ctx.aborted)
230
241
  break;
231
242
  if (interceptor.condition && !interceptor.condition(ctx))
@@ -268,7 +279,7 @@ export class InterceptorManager {
268
279
  async _runErrorPhase(ctx, dispatcher) {
269
280
  // Collect IDs to eject after iteration
270
281
  const toEject = new Set();
271
- for (const interceptor of sortByPriority(this.errorInterceptors)) {
282
+ for (const interceptor of this.errorInterceptors) {
272
283
  if (interceptor.condition && !interceptor.condition(ctx))
273
284
  continue;
274
285
  let result;
@@ -513,33 +524,43 @@ export function createTimeoutInterceptor(config = {}) {
513
524
  ctx.aborted = true;
514
525
  controller.abort(new TimeoutError(cfg.message, ms));
515
526
  }, ms);
516
- // Merge with any existing signal
527
+ // Merge with any existing signal. The listener is removed in the
528
+ // response/error cleanup below so a shared external signal does not
529
+ // accumulate one closure per request (leak fix).
517
530
  const existing = ctx.request.signal;
518
531
  if (existing) {
519
- existing.addEventListener("abort", () => {
532
+ const onExistingAbort = () => {
520
533
  clearTimeout(timer);
521
534
  controller.abort(existing.reason);
522
- }, { once: true });
535
+ };
536
+ existing.addEventListener("abort", onExistingAbort, { once: true });
537
+ ctx.store.set(TIMEOUT_SIGNAL_KEY, { existing, onExistingAbort });
523
538
  }
524
539
  ctx.request = { ...ctx.request, signal: controller.signal };
525
540
  // Store cleanup ref in context
526
541
  ctx.store.set(TIMEOUT_TIMER_KEY, timer);
527
542
  };
528
- // Response interceptor cleans up the timer after request completes
529
- const responseInterceptor = (ctx) => {
543
+ /** Shared cleanup for the response and error phases. */
544
+ const cleanupTimeout = (ctx) => {
530
545
  const timer = ctx.store.get(TIMEOUT_TIMER_KEY);
531
546
  if (timer) {
532
547
  clearTimeout(timer);
533
548
  ctx.store.delete(TIMEOUT_TIMER_KEY);
534
549
  }
550
+ const merged = ctx.store.get(TIMEOUT_SIGNAL_KEY);
551
+ if (merged) {
552
+ merged.existing.removeEventListener("abort", merged.onExistingAbort);
553
+ ctx.store.delete(TIMEOUT_SIGNAL_KEY);
554
+ }
555
+ };
556
+ // Response interceptor cleans up the timer and merged-signal listener after
557
+ // the request completes
558
+ const responseInterceptor = (ctx) => {
559
+ cleanupTimeout(ctx);
535
560
  };
536
- // Error interceptor also cleans up the timer
561
+ // Error interceptor also cleans up the timer and merged-signal listener
537
562
  const errorInterceptor = (ctx) => {
538
- const timer = ctx.store.get(TIMEOUT_TIMER_KEY);
539
- if (timer) {
540
- clearTimeout(timer);
541
- ctx.store.delete(TIMEOUT_TIMER_KEY);
542
- }
563
+ cleanupTimeout(ctx);
543
564
  };
544
565
  return {
545
566
  id: "timeout",
@@ -549,6 +570,7 @@ export function createTimeoutInterceptor(config = {}) {
549
570
  };
550
571
  }
551
572
  const TIMEOUT_TIMER_KEY = Symbol("timeoutTimer");
573
+ const TIMEOUT_SIGNAL_KEY = Symbol("timeoutSignal");
552
574
  /** Error thrown when a request exceeds the configured timeout. */
553
575
  export class TimeoutError extends Error {
554
576
  timeoutMs;
@@ -747,13 +769,22 @@ const CACHE_STALE_KEY = Symbol("cacheStale");
747
769
  */
748
770
  export function createDedupeInterceptor() {
749
771
  const inflight = new Map();
750
- function key(req) {
751
- return `${req.method.toUpperCase()}:${req.url}`;
772
+ /**
773
+ * Dedupe key. SECURITY: the auth fingerprint is part of the key. Without it,
774
+ * two callers with different Authorization / Cookie / API-key headers for the
775
+ * same URL were coalesced and one user received the other user's response.
776
+ */
777
+ async function key(req) {
778
+ const authFp = await getAuthFingerprint((req.headers ?? {}));
779
+ return `${req.method.toUpperCase()}:${req.url}${authFp ? ":" + authFp : ""}`;
752
780
  }
753
- const requestInterceptor = (ctx) => {
781
+ const requestInterceptor = async (ctx) => {
754
782
  if (ctx.request.method.toUpperCase() !== "GET" && ctx.request.method.toUpperCase() !== "HEAD")
755
783
  return;
756
- const k = key(ctx.request);
784
+ const k = await key(ctx.request);
785
+ // Stash the key on the context so the response/error phases can find this
786
+ // request' slot without re-deriving it.
787
+ ctx.store.set(DEDUPE_KEY, k);
757
788
  const slot = inflight.get(k);
758
789
  if (!slot) {
759
790
  // First request for this key — create in-flight entry
@@ -763,39 +794,61 @@ export function createDedupeInterceptor() {
763
794
  });
764
795
  return;
765
796
  }
766
- // A request is already in flight — queue up
767
- ctx.store.set(DEDUPE_QUEUED_KEY, k);
768
- // Return a Promise that resolves to InterceptorResponse (which is a valid RequestInterceptorResult)
797
+ // A request is already in flight — queue up. A queued caller must never be
798
+ // left hanging if its own signal aborts while it waits for the leader.
769
799
  return new Promise((resolve, reject) => {
770
- slot.waiters.push({
800
+ const signal = ctx.request.signal;
801
+ const waiter = {
771
802
  resolve: (res) => resolve(res),
772
803
  reject: (err) => reject(err), // Properly reject instead of throwing
773
- });
804
+ cleanup: () => signal?.removeEventListener("abort", onQueuedAbort),
805
+ };
806
+ const onQueuedAbort = () => {
807
+ const at = slot.waiters.indexOf(waiter);
808
+ if (at !== -1)
809
+ slot.waiters.splice(at, 1);
810
+ reject(new Error("Request aborted while queued for deduplication"));
811
+ };
812
+ if (signal?.aborted) {
813
+ onQueuedAbort();
814
+ return;
815
+ }
816
+ signal?.addEventListener("abort", onQueuedAbort, { once: true });
817
+ slot.waiters.push(waiter);
774
818
  });
775
819
  };
776
820
  const responseInterceptor = (ctx) => {
777
821
  if (!ctx.response)
778
822
  return;
779
- const k = key(ctx.request);
823
+ const k = ctx.store.get(DEDUPE_KEY);
824
+ if (!k)
825
+ return;
780
826
  const slot = inflight.get(k);
781
827
  if (!slot)
782
828
  return;
783
829
  inflight.delete(k);
784
- for (const w of slot.waiters)
830
+ for (const w of slot.waiters.splice(0)) {
831
+ w.cleanup();
785
832
  w.resolve(ctx.response);
833
+ }
786
834
  };
787
835
  const errorInterceptor = (ctx) => {
788
- const k = key(ctx.request);
836
+ const k = ctx.store.get(DEDUPE_KEY);
837
+ if (!k)
838
+ return;
789
839
  const slot = inflight.get(k);
790
840
  if (!slot)
791
841
  return;
792
842
  inflight.delete(k);
793
- for (const w of slot.waiters)
843
+ for (const w of slot.waiters.splice(0)) {
844
+ w.cleanup();
794
845
  w.reject(ctx.error);
846
+ }
795
847
  };
796
848
  return { requestInterceptor, responseInterceptor, errorInterceptor };
797
849
  }
798
- const DEDUPE_QUEUED_KEY = Symbol("dedupeQueued");
850
+ /** ctx.store key holding this request's dedupe key. */
851
+ const DEDUPE_KEY = Symbol("dedupeKey");
799
852
  const RATE_LIMIT_DEFAULTS = {
800
853
  limit: 60,
801
854
  windowMs: 60_000,
@@ -723,7 +723,7 @@ export class HTTPLogger {
723
723
  * ```ts
724
724
  * const logger = createProductionLogger();
725
725
  * const child = logger.child({ requestId: "req-123", userId: "user-456" });
726
- * child.logRequest(...); // Logs will include both requestId and userId
726
+ * child.logRequest("req-123", "GET", "/users", {}, null, 1); // Logs include both requestId and userId
727
727
  * ```
728
728
  */
729
729
  child(context) {
@@ -478,12 +478,20 @@ function sleep(ms, signal) {
478
478
  r();
479
479
  return;
480
480
  }
481
- const timer = setTimeout(r, ms);
482
- if (signal)
483
- signal.addEventListener("abort", () => {
484
- clearTimeout(timer);
485
- r();
486
- }, { once: true });
481
+ // The listener is removed on the timer path too. With `{ once: true }` it
482
+ // only self-removes when it FIRES, so a long-lived signal shared by many
483
+ // paginated requests accumulated one dead listener per completed sleep
484
+ // (MaxListeners warning + retained closures).
485
+ const onAbort = () => {
486
+ clearTimeout(timer);
487
+ signal?.removeEventListener("abort", onAbort);
488
+ r();
489
+ };
490
+ const timer = setTimeout(() => {
491
+ signal?.removeEventListener("abort", onAbort);
492
+ r();
493
+ }, ms);
494
+ signal?.addEventListener("abort", onAbort, { once: true });
487
495
  });
488
496
  }
489
497
  /**
@@ -178,36 +178,66 @@ export function withUploadProgress(body, total, options = {}) {
178
178
  },
179
179
  });
180
180
  }
181
+ // PULL-BASED, not start()-based. The old implementation drained the whole
182
+ // source inside start(), enqueueing every chunk before the transport read a
183
+ // single byte — a large upload was fully buffered in memory and the
184
+ // ReadableStream highWaterMark bought nothing. pull() reads one chunk per
185
+ // demand, so backpressure reaches the source.
186
+ let reader = null;
187
+ let onUploadAbort = null;
188
+ const detach = () => {
189
+ if (onUploadAbort) {
190
+ options.signal?.removeEventListener("abort", onUploadAbort);
191
+ onUploadAbort = null;
192
+ }
193
+ };
181
194
  const stream = new ReadableStream({
182
- async start(controller) {
183
- const reader = source.getReader();
195
+ start(controller) {
184
196
  // Handle already-aborted signal immediately
185
197
  if (options.signal?.aborted) {
186
- reader.cancel("aborted").catch(() => { });
187
198
  controller.error(new DOMException("Upload aborted", "AbortError"));
188
199
  return;
189
200
  }
190
- options.signal?.addEventListener("abort", () => {
191
- reader.cancel("aborted").catch(() => { });
201
+ reader = source.getReader();
202
+ onUploadAbort = () => {
203
+ const r = reader;
204
+ reader = null;
205
+ void r?.cancel("aborted").catch(() => { });
206
+ tracker.complete();
192
207
  controller.error(new DOMException("Upload aborted", "AbortError"));
193
- }, { once: true });
208
+ };
209
+ // Removed in detach() on close/cancel/error so repeated uploads sharing
210
+ // one signal do not accumulate listeners (leak fix).
211
+ options.signal?.addEventListener("abort", onUploadAbort, { once: true });
212
+ },
213
+ async pull(controller) {
214
+ if (!reader)
215
+ return;
194
216
  try {
195
- while (true) {
196
- const { done, value } = await reader.read();
197
- if (done) {
198
- tracker.complete();
199
- controller.close();
200
- break;
201
- }
202
- tracker.update(value.byteLength);
203
- controller.enqueue(value);
217
+ const { done, value } = await reader.read();
218
+ if (done) {
219
+ detach();
220
+ reader.releaseLock();
221
+ reader = null;
222
+ tracker.complete();
223
+ controller.close();
224
+ return;
204
225
  }
226
+ tracker.update(value.byteLength);
227
+ controller.enqueue(value);
205
228
  }
206
229
  catch (err) {
230
+ detach();
207
231
  tracker.complete();
208
232
  controller.error(err);
209
233
  }
210
234
  },
235
+ cancel(reason) {
236
+ detach();
237
+ const r = reader;
238
+ reader = null;
239
+ void r?.cancel(reason).catch(() => { });
240
+ },
211
241
  });
212
242
  return { stream, tracker };
213
243
  }
@@ -240,44 +270,67 @@ export function withDownloadProgress(response, options = {}) {
240
270
  return { response, tracker };
241
271
  }
242
272
  const body = response.body;
273
+ // PULL-BASED for the same reason as withUploadProgress: the start()-based
274
+ // version buffered the entire download in memory before the consumer read it.
275
+ let reader = null;
276
+ let aborted = false;
277
+ let onAbort = null;
278
+ const detach = () => {
279
+ if (onAbort) {
280
+ options.signal?.removeEventListener("abort", onAbort);
281
+ onAbort = null;
282
+ }
283
+ };
243
284
  const trackedStream = new ReadableStream({
244
- async start(controller) {
245
- const reader = body.getReader();
246
- let aborted = false;
247
- const onAbort = () => {
285
+ start(controller) {
286
+ if (options.signal?.aborted) {
287
+ aborted = true;
288
+ controller.error(new DOMException("Download aborted", "AbortError"));
289
+ return;
290
+ }
291
+ reader = body.getReader();
292
+ onAbort = () => {
248
293
  if (aborted)
249
294
  return;
250
295
  aborted = true;
296
+ const r = reader;
297
+ reader = null;
251
298
  // Ensure tracker knows we're aborting (don't wait for complete)
252
- reader.cancel("aborted").catch(() => { });
299
+ void r?.cancel("aborted").catch(() => { });
253
300
  controller.error(new DOMException("Download aborted", "AbortError"));
254
301
  };
255
302
  options.signal?.addEventListener("abort", onAbort, { once: true });
303
+ },
304
+ async pull(controller) {
305
+ if (!reader)
306
+ return;
256
307
  try {
257
- while (true) {
258
- const { done, value } = await reader.read();
259
- if (done) {
260
- tracker.complete();
261
- controller.close();
262
- break;
263
- }
264
- tracker.update(value.byteLength);
265
- controller.enqueue(value);
308
+ const { done, value } = await reader.read();
309
+ if (done) {
310
+ detach();
311
+ reader.releaseLock();
312
+ reader = null;
313
+ tracker.complete();
314
+ controller.close();
315
+ return;
266
316
  }
317
+ tracker.update(value.byteLength);
318
+ controller.enqueue(value);
267
319
  }
268
320
  catch (err) {
321
+ detach();
269
322
  // Ensure tracker is marked as complete even on error
270
323
  if (!aborted) {
271
324
  tracker.complete();
272
325
  }
273
326
  controller.error(err);
274
327
  }
275
- finally {
276
- // Remove abort listener if not already triggered
277
- if (!aborted) {
278
- options.signal?.removeEventListener("abort", onAbort);
279
- }
280
- }
328
+ },
329
+ cancel(reason) {
330
+ detach();
331
+ const r = reader;
332
+ reader = null;
333
+ void r?.cancel(reason).catch(() => { });
281
334
  },
282
335
  });
283
336
  const trackedResponse = new Response(trackedStream, {
@@ -285,6 +338,21 @@ export function withDownloadProgress(response, options = {}) {
285
338
  statusText: response.statusText,
286
339
  headers: response.headers,
287
340
  });
341
+ // A reconstructed Response reports url === "" and redirected === false, which
342
+ // silently broke anything downstream that reads them. `url` and `redirected`
343
+ // are prototype getters, so shadow them with own properties on the instance.
344
+ for (const prop of ["url", "redirected", "type"]) {
345
+ try {
346
+ Object.defineProperty(trackedResponse, prop, {
347
+ value: response[prop],
348
+ enumerable: true,
349
+ configurable: true,
350
+ });
351
+ }
352
+ catch {
353
+ /* non-configurable in this runtime — leave the default in place */
354
+ }
355
+ }
288
356
  return { response: trackedResponse, tracker };
289
357
  }
290
358
  // ============================================================================
@@ -342,11 +410,11 @@ export async function collectStream(stream, total, options = {}) {
342
410
  reader.cancel("aborted").catch(() => { });
343
411
  throw new DOMException("Stream aborted", "AbortError");
344
412
  }
345
- // Register abort handler
413
+ // Register abort handler (removed in the finally below — leak fix)
414
+ const abortHandler = () => {
415
+ reader.cancel("aborted").catch(() => { });
416
+ };
346
417
  if (options.signal) {
347
- const abortHandler = () => {
348
- reader.cancel("aborted").catch(() => { });
349
- };
350
418
  options.signal.addEventListener("abort", abortHandler, { once: true });
351
419
  }
352
420
  try {
@@ -360,6 +428,7 @@ export async function collectStream(stream, total, options = {}) {
360
428
  }
361
429
  finally {
362
430
  reader.releaseLock();
431
+ options.signal?.removeEventListener("abort", abortHandler);
363
432
  }
364
433
  tracker.complete();
365
434
  // Concatenate chunks
@@ -546,12 +615,23 @@ export function xhrFetch(url, options) {
546
615
  downloadTracker.update(e.loaded - downloadTracker.bytesLoaded);
547
616
  });
548
617
  }
549
- // Abort signal
550
- options.signal?.addEventListener("abort", () => {
618
+ // Abort signal. The listener is detached on settle: with `{ once: true }`
619
+ // it only self-removes when it actually fires, so a caller reusing one
620
+ // signal across many xhrFetch calls accumulated listeners — and each one
621
+ // kept the whole XHR + promise closures alive after the request finished.
622
+ const onAbort = () => {
551
623
  xhr.abort();
552
624
  reject(new DOMException("Request aborted", "AbortError"));
553
- }, { once: true });
625
+ };
626
+ const detach = () => options.signal?.removeEventListener("abort", onAbort);
627
+ options.signal?.addEventListener("abort", onAbort, { once: true });
628
+ if (options.signal?.aborted) {
629
+ detach();
630
+ onAbort();
631
+ return;
632
+ }
554
633
  xhr.addEventListener("load", () => {
634
+ detach();
555
635
  downloadTracker.complete();
556
636
  // Parse response headers
557
637
  const rawHeaders = xhr.getAllResponseHeaders();
@@ -570,11 +650,18 @@ export function xhrFetch(url, options) {
570
650
  });
571
651
  });
572
652
  xhr.addEventListener("error", () => {
653
+ detach();
573
654
  reject(new TypeError("Network request failed"));
574
655
  });
575
656
  xhr.addEventListener("timeout", () => {
657
+ detach();
576
658
  reject(new TypeError("Request timed out"));
577
659
  });
660
+ xhr.addEventListener("abort", () => {
661
+ // Covers aborts initiated from inside the XHR (not via the signal).
662
+ detach();
663
+ reject(new DOMException("Request aborted", "AbortError"));
664
+ });
578
665
  xhr.send(options.body ?? null);
579
666
  });
580
667
  }
@@ -501,31 +501,34 @@ export const denoTcpConnector = async (host, port, timeoutMs) => {
501
501
  // Type assertion for Deno global which has the connect method
502
502
  // Must use type assertion as Deno namespace is not in standard TypeScript types
503
503
  const denoGlobal = globalThis;
504
- let conn;
505
- if (timeoutMs && timeoutMs > 0) {
506
- // Wrap connect in a timeout race
507
- conn = await Promise.race([
508
- denoGlobal.Deno.connect({ hostname: host, port, transport: "tcp" }),
509
- new Promise((_, rej) => setTimeout(() => rej(new Socks5Error("TCP connect to proxy timed out", "SOCKS5_TIMEOUT", true)), timeoutMs)),
510
- ]);
511
- }
512
- else {
513
- conn = await denoGlobal.Deno.connect({ hostname: host, port, transport: "tcp" });
514
- }
515
- const wrappedRead = async (buf) => {
516
- if (timeoutMs && timeoutMs > 0) {
517
- return Promise.race([
518
- conn.read(buf).catch(() => null),
519
- new Promise((_, rej) => setTimeout(() => rej(new Socks5Error("TCP read timed out", "SOCKS5_TIMEOUT", true)), timeoutMs)),
520
- ]);
521
- }
504
+ // NOTE: every timeout below is created and cleared explicitly. The previous
505
+ // Promise.race timers were never cleared, so each read left a pending timer
506
+ // (holding a closure, and keeping the Deno event loop alive) for the full
507
+ // timeout window — one per chunk on a streaming tunnel.
508
+ const withTimeout = async (work, message) => {
509
+ if (!timeoutMs || timeoutMs <= 0)
510
+ return work;
511
+ let timer;
522
512
  try {
523
- return await conn.read(buf);
513
+ return await Promise.race([
514
+ work,
515
+ new Promise((_, rej) => {
516
+ timer = setTimeout(() => rej(new Socks5Error(message, "SOCKS5_TIMEOUT", true)), timeoutMs);
517
+ }),
518
+ ]);
524
519
  }
525
- catch {
526
- return null;
520
+ finally {
521
+ if (timer !== undefined)
522
+ clearTimeout(timer);
527
523
  }
528
524
  };
525
+ const conn = await withTimeout(denoGlobal.Deno.connect({ hostname: host, port, transport: "tcp" }), "TCP connect to proxy timed out");
526
+ // Not `async`: it returns withTimeout()'s promise directly, so there is no
527
+ // await to make the function async.
528
+ const wrappedRead = (buf) => {
529
+ const read = conn.read(buf).catch(() => null);
530
+ return withTimeout(read, "TCP read timed out");
531
+ };
529
532
  return {
530
533
  read: wrappedRead,
531
534
  write: (data) => conn.write(data),
@@ -590,7 +593,12 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
590
593
  buffer = next;
591
594
  flushBuffer();
592
595
  });
596
+ // Latch EOF. Without this, a read issued AFTER `end` parked forever:
597
+ // the socket will never emit data again, nothing rejects, and the caller
598
+ // hangs with no error. Callers now get an immediate null (clean EOF).
599
+ let ended = false;
593
600
  socket.on("end", () => {
601
+ ended = true;
594
602
  if (pendingRead) {
595
603
  pendingRead(null);
596
604
  pendingRead = null;
@@ -598,6 +606,9 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
598
606
  pendingBuf = null;
599
607
  }
600
608
  });
609
+ socket.on("close", () => {
610
+ ended = true;
611
+ });
601
612
  socket.once("error", (err) => {
602
613
  clearTimeout(timer);
603
614
  lastError = err;
@@ -616,6 +627,10 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
616
627
  rej(lastError);
617
628
  return;
618
629
  }
630
+ if (ended && buffer.length === 0) {
631
+ res(null);
632
+ return;
633
+ }
619
634
  if (buffer.length > 0) {
620
635
  const n = Math.min(buffer.length, buf.length);
621
636
  buf.set(buffer.subarray(0, n));