kinetex 1.2.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.
- package/README.md +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +169 -16
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +222 -48
- 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 +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +14 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +98 -23
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- 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 +109 -25
- 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 +31 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +62 -29
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +23 -4
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +7 -1
- package/dist/types/response.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 +139 -5
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.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.map +1 -1
- package/package.json +2 -2
package/dist/cjs/interceptors.js
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
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";
|
|
31
|
+
import { KinetexError } from "./types.js";
|
|
32
|
+
import { parseHTTPDate } from "./headers.js";
|
|
30
33
|
// ============================================================================
|
|
31
34
|
// §3 INTERCEPTOR MANAGER
|
|
32
35
|
// ============================================================================
|
|
@@ -34,8 +37,18 @@ let _idSeq = 0;
|
|
|
34
37
|
function nextId() {
|
|
35
38
|
return `interceptor_${++_idSeq}`;
|
|
36
39
|
}
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Insert keeping ascending priority order. Sorting at registration (once)
|
|
42
|
+
* instead of on every request avoids copying the whole array three times per
|
|
43
|
+
* request, and Array#sort is stable so equal priorities keep registration
|
|
44
|
+
* order — the same result the old per-request sort produced.
|
|
45
|
+
*/
|
|
46
|
+
function insertByPriority(arr, entry) {
|
|
47
|
+
const at = arr.findIndex((x) => x.priority > entry.priority);
|
|
48
|
+
if (at === -1)
|
|
49
|
+
arr.push(entry);
|
|
50
|
+
else
|
|
51
|
+
arr.splice(at, 0, entry);
|
|
39
52
|
}
|
|
40
53
|
/**
|
|
41
54
|
* Manages registration, ejection, and pipeline execution of interceptors.
|
|
@@ -57,7 +70,7 @@ export class InterceptorManager {
|
|
|
57
70
|
*/
|
|
58
71
|
useRequest(fn, opts = {}) {
|
|
59
72
|
const id = opts.id ?? nextId();
|
|
60
|
-
this.requestInterceptors
|
|
73
|
+
insertByPriority(this.requestInterceptors, {
|
|
61
74
|
id,
|
|
62
75
|
priority: opts.priority ?? 0,
|
|
63
76
|
once: opts.once ?? false,
|
|
@@ -75,7 +88,7 @@ export class InterceptorManager {
|
|
|
75
88
|
*/
|
|
76
89
|
useResponse(fn, opts = {}) {
|
|
77
90
|
const id = opts.id ?? nextId();
|
|
78
|
-
this.responseInterceptors
|
|
91
|
+
insertByPriority(this.responseInterceptors, {
|
|
79
92
|
id,
|
|
80
93
|
priority: opts.priority ?? 0,
|
|
81
94
|
once: opts.once ?? false,
|
|
@@ -93,7 +106,7 @@ export class InterceptorManager {
|
|
|
93
106
|
*/
|
|
94
107
|
useError(fn, opts = {}) {
|
|
95
108
|
const id = opts.id ?? nextId();
|
|
96
|
-
this.errorInterceptors
|
|
109
|
+
insertByPriority(this.errorInterceptors, {
|
|
97
110
|
id,
|
|
98
111
|
priority: opts.priority ?? 0,
|
|
99
112
|
once: opts.once ?? false,
|
|
@@ -178,7 +191,7 @@ export class InterceptorManager {
|
|
|
178
191
|
// Collect IDs to eject after iteration (avoids modifying array during for-of)
|
|
179
192
|
const toEject = new Set();
|
|
180
193
|
// ── Request phase ──────────────────────────────────────────────────────
|
|
181
|
-
for (const interceptor of
|
|
194
|
+
for (const interceptor of this.requestInterceptors) {
|
|
182
195
|
if (ctx.aborted)
|
|
183
196
|
break;
|
|
184
197
|
if (interceptor.condition && !interceptor.condition(ctx))
|
|
@@ -225,7 +238,7 @@ export class InterceptorManager {
|
|
|
225
238
|
async _runResponsePhase(ctx, dispatcher) {
|
|
226
239
|
// Collect IDs to eject after iteration
|
|
227
240
|
const toEject = new Set();
|
|
228
|
-
for (const interceptor of
|
|
241
|
+
for (const interceptor of this.responseInterceptors) {
|
|
229
242
|
if (ctx.aborted)
|
|
230
243
|
break;
|
|
231
244
|
if (interceptor.condition && !interceptor.condition(ctx))
|
|
@@ -268,7 +281,7 @@ export class InterceptorManager {
|
|
|
268
281
|
async _runErrorPhase(ctx, dispatcher) {
|
|
269
282
|
// Collect IDs to eject after iteration
|
|
270
283
|
const toEject = new Set();
|
|
271
|
-
for (const interceptor of
|
|
284
|
+
for (const interceptor of this.errorInterceptors) {
|
|
272
285
|
if (interceptor.condition && !interceptor.condition(ctx))
|
|
273
286
|
continue;
|
|
274
287
|
let result;
|
|
@@ -346,7 +359,9 @@ export function createRetryInterceptor(config = {}) {
|
|
|
346
359
|
const exp = cfg.baseDelayMs * Math.pow(2, attempt - 1);
|
|
347
360
|
const capped = Math.min(exp, cfg.maxDelayMs);
|
|
348
361
|
const jitterMs = capped * cfg.jitter * Math.random();
|
|
349
|
-
|
|
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);
|
|
350
365
|
}
|
|
351
366
|
function shouldRetryCtx(ctx) {
|
|
352
367
|
if (ctx.attempt > cfg.maxRetries)
|
|
@@ -367,8 +382,12 @@ export function createRetryInterceptor(config = {}) {
|
|
|
367
382
|
return null;
|
|
368
383
|
if (/^\d+$/.test(ra.trim()))
|
|
369
384
|
return parseInt(ra, 10) * 1000;
|
|
370
|
-
|
|
371
|
-
|
|
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)
|
|
372
391
|
return Math.max(0, ms - Date.now());
|
|
373
392
|
return null;
|
|
374
393
|
}
|
|
@@ -607,6 +626,7 @@ export function createLoggingInterceptor(config = {}) {
|
|
|
607
626
|
durationMs: type !== "request" ? now() - ctx.startedAt : null,
|
|
608
627
|
attempt: ctx.attempt,
|
|
609
628
|
error: ctx.error instanceof Error ? ctx.error.message : null,
|
|
629
|
+
headers: redactHeaders(ctx.request.headers ?? {}),
|
|
610
630
|
};
|
|
611
631
|
}
|
|
612
632
|
const requestInterceptor = (ctx) => {
|
|
@@ -626,7 +646,6 @@ export function createLoggingInterceptor(config = {}) {
|
|
|
626
646
|
return;
|
|
627
647
|
cfg.logger(makeEntry("error", ctx));
|
|
628
648
|
};
|
|
629
|
-
void redactHeaders; // used externally; suppress unused warning
|
|
630
649
|
return { requestInterceptor, responseInterceptor, errorInterceptor };
|
|
631
650
|
}
|
|
632
651
|
const CACHE_DEFAULTS = {
|
|
@@ -758,13 +777,22 @@ const CACHE_STALE_KEY = Symbol("cacheStale");
|
|
|
758
777
|
*/
|
|
759
778
|
export function createDedupeInterceptor() {
|
|
760
779
|
const inflight = new Map();
|
|
761
|
-
|
|
762
|
-
|
|
780
|
+
/**
|
|
781
|
+
* Dedupe key. SECURITY: the auth fingerprint is part of the key. Without it,
|
|
782
|
+
* two callers with different Authorization / Cookie / API-key headers for the
|
|
783
|
+
* same URL were coalesced and one user received the other user's response.
|
|
784
|
+
*/
|
|
785
|
+
async function key(req) {
|
|
786
|
+
const authFp = await getAuthFingerprint((req.headers ?? {}));
|
|
787
|
+
return `${req.method.toUpperCase()}:${req.url}${authFp ? ":" + authFp : ""}`;
|
|
763
788
|
}
|
|
764
|
-
const requestInterceptor = (ctx) => {
|
|
789
|
+
const requestInterceptor = async (ctx) => {
|
|
765
790
|
if (ctx.request.method.toUpperCase() !== "GET" && ctx.request.method.toUpperCase() !== "HEAD")
|
|
766
791
|
return;
|
|
767
|
-
const k = key(ctx.request);
|
|
792
|
+
const k = await key(ctx.request);
|
|
793
|
+
// Stash the key on the context so the response/error phases can find this
|
|
794
|
+
// request' slot without re-deriving it.
|
|
795
|
+
ctx.store.set(DEDUPE_KEY, k);
|
|
768
796
|
const slot = inflight.get(k);
|
|
769
797
|
if (!slot) {
|
|
770
798
|
// First request for this key — create in-flight entry
|
|
@@ -774,39 +802,61 @@ export function createDedupeInterceptor() {
|
|
|
774
802
|
});
|
|
775
803
|
return;
|
|
776
804
|
}
|
|
777
|
-
// A request is already in flight — queue up
|
|
778
|
-
|
|
779
|
-
// Return a Promise that resolves to InterceptorResponse (which is a valid RequestInterceptorResult)
|
|
805
|
+
// A request is already in flight — queue up. A queued caller must never be
|
|
806
|
+
// left hanging if its own signal aborts while it waits for the leader.
|
|
780
807
|
return new Promise((resolve, reject) => {
|
|
781
|
-
|
|
808
|
+
const signal = ctx.request.signal;
|
|
809
|
+
const waiter = {
|
|
782
810
|
resolve: (res) => resolve(res),
|
|
783
811
|
reject: (err) => reject(err), // Properly reject instead of throwing
|
|
784
|
-
|
|
812
|
+
cleanup: () => signal?.removeEventListener("abort", onQueuedAbort),
|
|
813
|
+
};
|
|
814
|
+
const onQueuedAbort = () => {
|
|
815
|
+
const at = slot.waiters.indexOf(waiter);
|
|
816
|
+
if (at !== -1)
|
|
817
|
+
slot.waiters.splice(at, 1);
|
|
818
|
+
reject(new Error("Request aborted while queued for deduplication"));
|
|
819
|
+
};
|
|
820
|
+
if (signal?.aborted) {
|
|
821
|
+
onQueuedAbort();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
signal?.addEventListener("abort", onQueuedAbort, { once: true });
|
|
825
|
+
slot.waiters.push(waiter);
|
|
785
826
|
});
|
|
786
827
|
};
|
|
787
828
|
const responseInterceptor = (ctx) => {
|
|
788
829
|
if (!ctx.response)
|
|
789
830
|
return;
|
|
790
|
-
const k =
|
|
831
|
+
const k = ctx.store.get(DEDUPE_KEY);
|
|
832
|
+
if (!k)
|
|
833
|
+
return;
|
|
791
834
|
const slot = inflight.get(k);
|
|
792
835
|
if (!slot)
|
|
793
836
|
return;
|
|
794
837
|
inflight.delete(k);
|
|
795
|
-
for (const w of slot.waiters)
|
|
838
|
+
for (const w of slot.waiters.splice(0)) {
|
|
839
|
+
w.cleanup();
|
|
796
840
|
w.resolve(ctx.response);
|
|
841
|
+
}
|
|
797
842
|
};
|
|
798
843
|
const errorInterceptor = (ctx) => {
|
|
799
|
-
const k =
|
|
844
|
+
const k = ctx.store.get(DEDUPE_KEY);
|
|
845
|
+
if (!k)
|
|
846
|
+
return;
|
|
800
847
|
const slot = inflight.get(k);
|
|
801
848
|
if (!slot)
|
|
802
849
|
return;
|
|
803
850
|
inflight.delete(k);
|
|
804
|
-
for (const w of slot.waiters)
|
|
851
|
+
for (const w of slot.waiters.splice(0)) {
|
|
852
|
+
w.cleanup();
|
|
805
853
|
w.reject(ctx.error);
|
|
854
|
+
}
|
|
806
855
|
};
|
|
807
856
|
return { requestInterceptor, responseInterceptor, errorInterceptor };
|
|
808
857
|
}
|
|
809
|
-
|
|
858
|
+
/** ctx.store key holding this request's dedupe key. */
|
|
859
|
+
const DEDUPE_KEY = Symbol("dedupeKey");
|
|
810
860
|
const RATE_LIMIT_DEFAULTS = {
|
|
811
861
|
limit: 60,
|
|
812
862
|
windowMs: 60_000,
|
|
@@ -821,6 +871,19 @@ const RATE_LIMIT_DEFAULTS = {
|
|
|
821
871
|
*/
|
|
822
872
|
export function createRateLimitInterceptor(config = {}) {
|
|
823
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
|
+
}
|
|
824
887
|
let tokens = cfg.limit;
|
|
825
888
|
let lastRefill = now();
|
|
826
889
|
const pending = [];
|
|
@@ -877,6 +940,191 @@ export class RateLimitError extends Error {
|
|
|
877
940
|
this.name = "RateLimitError";
|
|
878
941
|
}
|
|
879
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
|
+
}
|
|
880
1128
|
/**
|
|
881
1129
|
* Create a HAR (HTTP Archive) recording interceptor.
|
|
882
1130
|
* Captures request/response pairs for export as a HAR log.
|
|
@@ -908,7 +1156,11 @@ export function createHARInterceptor() {
|
|
|
908
1156
|
ctx.response.headers["Content-Type"] ??
|
|
909
1157
|
"application/octet-stream";
|
|
910
1158
|
const body = ctx.response.body;
|
|
911
|
-
|
|
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));
|
|
912
1164
|
entries.push({
|
|
913
1165
|
startedDateTime: new Date(Date.now() - total).toISOString(),
|
|
914
1166
|
time: total,
|
|
@@ -1038,8 +1290,12 @@ function sleep(ms) {
|
|
|
1038
1290
|
export function computeBodySize(body) {
|
|
1039
1291
|
if (!body)
|
|
1040
1292
|
return 0;
|
|
1041
|
-
if (typeof body === "string")
|
|
1042
|
-
|
|
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
|
+
}
|
|
1043
1299
|
if (body instanceof Uint8Array)
|
|
1044
1300
|
return body.byteLength;
|
|
1045
1301
|
if (body instanceof ArrayBuffer)
|
package/dist/cjs/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
81
|
+
invokedOnce.add(listener);
|
|
73
82
|
}
|
|
74
|
-
if (
|
|
75
|
-
const
|
|
76
|
-
this.listeners.set(event,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
/**
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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))
|