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