kinetex 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +169 -16
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +14 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +98 -23
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +109 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +31 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +62 -29
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +23 -4
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +139 -5
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/esm/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
|
/**
|
|
@@ -103,6 +122,12 @@ function isProductionEnvironment() {
|
|
|
103
122
|
return false;
|
|
104
123
|
}
|
|
105
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The accept-encoding value client.ts injects when the caller did not set
|
|
127
|
+
* one. FetchTransport removes exactly this string so fetch() negotiates its
|
|
128
|
+
* own encodings; any other value is treated as caller intent.
|
|
129
|
+
*/
|
|
130
|
+
export const DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
|
|
106
131
|
/**
|
|
107
132
|
* Universal fetch-based transport.
|
|
108
133
|
* Suitable for all runtimes where `fetch` is available.
|
|
@@ -111,6 +136,7 @@ export class FetchTransport {
|
|
|
111
136
|
fetchFn;
|
|
112
137
|
strict;
|
|
113
138
|
onDroppedHeader;
|
|
139
|
+
dispatcher;
|
|
114
140
|
/**
|
|
115
141
|
* @param fetchFnOrOptions - Custom fetch function or options object
|
|
116
142
|
*/
|
|
@@ -119,11 +145,13 @@ export class FetchTransport {
|
|
|
119
145
|
this.fetchFn = fetchFnOrOptions;
|
|
120
146
|
this.strict = false;
|
|
121
147
|
this.onDroppedHeader = undefined;
|
|
148
|
+
this.dispatcher = undefined;
|
|
122
149
|
}
|
|
123
150
|
else {
|
|
124
151
|
this.fetchFn = fetchFnOrOptions.fetchFn ?? globalThis.fetch;
|
|
125
152
|
this.strict = fetchFnOrOptions.strict ?? false;
|
|
126
153
|
this.onDroppedHeader = fetchFnOrOptions.onDroppedHeader;
|
|
154
|
+
this.dispatcher = fetchFnOrOptions.dispatcher;
|
|
127
155
|
}
|
|
128
156
|
}
|
|
129
157
|
/**
|
|
@@ -158,12 +186,28 @@ export class FetchTransport {
|
|
|
158
186
|
}
|
|
159
187
|
continue;
|
|
160
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
|
+
}
|
|
161
203
|
sanitizedHeaders[name] = value;
|
|
162
204
|
}
|
|
163
|
-
// Strip
|
|
164
|
-
//
|
|
205
|
+
// Strip the accept-encoding value INJECTED by client.ts so fetch() can add
|
|
206
|
+
// its own. The old check removed ANY value containing gzip+deflate+br,
|
|
207
|
+
// including one the caller set deliberately; matching the exact injected
|
|
208
|
+
// default keeps caller intent intact.
|
|
165
209
|
const ae = sanitizedHeaders["accept-encoding"];
|
|
166
|
-
if (ae && ae.toLowerCase().
|
|
210
|
+
if (ae && ae.toLowerCase().replace(/\s+/g, " ") === DEFAULT_ACCEPT_ENCODING) {
|
|
167
211
|
delete sanitizedHeaders["accept-encoding"];
|
|
168
212
|
}
|
|
169
213
|
// Build fetch init
|
|
@@ -187,6 +231,11 @@ export class FetchTransport {
|
|
|
187
231
|
init.duplex = "half";
|
|
188
232
|
}
|
|
189
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
|
+
}
|
|
190
239
|
let response;
|
|
191
240
|
try {
|
|
192
241
|
response = await this.fetchFn(req.url, init);
|
|
@@ -256,6 +305,25 @@ export class NodeHTTP2Transport {
|
|
|
256
305
|
_requestTimeoutMs;
|
|
257
306
|
/** Cached HTTP/1.1 fallback transport — reuse instead of creating fresh FetchTransport per call */
|
|
258
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;
|
|
259
327
|
/**
|
|
260
328
|
* @param options - Session pool and transport configuration
|
|
261
329
|
*/
|
|
@@ -263,6 +331,9 @@ export class NodeHTTP2Transport {
|
|
|
263
331
|
this.sessionTTLMs = options.sessionTTLMs ?? 5 * 60_000;
|
|
264
332
|
this.pingIntervalMs = options.pingIntervalMs ?? 30_000;
|
|
265
333
|
this.maxSessions = options.maxSessions ?? 100;
|
|
334
|
+
this._http1KeepAlive = options.http1KeepAlive ?? true;
|
|
335
|
+
this._http1MaxSockets = options.http1MaxSockets ?? 16;
|
|
336
|
+
this._proxy = options.proxy;
|
|
266
337
|
this._strict = options.strict ?? false;
|
|
267
338
|
this._onDroppedHeader = options.onDroppedHeader;
|
|
268
339
|
this._ca = options.ca;
|
|
@@ -325,10 +396,29 @@ export class NodeHTTP2Transport {
|
|
|
325
396
|
this._evictSession(lruOrigin, lruSession.session);
|
|
326
397
|
}
|
|
327
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
|
+
}
|
|
328
411
|
const session = await new Promise((resolve, reject) => {
|
|
329
412
|
const s = http2.connect(origin, {
|
|
330
413
|
rejectUnauthorized: true,
|
|
331
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
|
+
: {}),
|
|
332
422
|
});
|
|
333
423
|
// FIX 11: use configurable connect timeout instead of hardcoded 30 000 ms
|
|
334
424
|
// FIX 9: unref() the timer so it does not prevent process exit
|
|
@@ -417,6 +507,29 @@ export class NodeHTTP2Transport {
|
|
|
417
507
|
throw err;
|
|
418
508
|
}
|
|
419
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
|
+
}
|
|
420
533
|
/**
|
|
421
534
|
* Send a request over HTTP/2 with iterative redirect following.
|
|
422
535
|
* Each hop reuses or creates a session for the target origin.
|
|
@@ -449,14 +562,69 @@ export class NodeHTTP2Transport {
|
|
|
449
562
|
session = existing.session;
|
|
450
563
|
this.sessionUsage.set(origin, Date.now());
|
|
451
564
|
}
|
|
452
|
-
//
|
|
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.
|
|
453
599
|
const h2ReqHeaders = {
|
|
454
600
|
":method": currentReq.method,
|
|
455
601
|
":path": currentUrl.pathname + currentUrl.search,
|
|
456
602
|
":scheme": "https",
|
|
457
603
|
":authority": currentUrl.host,
|
|
458
|
-
...currentReq.headers,
|
|
459
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
|
+
}
|
|
460
628
|
// Header validation (HTTP/2 control-character check). Runs in BOTH modes:
|
|
461
629
|
// strict throws, non-strict drops with callback/warn — matching the
|
|
462
630
|
// FetchTransport contract. (Previously the whole loop was gated on
|
|
@@ -467,35 +635,29 @@ export class NodeHTTP2Transport {
|
|
|
467
635
|
if (hName.startsWith(":"))
|
|
468
636
|
continue;
|
|
469
637
|
const hStr = Array.isArray(hValue) ? hValue.join(", ") : String(hValue);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
this._onDroppedHeader(hName, hStr);
|
|
486
|
-
}
|
|
487
|
-
else if (typeof console !== "undefined") {
|
|
488
|
-
console.warn(`[kinetex] Invalid header dropped (HTTP/2): "${hName}" — value contains illegal control characters. ` +
|
|
489
|
-
`Pass strictHeaders: true to throw instead.`);
|
|
490
|
-
}
|
|
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);
|
|
491
653
|
delete h2ReqHeaders[hName];
|
|
492
654
|
}
|
|
493
655
|
}
|
|
494
|
-
const endStream = !
|
|
656
|
+
const endStream = !bodyForHop || currentReq.method === "GET" || currentReq.method === "HEAD";
|
|
495
657
|
const stream = session.request(h2ReqHeaders, { endStream });
|
|
496
658
|
// FIX 6 (backpressure): attachBodyToH2Stream now awaits drain events
|
|
497
|
-
if (
|
|
498
|
-
attachBodyToH2Stream(stream,
|
|
659
|
+
if (bodyForHop && !endStream) {
|
|
660
|
+
attachBodyToH2Stream(stream, bodyForHop).catch((err) => {
|
|
499
661
|
stream.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
500
662
|
});
|
|
501
663
|
}
|
|
@@ -581,8 +743,9 @@ export class NodeHTTP2Transport {
|
|
|
581
743
|
try {
|
|
582
744
|
if (raw.body) {
|
|
583
745
|
const drain = raw.body.getReader();
|
|
584
|
-
//
|
|
585
|
-
|
|
746
|
+
// `for(;;)` rather than `while (true)`: same loop, no condition for a
|
|
747
|
+
// linter to have an opinion about.
|
|
748
|
+
for (;;) {
|
|
586
749
|
const { done } = await drain.read();
|
|
587
750
|
if (done)
|
|
588
751
|
break;
|
|
@@ -595,14 +758,38 @@ export class NodeHTTP2Transport {
|
|
|
595
758
|
}
|
|
596
759
|
const location = raw.headers["location"];
|
|
597
760
|
let nextHref;
|
|
761
|
+
let nextProtocol;
|
|
598
762
|
try {
|
|
599
|
-
|
|
763
|
+
const nextUrl = new URL(location, currentReq.url);
|
|
764
|
+
nextHref = nextUrl.href;
|
|
765
|
+
nextProtocol = nextUrl.protocol.toLowerCase();
|
|
600
766
|
}
|
|
601
767
|
catch {
|
|
602
768
|
throw new KinetexError(`Invalid redirect Location: ${location}`, "ENETWORK", {
|
|
603
769
|
request: req,
|
|
604
770
|
});
|
|
605
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
|
+
}
|
|
606
793
|
// RFC 7231 §6.4: 301/302/303 → downgrade to GET; 307/308 → preserve method
|
|
607
794
|
const nextMethod = raw.status === 301 || raw.status === 302 || raw.status === 303 ? "GET" : currentReq.method;
|
|
608
795
|
const nextBody = nextMethod === "GET" || nextMethod === "HEAD" ? null : currentReq.body;
|
|
@@ -641,12 +828,33 @@ export class NodeHTTP2Transport {
|
|
|
641
828
|
async _sendHTTP1Legacy(req) {
|
|
642
829
|
const https = await import("node:https");
|
|
643
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
|
+
}
|
|
644
847
|
const options = {
|
|
645
848
|
hostname: url.hostname,
|
|
646
849
|
port: url.port || "443",
|
|
647
850
|
path: url.pathname + url.search,
|
|
648
|
-
method:
|
|
649
|
-
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 } : {}),
|
|
650
858
|
};
|
|
651
859
|
return new Promise((resolve, reject) => {
|
|
652
860
|
const httpReq = https.request(options, (httpRes) => {
|
|
@@ -668,7 +876,13 @@ export class NodeHTTP2Transport {
|
|
|
668
876
|
});
|
|
669
877
|
});
|
|
670
878
|
httpReq.once("error", (err) => {
|
|
671
|
-
|
|
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 }));
|
|
672
886
|
});
|
|
673
887
|
// Remove the abort listener once the request settles so the httpReq
|
|
674
888
|
// reference doesn't leak beyond the request lifetime.
|
|
@@ -681,13 +895,76 @@ export class NodeHTTP2Transport {
|
|
|
681
895
|
httpReq.once("close", cleanup);
|
|
682
896
|
httpReq.once("error", cleanup);
|
|
683
897
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {
|
|
684
|
-
pipeBodyToNodeReq(httpReq,
|
|
898
|
+
pipeBodyToNodeReq(httpReq, legacyReq.body).catch(reject);
|
|
685
899
|
}
|
|
686
900
|
else {
|
|
687
901
|
httpReq.end();
|
|
688
902
|
}
|
|
689
903
|
});
|
|
690
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
|
+
}
|
|
691
968
|
/** @internal Evict one session and its associated ping timer. */
|
|
692
969
|
_evictSession(origin, session) {
|
|
693
970
|
const timer = this.pingTimers.get(origin);
|
|
@@ -713,6 +990,10 @@ export class NodeHTTP2Transport {
|
|
|
713
990
|
this._evictSession(origin, session);
|
|
714
991
|
}
|
|
715
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;
|
|
716
997
|
}
|
|
717
998
|
}
|
|
718
999
|
// ============================================================================
|
|
@@ -735,7 +1016,23 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
735
1016
|
// "Custom fetch implementation" behavior holds on every runtime.
|
|
736
1017
|
// Use NodeHTTP2Transport for Node.js when HTTP/2 is preferred and no custom
|
|
737
1018
|
// fetch is given. Falls back to FetchTransport otherwise.
|
|
738
|
-
|
|
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) {
|
|
1024
|
+
if (!isProductionEnvironment()) {
|
|
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.");
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (IS_NODE && preferHTTP2 && !needsFetchTransport) {
|
|
739
1036
|
return new NodeHTTP2Transport({
|
|
740
1037
|
...(sessionOptions?.sessionTTLMs !== undefined
|
|
741
1038
|
? { sessionTTLMs: sessionOptions.sessionTTLMs }
|
|
@@ -743,6 +1040,17 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
743
1040
|
...(sessionOptions?.pingIntervalMs !== undefined
|
|
744
1041
|
? { pingIntervalMs: sessionOptions.pingIntervalMs }
|
|
745
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
|
+
: {}),
|
|
746
1054
|
...(sessionOptions?.connectTimeoutMs !== undefined
|
|
747
1055
|
? { connectTimeoutMs: sessionOptions.connectTimeoutMs }
|
|
748
1056
|
: {}),
|
|
@@ -753,6 +1061,8 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
753
1061
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
754
1062
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
755
1063
|
: {}),
|
|
1064
|
+
...(transportOptions?.ca !== undefined ? { ca: transportOptions.ca } : {}),
|
|
1065
|
+
...(transportOptions?.proxy !== undefined ? { proxy: transportOptions.proxy } : {}),
|
|
756
1066
|
});
|
|
757
1067
|
}
|
|
758
1068
|
return new FetchTransport({
|
|
@@ -761,6 +1071,9 @@ export function createTransport(fetchFn, preferHTTP2 = true, sessionOptions, tra
|
|
|
761
1071
|
...(transportOptions?.onDroppedHeader !== undefined
|
|
762
1072
|
? { onDroppedHeader: transportOptions.onDroppedHeader }
|
|
763
1073
|
: {}),
|
|
1074
|
+
...(transportOptions?.dispatcher !== undefined
|
|
1075
|
+
? { dispatcher: transportOptions.dispatcher }
|
|
1076
|
+
: {}),
|
|
764
1077
|
});
|
|
765
1078
|
}
|
|
766
1079
|
// ============================================================================
|
|
@@ -922,6 +1235,19 @@ export function parseBody(raw, contentType, customParser, onParseFailure, header
|
|
|
922
1235
|
if (result.success && result.value !== undefined) {
|
|
923
1236
|
parseResult = result.value;
|
|
924
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
|
+
}
|
|
925
1251
|
}
|
|
926
1252
|
catch (e) {
|
|
927
1253
|
parseError = e instanceof Error ? e : new Error(String(e));
|
|
@@ -969,29 +1295,52 @@ export function normalizeHeaders(headers) {
|
|
|
969
1295
|
* @param _headers - Parsed response headers (reserved)
|
|
970
1296
|
* @returns Detected HTTP version
|
|
971
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
|
+
}
|
|
972
1316
|
function detectHTTPVersion(response, _headers) {
|
|
973
|
-
// Deno
|
|
974
|
-
//
|
|
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.
|
|
975
1327
|
const denoResponse = response;
|
|
976
|
-
|
|
977
|
-
|
|
1328
|
+
const runtimeVersion = denoResponse.httpVersion;
|
|
1329
|
+
if (typeof runtimeVersion === "string") {
|
|
1330
|
+
const normalized = normalizeHTTPVersion(runtimeVersion);
|
|
1331
|
+
if (normalized)
|
|
1332
|
+
return normalized;
|
|
978
1333
|
}
|
|
979
|
-
//
|
|
980
|
-
const bunResponse = response;
|
|
981
|
-
if (bunResponse.httpVersion) {
|
|
982
|
-
return bunResponse.httpVersion;
|
|
983
|
-
}
|
|
984
|
-
// HTTP/3 (QUIC) detection via Alt-Svc header.
|
|
1334
|
+
// Server capability advertisement.
|
|
985
1335
|
// Servers that support HTTP/3 advertise: Alt-Svc: h3="...", h3-29="..."
|
|
986
|
-
//
|
|
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.
|
|
987
1343
|
const altSvc = response.headers.get("alt-svc");
|
|
988
|
-
// Check for active HTTP/3 negotiation (runtime-specific property)
|
|
989
|
-
const h3Response = response;
|
|
990
|
-
if (h3Response.httpVersion === "3" ||
|
|
991
|
-
h3Response.httpVersion === "3.0" ||
|
|
992
|
-
h3Response.protocol === "h3") {
|
|
993
|
-
return "HTTP/3";
|
|
994
|
-
}
|
|
995
1344
|
// Alt-Svc advertisement: infer HTTP version from the advertised protocols.
|
|
996
1345
|
// - h3 (QUIC) means the server supports HTTP/3
|
|
997
1346
|
// - h2 means the server supports HTTP/2
|
|
@@ -1111,7 +1460,7 @@ async function writeChunkWithBackpressure(stream, chunk) {
|
|
|
1111
1460
|
}
|
|
1112
1461
|
/**
|
|
1113
1462
|
* Write a request body to an HTTP/2 stream, respecting backpressure.
|
|
1114
|
-
* Handles ReadableStream, Uint8Array, ArrayBuffer, and
|
|
1463
|
+
* Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
|
|
1115
1464
|
*
|
|
1116
1465
|
* @param stream - HTTP/2 stream to write to
|
|
1117
1466
|
* @param body - Request body
|
|
@@ -1136,12 +1485,16 @@ async function attachBodyToH2Stream(stream, body) {
|
|
|
1136
1485
|
stream.end(body);
|
|
1137
1486
|
}
|
|
1138
1487
|
else {
|
|
1139
|
-
|
|
1488
|
+
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1489
|
+
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1490
|
+
// silently sent an empty body to the server.
|
|
1491
|
+
const { bytes } = await serializeRawBody(body);
|
|
1492
|
+
stream.end(bytes);
|
|
1140
1493
|
}
|
|
1141
1494
|
}
|
|
1142
1495
|
/**
|
|
1143
1496
|
* Write a request body to a Node.js http.ClientRequest, respecting backpressure.
|
|
1144
|
-
* Handles ReadableStream, Uint8Array, ArrayBuffer, and
|
|
1497
|
+
* Handles ReadableStream, Uint8Array, ArrayBuffer, string, URLSearchParams and Blob bodies.
|
|
1145
1498
|
*
|
|
1146
1499
|
* @param req - Node.js ClientRequest
|
|
1147
1500
|
* @param body - Request body
|
|
@@ -1166,8 +1519,92 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1166
1519
|
req.end(body);
|
|
1167
1520
|
}
|
|
1168
1521
|
else {
|
|
1169
|
-
|
|
1522
|
+
// The raw Node transports bypass fetch, so bodies fetch would normally
|
|
1523
|
+
// serialize (URLSearchParams, Blob) must be encoded here. Skipping them
|
|
1524
|
+
// silently sent an empty body to the server.
|
|
1525
|
+
const { bytes } = await serializeRawBody(body);
|
|
1526
|
+
req.end(bytes);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Serialize body types that `fetch` would normally encode for us, so the raw
|
|
1531
|
+
* Node HTTP/1.1 and HTTP/2 transports do not silently send an empty payload.
|
|
1532
|
+
*
|
|
1533
|
+
* @param body - Request body that is not a stream, byte array, or string
|
|
1534
|
+
* @returns The encoded bytes to write, plus a content type to announce when the
|
|
1535
|
+
* encoding generated one (empty bytes for unsupported types)
|
|
1536
|
+
*/
|
|
1537
|
+
async function serializeRawBody(body) {
|
|
1538
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
|
|
1539
|
+
return { bytes: new TextEncoder().encode(body.toString()) };
|
|
1540
|
+
}
|
|
1541
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
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");
|
|
1170
1605
|
}
|
|
1606
|
+
push(`--${bnd}--\r\n`);
|
|
1607
|
+
return { bytes: concatUint8Arrays(chunks), boundary: bnd };
|
|
1171
1608
|
}
|
|
1172
1609
|
// ============================================================================
|
|
1173
1610
|
// §10 DECOMPRESSION
|
|
@@ -1177,8 +1614,9 @@ async function pipeBodyToNodeReq(req, body) {
|
|
|
1177
1614
|
* Dynamically imports response.ts so that environments that don't use
|
|
1178
1615
|
* decompression don't pay the code cost. The import is cached by the runtime.
|
|
1179
1616
|
*
|
|
1180
|
-
* Supported encodings: gzip, deflate, br (brotli)
|
|
1181
|
-
* Unsupported encodings
|
|
1617
|
+
* Supported encodings: gzip, deflate, br (brotli), zstd.
|
|
1618
|
+
* Unsupported encodings are passed through compressed; the caller must handle
|
|
1619
|
+
* them.
|
|
1182
1620
|
*
|
|
1183
1621
|
* @param body - Raw body stream (or null)
|
|
1184
1622
|
* @param headers - Response headers (content-encoding is read and stripped on success)
|
|
@@ -1197,7 +1635,7 @@ export async function decompressBodyStream(body, headers) {
|
|
|
1197
1635
|
.map((e) => e.trim())
|
|
1198
1636
|
.filter(Boolean);
|
|
1199
1637
|
// Check for unsupported encodings
|
|
1200
|
-
const supportedEncodings = ["gzip", "deflate", "br", "identity"];
|
|
1638
|
+
const supportedEncodings = ["gzip", "deflate", "br", "zstd", "identity"];
|
|
1201
1639
|
for (const enc of encodings) {
|
|
1202
1640
|
if (!supportedEncodings.includes(enc)) {
|
|
1203
1641
|
console.warn(`[Kinetex] Unsupported Content-Encoding: ${enc}. ` +
|