kinetex 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -9
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +2545 -550
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +133 -19
- package/dist/cjs/cache.js +49 -7
- package/dist/cjs/circuit-breaker.js +45 -3
- package/dist/cjs/client.js +387 -104
- package/dist/cjs/cookie-parser.js +103 -5
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +465 -66
- package/dist/cjs/dedup.js +49 -11
- package/dist/cjs/digest.js +160 -24
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +303 -45
- package/dist/cjs/interceptors.js +221 -7
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +168 -15
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +177 -27
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +131 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/ws.js +19 -10
- package/dist/esm/aws-sigv4.js +133 -19
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +49 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +45 -3
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +387 -104
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +103 -5
- 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 +465 -66
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +49 -11
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +160 -24
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +303 -45
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +221 -7
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +168 -15
- 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 +177 -27
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +131 -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 +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/ws.js +19 -10
- 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 +14 -1
- 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 +103 -25
- package/dist/types/core.d.ts.map +1 -1
- 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 +45 -27
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +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 +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +114 -3
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +1 -1
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, 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,7 @@ export class FetchTransport {
|
|
|
117
136
|
fetchFn;
|
|
118
137
|
strict;
|
|
119
138
|
onDroppedHeader;
|
|
139
|
+
dispatcher;
|
|
120
140
|
/**
|
|
121
141
|
* @param fetchFnOrOptions - Custom fetch function or options object
|
|
122
142
|
*/
|
|
@@ -125,11 +145,13 @@ export class FetchTransport {
|
|
|
125
145
|
this.fetchFn = fetchFnOrOptions;
|
|
126
146
|
this.strict = false;
|
|
127
147
|
this.onDroppedHeader = undefined;
|
|
148
|
+
this.dispatcher = undefined;
|
|
128
149
|
}
|
|
129
150
|
else {
|
|
130
151
|
this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
|
|
131
152
|
this.strict = fetchFnOrOptions.strict ?? false;
|
|
132
153
|
this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
|
|
154
|
+
this.dispatcher = fetchFnOrOptions.dispatcher;
|
|
133
155
|
}
|
|
134
156
|
}
|
|
135
157
|
/**
|
|
@@ -164,6 +186,20 @@ export class FetchTransport {
|
|
|
164
186
|
}
|
|
165
187
|
continue;
|
|
166
188
|
}
|
|
189
|
+
// `__proto__` is a legal header name — it is made of token characters,
|
|
190
|
+
// so it passes the check above — and `sanitizedHeaders[name] = value` is a
|
|
191
|
+
// [[Set]], so it went to the inherited setter, which ignores a primitive.
|
|
192
|
+
// The header did not overwrite anything: it vanished, and the caller was
|
|
193
|
+
// never told. Written as a data property, which is what was meant.
|
|
194
|
+
if (name === "__proto__") {
|
|
195
|
+
Object.defineProperty(sanitizedHeaders, name, {
|
|
196
|
+
value,
|
|
197
|
+
writable: true,
|
|
198
|
+
enumerable: true,
|
|
199
|
+
configurable: true,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
167
203
|
sanitizedHeaders[name] = value;
|
|
168
204
|
}
|
|
169
205
|
// Strip the accept-encoding value INJECTED by client.ts so fetch() can add
|
|
@@ -195,6 +231,11 @@ export class FetchTransport {
|
|
|
195
231
|
init.duplex = "half";
|
|
196
232
|
}
|
|
197
233
|
}
|
|
234
|
+
// Forwarded only when set: an explicit `dispatcher: undefined` would
|
|
235
|
+
// otherwise override a globally-installed dispatcher on the runtime.
|
|
236
|
+
if (this.dispatcher !== undefined) {
|
|
237
|
+
init.dispatcher = this.dispatcher;
|
|
238
|
+
}
|
|
198
239
|
let response;
|
|
199
240
|
try {
|
|
200
241
|
response = await this.fetchFn(req.url, init);
|
|
@@ -264,6 +305,25 @@ export class NodeHTTP2Transport {
|
|
|
264
305
|
_requestTimeoutMs;
|
|
265
306
|
/** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
|
|
266
307
|
_http1Fallback = null;
|
|
308
|
+
/**
|
|
309
|
+
* Dedicated keep-alive agent for the legacy `node:https` path.
|
|
310
|
+
*
|
|
311
|
+
* This path called `https.request(options)` with no `agent`, so behaviour was
|
|
312
|
+
* whatever the global agent happened to do — and that default is a moving
|
|
313
|
+
* target. Node 19 turned `keepAlive` on for `https.globalAgent`, so this path
|
|
314
|
+
* silently changed from a TCP + TLS handshake per request (Node 18 and
|
|
315
|
+
* earlier) to pooled connections, with no code change here to explain it.
|
|
316
|
+
*
|
|
317
|
+
* An explicit agent makes the behaviour identical on every supported Node
|
|
318
|
+
* version, bounds the idle-socket pool (the global agent keeps 256 free
|
|
319
|
+
* sockets, which is a lot to hold open for a legacy fallback), and gives
|
|
320
|
+
* callers a way to opt out.
|
|
321
|
+
*/
|
|
322
|
+
_http1KeepAlive;
|
|
323
|
+
_http1MaxSockets;
|
|
324
|
+
_http1Agent = null;
|
|
325
|
+
/** HTTP(S) CONNECT proxy, or undefined for a direct connection. */
|
|
326
|
+
_proxy;
|
|
267
327
|
/**
|
|
268
328
|
* @param options - Session pool and transport configuration
|
|
269
329
|
*/
|
|
@@ -271,6 +331,9 @@ export class NodeHTTP2Transport {
|
|
|
271
331
|
this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
|
|
272
332
|
this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
|
|
273
333
|
this.maxSessions = options.maxSessions ?? 100;
|
|
334
|
+
this._http1KeepAlive = options.http1KeepAlive ?? true;
|
|
335
|
+
this._http1MaxSockets = options.http1MaxSockets ?? 16;
|
|
336
|
+
this._proxy = options.proxy;
|
|
274
337
|
this._strict = options.strict ?? false;
|
|
275
338
|
this._onDroppedHeader = options.onDroppedHeader;
|
|
276
339
|
this._ca = options.ca;
|
|
@@ -333,10 +396,29 @@ export class NodeHTTP2Transport {
|
|
|
333
396
|
this._evictSession(lruOrigin, lruSession.session);
|
|
334
397
|
}
|
|
335
398
|
}
|
|
399
|
+
// With a proxy, the tunnel (and, for an https origin, the TLS handshake
|
|
400
|
+
// to the target) is established *before* http2.connect is called.
|
|
401
|
+
//
|
|
402
|
+
// `createConnection` must return a socket synchronously — node:http2 does
|
|
403
|
+
// not await it, and handing it a Promise yields a session built on a
|
|
404
|
+
// thenable, which fails deep inside the stream layer with
|
|
405
|
+
// "stream.pause is not a function". So the async work happens here and
|
|
406
|
+
// the already-open socket is passed back synchronously.
|
|
407
|
+
let proxiedSocket = null;
|
|
408
|
+
if (this._proxy !== undefined) {
|
|
409
|
+
proxiedSocket = await this._createProxiedSocket(origin, undefined, req);
|
|
410
|
+
}
|
|
336
411
|
const session = await new Promise((resolve, reject) => {
|
|
337
412
|
const s = http2.connect(origin, {
|
|
338
413
|
rejectUnauthorized: true,
|
|
339
414
|
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
415
|
+
// Already connected (and already TLS-wrapped for an https origin),
|
|
416
|
+
// so node:http2 must use it as-is rather than negotiating again.
|
|
417
|
+
...(proxiedSocket !== null
|
|
418
|
+
? {
|
|
419
|
+
createConnection: (() => proxiedSocket),
|
|
420
|
+
}
|
|
421
|
+
: {}),
|
|
340
422
|
});
|
|
341
423
|
// FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
|
|
342
424
|
// FIX 9: unref() the timer so it does not prevent process exit
|
|
@@ -425,6 +507,29 @@ export class NodeHTTP2Transport {
|
|
|
425
507
|
throw err;
|
|
426
508
|
}
|
|
427
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Refuse one header on the HTTP/2 path. Strict mode raises `EVALIDATION`
|
|
512
|
+
* before anything is dialled; non-strict notifies the callback (if any) and
|
|
513
|
+
* warns, never dropping silently.
|
|
514
|
+
*
|
|
515
|
+
* Both the pseudo-header filter and the validation loop go through here so
|
|
516
|
+
* the two cannot drift apart, which is what let a caller-supplied `:path`
|
|
517
|
+
* reach the wire while `FetchTransport` dropped the identical header.
|
|
518
|
+
*/
|
|
519
|
+
_rejectHeader(name, value, reason, request) {
|
|
520
|
+
if (this._strict) {
|
|
521
|
+
throw new KinetexError(`Strict mode: header "${name}" ${reason}`, "EVALIDATION", {
|
|
522
|
+
request,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (this._onDroppedHeader) {
|
|
526
|
+
this._onDroppedHeader(name, value);
|
|
527
|
+
}
|
|
528
|
+
else if (typeof console !== "undefined") {
|
|
529
|
+
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${name}" — ${reason}. ` +
|
|
530
|
+
`Pass strictHeaders: true to throw instead.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
428
533
|
/**
|
|
429
534
|
* Send a request over HTTP/2 with iterative redirect following.
|
|
430
535
|
* Each hop reuses or creates a session for the target origin.
|
|
@@ -457,14 +562,69 @@ export class NodeHTTP2Transport {
|
|
|
457
562
|
session = existing.session;
|
|
458
563
|
this.sessionUsage.set(origin, Date.now());
|
|
459
564
|
}
|
|
460
|
-
//
|
|
565
|
+
// A `FormData` body has to be encoded BEFORE the header block is built:
|
|
566
|
+
// the multipart boundary is generated during encoding, and a boundary
|
|
567
|
+
// that cannot reach `content-type` leaves a body the peer cannot parse.
|
|
568
|
+
// This transport bypasses fetch, so nothing else would have encoded it.
|
|
569
|
+
// The client's own path never reaches this — it encodes the form and sets
|
|
570
|
+
// the header together before dispatch — but a caller handing a `FormData`
|
|
571
|
+
// straight to a transport would otherwise send a body whose boundary no
|
|
572
|
+
// header ever named.
|
|
573
|
+
let bodyForHop = currentReq.body;
|
|
574
|
+
let encodedForHop;
|
|
575
|
+
if (currentReq.body instanceof FormData) {
|
|
576
|
+
encodedForHop = await serializeRawBody(currentReq.body);
|
|
577
|
+
bodyForHop = encodedForHop.bytes;
|
|
578
|
+
// Request headers are lowercased before they reach the transport, so
|
|
579
|
+
// a direct key read is the right test here.
|
|
580
|
+
if (encodedForHop.contentType !== undefined && !currentReq.headers["content-type"]) {
|
|
581
|
+
currentReq = {
|
|
582
|
+
...currentReq,
|
|
583
|
+
headers: { ...currentReq.headers, "content-type": encodedForHop.contentType },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Build headers for this hop. The transport owns the request line, so a
|
|
588
|
+
// caller-supplied pseudo-header (":path", ":authority", ":method",
|
|
589
|
+
// ":scheme") is refused rather than merged.
|
|
590
|
+
//
|
|
591
|
+
// They used to be spread over the transport's own values, which meant a
|
|
592
|
+
// caller could send a request to a path and a `:authority` that the URL
|
|
593
|
+
// argument never contained — the URL that `isSafeURL` screened is not
|
|
594
|
+
// the URL that got dialled. The validation loop below could not catch it
|
|
595
|
+
// either, because it skipped every name starting with ":", so even
|
|
596
|
+
// `strict: true` returned 200 for a hijacked `:path`, and
|
|
597
|
+
// `FetchTransport` dropped the very same header because ":" is not a
|
|
598
|
+
// token character. Same request, two transports, two answers.
|
|
461
599
|
const h2ReqHeaders = {
|
|
462
600
|
":method": currentReq.method,
|
|
463
601
|
":path": currentUrl.pathname + currentUrl.search,
|
|
464
602
|
":scheme": "https",
|
|
465
603
|
":authority": currentUrl.host,
|
|
466
|
-
...currentReq.headers,
|
|
467
604
|
};
|
|
605
|
+
for (const [hName, hValue] of Object.entries(currentReq.headers)) {
|
|
606
|
+
if (hName.startsWith(":")) {
|
|
607
|
+
this._rejectHeader(hName, Array.isArray(hValue) ? hValue.join(", ") : String(hValue), "is not a valid header name", currentReq);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
// `__proto__` is a legal header name — it is made of token characters,
|
|
611
|
+
// so it passes `isValidHeaderName` — and `h2ReqHeaders[name] = value`
|
|
612
|
+
// is a [[Set]], so it hit the inherited setter, which ignores a
|
|
613
|
+
// primitive. The header did not overwrite anything: it vanished, and
|
|
614
|
+
// neither the callback nor the warning said so. Written as a data
|
|
615
|
+
// property, which is what `FetchTransport` already does — the two
|
|
616
|
+
// transports disagreed about whether it is sent at all.
|
|
617
|
+
if (hName === "__proto__") {
|
|
618
|
+
Object.defineProperty(h2ReqHeaders, hName, {
|
|
619
|
+
value: hValue,
|
|
620
|
+
writable: true,
|
|
621
|
+
enumerable: true,
|
|
622
|
+
configurable: true,
|
|
623
|
+
});
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
h2ReqHeaders[hName] = hValue;
|
|
627
|
+
}
|
|
468
628
|
// Header validation (HTTP/2 control-character check). Runs in BOTH modes:
|
|
469
629
|
// strict throws, non-strict drops with callback/warn — matching the
|
|
470
630
|
// FetchTransport contract. (Previously the whole loop was gated on
|
|
@@ -475,35 +635,29 @@ export class NodeHTTP2Transport {
|
|
|
475
635
|
if (hName.startsWith(":"))
|
|
476
636
|
continue;
|
|
477
637
|
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
|
-
}
|
|
638
|
+
// The same two checks FetchTransport runs, through the same helpers —
|
|
639
|
+
// not a hand-rolled control-character scan. The scan checked the value
|
|
640
|
+
// only, so a header *name* that was not a token ("X Bad", "X\u00e9")
|
|
641
|
+
// reached `session.request()` and came back as a raw
|
|
642
|
+
// ERR_INVALID_HTTP2_HEADER / ERR_INVALID_HEADER_VALUE instead of being
|
|
643
|
+
// dropped in non-strict mode or raising EVALIDATION in strict mode; and
|
|
644
|
+
// it had no upper bound, so a value above U+00FF — which FetchTransport
|
|
645
|
+
// refuses because no ByteString header value can carry it — was sent on
|
|
646
|
+
// this path and dropped on that one. Same request, two transports, two
|
|
647
|
+
// answers. The comment above this loop claimed they matched; they did
|
|
648
|
+
// not, and HTTP/2 is the default on Node.
|
|
649
|
+
const nameOk = isValidHeaderName(hName);
|
|
650
|
+
const valueOk = isValidHeaderValue(hStr);
|
|
651
|
+
if (!nameOk || !valueOk) {
|
|
652
|
+
this._rejectHeader(hName, hStr, nameOk ? "contains forbidden control characters" : "is not a valid header name", currentReq);
|
|
499
653
|
delete h2ReqHeaders[hName];
|
|
500
654
|
}
|
|
501
655
|
}
|
|
502
|
-
const endStream = !
|
|
656
|
+
const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
|
|
503
657
|
const stream = session.request(h2ReqHeaders, { endStream });
|
|
504
658
|
// FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
|
|
505
|
-
if (
|
|
506
|
-
attachBodyToH2Stream(stream,
|
|
659
|
+
if (bodyForHop && !endStream) {
|
|
660
|
+
attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
|
|
507
661
|
stream.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
508
662
|
});
|
|
509
663
|
}
|
|
@@ -589,8 +743,9 @@ export class NodeHTTP2Transport {
|
|
|
589
743
|
try {
|
|
590
744
|
if (raw.body) {
|
|
591
745
|
const drain = raw.body.getReader();
|
|
592
|
-
//
|
|
593
|
-
|
|
746
|
+
// `for(;;)` rather than `while (true)`: same loop, no condition for a
|
|
747
|
+
// linter to have an opinion about.
|
|
748
|
+
for (;;) {
|
|
594
749
|
const { done } = await drain.read();
|
|
595
750
|
if (done)
|
|
596
751
|
break;
|
|
@@ -603,14 +758,38 @@ export class NodeHTTP2Transport {
|
|
|
603
758
|
}
|
|
604
759
|
const location = raw.headers["location"];
|
|
605
760
|
let nextHref;
|
|
761
|
+
let nextProtocol;
|
|
606
762
|
try {
|
|
607
|
-
|
|
763
|
+
const nextUrl = new URL(location, currentReq.url);
|
|
764
|
+
nextHref = nextUrl.href;
|
|
765
|
+
nextProtocol = nextUrl.protocol.toLowerCase();
|
|
608
766
|
}
|
|
609
767
|
catch {
|
|
610
768
|
throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
|
|
611
769
|
request: req,
|
|
612
770
|
});
|
|
613
771
|
}
|
|
772
|
+
// The same two gates the client's manual redirect follower applies, for
|
|
773
|
+
// the same reasons. They were absent here, and this loop is the *only*
|
|
774
|
+
// follower on this path whenever the request arrives without
|
|
775
|
+
// `redirect: "manual"` — which is every direct use of this transport.
|
|
776
|
+
//
|
|
777
|
+
// - Protocol. This transport hardcodes `:scheme: "https"` and speaks
|
|
778
|
+
// HTTP/2, so a cleartext target cannot be dialled at all: the hop
|
|
779
|
+
// failed as `ERR_HTTP2_ERROR: Protocol error` with nothing to connect
|
|
780
|
+
// it to the target. A downgrade was therefore possible by accident
|
|
781
|
+
// rather than refused on purpose, and an `httpsOnly` client could not
|
|
782
|
+
// tell the difference.
|
|
783
|
+
// - SSRF. The hop origin is dialled directly by `http2.connect` below,
|
|
784
|
+
// with no `isSafeURL` screen — the client's follower screens every hop
|
|
785
|
+
// precisely because "a redirect target never went through that
|
|
786
|
+
// check". A 302 to `http://127.0.0.1:9/` opened the socket.
|
|
787
|
+
if (nextProtocol !== "https:") {
|
|
788
|
+
throw new KinetexError(`HTTP/2 redirect to a non-HTTPS target blocked: ${nextProtocol}//…`, "EVALIDATION", { request: req });
|
|
789
|
+
}
|
|
790
|
+
if (!isSafeURL(nextHref)) {
|
|
791
|
+
throw new KinetexError(`Unsafe redirect target blocked: ${nextHref.replace(/:\/\/[^/@]*@/, "://…@")}`, "EVALIDATION", { request: req });
|
|
792
|
+
}
|
|
614
793
|
// RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
|
|
615
794
|
const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
|
|
616
795
|
const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
|
|
@@ -649,12 +828,33 @@ export class NodeHTTP2Transport {
|
|
|
649
828
|
async _sendHTTP1Legacy(req) {
|
|
650
829
|
const https = await import("node:https");
|
|
651
830
|
const url = new URL(req.url);
|
|
831
|
+
// A `FormData` body is encoded before the request options are built, for
|
|
832
|
+
// the same reason as on the HTTP/2 path: the multipart boundary is
|
|
833
|
+
// generated during encoding, and this transport bypasses fetch, so
|
|
834
|
+
// nothing else would encode it or announce the boundary. A caller-set
|
|
835
|
+
// `content-type` wins — they may have encoded the form themselves.
|
|
836
|
+
let legacyReq = req;
|
|
837
|
+
if (req.body instanceof FormData) {
|
|
838
|
+
const encoded = await serializeRawBody(req.body);
|
|
839
|
+
legacyReq = { ...req, body: encoded.bytes };
|
|
840
|
+
if (encoded.contentType !== undefined && !legacyReq.headers["content-type"]) {
|
|
841
|
+
legacyReq = {
|
|
842
|
+
...legacyReq,
|
|
843
|
+
headers: { ...legacyReq.headers, "content-type": encoded.contentType },
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
652
847
|
const options = {
|
|
653
848
|
hostname: url.hostname,
|
|
654
849
|
port: url.port || "443",
|
|
655
850
|
path: url.pathname + url.search,
|
|
656
|
-
method:
|
|
657
|
-
headers:
|
|
851
|
+
method: legacyReq.method,
|
|
852
|
+
headers: legacyReq.headers,
|
|
853
|
+
...(this._http1KeepAlive ? { agent: this._getHttp1Agent(https) } : {}),
|
|
854
|
+
// `ca` was accepted by the transport but never reached this path, so a
|
|
855
|
+
// private or self-signed peer could not be reached without disabling
|
|
856
|
+
// verification process-wide.
|
|
857
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
658
858
|
};
|
|
659
859
|
return new Promise((resolve, reject) => {
|
|
660
860
|
const httpReq = https.request(options, (httpRes) => {
|
|
@@ -676,7 +876,13 @@ export class NodeHTTP2Transport {
|
|
|
676
876
|
});
|
|
677
877
|
});
|
|
678
878
|
httpReq.once("error", (err) => {
|
|
679
|
-
|
|
879
|
+
// An error raised while building the connection (a refused proxy
|
|
880
|
+
// tunnel, a TLS failure) is already a KinetexError carrying a
|
|
881
|
+
// meaningful code. Re-wrapping it as ENETWORK threw that away, so a
|
|
882
|
+
// 403 from the proxy and a DNS failure became indistinguishable.
|
|
883
|
+
reject(err instanceof KinetexError
|
|
884
|
+
? err
|
|
885
|
+
: new KinetexError(err.message, "ENETWORK", { request: req, cause: err }));
|
|
680
886
|
});
|
|
681
887
|
// Remove the abort listener once the request settles so the httpReq
|
|
682
888
|
// reference doesn't leak beyond the request lifetime.
|
|
@@ -689,13 +895,76 @@ export class NodeHTTP2Transport {
|
|
|
689
895
|
httpReq.once("close", cleanup);
|
|
690
896
|
httpReq.once("error", cleanup);
|
|
691
897
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
692
|
-
pipeBodyToNodeReq(httpReq,
|
|
898
|
+
pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
|
|
693
899
|
}
|
|
694
900
|
else {
|
|
695
901
|
httpReq.end();
|
|
696
902
|
}
|
|
697
903
|
});
|
|
698
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* Lazily create the keep-alive agent used by the legacy HTTP/1.1 path.
|
|
907
|
+
*
|
|
908
|
+
* @param https - The already-imported `node:https` module.
|
|
909
|
+
* @returns The shared agent, reused across requests.
|
|
910
|
+
*/
|
|
911
|
+
_getHttp1Agent(https) {
|
|
912
|
+
if (!this._http1Agent) {
|
|
913
|
+
this._http1Agent = new https.Agent({
|
|
914
|
+
keepAlive: true,
|
|
915
|
+
maxSockets: this._http1MaxSockets,
|
|
916
|
+
maxFreeSockets: this._http1MaxSockets,
|
|
917
|
+
// A private/self-signed peer must be trusted before the socket enters
|
|
918
|
+
// the pool, otherwise the agent only fails later on reuse.
|
|
919
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
920
|
+
});
|
|
921
|
+
if (this._proxy !== undefined) {
|
|
922
|
+
// Assigned to the INSTANCE, not passed in the agent options:
|
|
923
|
+
// `new Agent({ createConnection })` only copies it into
|
|
924
|
+
// `agent.options`, while `Agent.prototype.createSocket` calls
|
|
925
|
+
// `this.createConnection(...)` — the prototype method. Passing it in
|
|
926
|
+
// the options is silently ignored and the agent dials the origin
|
|
927
|
+
// directly, bypassing the proxy entirely.
|
|
928
|
+
//
|
|
929
|
+
// `createSocket` does:
|
|
930
|
+
// const s = this.createConnection(options, oncreate);
|
|
931
|
+
// if (s) oncreate(null, s);
|
|
932
|
+
// so returning a Promise would be truthy and the agent would adopt
|
|
933
|
+
// the thenable as a socket. The callback form is the supported way to
|
|
934
|
+
// connect asynchronously: this returns undefined and reports the
|
|
935
|
+
// tunneled socket through `oncreate`.
|
|
936
|
+
//
|
|
937
|
+
// The tunnel is already TLS-wrapped for an https origin, which is
|
|
938
|
+
// exactly what https.Agent expects createConnection to return.
|
|
939
|
+
this._http1Agent.createConnection = ((opts, oncreate) => {
|
|
940
|
+
void this._createProxiedSocket(`https://${String(opts.host ?? opts.servername ?? "localhost")}:${String(opts.port ?? 443)}`, undefined, undefined).then((socket) => oncreate(null, socket), (err) => oncreate(err));
|
|
941
|
+
return undefined;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return this._http1Agent;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Open a socket to `origin` through the configured proxy.
|
|
949
|
+
*
|
|
950
|
+
* The returned socket is fully established — tunneled, and TLS-wrapped when
|
|
951
|
+
* the origin is `https:` — so it can be handed to a transport that requires
|
|
952
|
+
* its connection synchronously.
|
|
953
|
+
*
|
|
954
|
+
* @param origin - Target origin, e.g. `https://api.example.com:443`.
|
|
955
|
+
* @param req - Originating request, attached to any thrown error.
|
|
956
|
+
* @returns A socket connected to the target through the proxy.
|
|
957
|
+
*/
|
|
958
|
+
async _createProxiedSocket(origin, _tlsOpts, req) {
|
|
959
|
+
const { connectThroughProxy } = await import("./proxy.js");
|
|
960
|
+
const target = new URL(origin);
|
|
961
|
+
return await connectThroughProxy(this._proxy, target, {
|
|
962
|
+
...(this._ca !== undefined ? { ca: this._ca } : {}),
|
|
963
|
+
connectTimeoutMs: this._connectTimeoutMs,
|
|
964
|
+
...(req?.signal != null ? { signal: req.signal } : {}),
|
|
965
|
+
...(req !== undefined ? { request: req } : {}),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
699
968
|
/** @internal Evict one session and its associated ping timer. */
|
|
700
969
|
_evictSession(origin, session) {
|
|
701
970
|
const timer = this.pingTimers.get(origin);
|
|
@@ -721,6 +990,10 @@ export class NodeHTTP2Transport {
|
|
|
721
990
|
this._evictSession(origin, session);
|
|
722
991
|
}
|
|
723
992
|
this.sessions.clear();
|
|
993
|
+
// Drain the legacy HTTP/1.1 keep-alive pool too, so a destroyed transport
|
|
994
|
+
// leaves no idle sockets behind holding the event loop open.
|
|
995
|
+
this._http1Agent?.destroy();
|
|
996
|
+
this._http1Agent = null;
|
|
724
997
|
}
|
|
725
998
|
}
|
|
726
999
|
// ============================================================================
|
|
@@ -743,14 +1016,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
743
1016
|
// "Custom fetch implementation" behavior holds on every runtime.
|
|
744
1017
|
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
745
1018
|
// fetch is given. Falls back to FetchTransport otherwise.
|
|
746
|
-
|
|
1019
|
+
// A dispatcher belongs to the fetch implementation, exactly like a custom
|
|
1020
|
+
// fetch does — NodeHTTP2Transport speaks `node:http2` and has no notion of
|
|
1021
|
+
// one. So it forces the same fallback rather than being silently dropped.
|
|
1022
|
+
const needsFetchTransport = fetchFn !== undefined || transportOptions?.dispatcher !== undefined;
|
|
1023
|
+
if (IS_NODE && preferHTTP2 && needsFetchTransport) {
|
|
747
1024
|
if (!isProductionEnvironment()) {
|
|
748
|
-
console.warn(
|
|
749
|
-
"
|
|
750
|
-
|
|
1025
|
+
console.warn(fetchFn !== undefined
|
|
1026
|
+
? '[kinetex] httpVersion: "HTTP/2" is ignored when a custom `fetch` is configured — ' +
|
|
1027
|
+
"NodeHTTP2Transport cannot use a custom fetch, so the request goes through " +
|
|
1028
|
+
"FetchTransport (HTTP/1.1 semantics). Drop the `fetch` option to use HTTP/2."
|
|
1029
|
+
: '[kinetex] httpVersion: "HTTP/2" is ignored when a `dispatcher` is configured — ' +
|
|
1030
|
+
"NodeHTTP2Transport talks to node:http2 directly and cannot use a fetch " +
|
|
1031
|
+
"dispatcher, so the request goes through FetchTransport (HTTP/1.1 semantics). " +
|
|
1032
|
+
"Drop the `dispatcher` option to use HTTP/2.");
|
|
751
1033
|
}
|
|
752
1034
|
}
|
|
753
|
-
if (IS_NODE && preferHTTP2 && !
|
|
1035
|
+
if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
|
|
754
1036
|
return new NodeHTTP2Transport({
|
|
755
1037
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
756
1038
|
? { sessionTTLMs: sessionOptions.sessionTTLMs }
|
|
@@ -758,6 +1040,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
758
1040
|
...(sessionOptions?.pingIntervalMs !== undefined
|
|
759
1041
|
? { pingIntervalMs: sessionOptions.pingIntervalMs }
|
|
760
1042
|
: {}),
|
|
1043
|
+
// maxSessions was a documented transport option but was never forwarded
|
|
1044
|
+
// here, so the LRU cap was unreachable and the pool grew unbounded.
|
|
1045
|
+
...(sessionOptions?.maxSessions !== undefined
|
|
1046
|
+
? { maxSessions: sessionOptions.maxSessions }
|
|
1047
|
+
: {}),
|
|
1048
|
+
...(sessionOptions?.http1KeepAlive !== undefined
|
|
1049
|
+
? { http1KeepAlive: sessionOptions.http1KeepAlive }
|
|
1050
|
+
: {}),
|
|
1051
|
+
...(sessionOptions?.http1MaxSockets !== undefined
|
|
1052
|
+
? { http1MaxSockets: sessionOptions.http1MaxSockets }
|
|
1053
|
+
: {}),
|
|
761
1054
|
...(sessionOptions?.connectTimeoutMs !== undefined
|
|
762
1055
|
? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
|
|
763
1056
|
: {}),
|
|
@@ -768,6 +1061,8 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
768
1061
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
769
1062
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
770
1063
|
: {}),
|
|
1064
|
+
...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
|
|
1065
|
+
...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
|
|
771
1066
|
});
|
|
772
1067
|
}
|
|
773
1068
|
return new FetchTransport({
|
|
@@ -776,6 +1071,9 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
776
1071
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
777
1072
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
778
1073
|
: {}),
|
|
1074
|
+
...(transportOptions?.dispatcher !== undefined
|
|
1075
|
+
? { dispatcher: transportOptions.dispatcher }
|
|
1076
|
+
: {}),
|
|
779
1077
|
});
|
|
780
1078
|
}
|
|
781
1079
|
// ============================================================================
|
|
@@ -937,6 +1235,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
|
|
|
937
1235
|
if (result.success && result.value !== undefined) {
|
|
938
1236
|
parseResult = result.value;
|
|
939
1237
|
}
|
|
1238
|
+
else if (!result.success) {
|
|
1239
|
+
// `safeJSONParse` refuses a payload for a reason it can name — a
|
|
1240
|
+
// depth, a length, a key count, a prototype-pollution key — and that
|
|
1241
|
+
// reason was dropped on the floor, so the caller was told "JSON parse
|
|
1242
|
+
// failed" for a body that is perfectly valid JSON and merely larger
|
|
1243
|
+
// than the limits this function chose. A response that quietly changes
|
|
1244
|
+
// from parsed to raw text is worth one specific sentence about which
|
|
1245
|
+
// limit it crossed, and the code is on the error so a handler can
|
|
1246
|
+
// branch on it.
|
|
1247
|
+
const failure = new Error(`JSON body rejected: ${result.message ?? "parse failed"} — falling back to raw text`);
|
|
1248
|
+
Object.assign(failure, { code: result.error ?? "EPARSE" });
|
|
1249
|
+
parseError = failure;
|
|
1250
|
+
}
|
|
940
1251
|
}
|
|
941
1252
|
catch (e) {
|
|
942
1253
|
parseError = e instanceof Error ? e : new Error(String(e));
|
|
@@ -984,29 +1295,52 @@ export function normalizeHeaders(headers) {
|
|
|
984
1295
|
* @param _headers - Parsed response headers (reserved)
|
|
985
1296
|
* @returns Detected HTTP version
|
|
986
1297
|
*/
|
|
1298
|
+
/**
|
|
1299
|
+
* Translate a runtime-reported protocol string into the {@link HTTPVersion}
|
|
1300
|
+
* union, or return null when it says nothing this library can act on.
|
|
1301
|
+
*
|
|
1302
|
+
* Accepts the spellings Deno and Bun actually use ("2", "2.0", "1", "1.0",
|
|
1303
|
+
* "1.1") plus the union's own members, case- and whitespace-insensitively, so a
|
|
1304
|
+
* peer cannot widen the field to a string by adding a prefix.
|
|
1305
|
+
*/
|
|
1306
|
+
function normalizeHTTPVersion(raw) {
|
|
1307
|
+
const v = raw.trim().toLowerCase();
|
|
1308
|
+
if (v === "2" || v === "2.0" || v === "h2" || v === "http/2" || v === "http/2.0")
|
|
1309
|
+
return "HTTP/2";
|
|
1310
|
+
if (v === "1" || v === "1.0" || v === "http/1" || v === "http/1.0")
|
|
1311
|
+
return "HTTP/1.0";
|
|
1312
|
+
if (v === "1.1" || v === "http/1.1")
|
|
1313
|
+
return "HTTP/1.1";
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
987
1316
|
function detectHTTPVersion(response, _headers) {
|
|
988
|
-
// Deno
|
|
989
|
-
//
|
|
1317
|
+
// Deno and Bun both expose `httpVersion` on the Response, and both spell it
|
|
1318
|
+
// their own way: Deno answers "2.0", Bun answers "1.1" and "2". The Deno arm
|
|
1319
|
+
// translated its values; the Bun arm returned whatever it was given, so a
|
|
1320
|
+
// plain HTTP/1.1 response on Bun reported the string "1.1" — a value outside
|
|
1321
|
+
// the `HTTPVersion` union, reaching every caller of `res.httpVersion` through
|
|
1322
|
+
// a `[[typed]]` lie. A consumer switching on "HTTP/1.1" silently fell through,
|
|
1323
|
+
// and on a runtime the library does not run in CI the type checker is the only
|
|
1324
|
+
// thing that would have said so. Both arms now go through one translation, and
|
|
1325
|
+
// anything unrecognised falls through to the evidence below rather than being
|
|
1326
|
+
// reported as a protocol.
|
|
990
1327
|
const denoResponse = response;
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
if (bunResponse.httpVersion) {
|
|
997
|
-
return bunResponse.httpVersion;
|
|
1328
|
+
const runtimeVersion = denoResponse.httpVersion;
|
|
1329
|
+
if (typeof runtimeVersion === "string") {
|
|
1330
|
+
const normalized = normalizeHTTPVersion(runtimeVersion);
|
|
1331
|
+
if (normalized)
|
|
1332
|
+
return normalized;
|
|
998
1333
|
}
|
|
999
|
-
//
|
|
1334
|
+
// Server capability advertisement.
|
|
1000
1335
|
// Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
|
|
1001
|
-
//
|
|
1336
|
+
//
|
|
1337
|
+
// kinetex does not speak HTTP/3, and no runtime it targets has a stable
|
|
1338
|
+
// HTTP/3 client, so an h3 advertisement is deliberately NOT reported as
|
|
1339
|
+
// HTTP/3: the response in hand was served over HTTP/2, and saying otherwise
|
|
1340
|
+
// would misreport the protocol actually used. A runtime that ever does
|
|
1341
|
+
// negotiate h3 itself reaches the same answer below rather than claiming a
|
|
1342
|
+
// version the transport cannot produce.
|
|
1002
1343
|
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
1344
|
// Alt-Svc advertisement: infer HTTP version from the advertised protocols.
|
|
1011
1345
|
// - h3 (QUIC) means the server supports HTTP/3
|
|
1012
1346
|
// - h2 means the server supports HTTP/2
|
|
@@ -1154,7 +1488,7 @@ async function attachBodyToH2Stream(stream, body) {
|
|
|
1154
1488
|
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1155
1489
|
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1156
1490
|
// silently sent an empty body to the server.
|
|
1157
|
-
const bytes = await serializeRawBody(body);
|
|
1491
|
+
const { bytes } = await serializeRawBody(body);
|
|
1158
1492
|
stream.end(bytes);
|
|
1159
1493
|
}
|
|
1160
1494
|
}
|
|
@@ -1188,7 +1522,7 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1188
1522
|
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1189
1523
|
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1190
1524
|
// silently sent an empty body to the server.
|
|
1191
|
-
const bytes = await serializeRawBody(body);
|
|
1525
|
+
const { bytes } = await serializeRawBody(body);
|
|
1192
1526
|
req.end(bytes);
|
|
1193
1527
|
}
|
|
1194
1528
|
}
|
|
@@ -1197,16 +1531,80 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1197
1531
|
* Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
|
|
1198
1532
|
*
|
|
1199
1533
|
* @param body - Request body that is not a stream, byte array, or string
|
|
1200
|
-
* @returns The encoded bytes to write
|
|
1534
|
+
* @returns The encoded bytes to write, plus a content type to announce when the
|
|
1535
|
+
* encoding generated one (empty bytes for unsupported types)
|
|
1201
1536
|
*/
|
|
1202
1537
|
async function serializeRawBody(body) {
|
|
1203
1538
|
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
1204
|
-
return new TextEncoder().encode(body.toString());
|
|
1539
|
+
return { bytes: new TextEncoder().encode(body.toString()) };
|
|
1205
1540
|
}
|
|
1206
1541
|
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
1207
|
-
return new Uint8Array(await body.arrayBuffer());
|
|
1542
|
+
return { bytes: new Uint8Array(await body.arrayBuffer()) };
|
|
1543
|
+
}
|
|
1544
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
1545
|
+
const { bytes, boundary } = await encodeMultipart(body);
|
|
1546
|
+
return { bytes, contentType: `multipart/form-data; boundary=${boundary}` };
|
|
1547
|
+
}
|
|
1548
|
+
return { bytes: new Uint8Array(0) };
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Escape a multipart field name or filename for a `Content-Disposition`
|
|
1552
|
+
* parameter. RFC 7578 §5.1 percent-encodes CR, LF and a double quote; a bare
|
|
1553
|
+
* backslash is escaped too so a name cannot terminate the quoted string early.
|
|
1554
|
+
*/
|
|
1555
|
+
function escapeFieldName(name) {
|
|
1556
|
+
return name.replace(/[\r\n"\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Encode a `FormData` as `multipart/form-data` (RFC 7578).
|
|
1560
|
+
*
|
|
1561
|
+
* The function this lives in was written to stop the raw Node transports from
|
|
1562
|
+
* sending an *empty* body for the types fetch would have serialized, and it
|
|
1563
|
+
* covered `URLSearchParams` and `Blob`. `FormData` was missed, so on the
|
|
1564
|
+
* default transport on Node — `NodeHTTP2Transport`, which bypasses fetch
|
|
1565
|
+
* entirely — a form upload went out as a request with no body at all and no
|
|
1566
|
+
* `Content-Type`, and the server recorded an empty form. The response was an
|
|
1567
|
+
* ordinary 200, so nothing looked wrong.
|
|
1568
|
+
*
|
|
1569
|
+
* The boundary is generated per call and written into the body. The transports
|
|
1570
|
+
* that call this only ever see bytes, so the header is set by the client from
|
|
1571
|
+
* `options.headers`; a body encoded here without a matching header is
|
|
1572
|
+
* unparseable, which is why the boundary is returned alongside the bytes for a
|
|
1573
|
+
* caller that has to announce it. Passing one in makes the output
|
|
1574
|
+
* deterministic, which is what the test suite pins.
|
|
1575
|
+
*/
|
|
1576
|
+
export async function encodeMultipart(form, boundary) {
|
|
1577
|
+
// 24 random bytes as hex: 192 bits is far past any collision concern, and hex
|
|
1578
|
+
// is all legal in a boundary.
|
|
1579
|
+
const bnd = boundary ?? `----kinetexFormBoundary${randomBytes(24)}`;
|
|
1580
|
+
const encoder = new TextEncoder();
|
|
1581
|
+
const chunks = [];
|
|
1582
|
+
const push = (text) => {
|
|
1583
|
+
chunks.push(encoder.encode(text));
|
|
1584
|
+
};
|
|
1585
|
+
for (const [name, value] of form.entries()) {
|
|
1586
|
+
// A field name is application- and sometimes user-controlled, and CR/LF in
|
|
1587
|
+
// one would forge a part header. Such a name is refused rather than sent
|
|
1588
|
+
// or silently dropped.
|
|
1589
|
+
if (/[\r\n"]/.test(name)) {
|
|
1590
|
+
throw new KinetexError(`Cannot send a multipart field whose name contains CR, LF or a quote: ${JSON.stringify(name)}`, "EVALIDATION");
|
|
1591
|
+
}
|
|
1592
|
+
push(`--${bnd}\r\n`);
|
|
1593
|
+
if (typeof value === "string") {
|
|
1594
|
+
push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"\r\n\r\n`);
|
|
1595
|
+
push(value);
|
|
1596
|
+
push("\r\n");
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
// A File/Blob part: RFC 7578 §4.2 wants its own type and filename.
|
|
1600
|
+
push(`Content-Disposition: form-data; name="${escapeFieldName(name)}"; ` +
|
|
1601
|
+
`filename="${escapeFieldName(value.name || "blob")}"\r\n` +
|
|
1602
|
+
`Content-Type: ${value.type || "application/octet-stream"}\r\n\r\n`);
|
|
1603
|
+
chunks.push(new Uint8Array(await value.arrayBuffer()));
|
|
1604
|
+
push("\r\n");
|
|
1208
1605
|
}
|
|
1209
|
-
|
|
1606
|
+
push(`--${bnd}--\r\n`);
|
|
1607
|
+
return { bytes: concatUint8Arrays(chunks), boundary: bnd };
|
|
1210
1608
|
}
|
|
1211
1609
|
// ============================================================================
|
|
1212
1610
|
// §10 DECOMPRESSION
|
|
@@ -1216,8 +1614,9 @@ async function serializeRawBody(body) {
|
|
|
1216
1614
|
* Dynamically imports response.ts so that environments that don't use
|
|
1217
1615
|
* decompression don't pay the code cost. The import is cached by the runtime.
|
|
1218
1616
|
*
|
|
1219
|
-
* Supported encodings: gzip, deflate, br (brotli)
|
|
1220
|
-
* Unsupported encodings
|
|
1617
|
+
* Supported encodings: gzip, deflate, br (brotli), zstd.
|
|
1618
|
+
* Unsupported encodings are passed through compressed; the caller must handle
|
|
1619
|
+
* them.
|
|
1221
1620
|
*
|
|
1222
1621
|
* @param body - Raw body stream (or null)
|
|
1223
1622
|
* @param headers - Response headers (content-encoding is read and stripped on success)
|
|
@@ -1236,7 +1635,7 @@ export async function decompressBodyStream(body, headers) {
|
|
|
1236
1635
|
.map((e) => e.trim())
|
|
1237
1636
|
.filter(Boolean);
|
|
1238
1637
|
// Check for unsupported encodings
|
|
1239
|
-
const supportedEncodings = ["gzip", "deflate", "br", "identity"];
|
|
1638
|
+
const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
|
|
1240
1639
|
for (const enc of encodings) {
|
|
1241
1640
|
if (!supportedEncodings.includes(enc)) {
|
|
1242
1641
|
console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +
|