kinetex 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +919 -445
- package/dist/browser/kinetex.esm.js +18 -18
- package/dist/browser/kinetex.js +749 -295
- package/dist/browser/kinetex.min.js +18 -18
- package/dist/cjs/aws-sigv4.js +4 -1
- package/dist/cjs/cache.js +52 -14
- package/dist/cjs/circuit-breaker.js +24 -4
- package/dist/cjs/client.js +495 -131
- package/dist/cjs/cookie-parser.js +7 -4
- package/dist/cjs/cookie-store.js +16 -8
- package/dist/cjs/core.js +83 -35
- package/dist/cjs/dedup.js +9 -7
- package/dist/cjs/digest.js +26 -0
- package/dist/cjs/graphql.js +19 -4
- package/dist/cjs/headers.js +64 -8
- package/dist/cjs/interceptors.js +86 -33
- package/dist/cjs/logging.js +1 -1
- package/dist/cjs/pagination.js +14 -6
- package/dist/cjs/progress.js +129 -42
- package/dist/cjs/socks5.js +36 -21
- package/dist/cjs/sse.js +48 -11
- package/dist/cjs/utils.js +24 -12
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +23 -7
- package/dist/esm/aws-sigv4.js +4 -1
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +52 -14
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +24 -4
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +495 -131
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +7 -4
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +16 -8
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +83 -35
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +9 -7
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +26 -0
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +19 -4
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +64 -8
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +86 -33
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/logging.js +1 -1
- package/dist/esm/pagination.js +14 -6
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +129 -42
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/socks5.js +36 -21
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +48 -11
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +24 -12
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +23 -7
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +8 -1
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +29 -12
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +10 -0
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +14 -0
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +25 -10
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/logging.d.ts +1 -1
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +25 -2
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts +4 -0
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/cjs/interceptors.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
532
|
+
const onExistingAbort = () => {
|
|
520
533
|
clearTimeout(timer);
|
|
521
534
|
controller.abort(existing.reason);
|
|
522
|
-
}
|
|
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
|
-
|
|
529
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/dist/cjs/logging.js
CHANGED
|
@@ -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(
|
|
726
|
+
* child.logRequest("req-123", "GET", "/users", {}, null, 1); // Logs include both requestId and userId
|
|
727
727
|
* ```
|
|
728
728
|
*/
|
|
729
729
|
child(context) {
|
package/dist/cjs/pagination.js
CHANGED
|
@@ -478,12 +478,20 @@ function sleep(ms, signal) {
|
|
|
478
478
|
r();
|
|
479
479
|
return;
|
|
480
480
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
/**
|
package/dist/cjs/progress.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
}
|
package/dist/cjs/socks5.js
CHANGED
|
@@ -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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
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));
|