kinetex 1.2.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 +663 -246
- 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 +46 -7
- package/dist/cjs/dedup.js +9 -7
- package/dist/cjs/digest.js +26 -0
- package/dist/cjs/headers.js +64 -8
- package/dist/cjs/interceptors.js +64 -22
- package/dist/cjs/logging.js +1 -1
- package/dist/cjs/pagination.js +14 -6
- package/dist/cjs/progress.js +122 -42
- package/dist/cjs/socks5.js +36 -21
- package/dist/cjs/sse.js +48 -11
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +13 -6
- 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 +46 -7
- 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/headers.js +64 -8
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +64 -22
- 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 +122 -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/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +13 -6
- 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 +6 -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/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/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
|
@@ -1735,10 +1735,13 @@ export function domainMatch(requestHost, cookieDomain) {
|
|
|
1735
1735
|
if (isIPAddress(cd))
|
|
1736
1736
|
return false;
|
|
1737
1737
|
// RFC 6265 §5.3: cookie domain must not be a public suffix
|
|
1738
|
-
//
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1738
|
+
// The COOKIE DOMAIN must not be a public suffix: "com" or "co.uk" must
|
|
1739
|
+
// never be accepted as a cookie domain. This check used to test the request
|
|
1740
|
+
// host instead, so it could never fire and a Domain=com cookie was reported
|
|
1741
|
+
// as matching every .com host. (HTTPCookieJar.setCookie guards it separately
|
|
1742
|
+
// with isPublicSuffix(cd); this exported helper must be correct on its own.)
|
|
1743
|
+
if (isPublicSuffix(cd))
|
|
1744
|
+
return false;
|
|
1742
1745
|
return true;
|
|
1743
1746
|
}
|
|
1744
1747
|
// ============================================================================
|
package/dist/cjs/cookie-store.js
CHANGED
|
@@ -180,7 +180,11 @@ export class CookieJar {
|
|
|
180
180
|
let cookieDomain;
|
|
181
181
|
let hostOnly;
|
|
182
182
|
if (parsed.domain !== null && parsed.domain !== "") {
|
|
183
|
-
|
|
183
|
+
// Normalize the case: the Domain attribute is case-insensitive, but the
|
|
184
|
+
// value was stored verbatim while every lookup compares against a
|
|
185
|
+
// lower-cased request host. `Domain=Example.COM` therefore stored a cookie
|
|
186
|
+
// that could never be matched again — silently lost for the jar's lifetime.
|
|
187
|
+
const cd = parsed.domain.toLowerCase();
|
|
184
188
|
// Must domain-match the request host (use custom matcher if provided)
|
|
185
189
|
const matcher = this.domainMatcherFn ?? domainMatch;
|
|
186
190
|
if (!matcher(reqHost, cd))
|
|
@@ -269,10 +273,9 @@ export class CookieJar {
|
|
|
269
273
|
* @returns Array of matching Cookie objects (direct references into storage)
|
|
270
274
|
*/
|
|
271
275
|
getCookies(options) {
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
+
// Expired cookies are removed by the periodic cleanup timer (and by
|
|
277
|
+
// clearExpired()); the old 1%-per-access coin flip walked the whole store
|
|
278
|
+
// at random and made eviction timing non-deterministic.
|
|
276
279
|
const url = safeParseUrl(options.url);
|
|
277
280
|
if (!url)
|
|
278
281
|
return [];
|
|
@@ -747,11 +750,16 @@ export class CookieJar {
|
|
|
747
750
|
// For domain cookies: domain-match (subdomains allowed).
|
|
748
751
|
// We can't know per-domain whether it's host-only without checking cookies,
|
|
749
752
|
// so we do the broader domain-match here and filter host-only per cookie.
|
|
750
|
-
|
|
753
|
+
// Lower-case both sides: cookie domains are stored from the (already
|
|
754
|
+
// normalized) Domain attribute, but a caller can also insert cookies
|
|
755
|
+
// directly via putCookie() with mixed-case input.
|
|
756
|
+
const host = reqHost.toLowerCase();
|
|
757
|
+
const domain = cookieDomain.toLowerCase();
|
|
758
|
+
if (host === domain)
|
|
751
759
|
return true;
|
|
752
|
-
if (isIPAddress(
|
|
760
|
+
if (isIPAddress(host))
|
|
753
761
|
return false;
|
|
754
|
-
return
|
|
762
|
+
return host.endsWith("." + domain);
|
|
755
763
|
}
|
|
756
764
|
}
|
|
757
765
|
// ============================================================================
|
package/dist/cjs/core.js
CHANGED
|
@@ -103,6 +103,12 @@ function isProductionEnvironment() {
|
|
|
103
103
|
return false;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The accept-encoding value client.ts injects when the caller did not set
|
|
108
|
+
* one. FetchTransport removes exactly this string so fetch() negotiates its
|
|
109
|
+
* own encodings; any other value is treated as caller intent.
|
|
110
|
+
*/
|
|
111
|
+
export const DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
|
|
106
112
|
/**
|
|
107
113
|
* Universal fetch-based transport.
|
|
108
114
|
* Suitable for all runtimes where `fetch` is available.
|
|
@@ -160,10 +166,12 @@ export class FetchTransport {
|
|
|
160
166
|
}
|
|
161
167
|
sanitizedHeaders[name] = value;
|
|
162
168
|
}
|
|
163
|
-
// Strip
|
|
164
|
-
//
|
|
169
|
+
// Strip the accept-encoding value INJECTED by client.ts so fetch() can add
|
|
170
|
+
// its own. The old check removed ANY value containing gzip+deflate+br,
|
|
171
|
+
// including one the caller set deliberately; matching the exact injected
|
|
172
|
+
// default keeps caller intent intact.
|
|
165
173
|
const ae = sanitizedHeaders["accept-encoding"];
|
|
166
|
-
if (ae && ae.toLowerCase().
|
|
174
|
+
if (ae && ae.toLowerCase().replace(/\s+/g, " ") === DEFAULT_ACCEPT_ENCODING) {
|
|
167
175
|
delete sanitizedHeaders["accept-encoding"];
|
|
168
176
|
}
|
|
169
177
|
// Build fetch init
|
|
@@ -735,6 +743,13 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
735
743
|
// "Custom fetch implementation" behavior holds on every runtime.
|
|
736
744
|
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
737
745
|
// fetch is given. Falls back to FetchTransport otherwise.
|
|
746
|
+
if (IS_NODE && preferHTTP2 && fetchFn) {
|
|
747
|
+
if (!isProductionEnvironment()) {
|
|
748
|
+
console.warn('[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
|
|
749
|
+
"NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
|
|
750
|
+
"FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2.");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
738
753
|
if (IS_NODE && preferHTTP2 && !fetchFn) {
|
|
739
754
|
return new NodeHTTP2Transport({
|
|
740
755
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
@@ -1111,7 +1126,7 @@ async function writeChunkWithBackpressure(stream, chunk) {
|
|
|
1111
1126
|
}
|
|
1112
1127
|
/**
|
|
1113
1128
|
* Write a request body to an HTTP/2 stream, respecting backpressure.
|
|
1114
|
-
* Handles ReadableStream, Uint8Array, ArrayBuffer, and
|
|
1129
|
+
* Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
|
|
1115
1130
|
*
|
|
1116
1131
|
* @param stream - HTTP/2 stream to write to
|
|
1117
1132
|
* @param body - Request body
|
|
@@ -1136,12 +1151,16 @@ async function attachBodyToH2Stream(stream, body) {
|
|
|
1136
1151
|
stream.end(body);
|
|
1137
1152
|
}
|
|
1138
1153
|
else {
|
|
1139
|
-
|
|
1154
|
+
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1155
|
+
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1156
|
+
// silently sent an empty body to the server.
|
|
1157
|
+
const bytes = await serializeRawBody(body);
|
|
1158
|
+
stream.end(bytes);
|
|
1140
1159
|
}
|
|
1141
1160
|
}
|
|
1142
1161
|
/**
|
|
1143
1162
|
* Write a request body to a Node.js http.ClientRequest, respecting backpressure.
|
|
1144
|
-
* Handles ReadableStream, Uint8Array, ArrayBuffer, and
|
|
1163
|
+
* Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
|
|
1145
1164
|
*
|
|
1146
1165
|
* @param req - Node.js ClientRequest
|
|
1147
1166
|
* @param body - Request body
|
|
@@ -1166,8 +1185,28 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1166
1185
|
req.end(body);
|
|
1167
1186
|
}
|
|
1168
1187
|
else {
|
|
1169
|
-
|
|
1188
|
+
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1189
|
+
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1190
|
+
// silently sent an empty body to the server.
|
|
1191
|
+
const bytes = await serializeRawBody(body);
|
|
1192
|
+
req.end(bytes);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Serialize body types that `fetch` would normally encode for us, so the raw
|
|
1197
|
+
* Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
|
|
1198
|
+
*
|
|
1199
|
+
* @param body - Request body that is not a stream, byte array, or string
|
|
1200
|
+
* @returns The encoded bytes to write (empty for unsupported types)
|
|
1201
|
+
*/
|
|
1202
|
+
async function serializeRawBody(body) {
|
|
1203
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
1204
|
+
return new TextEncoder().encode(body.toString());
|
|
1205
|
+
}
|
|
1206
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
1207
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
1170
1208
|
}
|
|
1209
|
+
return new Uint8Array(0);
|
|
1171
1210
|
}
|
|
1172
1211
|
// ============================================================================
|
|
1173
1212
|
// §10 DECOMPRESSION
|
package/dist/cjs/dedup.js
CHANGED
|
@@ -136,6 +136,15 @@ export class DedupMap {
|
|
|
136
136
|
this.inflight.delete(key);
|
|
137
137
|
}
|
|
138
138
|
else {
|
|
139
|
+
// Clear any previous timer for this key first. Registering a new
|
|
140
|
+
// window without clearing the old one let the stale timer fire later
|
|
141
|
+
// and `inflight.delete(key)` — wiping the *new*, still-in-flight entry
|
|
142
|
+
// out from under callers that were sharing it.
|
|
143
|
+
const stale = this.timeouts.get(key);
|
|
144
|
+
if (stale !== undefined) {
|
|
145
|
+
clearTimeout(stale);
|
|
146
|
+
this.timeouts.delete(key);
|
|
147
|
+
}
|
|
139
148
|
const timeoutId = setTimeout(() => {
|
|
140
149
|
this.inflight.delete(key);
|
|
141
150
|
this.timeouts.delete(key);
|
|
@@ -177,13 +186,6 @@ export class DedupMap {
|
|
|
177
186
|
get keys() {
|
|
178
187
|
return [...this.inflight.keys()];
|
|
179
188
|
}
|
|
180
|
-
/**
|
|
181
|
-
* Get comprehensive deduplication statistics.
|
|
182
|
-
* NOTE: inFlightCount and trackedKeys reflect snapshot time; entries may
|
|
183
|
-
* resolve asynchronously between sampling and return (non-blocking, acceptable).
|
|
184
|
-
*
|
|
185
|
-
* @returns Snapshot of hits, misses, hit rate, and tracked entries
|
|
186
|
-
*/
|
|
187
189
|
/**
|
|
188
190
|
* Get comprehensive deduplication statistics.
|
|
189
191
|
*
|
package/dist/cjs/digest.js
CHANGED
|
@@ -550,3 +550,29 @@ export async function createDigestAuthorization(wwwAuth, username, password, met
|
|
|
550
550
|
const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
|
|
551
551
|
return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
|
|
552
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Stateful Digest-auth authorizer.
|
|
555
|
+
*
|
|
556
|
+
* Keeps a per-nonce request counter and increments it on every call, as
|
|
557
|
+
* RFC 7616 §3.4.1 requires: `nc` is the hex request count for the current
|
|
558
|
+
* nonce and MUST increase for each request. The stateless
|
|
559
|
+
* {@link createDigestAuthorization} always sends 00000001, which replay-
|
|
560
|
+
* protecting servers (nginx, Apache with `AuthDigestNonceLifetime`) reject on
|
|
561
|
+
* the second request. A new nonce (challenge change) resets the counter to 1.
|
|
562
|
+
*
|
|
563
|
+
* Not safe to share across clients that authenticate as different users —
|
|
564
|
+
* create one per client.
|
|
565
|
+
*/
|
|
566
|
+
export function createDigestAuthorizer() {
|
|
567
|
+
const counters = new Map();
|
|
568
|
+
return async (wwwAuth, username, password, method, uri) => {
|
|
569
|
+
const challenge = parseDigestChallenge(wwwAuth);
|
|
570
|
+
const cnonce = randomBytes(5);
|
|
571
|
+
const scope = `${challenge.realm}\u0000${challenge.nonce}`;
|
|
572
|
+
const next = (counters.get(scope) ?? 0) + 1;
|
|
573
|
+
counters.set(scope, next);
|
|
574
|
+
const nc = next.toString(16).padStart(8, "0");
|
|
575
|
+
const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
|
|
576
|
+
return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
|
|
577
|
+
};
|
|
578
|
+
}
|
package/dist/cjs/headers.js
CHANGED
|
@@ -897,6 +897,31 @@ export function formatContentDisposition(cd) {
|
|
|
897
897
|
* @param value - Raw `Cache-Control` header value
|
|
898
898
|
* @returns Structured directives object with boolean flags and numeric values
|
|
899
899
|
*/
|
|
900
|
+
/**
|
|
901
|
+
* Split a `Cache-Control` value on commas, ignoring commas inside a quoted
|
|
902
|
+
* string. RFC 7234 §5.2 allows quoted-string values that contain commas, e.g.
|
|
903
|
+
* `private="field1, field2"`, so a plain `split(",")` corrupts them.
|
|
904
|
+
*
|
|
905
|
+
* @param value - Raw `Cache-Control` header value
|
|
906
|
+
* @returns Each directive as its own (untrimmed) string
|
|
907
|
+
*/
|
|
908
|
+
function splitCacheControlDirectives(value) {
|
|
909
|
+
const parts = [];
|
|
910
|
+
let current = "";
|
|
911
|
+
let inQuotes = false;
|
|
912
|
+
for (const ch of value) {
|
|
913
|
+
if (ch === '"')
|
|
914
|
+
inQuotes = !inQuotes;
|
|
915
|
+
if (ch === "," && !inQuotes) {
|
|
916
|
+
parts.push(current);
|
|
917
|
+
current = "";
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
current += ch;
|
|
921
|
+
}
|
|
922
|
+
parts.push(current);
|
|
923
|
+
return parts;
|
|
924
|
+
}
|
|
900
925
|
export function parseCacheControl(value) {
|
|
901
926
|
const d = {
|
|
902
927
|
noCache: false,
|
|
@@ -917,7 +942,7 @@ export function parseCacheControl(value) {
|
|
|
917
942
|
staleWhileRevalidate: null,
|
|
918
943
|
unknown: new Map(),
|
|
919
944
|
};
|
|
920
|
-
for (const part of value
|
|
945
|
+
for (const part of splitCacheControlDirectives(value)) {
|
|
921
946
|
const t = part.trim();
|
|
922
947
|
const eq = t.indexOf("=");
|
|
923
948
|
const k = (eq === -1 ? t : t.slice(0, eq)).trim().toLowerCase();
|
|
@@ -1379,17 +1404,48 @@ export function normalizeForwardedHeaders(headers) {
|
|
|
1379
1404
|
};
|
|
1380
1405
|
}
|
|
1381
1406
|
/**
|
|
1382
|
-
* Extract the
|
|
1383
|
-
*
|
|
1407
|
+
* Extract the client IP from Forwarded, X-Forwarded-For, or X-Real-IP.
|
|
1408
|
+
*
|
|
1409
|
+
* ⚠️ SECURITY: the value is derived from client-supplied headers and must NOT be
|
|
1410
|
+
* used for access control, rate limiting or audit trails unless `trustedHops` is
|
|
1411
|
+
* set to the real number of proxies in front of the server. The returned string
|
|
1412
|
+
* is also not validated as an IP address.
|
|
1384
1413
|
*
|
|
1385
1414
|
* @param headers - Source headers object
|
|
1386
|
-
* @
|
|
1415
|
+
* @param options - Trust configuration
|
|
1416
|
+
* @returns The selected client IP, or `null` if none present
|
|
1387
1417
|
*/
|
|
1388
|
-
export function getClientIP(headers) {
|
|
1418
|
+
export function getClientIP(headers, options = {}) {
|
|
1419
|
+
const trustedHops = Math.max(0, Math.trunc(options.trustedHops ?? 0));
|
|
1389
1420
|
const fwd = normalizeForwardedHeaders(headers);
|
|
1390
|
-
if (fwd.for.length > 0)
|
|
1391
|
-
|
|
1392
|
-
|
|
1421
|
+
if (fwd.for.length > 0) {
|
|
1422
|
+
const list = fwd.for;
|
|
1423
|
+
// Right-most entry is the closest hop. trustedHops = 1 → the entry the
|
|
1424
|
+
// nearest trusted proxy appended; index = length - trustedHops.
|
|
1425
|
+
const index = trustedHops === 0 ? 0 : list.length - trustedHops;
|
|
1426
|
+
const value = list[index] ?? list[0];
|
|
1427
|
+
return stripIPPortAndBrackets(value);
|
|
1428
|
+
}
|
|
1429
|
+
return stripIPPortAndBrackets(headers.get(HeaderName.XRealIP) ?? "") || null;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Normalize a Forwarded/XFF entry to a bare host: strip `for="…"` quoting,
|
|
1433
|
+
* `[ipv6]:port`, and a bare `:port` suffix.
|
|
1434
|
+
*/
|
|
1435
|
+
function stripIPPortAndBrackets(value) {
|
|
1436
|
+
let v = value.trim();
|
|
1437
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
|
|
1438
|
+
v = v.slice(1, -1);
|
|
1439
|
+
if (v.startsWith("[")) {
|
|
1440
|
+
const end = v.indexOf("]");
|
|
1441
|
+
if (end !== -1)
|
|
1442
|
+
return v.slice(1, end);
|
|
1443
|
+
}
|
|
1444
|
+
// Only strip a trailing :digits (never the colons inside an IPv6 literal).
|
|
1445
|
+
const m = /^(.*):\d+$/.exec(v);
|
|
1446
|
+
if (m && (m[1] ?? "").includes("."))
|
|
1447
|
+
return m[1];
|
|
1448
|
+
return v;
|
|
1393
1449
|
}
|
|
1394
1450
|
/**
|
|
1395
1451
|
* Parse a Retry-After header which may be either delta-seconds or an
|
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;
|
|
@@ -758,13 +769,22 @@ const CACHE_STALE_KEY = Symbol("cacheStale");
|
|
|
758
769
|
*/
|
|
759
770
|
export function createDedupeInterceptor() {
|
|
760
771
|
const inflight = new Map();
|
|
761
|
-
|
|
762
|
-
|
|
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 : ""}`;
|
|
763
780
|
}
|
|
764
|
-
const requestInterceptor = (ctx) => {
|
|
781
|
+
const requestInterceptor = async (ctx) => {
|
|
765
782
|
if (ctx.request.method.toUpperCase() !== "GET" && ctx.request.method.toUpperCase() !== "HEAD")
|
|
766
783
|
return;
|
|
767
|
-
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);
|
|
768
788
|
const slot = inflight.get(k);
|
|
769
789
|
if (!slot) {
|
|
770
790
|
// First request for this key — create in-flight entry
|
|
@@ -774,39 +794,61 @@ export function createDedupeInterceptor() {
|
|
|
774
794
|
});
|
|
775
795
|
return;
|
|
776
796
|
}
|
|
777
|
-
// A request is already in flight — queue up
|
|
778
|
-
|
|
779
|
-
// 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.
|
|
780
799
|
return new Promise((resolve, reject) => {
|
|
781
|
-
|
|
800
|
+
const signal = ctx.request.signal;
|
|
801
|
+
const waiter = {
|
|
782
802
|
resolve: (res) => resolve(res),
|
|
783
803
|
reject: (err) => reject(err), // Properly reject instead of throwing
|
|
784
|
-
|
|
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);
|
|
785
818
|
});
|
|
786
819
|
};
|
|
787
820
|
const responseInterceptor = (ctx) => {
|
|
788
821
|
if (!ctx.response)
|
|
789
822
|
return;
|
|
790
|
-
const k =
|
|
823
|
+
const k = ctx.store.get(DEDUPE_KEY);
|
|
824
|
+
if (!k)
|
|
825
|
+
return;
|
|
791
826
|
const slot = inflight.get(k);
|
|
792
827
|
if (!slot)
|
|
793
828
|
return;
|
|
794
829
|
inflight.delete(k);
|
|
795
|
-
for (const w of slot.waiters)
|
|
830
|
+
for (const w of slot.waiters.splice(0)) {
|
|
831
|
+
w.cleanup();
|
|
796
832
|
w.resolve(ctx.response);
|
|
833
|
+
}
|
|
797
834
|
};
|
|
798
835
|
const errorInterceptor = (ctx) => {
|
|
799
|
-
const k =
|
|
836
|
+
const k = ctx.store.get(DEDUPE_KEY);
|
|
837
|
+
if (!k)
|
|
838
|
+
return;
|
|
800
839
|
const slot = inflight.get(k);
|
|
801
840
|
if (!slot)
|
|
802
841
|
return;
|
|
803
842
|
inflight.delete(k);
|
|
804
|
-
for (const w of slot.waiters)
|
|
843
|
+
for (const w of slot.waiters.splice(0)) {
|
|
844
|
+
w.cleanup();
|
|
805
845
|
w.reject(ctx.error);
|
|
846
|
+
}
|
|
806
847
|
};
|
|
807
848
|
return { requestInterceptor, responseInterceptor, errorInterceptor };
|
|
808
849
|
}
|
|
809
|
-
|
|
850
|
+
/** ctx.store key holding this request's dedupe key. */
|
|
851
|
+
const DEDUPE_KEY = Symbol("dedupeKey");
|
|
810
852
|
const RATE_LIMIT_DEFAULTS = {
|
|
811
853
|
limit: 60,
|
|
812
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
|
/**
|