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/core.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Node.js globals accessed via globalThis for cross-runtime compatibility
|
|
2
2
|
const g = globalThis;
|
|
3
3
|
import { KinetexError, TimeoutError, SizeLimitError } from "./types.js";
|
|
4
|
-
import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse } from "./utils.js";
|
|
4
|
+
import { concatUint8Arrays, mergeSignals, isAbortError, safeJSONParse, isSafeURL, isBlockedAddress, randomBytes, } from "./utils.js";
|
|
5
5
|
import { isValidHeaderName, isValidHeaderValue } from "./headers.js";
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// §1 RUNTIME DETECTION
|
|
@@ -65,7 +65,26 @@ let _runtimeOverride = null;
|
|
|
65
65
|
* setRuntime(null); // restore detection
|
|
66
66
|
* ```
|
|
67
67
|
*/
|
|
68
|
+
/** Every value {@link Runtime} admits, for runtime validation. */
|
|
69
|
+
const KNOWN_RUNTIMES = [
|
|
70
|
+
"node",
|
|
71
|
+
"deno",
|
|
72
|
+
"bun",
|
|
73
|
+
"browser",
|
|
74
|
+
"cloudflare-workers",
|
|
75
|
+
"edge",
|
|
76
|
+
"unknown",
|
|
77
|
+
];
|
|
68
78
|
export function setRuntime(rt) {
|
|
79
|
+
// The parameter is typed, but nothing checks it at runtime, and the callers
|
|
80
|
+
// that matter pass a value read from configuration. A typo — "denno" — was
|
|
81
|
+
// stored verbatim and became the effective runtime, and every branch in the
|
|
82
|
+
// library gated on `RUNTIME === "..."` then missed: no fetch, no HTTP/2, no
|
|
83
|
+
// proxy, no Node-only path, with nothing thrown and nothing logged. An
|
|
84
|
+
// unrecognised value is a mistake worth reporting at the point it is made.
|
|
85
|
+
if (rt !== null && !KNOWN_RUNTIMES.includes(rt)) {
|
|
86
|
+
throw new TypeError(`setRuntime: unknown runtime ${JSON.stringify(rt)}. Expected one of: ${KNOWN_RUNTIMES.join(", ")}`);
|
|
87
|
+
}
|
|
69
88
|
_runtimeOverride = rt;
|
|
70
89
|
}
|
|
71
90
|
/**
|
|
@@ -117,6 +136,14 @@ export class FetchTransport {
|
|
|
117
136
|
fetchFn;
|
|
118
137
|
strict;
|
|
119
138
|
onDroppedHeader;
|
|
139
|
+
dispatcher;
|
|
140
|
+
/** Per-request timeout; omitted means no timeout. */
|
|
141
|
+
requestTimeoutMs;
|
|
142
|
+
/**
|
|
143
|
+
* Permit connections to hosts resolving into private/loopback space.
|
|
144
|
+
* Default: false.
|
|
145
|
+
*/
|
|
146
|
+
_allowPrivateNetwork;
|
|
120
147
|
/**
|
|
121
148
|
* @param fetchFnOrOptions - Custom fetch function or options object
|
|
122
149
|
*/
|
|
@@ -125,12 +152,76 @@ export class FetchTransport {
|
|
|
125
152
|
this.fetchFn = fetchFnOrOptions;
|
|
126
153
|
this.strict = false;
|
|
127
154
|
this.onDroppedHeader = undefined;
|
|
155
|
+
this.dispatcher = undefined;
|
|
156
|
+
this.requestTimeoutMs = undefined;
|
|
157
|
+
this._allowPrivateNetwork = false;
|
|
128
158
|
}
|
|
129
159
|
else {
|
|
130
160
|
this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
|
|
131
161
|
this.strict = fetchFnOrOptions.strict ?? false;
|
|
132
162
|
this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
|
|
163
|
+
this.dispatcher = fetchFnOrOptions.dispatcher;
|
|
164
|
+
this.requestTimeoutMs = fetchFnOrOptions.requestTimeoutMs;
|
|
165
|
+
this._allowPrivateNetwork = fetchFnOrOptions.allowPrivateNetwork ?? false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Refuse a host that resolves into blocked address space.
|
|
170
|
+
*
|
|
171
|
+
* The Node transports close DNS rebinding properly: they resolve once and
|
|
172
|
+
* pin the checked address to the socket (`createPinnedLookup`). `fetch` has no
|
|
173
|
+
* resolver hook — undici owns the connection and exposes no way to install
|
|
174
|
+
* one — so the only place to intervene is before the call.
|
|
175
|
+
*
|
|
176
|
+
* That closes the case that matters in practice: a name resolving to loopback
|
|
177
|
+
* or the cloud metadata address is refused outright, which is the vector
|
|
178
|
+
* behind the classic SSRF-through-DNS attacks. What it cannot close is a
|
|
179
|
+
* resolver that answers public on this lookup and private on the connect a
|
|
180
|
+
* moment later. Closing *that* needs a resolver hook `fetch` does not offer,
|
|
181
|
+
* and claiming otherwise would be worse than saying so.
|
|
182
|
+
*
|
|
183
|
+
* The host has already passed the lexical screen by this point, so this is a
|
|
184
|
+
* second line of defence rather than the first.
|
|
185
|
+
*/
|
|
186
|
+
async assertReachableHost(url) {
|
|
187
|
+
if (this._allowPrivateNetwork)
|
|
188
|
+
return;
|
|
189
|
+
// A caller-supplied `fetch` owns its own name resolution — its dispatcher,
|
|
190
|
+
// agent or resolver may answer something the system resolver never would,
|
|
191
|
+
// and vice versa. Screening the *system* answer for a name this transport
|
|
192
|
+
// will not resolve is theatre: it cannot observe the connection that
|
|
193
|
+
// actually happens, so it neither protects the caller nor describes the
|
|
194
|
+
// risk honestly. The lexical screen still applies to every URL, including
|
|
195
|
+
// redirects; the resolution half of the defence is the transport's job, and
|
|
196
|
+
// a custom one is the caller's.
|
|
197
|
+
if (this.fetchFn !== globalThis.fetch)
|
|
198
|
+
return;
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = new URL(url);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return; // Malformed URLs fail later, with a better message.
|
|
205
|
+
}
|
|
206
|
+
if (!parsed.hostname || isIP(parsed.hostname))
|
|
207
|
+
return;
|
|
208
|
+
let addresses;
|
|
209
|
+
try {
|
|
210
|
+
// `all: true` so every candidate is inspected, matching what the socket
|
|
211
|
+
// would have been able to reach.
|
|
212
|
+
addresses = await new Promise((resolve, reject) => {
|
|
213
|
+
dnsLookup(parsed.hostname, { all: true }, (err, res) => err ? reject(err) : resolve(res));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Resolution failed; let the request itself report it rather than
|
|
218
|
+
// inventing a different failure.
|
|
219
|
+
return;
|
|
133
220
|
}
|
|
221
|
+
const safe = filterSafeAddresses(addresses);
|
|
222
|
+
if (safe.length > 0)
|
|
223
|
+
return;
|
|
224
|
+
throw blockedHostError(url, addresses);
|
|
134
225
|
}
|
|
135
226
|
/**
|
|
136
227
|
* Send a request via the native fetch() API.
|
|
@@ -143,6 +234,7 @@ export class FetchTransport {
|
|
|
143
234
|
* @returns Raw response from the server
|
|
144
235
|
*/
|
|
145
236
|
async send(req) {
|
|
237
|
+
await this.assertReachableHost(req.url);
|
|
146
238
|
// Validate and sanitize headers to prevent injection attacks
|
|
147
239
|
const sanitizedHeaders = {};
|
|
148
240
|
for (const [name, value] of Object.entries(req.headers ?? {})) {
|
|
@@ -164,6 +256,20 @@ export class FetchTransport {
|
|
|
164
256
|
}
|
|
165
257
|
continue;
|
|
166
258
|
}
|
|
259
|
+
// `__proto__` is a legal header name — it is made of token characters,
|
|
260
|
+
// so it passes the check above — and `sanitizedHeaders[name] = value` is a
|
|
261
|
+
// [[Set]], so it went to the inherited setter, which ignores a primitive.
|
|
262
|
+
// The header did not overwrite anything: it vanished, and the caller was
|
|
263
|
+
// never told. Written as a data property, which is what was meant.
|
|
264
|
+
if (name === "__proto__") {
|
|
265
|
+
Object.defineProperty(sanitizedHeaders, name, {
|
|
266
|
+
value,
|
|
267
|
+
writable: true,
|
|
268
|
+
enumerable: true,
|
|
269
|
+
configurable: true,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
167
273
|
sanitizedHeaders[name] = value;
|
|
168
274
|
}
|
|
169
275
|
// Strip the accept-encoding value INJECTED by client.ts so fetch() can add
|
|
@@ -195,16 +301,53 @@ export class FetchTransport {
|
|
|
195
301
|
init.duplex = "half";
|
|
196
302
|
}
|
|
197
303
|
}
|
|
304
|
+
// Forwarded only when set: an explicit `dispatcher: undefined` would
|
|
305
|
+
// otherwise override a globally-installed dispatcher on the runtime.
|
|
306
|
+
if (this.dispatcher !== undefined) {
|
|
307
|
+
init.dispatcher = this.dispatcher;
|
|
308
|
+
}
|
|
309
|
+
// fetch() has no timeout of its own, so one is enforced here with an
|
|
310
|
+
// AbortController. Composed with the caller's signal rather than replacing
|
|
311
|
+
// it, so cancelling the caller still cancels the request.
|
|
312
|
+
const timeoutMs = this.requestTimeoutMs;
|
|
313
|
+
const timeoutCtl = timeoutMs === undefined ? undefined : new AbortController();
|
|
314
|
+
let timedOut = false;
|
|
315
|
+
const onCallerAbort = () => timeoutCtl?.abort();
|
|
316
|
+
if (timeoutCtl && req.signal) {
|
|
317
|
+
if (req.signal.aborted)
|
|
318
|
+
timeoutCtl.abort();
|
|
319
|
+
else
|
|
320
|
+
req.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
321
|
+
}
|
|
322
|
+
const timer = timeoutCtl && timeoutMs !== undefined
|
|
323
|
+
? setTimeout(() => {
|
|
324
|
+
timedOut = true;
|
|
325
|
+
timeoutCtl.abort();
|
|
326
|
+
}, timeoutMs)
|
|
327
|
+
: undefined;
|
|
328
|
+
const initWithSignal = timeoutCtl && !init.signal ? { ...init, signal: timeoutCtl.signal } : init;
|
|
198
329
|
let response;
|
|
199
330
|
try {
|
|
200
|
-
response = await this.fetchFn(req.url,
|
|
331
|
+
response = await this.fetchFn(req.url, initWithSignal);
|
|
201
332
|
}
|
|
202
333
|
catch (err) {
|
|
334
|
+
// Clear the timer on every exit path, and drop the listener we added.
|
|
335
|
+
if (timer !== undefined)
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
if (timeoutCtl && req.signal)
|
|
338
|
+
req.signal.removeEventListener("abort", onCallerAbort);
|
|
339
|
+
if (timedOut) {
|
|
340
|
+
throw new KinetexError(`HTTP/1.1 request to ${req.url} timed out after ${timeoutMs}ms`, "ETIMEOUT", { request: req, cause: err });
|
|
341
|
+
}
|
|
203
342
|
if (isAbortError(err)) {
|
|
204
343
|
throw new KinetexError("Request was aborted", "EABORT", { request: req, cause: err });
|
|
205
344
|
}
|
|
206
345
|
throw new KinetexError(err instanceof Error ? err.message : "Network request failed", "ENETWORK", { request: req, cause: err });
|
|
207
346
|
}
|
|
347
|
+
if (timer !== undefined)
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
if (timeoutCtl && req.signal)
|
|
350
|
+
req.signal.removeEventListener("abort", onCallerAbort);
|
|
208
351
|
const headers = normalizeHeaders(response.headers);
|
|
209
352
|
const httpVersion = detectHTTPVersion(response, headers);
|
|
210
353
|
// Determine if the runtime already decompressed the body.
|
|
@@ -264,6 +407,27 @@ export class NodeHTTP2Transport {
|
|
|
264
407
|
_requestTimeoutMs;
|
|
265
408
|
/** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
|
|
266
409
|
_http1Fallback = null;
|
|
410
|
+
/**
|
|
411
|
+
* Dedicated keep-alive agent for the legacy `node:https` path.
|
|
412
|
+
*
|
|
413
|
+
* This path called `https.request(options)` with no `agent`, so behaviour was
|
|
414
|
+
* whatever the global agent happened to do — and that default is a moving
|
|
415
|
+
* target. Node 19 turned `keepAlive` on for `https.globalAgent`, so this path
|
|
416
|
+
* silently changed from a TCP + TLS handshake per request (Node 18 and
|
|
417
|
+
* earlier) to pooled connections, with no code change here to explain it.
|
|
418
|
+
*
|
|
419
|
+
* An explicit agent makes the behaviour identical on every supported Node
|
|
420
|
+
* version, bounds the idle-socket pool (the global agent keeps 256 free
|
|
421
|
+
* sockets, which is a lot to hold open for a legacy fallback), and gives
|
|
422
|
+
* callers a way to opt out.
|
|
423
|
+
*/
|
|
424
|
+
/** Whether to permit connections into private/loopback address space. */
|
|
425
|
+
_allowPrivateNetwork;
|
|
426
|
+
_http1KeepAlive;
|
|
427
|
+
_http1MaxSockets;
|
|
428
|
+
_http1Agent = null;
|
|
429
|
+
/** HTTP(S) CONNECT proxy, or undefined for a direct connection. */
|
|
430
|
+
_proxy;
|
|
267
431
|
/**
|
|
268
432
|
* @param options - Session pool and transport configuration
|
|
269
433
|
*/
|
|
@@ -271,6 +435,10 @@ export class NodeHTTP2Transport {
|
|
|
271
435
|
this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
|
|
272
436
|
this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
|
|
273
437
|
this.maxSessions = options.maxSessions ?? 100;
|
|
438
|
+
this._allowPrivateNetwork = options.allowPrivateNetwork ?? false;
|
|
439
|
+
this._http1KeepAlive = options.http1KeepAlive ?? true;
|
|
440
|
+
this._http1MaxSockets = options.http1MaxSockets ?? 16;
|
|
441
|
+
this._proxy = options.proxy;
|
|
274
442
|
this._strict = options.strict ?? false;
|
|
275
443
|
this._onDroppedHeader = options.onDroppedHeader;
|
|
276
444
|
this._ca = options.ca;
|
|
@@ -333,10 +501,32 @@ export class NodeHTTP2Transport {
|
|
|
333
501
|
this._evictSession(lruOrigin, lruSession.session);
|
|
334
502
|
}
|
|
335
503
|
}
|
|
504
|
+
// With a proxy, the tunnel (and, for an https origin, the TLS handshake
|
|
505
|
+
// to the target) is established *before* http2.connect is called.
|
|
506
|
+
//
|
|
507
|
+
// `createConnection` must return a socket synchronously — node:http2 does
|
|
508
|
+
// not await it, and handing it a Promise yields a session built on a
|
|
509
|
+
// thenable, which fails deep inside the stream layer with
|
|
510
|
+
// "stream.pause is not a function". So the async work happens here and
|
|
511
|
+
// the already-open socket is passed back synchronously.
|
|
512
|
+
let proxiedSocket = null;
|
|
513
|
+
if (this._proxy !== undefined) {
|
|
514
|
+
proxiedSocket = await this._createProxiedSocket(origin, undefined, req);
|
|
515
|
+
}
|
|
336
516
|
const session = await new Promise((resolve, reject) => {
|
|
337
517
|
const s = http2.connect(origin, {
|
|
338
518
|
rejectUnauthorized: true,
|
|
519
|
+
// Resolve-and-check, so a hostname that points at a private address is
|
|
520
|
+
// refused before anything is dialled. See `createPinnedLookup`.
|
|
521
|
+
...(this._allowPrivateNetwork ? {} : { lookup: PINNED_LOOKUP }),
|
|
339
522
|
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
523
|
+
// Already connected (and already TLS-wrapped for an https origin),
|
|
524
|
+
// so node:http2 must use it as-is rather than negotiating again.
|
|
525
|
+
...(proxiedSocket !== null
|
|
526
|
+
? {
|
|
527
|
+
createConnection: (() => proxiedSocket),
|
|
528
|
+
}
|
|
529
|
+
: {}),
|
|
340
530
|
});
|
|
341
531
|
// FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
|
|
342
532
|
// FIX 9: unref() the timer so it does not prevent process exit
|
|
@@ -425,6 +615,29 @@ export class NodeHTTP2Transport {
|
|
|
425
615
|
throw err;
|
|
426
616
|
}
|
|
427
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* Refuse one header on the HTTP/2 path. Strict mode raises `EVALIDATION`
|
|
620
|
+
* before anything is dialled; non-strict notifies the callback (if any) and
|
|
621
|
+
* warns, never dropping silently.
|
|
622
|
+
*
|
|
623
|
+
* Both the pseudo-header filter and the validation loop go through here so
|
|
624
|
+
* the two cannot drift apart, which is what let a caller-supplied `:path`
|
|
625
|
+
* reach the wire while `FetchTransport` dropped the identical header.
|
|
626
|
+
*/
|
|
627
|
+
_rejectHeader(name, value, reason, request) {
|
|
628
|
+
if (this._strict) {
|
|
629
|
+
throw new KinetexError(`Strict mode: header "${name}" ${reason}`, "EVALIDATION", {
|
|
630
|
+
request,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (this._onDroppedHeader) {
|
|
634
|
+
this._onDroppedHeader(name, value);
|
|
635
|
+
}
|
|
636
|
+
else if (typeof console !== "undefined") {
|
|
637
|
+
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${name}" — ${reason}. ` +
|
|
638
|
+
`Pass strictHeaders: true to throw instead.`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
428
641
|
/**
|
|
429
642
|
* Send a request over HTTP/2 with iterative redirect following.
|
|
430
643
|
* Each hop reuses or creates a session for the target origin.
|
|
@@ -457,14 +670,69 @@ export class NodeHTTP2Transport {
|
|
|
457
670
|
session = existing.session;
|
|
458
671
|
this.sessionUsage.set(origin, Date.now());
|
|
459
672
|
}
|
|
460
|
-
//
|
|
673
|
+
// A `FormData` body has to be encoded BEFORE the header block is built:
|
|
674
|
+
// the multipart boundary is generated during encoding, and a boundary
|
|
675
|
+
// that cannot reach `content-type` leaves a body the peer cannot parse.
|
|
676
|
+
// This transport bypasses fetch, so nothing else would have encoded it.
|
|
677
|
+
// The client's own path never reaches this — it encodes the form and sets
|
|
678
|
+
// the header together before dispatch — but a caller handing a `FormData`
|
|
679
|
+
// straight to a transport would otherwise send a body whose boundary no
|
|
680
|
+
// header ever named.
|
|
681
|
+
let bodyForHop = currentReq.body;
|
|
682
|
+
let encodedForHop;
|
|
683
|
+
if (currentReq.body instanceof FormData) {
|
|
684
|
+
encodedForHop = await serializeRawBody(currentReq.body);
|
|
685
|
+
bodyForHop = encodedForHop.bytes;
|
|
686
|
+
// Request headers are lowercased before they reach the transport, so
|
|
687
|
+
// a direct key read is the right test here.
|
|
688
|
+
if (encodedForHop.contentType !== undefined && !currentReq.headers["content-type"]) {
|
|
689
|
+
currentReq = {
|
|
690
|
+
...currentReq,
|
|
691
|
+
headers: { ...currentReq.headers, "content-type": encodedForHop.contentType },
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Build headers for this hop. The transport owns the request line, so a
|
|
696
|
+
// caller-supplied pseudo-header (":path", ":authority", ":method",
|
|
697
|
+
// ":scheme") is refused rather than merged.
|
|
698
|
+
//
|
|
699
|
+
// They used to be spread over the transport's own values, which meant a
|
|
700
|
+
// caller could send a request to a path and a `:authority` that the URL
|
|
701
|
+
// argument never contained — the URL that `isSafeURL` screened is not
|
|
702
|
+
// the URL that got dialled. The validation loop below could not catch it
|
|
703
|
+
// either, because it skipped every name starting with ":", so even
|
|
704
|
+
// `strict: true` returned 200 for a hijacked `:path`, and
|
|
705
|
+
// `FetchTransport` dropped the very same header because ":" is not a
|
|
706
|
+
// token character. Same request, two transports, two answers.
|
|
461
707
|
const h2ReqHeaders = {
|
|
462
708
|
":method": currentReq.method,
|
|
463
709
|
":path": currentUrl.pathname + currentUrl.search,
|
|
464
710
|
":scheme": "https",
|
|
465
711
|
":authority": currentUrl.host,
|
|
466
|
-
...currentReq.headers,
|
|
467
712
|
};
|
|
713
|
+
for (const [hName, hValue] of Object.entries(currentReq.headers)) {
|
|
714
|
+
if (hName.startsWith(":")) {
|
|
715
|
+
this._rejectHeader(hName, Array.isArray(hValue) ? hValue.join(", ") : String(hValue), "is not a valid header name", currentReq);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
// `__proto__` is a legal header name — it is made of token characters,
|
|
719
|
+
// so it passes `isValidHeaderName` — and `h2ReqHeaders[name] = value`
|
|
720
|
+
// is a [[Set]], so it hit the inherited setter, which ignores a
|
|
721
|
+
// primitive. The header did not overwrite anything: it vanished, and
|
|
722
|
+
// neither the callback nor the warning said so. Written as a data
|
|
723
|
+
// property, which is what `FetchTransport` already does — the two
|
|
724
|
+
// transports disagreed about whether it is sent at all.
|
|
725
|
+
if (hName === "__proto__") {
|
|
726
|
+
Object.defineProperty(h2ReqHeaders, hName, {
|
|
727
|
+
value: hValue,
|
|
728
|
+
writable: true,
|
|
729
|
+
enumerable: true,
|
|
730
|
+
configurable: true,
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
h2ReqHeaders[hName] = hValue;
|
|
735
|
+
}
|
|
468
736
|
// Header validation (HTTP/2 control-character check). Runs in BOTH modes:
|
|
469
737
|
// strict throws, non-strict drops with callback/warn — matching the
|
|
470
738
|
// FetchTransport contract. (Previously the whole loop was gated on
|
|
@@ -475,35 +743,29 @@ export class NodeHTTP2Transport {
|
|
|
475
743
|
if (hName.startsWith(":"))
|
|
476
744
|
continue;
|
|
477
745
|
const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
this._onDroppedHeader(hName, hStr);
|
|
494
|
-
}
|
|
495
|
-
else if (typeof console !== "undefined") {
|
|
496
|
-
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
|
|
497
|
-
`Pass strictHeaders: true to throw instead.`);
|
|
498
|
-
}
|
|
746
|
+
// The same two checks FetchTransport runs, through the same helpers —
|
|
747
|
+
// not a hand-rolled control-character scan. The scan checked the value
|
|
748
|
+
// only, so a header *name* that was not a token ("X Bad", "X\u00e9")
|
|
749
|
+
// reached `session.request()` and came back as a raw
|
|
750
|
+
// ERR_INVALID_HTTP2_HEADER / ERR_INVALID_HEADER_VALUE instead of being
|
|
751
|
+
// dropped in non-strict mode or raising EVALIDATION in strict mode; and
|
|
752
|
+
// it had no upper bound, so a value above U+00FF — which FetchTransport
|
|
753
|
+
// refuses because no ByteString header value can carry it — was sent on
|
|
754
|
+
// this path and dropped on that one. Same request, two transports, two
|
|
755
|
+
// answers. The comment above this loop claimed they matched; they did
|
|
756
|
+
// not, and HTTP/2 is the default on Node.
|
|
757
|
+
const nameOk = isValidHeaderName(hName);
|
|
758
|
+
const valueOk = isValidHeaderValue(hStr);
|
|
759
|
+
if (!nameOk || !valueOk) {
|
|
760
|
+
this._rejectHeader(hName, hStr, nameOk ? "contains forbidden control characters" : "is not a valid header name", currentReq);
|
|
499
761
|
delete h2ReqHeaders[hName];
|
|
500
762
|
}
|
|
501
763
|
}
|
|
502
|
-
const endStream = !
|
|
764
|
+
const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
|
|
503
765
|
const stream = session.request(h2ReqHeaders, { endStream });
|
|
504
766
|
// FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
|
|
505
|
-
if (
|
|
506
|
-
attachBodyToH2Stream(stream,
|
|
767
|
+
if (bodyForHop && !endStream) {
|
|
768
|
+
attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
|
|
507
769
|
stream.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
508
770
|
});
|
|
509
771
|
}
|
|
@@ -589,8 +851,9 @@ export class NodeHTTP2Transport {
|
|
|
589
851
|
try {
|
|
590
852
|
if (raw.body) {
|
|
591
853
|
const drain = raw.body.getReader();
|
|
592
|
-
//
|
|
593
|
-
|
|
854
|
+
// `for(;;)` rather than `while (true)`: same loop, no condition for a
|
|
855
|
+
// linter to have an opinion about.
|
|
856
|
+
for (;;) {
|
|
594
857
|
const { done } = await drain.read();
|
|
595
858
|
if (done)
|
|
596
859
|
break;
|
|
@@ -603,14 +866,38 @@ export class NodeHTTP2Transport {
|
|
|
603
866
|
}
|
|
604
867
|
const location = raw.headers["location"];
|
|
605
868
|
let nextHref;
|
|
869
|
+
let nextProtocol;
|
|
606
870
|
try {
|
|
607
|
-
|
|
871
|
+
const nextUrl = new URL(location, currentReq.url);
|
|
872
|
+
nextHref = nextUrl.href;
|
|
873
|
+
nextProtocol = nextUrl.protocol.toLowerCase();
|
|
608
874
|
}
|
|
609
875
|
catch {
|
|
610
876
|
throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
|
|
611
877
|
request: req,
|
|
612
878
|
});
|
|
613
879
|
}
|
|
880
|
+
// The same two gates the client's manual redirect follower applies, for
|
|
881
|
+
// the same reasons. They were absent here, and this loop is the *only*
|
|
882
|
+
// follower on this path whenever the request arrives without
|
|
883
|
+
// `redirect: "manual"` — which is every direct use of this transport.
|
|
884
|
+
//
|
|
885
|
+
// - Protocol. This transport hardcodes `:scheme: "https"` and speaks
|
|
886
|
+
// HTTP/2, so a cleartext target cannot be dialled at all: the hop
|
|
887
|
+
// failed as `ERR_HTTP2_ERROR: Protocol error` with nothing to connect
|
|
888
|
+
// it to the target. A downgrade was therefore possible by accident
|
|
889
|
+
// rather than refused on purpose, and an `httpsOnly` client could not
|
|
890
|
+
// tell the difference.
|
|
891
|
+
// - SSRF. The hop origin is dialled directly by `http2.connect` below,
|
|
892
|
+
// with no `isSafeURL` screen — the client's follower screens every hop
|
|
893
|
+
// precisely because "a redirect target never went through that
|
|
894
|
+
// check". A 302 to `http://127.0.0.1:9/` opened the socket.
|
|
895
|
+
if (nextProtocol !== "https:") {
|
|
896
|
+
throw new KinetexError(`HTTP/2 redirect to a non-HTTPS target blocked: ${nextProtocol}//…`, "EVALIDATION", { request: req });
|
|
897
|
+
}
|
|
898
|
+
if (!isSafeURL(nextHref)) {
|
|
899
|
+
throw new KinetexError(`Unsafe redirect target blocked: ${nextHref.replace(/:\/\/[^/@]*@/, "://…@")}`, "EVALIDATION", { request: req });
|
|
900
|
+
}
|
|
614
901
|
// RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
|
|
615
902
|
const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
|
|
616
903
|
const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
|
|
@@ -634,6 +921,10 @@ export class NodeHTTP2Transport {
|
|
|
634
921
|
const opts = {
|
|
635
922
|
fetchFn: globalThis.fetch,
|
|
636
923
|
strict: this._strict,
|
|
924
|
+
// The h2 path enforces `_requestTimeoutMs` itself; without passing it
|
|
925
|
+
// down, `new NodeHTTP2Transport({ requestTimeoutMs: 500 })` hung for
|
|
926
|
+
// the full response time on any non-https URL or HTTP/1.1 request.
|
|
927
|
+
requestTimeoutMs: this._requestTimeoutMs,
|
|
637
928
|
};
|
|
638
929
|
if (this._onDroppedHeader !== undefined)
|
|
639
930
|
opts.onDroppedHeader = this._onDroppedHeader;
|
|
@@ -649,12 +940,35 @@ export class NodeHTTP2Transport {
|
|
|
649
940
|
async _sendHTTP1Legacy(req) {
|
|
650
941
|
const https = await import("node:https");
|
|
651
942
|
const url = new URL(req.url);
|
|
943
|
+
// A `FormData` body is encoded before the request options are built, for
|
|
944
|
+
// the same reason as on the HTTP/2 path: the multipart boundary is
|
|
945
|
+
// generated during encoding, and this transport bypasses fetch, so
|
|
946
|
+
// nothing else would encode it or announce the boundary. A caller-set
|
|
947
|
+
// `content-type` wins — they may have encoded the form themselves.
|
|
948
|
+
let legacyReq = req;
|
|
949
|
+
if (req.body instanceof FormData) {
|
|
950
|
+
const encoded = await serializeRawBody(req.body);
|
|
951
|
+
legacyReq = { ...req, body: encoded.bytes };
|
|
952
|
+
if (encoded.contentType !== undefined && !legacyReq.headers["content-type"]) {
|
|
953
|
+
legacyReq = {
|
|
954
|
+
...legacyReq,
|
|
955
|
+
headers: { ...legacyReq.headers, "content-type": encoded.contentType },
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
}
|
|
652
959
|
const options = {
|
|
653
960
|
hostname: url.hostname,
|
|
654
961
|
port: url.port || "443",
|
|
655
962
|
path: url.pathname + url.search,
|
|
656
|
-
method:
|
|
657
|
-
headers:
|
|
963
|
+
method: legacyReq.method,
|
|
964
|
+
headers: legacyReq.headers,
|
|
965
|
+
...(this._http1KeepAlive ? { agent: this._getHttp1Agent(https) } : {}),
|
|
966
|
+
// `ca` was accepted by the transport but never reached this path, so a
|
|
967
|
+
// private or self-signed peer could not be reached without disabling
|
|
968
|
+
// verification process-wide.
|
|
969
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
970
|
+
// Same reason as the HTTP/2 path above.
|
|
971
|
+
...(this._allowPrivateNetwork ? {} : { lookup: PINNED_LOOKUP }),
|
|
658
972
|
};
|
|
659
973
|
return new Promise((resolve, reject) => {
|
|
660
974
|
const httpReq = https.request(options, (httpRes) => {
|
|
@@ -676,7 +990,13 @@ export class NodeHTTP2Transport {
|
|
|
676
990
|
});
|
|
677
991
|
});
|
|
678
992
|
httpReq.once("error", (err) => {
|
|
679
|
-
|
|
993
|
+
// An error raised while building the connection (a refused proxy
|
|
994
|
+
// tunnel, a TLS failure) is already a KinetexError carrying a
|
|
995
|
+
// meaningful code. Re-wrapping it as ENETWORK threw that away, so a
|
|
996
|
+
// 403 from the proxy and a DNS failure became indistinguishable.
|
|
997
|
+
reject(err instanceof KinetexError
|
|
998
|
+
? err
|
|
999
|
+
: new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
|
|
680
1000
|
});
|
|
681
1001
|
// Remove the abort listener once the request settles so the httpReq
|
|
682
1002
|
// reference doesn't leak beyond the request lifetime.
|
|
@@ -689,13 +1009,76 @@ export class NodeHTTP2Transport {
|
|
|
689
1009
|
httpReq.once("close", cleanup);
|
|
690
1010
|
httpReq.once("error", cleanup);
|
|
691
1011
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
692
|
-
pipeBodyToNodeReq(httpReq,
|
|
1012
|
+
pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
|
|
693
1013
|
}
|
|
694
1014
|
else {
|
|
695
1015
|
httpReq.end();
|
|
696
1016
|
}
|
|
697
1017
|
});
|
|
698
1018
|
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Lazily create the keep-alive agent used by the legacy HTTP/1.1 path.
|
|
1021
|
+
*
|
|
1022
|
+
* @param https - The already-imported `node:https` module.
|
|
1023
|
+
* @returns The shared agent, reused across requests.
|
|
1024
|
+
*/
|
|
1025
|
+
_getHttp1Agent(https) {
|
|
1026
|
+
if (!this._http1Agent) {
|
|
1027
|
+
this._http1Agent = new https.Agent({
|
|
1028
|
+
keepAlive: true,
|
|
1029
|
+
maxSockets: this._http1MaxSockets,
|
|
1030
|
+
maxFreeSockets: this._http1MaxSockets,
|
|
1031
|
+
// A private/self-signed peer must be trusted before the socket enters
|
|
1032
|
+
// the pool, otherwise the agent only fails later on reuse.
|
|
1033
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
1034
|
+
});
|
|
1035
|
+
if (this._proxy !== undefined) {
|
|
1036
|
+
// Assigned to the INSTANCE, not passed in the agent options:
|
|
1037
|
+
// `new Agent({ createConnection })` only copies it into
|
|
1038
|
+
// `agent.options`, while `Agent.prototype.createSocket` calls
|
|
1039
|
+
// `this.createConnection(...)` — the prototype method. Passing it in
|
|
1040
|
+
// the options is silently ignored and the agent dials the origin
|
|
1041
|
+
// directly, bypassing the proxy entirely.
|
|
1042
|
+
//
|
|
1043
|
+
// `createSocket` does:
|
|
1044
|
+
// const s = this.createConnection(options, oncreate);
|
|
1045
|
+
// if (s) oncreate(null, s);
|
|
1046
|
+
// so returning a Promise would be truthy and the agent would adopt
|
|
1047
|
+
// the thenable as a socket. The callback form is the supported way to
|
|
1048
|
+
// connect asynchronously: this returns undefined and reports the
|
|
1049
|
+
// tunneled socket through `oncreate`.
|
|
1050
|
+
//
|
|
1051
|
+
// The tunnel is already TLS-wrapped for an https origin, which is
|
|
1052
|
+
// exactly what https.Agent expects createConnection to return.
|
|
1053
|
+
this._http1Agent.createConnection = ((opts, oncreate) => {
|
|
1054
|
+
void this._createProxiedSocket(`https://${String(opts.host ?? opts.servername ?? "localhost")}:${String(opts.port ?? 443)}`, undefined, undefined).then((socket) => oncreate(null, socket), (err) => oncreate(err));
|
|
1055
|
+
return undefined;
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return this._http1Agent;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Open a socket to `origin` through the configured proxy.
|
|
1063
|
+
*
|
|
1064
|
+
* The returned socket is fully established — tunneled, and TLS-wrapped when
|
|
1065
|
+
* the origin is `https:` — so it can be handed to a transport that requires
|
|
1066
|
+
* its connection synchronously.
|
|
1067
|
+
*
|
|
1068
|
+
* @param origin - Target origin, e.g. `https://api.example.com:443`.
|
|
1069
|
+
* @param req - Originating request, attached to any thrown error.
|
|
1070
|
+
* @returns A socket connected to the target through the proxy.
|
|
1071
|
+
*/
|
|
1072
|
+
async _createProxiedSocket(origin, _tlsOpts, req) {
|
|
1073
|
+
const { connectThroughProxy } = await import("./proxy.js");
|
|
1074
|
+
const target = new URL(origin);
|
|
1075
|
+
return await connectThroughProxy(this._proxy, target, {
|
|
1076
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
1077
|
+
connectTimeoutMs: this._connectTimeoutMs,
|
|
1078
|
+
...(req?.signal != null ? { signal: req.signal } : {}),
|
|
1079
|
+
...(req !== undefined ? { request: req } : {}),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
699
1082
|
/** @internal Evict one session and its associated ping timer. */
|
|
700
1083
|
_evictSession(origin, session) {
|
|
701
1084
|
const timer = this.pingTimers.get(origin);
|
|
@@ -721,6 +1104,10 @@ export class NodeHTTP2Transport {
|
|
|
721
1104
|
this._evictSession(origin, session);
|
|
722
1105
|
}
|
|
723
1106
|
this.sessions.clear();
|
|
1107
|
+
// Drain the legacy HTTP/1.1 keep-alive pool too, so a destroyed transport
|
|
1108
|
+
// leaves no idle sockets behind holding the event loop open.
|
|
1109
|
+
this._http1Agent?.destroy();
|
|
1110
|
+
this._http1Agent = null;
|
|
724
1111
|
}
|
|
725
1112
|
}
|
|
726
1113
|
// ============================================================================
|
|
@@ -743,14 +1130,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
743
1130
|
// "Custom fetch implementation" behavior holds on every runtime.
|
|
744
1131
|
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
745
1132
|
// fetch is given. Falls back to FetchTransport otherwise.
|
|
746
|
-
|
|
1133
|
+
// A dispatcher belongs to the fetch implementation, exactly like a custom
|
|
1134
|
+
// fetch does — NodeHTTP2Transport speaks `node:http2` and has no notion of
|
|
1135
|
+
// one. So it forces the same fallback rather than being silently dropped.
|
|
1136
|
+
const needsFetchTransport = fetchFn !== undefined || transportOptions?.dispatcher !== undefined;
|
|
1137
|
+
if (IS_NODE && preferHTTP2 && needsFetchTransport) {
|
|
747
1138
|
if (!isProductionEnvironment()) {
|
|
748
|
-
console.warn(
|
|
749
|
-
"
|
|
750
|
-
|
|
1139
|
+
console.warn(fetchFn !== undefined
|
|
1140
|
+
? '[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
|
|
1141
|
+
"NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
|
|
1142
|
+
"FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2."
|
|
1143
|
+
: '[kinetex] httpVersion: "HTTP/2" is ignored when a `dispatcher` is configured — ' +
|
|
1144
|
+
"NodeHTTP2Transport talks to node:http2 directly and cannot use a fetch " +
|
|
1145
|
+
"dispatcher, so the request goes through FetchTransport (HTTP/1.1 semantics). " +
|
|
1146
|
+
"Drop the `dispatcher` option to use HTTP/2.");
|
|
751
1147
|
}
|
|
752
1148
|
}
|
|
753
|
-
if (IS_NODE && preferHTTP2 && !
|
|
1149
|
+
if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
|
|
754
1150
|
return new NodeHTTP2Transport({
|
|
755
1151
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
756
1152
|
? { sessionTTLMs: sessionOptions.sessionTTLMs }
|
|
@@ -758,6 +1154,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
758
1154
|
...(sessionOptions?.pingIntervalMs !== undefined
|
|
759
1155
|
? { pingIntervalMs: sessionOptions.pingIntervalMs }
|
|
760
1156
|
: {}),
|
|
1157
|
+
// maxSessions was a documented transport option but was never forwarded
|
|
1158
|
+
// here, so the LRU cap was unreachable and the pool grew unbounded.
|
|
1159
|
+
...(sessionOptions?.maxSessions !== undefined
|
|
1160
|
+
? { maxSessions: sessionOptions.maxSessions }
|
|
1161
|
+
: {}),
|
|
1162
|
+
...(sessionOptions?.http1KeepAlive !== undefined
|
|
1163
|
+
? { http1KeepAlive: sessionOptions.http1KeepAlive }
|
|
1164
|
+
: {}),
|
|
1165
|
+
...(sessionOptions?.http1MaxSockets !== undefined
|
|
1166
|
+
? { http1MaxSockets: sessionOptions.http1MaxSockets }
|
|
1167
|
+
: {}),
|
|
761
1168
|
...(sessionOptions?.connectTimeoutMs !== undefined
|
|
762
1169
|
? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
|
|
763
1170
|
: {}),
|
|
@@ -765,9 +1172,14 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
765
1172
|
? { requestTimeoutMs: sessionOptions.requestTimeoutMs }
|
|
766
1173
|
: {}),
|
|
767
1174
|
...(transportOptions?.strict !== undefined ? { strict: transportOptions.strict } : {}),
|
|
1175
|
+
...(transportOptions?.allowPrivateNetwork !== undefined
|
|
1176
|
+
? { allowPrivateNetwork: transportOptions.allowPrivateNetwork }
|
|
1177
|
+
: {}),
|
|
768
1178
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
769
1179
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
770
1180
|
: {}),
|
|
1181
|
+
...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
|
|
1182
|
+
...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
|
|
771
1183
|
});
|
|
772
1184
|
}
|
|
773
1185
|
return new FetchTransport({
|
|
@@ -776,6 +1188,15 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
776
1188
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
777
1189
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
778
1190
|
: {}),
|
|
1191
|
+
...(transportOptions?.dispatcher !== undefined
|
|
1192
|
+
? { dispatcher: transportOptions.dispatcher }
|
|
1193
|
+
: {}),
|
|
1194
|
+
// Reaches the pre-flight DNS screen in FetchTransport. It was accepted by
|
|
1195
|
+
// createTransport and then dropped, so every fetch-based runtime silently
|
|
1196
|
+
// had the screen off.
|
|
1197
|
+
...(transportOptions?.allowPrivateNetwork !== undefined
|
|
1198
|
+
? { allowPrivateNetwork: transportOptions.allowPrivateNetwork }
|
|
1199
|
+
: {}),
|
|
779
1200
|
});
|
|
780
1201
|
}
|
|
781
1202
|
// ============================================================================
|
|
@@ -877,11 +1298,19 @@ export async function readRawBody(stream, maxBytes, _url, signal) {
|
|
|
877
1298
|
const { done, value } = readResult;
|
|
878
1299
|
if (done)
|
|
879
1300
|
break;
|
|
1301
|
+
// Same policy as the readers in response.ts, reached through the same
|
|
1302
|
+
// helper. The error class stays this entry point's own: `SizeLimitError`
|
|
1303
|
+
// is a `KinetexError` carrying code `ESIZELIMIT`, where the response
|
|
1304
|
+
// readers raise `ResponseSizeLimitError`, which is a bare `Error` with the
|
|
1305
|
+
// same code. Both are public and documented, and merging them would be a
|
|
1306
|
+
// breaking change, so the factory is passed in rather than shared.
|
|
880
1307
|
if (maxBytes > 0) {
|
|
881
|
-
|
|
882
|
-
|
|
1308
|
+
try {
|
|
1309
|
+
limitAction(value, total, { maxBytes }, "throw", (n, max) => new SizeLimitError(n, max));
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
883
1312
|
await reader.cancel("size limit exceeded");
|
|
884
|
-
throw
|
|
1313
|
+
throw err;
|
|
885
1314
|
}
|
|
886
1315
|
}
|
|
887
1316
|
chunks.push(value);
|
|
@@ -937,6 +1366,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
|
|
|
937
1366
|
if (result.success && result.value !== undefined) {
|
|
938
1367
|
parseResult = result.value;
|
|
939
1368
|
}
|
|
1369
|
+
else if (!result.success) {
|
|
1370
|
+
// `safeJSONParse` refuses a payload for a reason it can name — a
|
|
1371
|
+
// depth, a length, a key count, a prototype-pollution key — and that
|
|
1372
|
+
// reason was dropped on the floor, so the caller was told "JSON parse
|
|
1373
|
+
// failed" for a body that is perfectly valid JSON and merely larger
|
|
1374
|
+
// than the limits this function chose. A response that quietly changes
|
|
1375
|
+
// from parsed to raw text is worth one specific sentence about which
|
|
1376
|
+
// limit it crossed, and the code is on the error so a handler can
|
|
1377
|
+
// branch on it.
|
|
1378
|
+
const failure = new Error(`JSON body rejected: ${result.message ?? "parse failed"} — falling back to raw text`);
|
|
1379
|
+
Object.assign(failure, { code: result.error ?? "EPARSE" });
|
|
1380
|
+
parseError = failure;
|
|
1381
|
+
}
|
|
940
1382
|
}
|
|
941
1383
|
catch (e) {
|
|
942
1384
|
parseError = e instanceof Error ? e : new Error(String(e));
|
|
@@ -964,7 +1406,98 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
|
|
|
964
1406
|
// §9 UTILITIES
|
|
965
1407
|
// ============================================================================
|
|
966
1408
|
// FIX 12: normalizeHeaders centralised in utils.ts.
|
|
1409
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
1410
|
+
import { isIP } from "node:net";
|
|
1411
|
+
/** One instance, shared: it holds no per-request state. */
|
|
1412
|
+
const PINNED_LOOKUP = createPinnedLookup();
|
|
1413
|
+
/**
|
|
1414
|
+
* A `lookup` for node:http and node:http2 that closes the DNS-rebinding gap.
|
|
1415
|
+
*
|
|
1416
|
+
* `isSafeURL` reads the hostname the caller wrote. The socket, however, is
|
|
1417
|
+
* dialled at whatever that hostname *resolves to*, and nothing between the two
|
|
1418
|
+
* points re-checked the answer — so a name that resolves to a public address on
|
|
1419
|
+
* the first lookup and to `127.0.0.1` on the second reached a private service.
|
|
1420
|
+
* That is DNS rebinding, and it needs an attacker-controlled resolver, which is
|
|
1421
|
+
* an ordinary capability for whoever controls the domain being requested.
|
|
1422
|
+
*
|
|
1423
|
+
* Resolving here and pinning the result closes it for the connection that
|
|
1424
|
+
* follows: every address the resolver returns is checked against the same
|
|
1425
|
+
* blocklist `isSafeURL` applies to a literal, and only a checked address is
|
|
1426
|
+
* handed back for the dial. A name that resolves solely to private space fails
|
|
1427
|
+
* instead of connecting.
|
|
1428
|
+
*
|
|
1429
|
+
* The alternative — checking the hostname again after connecting — is too late:
|
|
1430
|
+
* the request is already on the wire.
|
|
1431
|
+
*
|
|
1432
|
+
* `dns.lookup` (not `resolve`) is used deliberately: it honours `/etc/hosts`,
|
|
1433
|
+
* the resolver search list and `dns.setDefaultResultOrder`, so a host pinned in
|
|
1434
|
+
* the system file resolves the way it would have without this, and the address
|
|
1435
|
+
* handed back is the one the socket would have reached anyway.
|
|
1436
|
+
*/
|
|
1437
|
+
/**
|
|
1438
|
+
* Drop every resolved address that falls in a blocked range.
|
|
1439
|
+
*
|
|
1440
|
+
* Exported separately from `createPinnedLookup` so the decision can be tested
|
|
1441
|
+
* against addresses a resolver would have to be trusted to produce: a name
|
|
1442
|
+
* that answers with a public address and a private one is the rebinding case,
|
|
1443
|
+
* and it cannot be arranged offline through a real resolver.
|
|
1444
|
+
*/
|
|
1445
|
+
export function filterSafeAddresses(addresses) {
|
|
1446
|
+
return addresses.filter((a) => a.family !== 0 && !isBlockedAddress(a.address));
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* The refusal a host whose every resolved address is blocked earns.
|
|
1450
|
+
*
|
|
1451
|
+
* Split out from {@link FetchTransport} so the message and code can be
|
|
1452
|
+
* asserted without arranging a resolver that answers with a blocked address.
|
|
1453
|
+
*/
|
|
1454
|
+
export function blockedHostError(url, addresses) {
|
|
1455
|
+
return new KinetexError(`URL "${url}" resolved only to blocked addresses (${addresses.map((a) => a.address).join(", ") || "none"})`, "EBLOCKEDHOST");
|
|
1456
|
+
}
|
|
1457
|
+
export function createPinnedLookup() {
|
|
1458
|
+
return (hostname, options, callback) => {
|
|
1459
|
+
// Node's declared callback takes an address even on failure, and it ignores
|
|
1460
|
+
// it — callers branch on `err` first. Node's own resolvers call back with
|
|
1461
|
+
// the error alone, so this matches them rather than inventing a bogus
|
|
1462
|
+
// address.
|
|
1463
|
+
const fail = (err) => {
|
|
1464
|
+
callback(err);
|
|
1465
|
+
};
|
|
1466
|
+
// `all: true` so every candidate is inspected. Node sets it when
|
|
1467
|
+
// `autoSelectFamily` is on (the default since Node 20), in which case the
|
|
1468
|
+
// callback expects an array; when it is not, a single address is expected.
|
|
1469
|
+
const wantsAll = options.all === true;
|
|
1470
|
+
// Typed as `LookupAllOptions` so TypeScript selects the overload that
|
|
1471
|
+
// yields `LookupAddress[]`. Left as a plain object literal it picks the
|
|
1472
|
+
// single-address overload, and `.filter` below does not exist on a string.
|
|
1473
|
+
const resolved = { ...options, all: true };
|
|
1474
|
+
dnsLookup(hostname, resolved, (err, addresses) => {
|
|
1475
|
+
if (err) {
|
|
1476
|
+
fail(err);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const safe = filterSafeAddresses(addresses ?? []);
|
|
1480
|
+
if (safe.length === 0) {
|
|
1481
|
+
// Report it as a name-resolution failure rather than a connection
|
|
1482
|
+
// error: nothing was dialled, and the distinction matters to a caller
|
|
1483
|
+
// deciding whether to retry or to fail over.
|
|
1484
|
+
const blocked = (addresses ?? []).map((a) => a.address).join(", ") || "no addresses";
|
|
1485
|
+
const e = new Error(`Refusing to connect to ${hostname}: it resolves only to a blocked address (${blocked})`);
|
|
1486
|
+
e.code = "EBLOCKEDHOST";
|
|
1487
|
+
fail(e);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (wantsAll) {
|
|
1491
|
+
callback(null, safe);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const first = safe[0];
|
|
1495
|
+
callback(null, first.address, first.family);
|
|
1496
|
+
});
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
967
1499
|
import { normalizeHeaders as _normalizeHeaders } from "./utils.js";
|
|
1500
|
+
import { limitAction } from "./response.js";
|
|
968
1501
|
/**
|
|
969
1502
|
* Normalize a Headers object to a plain record.
|
|
970
1503
|
*
|
|
@@ -984,29 +1517,52 @@ export function normalizeHeaders(headers) {
|
|
|
984
1517
|
* @param _headers - Parsed response headers (reserved)
|
|
985
1518
|
* @returns Detected HTTP version
|
|
986
1519
|
*/
|
|
1520
|
+
/**
|
|
1521
|
+
* Translate a runtime-reported protocol string into the {@link HTTPVersion}
|
|
1522
|
+
* union, or return null when it says nothing this library can act on.
|
|
1523
|
+
*
|
|
1524
|
+
* Accepts the spellings Deno and Bun actually use ("2", "2.0", "1", "1.0",
|
|
1525
|
+
* "1.1") plus the union's own members, case- and whitespace-insensitively, so a
|
|
1526
|
+
* peer cannot widen the field to a string by adding a prefix.
|
|
1527
|
+
*/
|
|
1528
|
+
function normalizeHTTPVersion(raw) {
|
|
1529
|
+
const v = raw.trim().toLowerCase();
|
|
1530
|
+
if (v === "2" || v === "2.0" || v === "h2" || v === "http/2" || v === "http/2.0")
|
|
1531
|
+
return "HTTP/2";
|
|
1532
|
+
if (v === "1" || v === "1.0" || v === "http/1" || v === "http/1.0")
|
|
1533
|
+
return "HTTP/1.0";
|
|
1534
|
+
if (v === "1.1" || v === "http/1.1")
|
|
1535
|
+
return "HTTP/1.1";
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
987
1538
|
function detectHTTPVersion(response, _headers) {
|
|
988
|
-
// Deno
|
|
989
|
-
//
|
|
1539
|
+
// Deno and Bun both expose `httpVersion` on the Response, and both spell it
|
|
1540
|
+
// their own way: Deno answers "2.0", Bun answers "1.1" and "2". The Deno arm
|
|
1541
|
+
// translated its values; the Bun arm returned whatever it was given, so a
|
|
1542
|
+
// plain HTTP/1.1 response on Bun reported the string "1.1" — a value outside
|
|
1543
|
+
// the `HTTPVersion` union, reaching every caller of `res.httpVersion` through
|
|
1544
|
+
// a `[[typed]]` lie. A consumer switching on "HTTP/1.1" silently fell through,
|
|
1545
|
+
// and on a runtime the library does not run in CI the type checker is the only
|
|
1546
|
+
// thing that would have said so. Both arms now go through one translation, and
|
|
1547
|
+
// anything unrecognised falls through to the evidence below rather than being
|
|
1548
|
+
// reported as a protocol.
|
|
990
1549
|
const denoResponse = response;
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
if (bunResponse.httpVersion) {
|
|
997
|
-
return bunResponse.httpVersion;
|
|
1550
|
+
const runtimeVersion = denoResponse.httpVersion;
|
|
1551
|
+
if (typeof runtimeVersion === "string") {
|
|
1552
|
+
const normalized = normalizeHTTPVersion(runtimeVersion);
|
|
1553
|
+
if (normalized)
|
|
1554
|
+
return normalized;
|
|
998
1555
|
}
|
|
999
|
-
//
|
|
1556
|
+
// Server capability advertisement.
|
|
1000
1557
|
// Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
|
|
1001
|
-
//
|
|
1558
|
+
//
|
|
1559
|
+
// kinetex does not speak HTTP/3, and no runtime it targets has a stable
|
|
1560
|
+
// HTTP/3 client, so an h3 advertisement is deliberately NOT reported as
|
|
1561
|
+
// HTTP/3: the response in hand was served over HTTP/2, and saying otherwise
|
|
1562
|
+
// would misreport the protocol actually used. A runtime that ever does
|
|
1563
|
+
// negotiate h3 itself reaches the same answer below rather than claiming a
|
|
1564
|
+
// version the transport cannot produce.
|
|
1002
1565
|
const altSvc = response.headers.get("alt-svc");
|
|
1003
|
-
// Check for active HTTP/3 negotiation (runtime-specific property)
|
|
1004
|
-
const h3Response = response;
|
|
1005
|
-
if (h3Response.httpVersion === "3" ||
|
|
1006
|
-
h3Response.httpVersion === "3.0" ||
|
|
1007
|
-
h3Response.protocol === "h3") {
|
|
1008
|
-
return "HTTP/3";
|
|
1009
|
-
}
|
|
1010
1566
|
// Alt-Svc advertisement: infer HTTP version from the advertised protocols.
|
|
1011
1567
|
// - h3 (QUIC) means the server supports HTTP/3
|
|
1012
1568
|
// - h2 means the server supports HTTP/2
|
|
@@ -1154,7 +1710,7 @@ async function attachBodyToH2Stream(stream, body) {
|
|
|
1154
1710
|
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1155
1711
|
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1156
1712
|
// silently sent an empty body to the server.
|
|
1157
|
-
const bytes = await serializeRawBody(body);
|
|
1713
|
+
const { bytes } = await serializeRawBody(body);
|
|
1158
1714
|
stream.end(bytes);
|
|
1159
1715
|
}
|
|
1160
1716
|
}
|
|
@@ -1188,7 +1744,7 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1188
1744
|
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1189
1745
|
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1190
1746
|
// silently sent an empty body to the server.
|
|
1191
|
-
const bytes = await serializeRawBody(body);
|
|
1747
|
+
const { bytes } = await serializeRawBody(body);
|
|
1192
1748
|
req.end(bytes);
|
|
1193
1749
|
}
|
|
1194
1750
|
}
|
|
@@ -1197,16 +1753,80 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1197
1753
|
* Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
|
|
1198
1754
|
*
|
|
1199
1755
|
* @param body - Request body that is not a stream, byte array, or string
|
|
1200
|
-
* @returns The encoded bytes to write
|
|
1756
|
+
* @returns The encoded bytes to write, plus a content type to announce when the
|
|
1757
|
+
* encoding generated one (empty bytes for unsupported types)
|
|
1201
1758
|
*/
|
|
1202
1759
|
async function serializeRawBody(body) {
|
|
1203
1760
|
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
1204
|
-
return new TextEncoder().encode(body.toString());
|
|
1761
|
+
return { bytes: new TextEncoder().encode(body.toString()) };
|
|
1205
1762
|
}
|
|
1206
1763
|
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
1207
|
-
return new Uint8Array(await body.arrayBuffer());
|
|
1764
|
+
return { bytes: new Uint8Array(await body.arrayBuffer()) };
|
|
1765
|
+
}
|
|
1766
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
1767
|
+
const { bytes, boundary } = await encodeMultipart(body);
|
|
1768
|
+
return { bytes, contentType: `multipart/form-data; boundary=${boundary}` };
|
|
1769
|
+
}
|
|
1770
|
+
return { bytes: new Uint8Array(0) };
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Escape a multipart field name or filename for a `Content-Disposition`
|
|
1774
|
+
* parameter. RFC 7578 §5.1 percent-encodes CR, LF and a double quote; a bare
|
|
1775
|
+
* backslash is escaped too so a name cannot terminate the quoted string early.
|
|
1776
|
+
*/
|
|
1777
|
+
function escapeFieldName(name) {
|
|
1778
|
+
return name.replace(/[\r\n"\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Encode a `FormData` as `multipart/form-data` (RFC 7578).
|
|
1782
|
+
*
|
|
1783
|
+
* The function this lives in was written to stop the raw Node transports from
|
|
1784
|
+
* sending an *empty* body for the types fetch would have serialized, and it
|
|
1785
|
+
* covered `URLSearchParams` and `Blob`. `FormData` was missed, so on the
|
|
1786
|
+
* default transport on Node — `NodeHTTP2Transport`, which bypasses fetch
|
|
1787
|
+
* entirely — a form upload went out as a request with no body at all and no
|
|
1788
|
+
* `Content-Type`, and the server recorded an empty form. The response was an
|
|
1789
|
+
* ordinary 200, so nothing looked wrong.
|
|
1790
|
+
*
|
|
1791
|
+
* The boundary is generated per call and written into the body. The transports
|
|
1792
|
+
* that call this only ever see bytes, so the header is set by the client from
|
|
1793
|
+
* `options.headers`; a body encoded here without a matching header is
|
|
1794
|
+
* unparseable, which is why the boundary is returned alongside the bytes for a
|
|
1795
|
+
* caller that has to announce it. Passing one in makes the output
|
|
1796
|
+
* deterministic, which is what the test suite pins.
|
|
1797
|
+
*/
|
|
1798
|
+
export async function encodeMultipart(form, boundary) {
|
|
1799
|
+
// 24 random bytes as hex: 192 bits is far past any collision concern, and hex
|
|
1800
|
+
// is all legal in a boundary.
|
|
1801
|
+
const bnd = boundary ?? `----kinetexFormBoundary${randomBytes(24)}`;
|
|
1802
|
+
const encoder = new TextEncoder();
|
|
1803
|
+
const chunks = [];
|
|
1804
|
+
const push = (text) => {
|
|
1805
|
+
chunks.push(encoder.encode(text));
|
|
1806
|
+
};
|
|
1807
|
+
for (const [name, value] of form.entries()) {
|
|
1808
|
+
// A field name is application- and sometimes user-controlled, and CR/LF in
|
|
1809
|
+
// one would forge a part header. Such a name is refused rather than sent
|
|
1810
|
+
// or silently dropped.
|
|
1811
|
+
if (/[\r\n"]/.test(name)) {
|
|
1812
|
+
throw new KinetexError(`Cannot send a multipart field whose name contains CR, LF or a quote: ${JSON.stringify(name)}`, "EVALIDATION");
|
|
1813
|
+
}
|
|
1814
|
+
push(`--${bnd}\r\n`);
|
|
1815
|
+
if (typeof value === "string") {
|
|
1816
|
+
push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"\r\n\r\n`);
|
|
1817
|
+
push(value);
|
|
1818
|
+
push("\r\n");
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
// A File/Blob part: RFC 7578 §4.2 wants its own type and filename.
|
|
1822
|
+
push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"; ` +
|
|
1823
|
+
`filename="${escapeFieldName(value.name || "blob")}"\r\n` +
|
|
1824
|
+
`Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`);
|
|
1825
|
+
chunks.push(new Uint8Array(await value.arrayBuffer()));
|
|
1826
|
+
push("\r\n");
|
|
1208
1827
|
}
|
|
1209
|
-
|
|
1828
|
+
push(`--${bnd}--\r\n`);
|
|
1829
|
+
return { bytes: concatUint8Arrays(chunks), boundary: bnd };
|
|
1210
1830
|
}
|
|
1211
1831
|
// ============================================================================
|
|
1212
1832
|
// §10 DECOMPRESSION
|
|
@@ -1216,8 +1836,9 @@ async function serializeRawBody(body) {
|
|
|
1216
1836
|
* Dynamically imports response.ts so that environments that don't use
|
|
1217
1837
|
* decompression don't pay the code cost. The import is cached by the runtime.
|
|
1218
1838
|
*
|
|
1219
|
-
* Supported encodings: gzip, deflate, br (brotli)
|
|
1220
|
-
* Unsupported encodings
|
|
1839
|
+
* Supported encodings: gzip, deflate, br (brotli), zstd.
|
|
1840
|
+
* Unsupported encodings are passed through compressed; the caller must handle
|
|
1841
|
+
* them.
|
|
1221
1842
|
*
|
|
1222
1843
|
* @param body - Raw body stream (or null)
|
|
1223
1844
|
* @param headers - Response headers (content-encoding is read and stripped on success)
|
|
@@ -1236,7 +1857,7 @@ export async function decompressBodyStream(body, headers) {
|
|
|
1236
1857
|
.map((e) => e.trim())
|
|
1237
1858
|
.filter(Boolean);
|
|
1238
1859
|
// Check for unsupported encodings
|
|
1239
|
-
const supportedEncodings = ["gzip", "deflate", "br", "identity"];
|
|
1860
|
+
const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
|
|
1240
1861
|
for (const enc of encodings) {
|
|
1241
1862
|
if (!supportedEncodings.includes(enc)) {
|
|
1242
1863
|
console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +
|