@nifrajs/proxy 2.14.1 → 3.2.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/dist/index.js CHANGED
@@ -10,10 +10,22 @@
10
10
  * by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
11
11
  * the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
12
12
  * Hono proxy CVE-2026-71849 class). Forwarding metadata (`Forwarded`, `X-Forwarded-*`) is stripped
13
- * unless `forwardClientIp: true`, in which case the caller chain is appended truthfully. Upstream
13
+ * unless explicitly configured. `forwardClientIp: true` emits caller IP/protocol, while
14
+ * `forwardedHost` emits only its fixed authority and never the inbound Host. An inbound IP chain is
15
+ * preserved only with the explicit `trustForwardedFor: true` declaration. Upstream
14
16
  * redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
15
17
  * the proxy anywhere. TLS verification is always on; there is no knob to disable it.
16
18
  */
19
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
20
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
21
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
22
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
23
+ });
24
+ }
25
+ return path;
26
+ };
27
+ const NODE_NATIVE_MOUNT = Symbol.for("@nifrajs/core/node-native-mount");
28
+ const NODE_NATIVE_TRANSPORT = Symbol.for("@nifrajs/proxy/node-native-transport");
17
29
  /** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
18
30
  const HOP_BY_HOP = new Set([
19
31
  "connection",
@@ -26,7 +38,7 @@ const HOP_BY_HOP = new Set([
26
38
  "transfer-encoding",
27
39
  "upgrade",
28
40
  ]);
29
- /** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
41
+ /** Forwarding metadata is an explicit opt-in (`forwardClientIp` / `forwardedHost`), never passthrough. */
30
42
  const FORWARDING = new Set([
31
43
  "forwarded",
32
44
  "x-forwarded-for",
@@ -34,9 +46,14 @@ const FORWARDING = new Set([
34
46
  "x-forwarded-proto",
35
47
  ]);
36
48
  const EMPTY = new Set();
37
- /** Header names nominated hop-by-hop by a `Connection` header (lowercased). */
38
- function connectionNominated(headers) {
39
- const raw = headers.get("connection");
49
+ /**
50
+ * Header names nominated hop-by-hop by a `Connection` header value (lowercased).
51
+ *
52
+ * Takes the raw value, not a header container, so the Web lane (`Headers`) and the Node-native lane
53
+ * (a header record) resolve `connection` their own way and share this one parse - the hop-by-hop
54
+ * decision is security-critical and must not exist in two copies that can drift.
55
+ */
56
+ function parseConnectionNominated(raw) {
40
57
  if (raw === null)
41
58
  return EMPTY;
42
59
  const out = new Set();
@@ -50,56 +67,407 @@ function connectionNominated(headers) {
50
67
  function dropHeader(name, nominated) {
51
68
  return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-");
52
69
  }
70
+ /**
71
+ * A request header dropped before the hop: it names the proxy (`host`, which the transport derives
72
+ * from the target), is hop-by-hop / Connection-nominated / `proxy-*`, or is forwarding metadata that
73
+ * only explicit proxy options may reintroduce. The single predicate both lanes filter through.
74
+ */
75
+ function isDroppedRequestHeader(name, nominated) {
76
+ return name === "host" || dropHeader(name, nominated) || FORWARDING.has(name);
77
+ }
78
+ /**
79
+ * A response header kept on relay. Drops hop-by-hop / Connection-nominated, and - only when the
80
+ * transport decoded the body, so the stored `Content-Encoding` and length describe bytes the client
81
+ * will never see - drops those two as well. The undici lanes pass bytes through untouched
82
+ * (`bodyEncoded` true) and keep them; the `fetch` lane decoded and drops them.
83
+ */
84
+ function isKeptResponseHeader(name, nominated, bodyEncoded) {
85
+ if (dropHeader(name, nominated))
86
+ return false;
87
+ if (!bodyEncoded && (name === "content-encoding" || name === "content-length"))
88
+ return false;
89
+ return true;
90
+ }
91
+ /**
92
+ * The caller-IP `x-forwarded-*` overrides to SET when `forwardClientIp` is on. Shared so the trust
93
+ * decision - the one place that may preserve inbound `x-forwarded-for` - cannot diverge between lanes.
94
+ * Host is intentionally absent: only the fixed `forwardedHost` option may emit it.
95
+ */
96
+ function forwardedClientHeaders(priorXff, requestUrl, clientIp, trustInbound) {
97
+ return [
98
+ ["x-forwarded-for", trustInbound && priorXff !== null ? `${priorXff}, ${clientIp}` : clientIp],
99
+ ["x-forwarded-proto", new URL(requestUrl).protocol.slice(0, -1)],
100
+ ];
101
+ }
53
102
  const flatError = (status, error) => new Response(JSON.stringify({ ok: false, error }), {
54
103
  status,
55
104
  headers: { "content-type": "application/json" },
56
105
  });
57
106
  function upstreamRequestHeaders(req, clientIp, options) {
58
- const nominated = connectionNominated(req.headers);
107
+ const nominated = parseConnectionNominated(req.headers.get("connection"));
59
108
  const out = new Headers();
60
109
  for (const [name, value] of req.headers) {
61
- // `host` names the proxy, not the upstream - fetch derives the right one from the target URL.
62
- if (name === "host" || dropHeader(name, nominated))
63
- continue;
64
- if (FORWARDING.has(name))
110
+ if (isDroppedRequestHeader(name, nominated))
65
111
  continue;
66
112
  out.append(name, value);
67
113
  }
68
114
  if (options.forwardClientIp === true && clientIp !== undefined) {
69
- const prior = req.headers.get("x-forwarded-for");
70
- const chain = prior !== null ? `${prior}, ${clientIp}` : clientIp;
71
- if (chain !== undefined)
72
- out.set("x-forwarded-for", chain);
73
- out.set("x-forwarded-proto", new URL(req.url).protocol.slice(0, -1));
74
- const host = req.headers.get("host");
75
- if (host !== null)
76
- out.set("x-forwarded-host", host);
115
+ for (const [name, value] of forwardedClientHeaders(req.headers.get("x-forwarded-for"), req.url, clientIp, options.trustForwardedFor === true)) {
116
+ out.set(name, value);
117
+ }
77
118
  }
119
+ if (options.forwardedHost !== undefined)
120
+ out.set("x-forwarded-host", options.forwardedHost);
78
121
  if (options.headers !== undefined) {
79
122
  for (const [name, value] of Object.entries(options.headers))
80
123
  out.set(name, value);
81
124
  }
82
125
  return out;
83
126
  }
84
- function relayedResponseHeaders(upstream) {
85
- const nominated = connectionNominated(upstream.headers);
127
+ function relayedResponseHeaders(upstreamHeaders, bodyEncoded) {
128
+ const nominated = parseConnectionNominated(upstreamHeaders.get("connection"));
86
129
  const out = new Headers();
87
- for (const [name, value] of upstream.headers) {
88
- if (dropHeader(name, nominated))
89
- continue;
90
- // fetch() already decoded the body per Content-Encoding, so the stored encoding and length no
91
- // longer describe the bytes being relayed. (Re-compression is the compression() middleware's job.)
92
- if (name === "content-encoding" || name === "content-length")
93
- continue;
94
- // Re-added below via getSetCookie() so multiple cookies survive on every runtime.
130
+ for (const [name, value] of upstreamHeaders) {
131
+ // Re-added below via getSetCookie() so multiple cookies survive on every runtime - a `Headers`
132
+ // iteration would hand back one comma-joined value that no longer parses as separate cookies.
95
133
  if (name === "set-cookie")
96
134
  continue;
135
+ if (!isKeptResponseHeader(name, nominated, bodyEncoded))
136
+ continue;
97
137
  out.append(name, value);
98
138
  }
99
- for (const cookie of upstream.headers.getSetCookie())
139
+ for (const cookie of upstreamHeaders.getSetCookie())
100
140
  out.append("set-cookie", cookie);
101
141
  return out;
102
142
  }
143
+ /**
144
+ * Bound the gap *between* response body chunks. `timeoutMs` only covers up to the response headers -
145
+ * the sole window a 504 is still sendable - and `fetch` has no body-side deadline of its own, so an
146
+ * upstream that answers and then stops sending would otherwise pin the caller's connection and this
147
+ * proxy's socket open indefinitely. The timer is armed per read and cleared the moment bytes land,
148
+ * so a healthy stream pays one setTimeout/clearTimeout pair per chunk and nothing else. On expiry
149
+ * the upstream read is cancelled and the relayed stream errors, which is the only signal left once
150
+ * the status line has already been sent.
151
+ */
152
+ function idleBoundedBody(body, ms) {
153
+ const reader = body.getReader();
154
+ let timer;
155
+ const disarm = () => {
156
+ if (timer !== undefined) {
157
+ clearTimeout(timer);
158
+ timer = undefined;
159
+ }
160
+ };
161
+ return new ReadableStream({
162
+ async pull(controller) {
163
+ // Rejects only while the read below is still outstanding: `disarm()` runs before this pull
164
+ // settles either way, so the timer can never fire against an already-answered read and leave
165
+ // an unobserved rejection behind.
166
+ const stalled = new Promise((_, reject) => {
167
+ timer = setTimeout(() => {
168
+ reject(new Error(`[nifra/proxy] upstream response body stalled for ${ms}ms`));
169
+ }, ms);
170
+ });
171
+ try {
172
+ const result = await Promise.race([reader.read(), stalled]);
173
+ if (result.done) {
174
+ controller.close();
175
+ return;
176
+ }
177
+ controller.enqueue(result.value);
178
+ }
179
+ catch (error) {
180
+ // Tear the upstream down as well - erroring only the relayed stream would leave the
181
+ // upstream socket reading into nothing.
182
+ void reader.cancel(error).catch(() => { });
183
+ throw error;
184
+ }
185
+ finally {
186
+ disarm();
187
+ }
188
+ },
189
+ cancel(reason) {
190
+ disarm();
191
+ return reader.cancel(reason);
192
+ },
193
+ });
194
+ }
195
+ /**
196
+ * The Node-stream claim seam, by value rather than by import: `node-stream.ts` pulls `node:stream`,
197
+ * and this file has to stay loadable on every runtime. Only the key and the shape below are the
198
+ * contract - see that module for what claiming means and why it is one-shot.
199
+ */
200
+ const NODE_STREAM_CLAIM = Symbol.for("nifra.node.stream-claim");
201
+ /**
202
+ * Run `onSettled` once the relayed body is finished with - closed, errored, or cancelled.
203
+ *
204
+ * A body that arrives claimable (the undici transport hands the upstream's Node stream through a Web
205
+ * view that `@nifrajs/node` can trade back in) keeps its claim: the wrapper forwards it, and when the
206
+ * claim is taken the callback moves to the Node stream's `close` instead. Replacing the stream
207
+ * outright would strip the claim and put every proxied byte back through a Web round trip.
208
+ *
209
+ * `highWaterMark: 0` for the same reason it is zero in `claimableWebStream`: a default strategy pulls
210
+ * speculatively at construction, which would read the source before anyone asked and make the claim
211
+ * unavailable in every case.
212
+ */
213
+ function settleTracked(body, onSettled) {
214
+ let settled = false;
215
+ const settle = () => {
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ onSettled();
220
+ };
221
+ const reader = body.getReader();
222
+ const tracked = new ReadableStream({
223
+ async pull(controller) {
224
+ try {
225
+ const result = await reader.read();
226
+ if (result.done) {
227
+ controller.close();
228
+ settle();
229
+ return;
230
+ }
231
+ controller.enqueue(result.value);
232
+ }
233
+ catch (error) {
234
+ settle();
235
+ throw error;
236
+ }
237
+ },
238
+ cancel(reason) {
239
+ settle();
240
+ return reader.cancel(reason);
241
+ },
242
+ }, { highWaterMark: 0 });
243
+ const holder = body[NODE_STREAM_CLAIM];
244
+ if (holder !== undefined && typeof holder.claim === "function") {
245
+ const forwarded = {
246
+ claim() {
247
+ const claimed = holder.claim();
248
+ if (claimed === null)
249
+ return null;
250
+ // The wrapper is bypassed from here on, so the Node stream's own end is the settle point.
251
+ claimed.once("close", settle);
252
+ return claimed;
253
+ },
254
+ };
255
+ Object.defineProperty(tracked, NODE_STREAM_CLAIM, { value: forwarded, enumerable: false });
256
+ }
257
+ return tracked;
258
+ }
259
+ /**
260
+ * Create the portable `fetch`-backed {@link ProxyTransport} - the default off Node, and the fallback
261
+ * everywhere the undici transport cannot be used. `redirect: "manual"` is not a preference: following
262
+ * an upstream redirect would let the upstream choose the proxy's next destination, so it is pinned
263
+ * here and a 3xx is relayed to the caller untouched.
264
+ *
265
+ * Pass it explicitly only to tune {@link FetchTransportOptions.bodyTimeoutMs} or to pin the choice
266
+ * on Node; leaving `transport` unset picks the right one per runtime.
267
+ */
268
+ export function fetchTransport(options = {}) {
269
+ const bodyTimeout = options.bodyTimeoutMs ?? 30_000;
270
+ if (!Number.isFinite(bodyTimeout) || bodyTimeout < 0) {
271
+ throw new Error("[nifra/proxy] bodyTimeoutMs must be a non-negative number");
272
+ }
273
+ return async (target, request) => {
274
+ const init = {
275
+ method: request.method,
276
+ headers: request.headers,
277
+ redirect: "manual",
278
+ signal: request.signal,
279
+ };
280
+ if (request.body !== null) {
281
+ init.body = request.body;
282
+ init.duplex = "half";
283
+ }
284
+ const response = await fetch(target, init);
285
+ const body = response.body;
286
+ return {
287
+ status: response.status,
288
+ statusText: response.statusText,
289
+ headers: response.headers,
290
+ body: body === null || bodyTimeout === 0 ? body : idleBoundedBody(body, bodyTimeout),
291
+ // fetch decoded any Content-Encoding, so the relayed body is identity.
292
+ bodyEncoded: false,
293
+ };
294
+ };
295
+ }
296
+ /** The portable transport with its defaults - built once, not per request. */
297
+ const defaultFetchTransport = fetchTransport();
298
+ /**
299
+ * Pick the transport when the caller named none. On Node, `undici`'s dispatcher is ~2.5x `fetch` on
300
+ * GET and ~2.2x on POST for this workload, so it is the default there when installed - resolved
301
+ * lazily through a dynamic `import` so the base package keeps no static dependency on `undici` and
302
+ * stays loadable on runtimes that do not ship it. Everywhere else `fetch` is both the portable
303
+ * choice and the fast one: under Bun the `undici` specifier is a shim `undiciTransport` refuses, and
304
+ * Bun/Deno/edge `fetch` already measures level with a raw client. A Node install without the
305
+ * optional `undici` peer falls back to `fetch` rather than failing.
306
+ */
307
+ async function selectDefaultTransport() {
308
+ const runtime = globalThis;
309
+ const onNode = runtime.Bun === undefined &&
310
+ runtime.Deno === undefined &&
311
+ typeof process !== "undefined" &&
312
+ process.versions?.node !== undefined;
313
+ if (!onNode)
314
+ return defaultFetchTransport;
315
+ try {
316
+ // The specifier is a variable, not a literal, on purpose: a bundler targeting an edge runtime
317
+ // must NOT follow this into `undici` (which pulls `node:*` builtins), and this branch only ever
318
+ // executes on Node. `import()` of a non-literal is left as a genuine runtime import, so `undici`
319
+ // enters the graph only for a Node consumer that actually reaches here. The `.js` names the
320
+ // emitted file directly (tsc does not resolve a variable specifier); the cast restores its type.
321
+ const undiciModule = "./undici.js";
322
+ const mod = (await import(__rewriteRelativeImportExtension(undiciModule)));
323
+ return mod.undiciTransport();
324
+ }
325
+ catch {
326
+ // `undici` is an optional peer - absent, we simply keep the portable transport.
327
+ return defaultFetchTransport;
328
+ }
329
+ }
330
+ function nativeTransportOf(value) {
331
+ if (typeof value !== "function")
332
+ return undefined;
333
+ const candidate = value[NODE_NATIVE_TRANSPORT];
334
+ return typeof candidate === "function" ? candidate : undefined;
335
+ }
336
+ /** Load the optional client directly for a bundled Node consumer. A variable specifier keeps
337
+ * `undici` out of edge bundles; unlike a relative dynamic import, Node-targeted single-file bundlers
338
+ * leave this as the real package lookup rather than emitting a sibling `undici.js` that does not
339
+ * exist next to the one-file application bundle. */
340
+ async function defaultNativeTransport() {
341
+ try {
342
+ const packageName = "undici";
343
+ const mod = (await import(__rewriteRelativeImportExtension(packageName)));
344
+ if (typeof mod.request !== "function")
345
+ return undefined;
346
+ const request = mod.request;
347
+ return async (target, input) => {
348
+ const upstream = await request(target, {
349
+ method: input.method,
350
+ headers: Array.from(input.headers),
351
+ body: input.body,
352
+ signal: input.signal,
353
+ headersTimeout: input.headersTimeout,
354
+ bodyTimeout: 30_000,
355
+ });
356
+ return {
357
+ status: upstream.statusCode,
358
+ headers: upstream.headers,
359
+ body: upstream.body,
360
+ dump: () => upstream.body.dump(),
361
+ };
362
+ };
363
+ }
364
+ catch {
365
+ return undefined;
366
+ }
367
+ }
368
+ function nativeHeaderValue(headers, name) {
369
+ const value = headers[name];
370
+ if (value === undefined)
371
+ return null;
372
+ return typeof value === "string" ? value : Array.from(value).join(", ");
373
+ }
374
+ function nativeHeaderValues(value) {
375
+ if (value === undefined)
376
+ return [];
377
+ return typeof value === "string" ? [value] : value;
378
+ }
379
+ function nativeRequestHeaders(request, clientIp, options) {
380
+ const nominated = parseConnectionNominated(nativeHeaderValue(request.headers, "connection"));
381
+ const entries = [];
382
+ for (const [name, raw] of Object.entries(request.headers)) {
383
+ if (raw === undefined)
384
+ continue;
385
+ if (isDroppedRequestHeader(name, nominated))
386
+ continue;
387
+ for (const value of nativeHeaderValues(raw))
388
+ entries.push(name, value);
389
+ }
390
+ const replace = (name, value) => {
391
+ for (let i = entries.length - 2; i >= 0; i -= 2) {
392
+ if (entries[i] === name)
393
+ entries.splice(i, 2);
394
+ }
395
+ entries.push(name, value);
396
+ };
397
+ if (options.forwardClientIp === true && clientIp !== undefined) {
398
+ for (const [name, value] of forwardedClientHeaders(nativeHeaderValue(request.headers, "x-forwarded-for"), request.url, clientIp, options.trustForwardedFor === true)) {
399
+ replace(name, value);
400
+ }
401
+ }
402
+ if (options.forwardedHost !== undefined)
403
+ replace("x-forwarded-host", options.forwardedHost);
404
+ if (options.headers !== undefined) {
405
+ for (const [name, value] of Object.entries(options.headers))
406
+ replace(name.toLowerCase(), value);
407
+ }
408
+ return entries;
409
+ }
410
+ function nativeResponseHeaders(headers) {
411
+ const nominated = parseConnectionNominated(nativeHeaderValue(headers, "connection"));
412
+ const out = [];
413
+ for (const [name, value] of Object.entries(headers)) {
414
+ // The native lane is always the undici passthrough transport, which does not decode the body,
415
+ // so the stored content-encoding/length still describe the relayed bytes: `bodyEncoded` true.
416
+ if (value === undefined || !isKeptResponseHeader(name, nominated, true))
417
+ continue;
418
+ out.push([name, value]);
419
+ }
420
+ return out;
421
+ }
422
+ function nativeResponseOpen(response) {
423
+ return (response.destroyed !== true && response.writableEnded !== true && response.writable !== false);
424
+ }
425
+ function nativeFlatError(response, statusCode, error) {
426
+ if (!nativeResponseOpen(response))
427
+ return;
428
+ const body = JSON.stringify({ ok: false, error });
429
+ response.setHeader("content-type", "application/json");
430
+ response.setHeader("content-length", String(new TextEncoder().encode(body).byteLength));
431
+ response.writeHead(statusCode);
432
+ response.end(body);
433
+ }
434
+ function pipeNativeBody(body, response, cleanup) {
435
+ return new Promise((resolve) => {
436
+ let settled = false;
437
+ const done = () => {
438
+ if (settled)
439
+ return;
440
+ settled = true;
441
+ response.removeListener("close", onClose);
442
+ response.removeListener("finish", onFinish);
443
+ body.removeListener("error", onError);
444
+ cleanup();
445
+ if (body.destroyed !== true)
446
+ body.destroy();
447
+ resolve();
448
+ };
449
+ const onClose = () => done();
450
+ const onFinish = () => done();
451
+ const onError = () => {
452
+ if (!settled && nativeResponseOpen(response))
453
+ response.destroy();
454
+ done();
455
+ };
456
+ body.on("error", onError);
457
+ response.once("close", onClose);
458
+ response.once("finish", onFinish);
459
+ if (!nativeResponseOpen(response)) {
460
+ done();
461
+ return;
462
+ }
463
+ try {
464
+ body.pipe(response);
465
+ }
466
+ catch {
467
+ onError();
468
+ }
469
+ });
470
+ }
103
471
  /**
104
472
  * Create a proxy handler bound to one upstream origin.
105
473
  *
@@ -129,6 +497,28 @@ export function createProxy(options) {
129
497
  if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
130
498
  throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one');
131
499
  }
500
+ if (options.trustForwardedFor === true && options.forwardClientIp !== true) {
501
+ throw new Error("[nifra/proxy] trustForwardedFor requires forwardClientIp: true");
502
+ }
503
+ if (options.forwardedHost !== undefined) {
504
+ let parsed;
505
+ try {
506
+ parsed = new URL(`http://${options.forwardedHost}`);
507
+ }
508
+ catch {
509
+ throw new TypeError("[nifra/proxy] forwardedHost must be a bare host authority");
510
+ }
511
+ if (options.forwardedHost === "" ||
512
+ parsed.host === "" ||
513
+ parsed.username !== "" ||
514
+ parsed.password !== "" ||
515
+ parsed.pathname !== "/" ||
516
+ parsed.search !== "" ||
517
+ parsed.hash !== "" ||
518
+ /[\r\n\s]/.test(options.forwardedHost)) {
519
+ throw new TypeError("[nifra/proxy] forwardedHost must be a bare host authority");
520
+ }
521
+ }
132
522
  const timeoutMs = options.timeoutMs ?? 30_000;
133
523
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
134
524
  throw new Error("[nifra/proxy] timeoutMs must be a positive number");
@@ -142,7 +532,40 @@ export function createProxy(options) {
142
532
  throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`);
143
533
  }
144
534
  }
145
- return async (input) => {
535
+ // An explicit transport is used as given. Otherwise the default is resolved lazily on the first
536
+ // request and memoised: the dynamic `import` of the undici transport cannot run in this synchronous
537
+ // constructor, and doing it per request would re-pay the resolution every time.
538
+ const explicitTransport = options.transport;
539
+ let resolvedTransport = explicitTransport;
540
+ let resolving;
541
+ const getTransport = () => {
542
+ if (resolvedTransport !== undefined)
543
+ return resolvedTransport;
544
+ resolving ??= selectDefaultTransport().then((t) => {
545
+ resolvedTransport = t;
546
+ return t;
547
+ });
548
+ return resolving;
549
+ };
550
+ // The native lane is available only for the built-in undici transport. A custom transport keeps
551
+ // the documented Web contract and therefore falls back to the existing path rather than silently
552
+ // bypassing its implementation. The default is resolved lazily for the same optional-peer and
553
+ // edge-bundle reasons as `selectDefaultTransport` above.
554
+ const explicitNativeTransport = nativeTransportOf(explicitTransport);
555
+ let nativeTransport = explicitNativeTransport;
556
+ let nativeResolving;
557
+ const getNativeTransport = () => {
558
+ if (nativeTransport !== undefined)
559
+ return nativeTransport;
560
+ if (explicitTransport !== undefined)
561
+ return undefined;
562
+ nativeResolving ??= defaultNativeTransport().then((candidate) => {
563
+ nativeTransport = candidate;
564
+ return candidate;
565
+ });
566
+ return nativeResolving;
567
+ };
568
+ const proxy = async (input) => {
146
569
  const req = input instanceof Request ? input : input.req;
147
570
  const clientIp = input instanceof Request ? undefined : input.clientIp;
148
571
  const callerSignal = input instanceof Request ? input.signal : (input.signal ?? input.req.signal);
@@ -157,35 +580,166 @@ export function createProxy(options) {
157
580
  const target = new URL(base);
158
581
  target.pathname = path;
159
582
  target.search = incoming.search;
160
- const timeout = AbortSignal.timeout(timeoutMs);
161
- const signal = callerSignal !== undefined ? AbortSignal.any([timeout, callerSignal]) : timeout;
162
- const body = req.method === "GET" || req.method === "HEAD" ? undefined : req.body;
163
- const init = {
164
- method: req.method,
165
- headers: upstreamRequestHeaders(req, clientIp, options),
166
- redirect: "manual",
167
- signal,
168
- };
169
- if (body !== null && body !== undefined) {
170
- init.body = body;
171
- init.duplex = "half";
583
+ // One AbortController instead of `AbortSignal.timeout` + `AbortSignal.any`: that pair costs
584
+ // ~12x more per request on Node, and its timer is only reclaimed once GC gets to the signal.
585
+ // Here the timer is cancelled the moment the upstream answers, so a burst of in-flight requests
586
+ // cannot leave a deadline-long backlog of live timers in the wheel. The caller listener
587
+ // deliberately outlives the timer - a client that disconnects mid-body must still tear the
588
+ // upstream stream down - so it is dropped when the exchange ends instead.
589
+ const controller = new AbortController();
590
+ let timedOut = false;
591
+ const timer = setTimeout(() => {
592
+ timedOut = true;
593
+ controller.abort();
594
+ }, timeoutMs);
595
+ const abortUpstream = () => controller.abort();
596
+ // Removing the listener only matters for a signal that outlives this request: `req.signal`
597
+ // belongs to the request and is collected with it, while a caller-supplied `signal` may be
598
+ // shared by every request a process proxies, where one retained listener per exchange
599
+ // accumulates without bound. Only that case pays for the bookkeeping below.
600
+ const sharedSignal = callerSignal !== req.signal;
601
+ let releaseCaller = () => { };
602
+ if (callerSignal.aborted) {
603
+ clearTimeout(timer);
604
+ controller.abort();
605
+ }
606
+ else {
607
+ callerSignal.addEventListener("abort", abortUpstream, { once: true });
608
+ if (sharedSignal) {
609
+ releaseCaller = () => {
610
+ callerSignal.removeEventListener("abort", abortUpstream);
611
+ };
612
+ }
172
613
  }
614
+ const transport = resolvedTransport ?? (await getTransport());
173
615
  let upstream;
174
616
  try {
175
- upstream = await fetch(target, init);
617
+ upstream = await transport(target, {
618
+ method: req.method,
619
+ headers: upstreamRequestHeaders(req, clientIp, options),
620
+ body: req.method === "GET" || req.method === "HEAD" ? null : req.body,
621
+ signal: controller.signal,
622
+ });
176
623
  }
177
624
  catch (error) {
178
- if (timeout.aborted)
625
+ clearTimeout(timer);
626
+ releaseCaller();
627
+ if (timedOut)
179
628
  return flatError(504, "gateway_timeout");
180
- if (callerSignal?.aborted === true)
629
+ if (callerSignal.aborted)
181
630
  throw error;
182
631
  return flatError(502, "bad_gateway");
183
632
  }
184
- return new Response(upstream.body, {
633
+ clearTimeout(timer);
634
+ const relayed = upstream.body;
635
+ if (relayed === null)
636
+ releaseCaller();
637
+ return new Response(relayed !== null && sharedSignal ? settleTracked(relayed, releaseCaller) : relayed, {
185
638
  status: upstream.status,
186
639
  statusText: upstream.statusText,
187
- headers: relayedResponseHeaders(upstream),
640
+ headers: relayedResponseHeaders(upstream.headers, upstream.bodyEncoded === true),
188
641
  });
189
642
  };
643
+ const nativeMount = async (input, response, platform) => {
644
+ const transport = await getNativeTransport();
645
+ if (transport === undefined)
646
+ return false;
647
+ const raw = input.raw;
648
+ const controller = new AbortController();
649
+ let timedOut = false;
650
+ let cleaned = false;
651
+ const onAbort = () => controller.abort();
652
+ const cleanup = () => {
653
+ if (cleaned)
654
+ return;
655
+ cleaned = true;
656
+ raw.removeListener("aborted", onAbort);
657
+ response.removeListener("close", onAbort);
658
+ };
659
+ raw.once("aborted", onAbort);
660
+ response.once("close", onAbort);
661
+ if (raw.destroyed === true || raw.aborted === true || response.destroyed === true) {
662
+ cleanup();
663
+ return;
664
+ }
665
+ const timer = setTimeout(() => {
666
+ timedOut = true;
667
+ controller.abort();
668
+ }, timeoutMs);
669
+ const incoming = new URL(input.url);
670
+ let path = incoming.pathname;
671
+ if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
672
+ const rest = path.slice(stripPrefix.length);
673
+ path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
674
+ }
675
+ // As in the Web path, mutate only a clone of the fixed origin. The Node adapter's URL is used
676
+ // for path/query and never as a URL base, so a request path cannot redirect the dialed host.
677
+ const target = new URL(base);
678
+ target.pathname = path;
679
+ target.search = incoming.search;
680
+ try {
681
+ const upstream = await transport(target, {
682
+ method: input.method,
683
+ headers: nativeRequestHeaders(input, platform?.clientIp, options),
684
+ body: input.method === "GET" || input.method === "HEAD" ? null : input.raw,
685
+ signal: controller.signal,
686
+ headersTimeout: timeoutMs,
687
+ });
688
+ clearTimeout(timer);
689
+ if (controller.signal.aborted) {
690
+ cleanup();
691
+ return;
692
+ }
693
+ for (const [name, value] of nativeResponseHeaders(upstream.headers)) {
694
+ response.setHeader(name, value);
695
+ }
696
+ const noBody = input.method === "HEAD" ||
697
+ upstream.status === 101 ||
698
+ upstream.status === 204 ||
699
+ upstream.status === 205 ||
700
+ upstream.status === 304;
701
+ if (noBody) {
702
+ await upstream.dump();
703
+ if (!nativeResponseOpen(response)) {
704
+ cleanup();
705
+ return;
706
+ }
707
+ response.writeHead(upstream.status);
708
+ response.end();
709
+ cleanup();
710
+ return;
711
+ }
712
+ if (!nativeResponseOpen(response)) {
713
+ cleanup();
714
+ return;
715
+ }
716
+ response.writeHead(upstream.status);
717
+ await pipeNativeBody(upstream.body, response, cleanup);
718
+ return;
719
+ }
720
+ catch {
721
+ clearTimeout(timer);
722
+ cleanup();
723
+ if (controller.signal.aborted && !timedOut)
724
+ return;
725
+ if (timedOut)
726
+ nativeFlatError(response, 504, "gateway_timeout");
727
+ else
728
+ nativeFlatError(response, 502, "bad_gateway");
729
+ return;
730
+ }
731
+ };
732
+ const runtime = globalThis;
733
+ const nodeRuntime = runtime.Bun === undefined &&
734
+ runtime.Deno === undefined &&
735
+ typeof process !== "undefined" &&
736
+ process.versions?.node !== undefined;
737
+ // A caller-supplied transport without the native marker must not pay a promise-based probe on
738
+ // every Node request. The default is advertised on Node and can still fall back once if the
739
+ // optional peer is absent; all other runtimes keep the exact portable mount shape.
740
+ if (explicitNativeTransport !== undefined || (explicitTransport === undefined && nodeRuntime)) {
741
+ Object.defineProperty(proxy, NODE_NATIVE_MOUNT, { value: nativeMount });
742
+ }
743
+ return proxy;
190
744
  }
191
745
  //# sourceMappingURL=index.js.map