@nifrajs/proxy 2.13.0 → 3.1.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,21 @@
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 `forwardClientIp: true`, in which case only the observed caller IP is emitted by default.
14
+ * An inbound chain is preserved only with the explicit `trustForwardedFor: true` declaration. Upstream
14
15
  * redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
15
16
  * the proxy anywhere. TLS verification is always on; there is no knob to disable it.
16
17
  */
18
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
19
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
20
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
21
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
22
+ });
23
+ }
24
+ return path;
25
+ };
26
+ const NODE_NATIVE_MOUNT = Symbol.for("@nifrajs/core/node-native-mount");
27
+ const NODE_NATIVE_TRANSPORT = Symbol.for("@nifrajs/proxy/node-native-transport");
17
28
  /** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
18
29
  const HOP_BY_HOP = new Set([
19
30
  "connection",
@@ -34,9 +45,14 @@ const FORWARDING = new Set([
34
45
  "x-forwarded-proto",
35
46
  ]);
36
47
  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");
48
+ /**
49
+ * Header names nominated hop-by-hop by a `Connection` header value (lowercased).
50
+ *
51
+ * Takes the raw value, not a header container, so the Web lane (`Headers`) and the Node-native lane
52
+ * (a header record) resolve `connection` their own way and share this one parse - the hop-by-hop
53
+ * decision is security-critical and must not exist in two copies that can drift.
54
+ */
55
+ function parseConnectionNominated(raw) {
40
56
  if (raw === null)
41
57
  return EMPTY;
42
58
  const out = new Set();
@@ -50,30 +66,57 @@ function connectionNominated(headers) {
50
66
  function dropHeader(name, nominated) {
51
67
  return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-");
52
68
  }
69
+ /**
70
+ * A request header dropped before the hop: it names the proxy (`host`, which the transport derives
71
+ * from the target), is hop-by-hop / Connection-nominated / `proxy-*`, or is forwarding metadata that
72
+ * only `forwardClientIp` may reintroduce. The single predicate both lanes filter through.
73
+ */
74
+ function isDroppedRequestHeader(name, nominated) {
75
+ return name === "host" || dropHeader(name, nominated) || FORWARDING.has(name);
76
+ }
77
+ /**
78
+ * A response header kept on relay. Drops hop-by-hop / Connection-nominated, and - only when the
79
+ * transport decoded the body, so the stored `Content-Encoding` and length describe bytes the client
80
+ * will never see - drops those two as well. The undici lanes pass bytes through untouched
81
+ * (`bodyEncoded` true) and keep them; the `fetch` lane decoded and drops them.
82
+ */
83
+ function isKeptResponseHeader(name, nominated, bodyEncoded) {
84
+ if (dropHeader(name, nominated))
85
+ return false;
86
+ if (!bodyEncoded && (name === "content-encoding" || name === "content-length"))
87
+ return false;
88
+ return true;
89
+ }
90
+ /**
91
+ * The `x-forwarded-*` overrides to SET when `forwardClientIp` is on. Shared so the trust decision - the
92
+ * one place that may preserve inbound `x-forwarded-for` - cannot diverge between lanes. `priorXff` is
93
+ * the inbound value (null if absent); `host` rides through only when present.
94
+ */
95
+ function forwardedClientHeaders(priorXff, requestUrl, host, clientIp, trustInbound) {
96
+ const overrides = [
97
+ ["x-forwarded-for", trustInbound && priorXff !== null ? `${priorXff}, ${clientIp}` : clientIp],
98
+ ["x-forwarded-proto", new URL(requestUrl).protocol.slice(0, -1)],
99
+ ];
100
+ if (host !== null)
101
+ overrides.push(["x-forwarded-host", host]);
102
+ return overrides;
103
+ }
53
104
  const flatError = (status, error) => new Response(JSON.stringify({ ok: false, error }), {
54
105
  status,
55
106
  headers: { "content-type": "application/json" },
56
107
  });
57
108
  function upstreamRequestHeaders(req, clientIp, options) {
58
- const nominated = connectionNominated(req.headers);
109
+ const nominated = parseConnectionNominated(req.headers.get("connection"));
59
110
  const out = new Headers();
60
111
  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))
112
+ if (isDroppedRequestHeader(name, nominated))
65
113
  continue;
66
114
  out.append(name, value);
67
115
  }
68
116
  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);
117
+ for (const [name, value] of forwardedClientHeaders(req.headers.get("x-forwarded-for"), req.url, req.headers.get("host"), clientIp, options.trustForwardedFor === true)) {
118
+ out.set(name, value);
119
+ }
77
120
  }
78
121
  if (options.headers !== undefined) {
79
122
  for (const [name, value] of Object.entries(options.headers))
@@ -81,25 +124,348 @@ function upstreamRequestHeaders(req, clientIp, options) {
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, nativeHeaderValue(request.headers, "host"), clientIp, options.trustForwardedFor === true)) {
399
+ replace(name, value);
400
+ }
401
+ }
402
+ if (options.headers !== undefined) {
403
+ for (const [name, value] of Object.entries(options.headers))
404
+ replace(name.toLowerCase(), value);
405
+ }
406
+ return entries;
407
+ }
408
+ function nativeResponseHeaders(headers) {
409
+ const nominated = parseConnectionNominated(nativeHeaderValue(headers, "connection"));
410
+ const out = [];
411
+ for (const [name, value] of Object.entries(headers)) {
412
+ // The native lane is always the undici passthrough transport, which does not decode the body,
413
+ // so the stored content-encoding/length still describe the relayed bytes: `bodyEncoded` true.
414
+ if (value === undefined || !isKeptResponseHeader(name, nominated, true))
415
+ continue;
416
+ out.push([name, value]);
417
+ }
418
+ return out;
419
+ }
420
+ function nativeResponseOpen(response) {
421
+ return (response.destroyed !== true && response.writableEnded !== true && response.writable !== false);
422
+ }
423
+ function nativeFlatError(response, statusCode, error) {
424
+ if (!nativeResponseOpen(response))
425
+ return;
426
+ const body = JSON.stringify({ ok: false, error });
427
+ response.setHeader("content-type", "application/json");
428
+ response.setHeader("content-length", String(new TextEncoder().encode(body).byteLength));
429
+ response.writeHead(statusCode);
430
+ response.end(body);
431
+ }
432
+ function pipeNativeBody(body, response, cleanup) {
433
+ return new Promise((resolve) => {
434
+ let settled = false;
435
+ const done = () => {
436
+ if (settled)
437
+ return;
438
+ settled = true;
439
+ response.removeListener("close", onClose);
440
+ response.removeListener("finish", onFinish);
441
+ body.removeListener("error", onError);
442
+ cleanup();
443
+ if (body.destroyed !== true)
444
+ body.destroy();
445
+ resolve();
446
+ };
447
+ const onClose = () => done();
448
+ const onFinish = () => done();
449
+ const onError = () => {
450
+ if (!settled && nativeResponseOpen(response))
451
+ response.destroy();
452
+ done();
453
+ };
454
+ body.on("error", onError);
455
+ response.once("close", onClose);
456
+ response.once("finish", onFinish);
457
+ if (!nativeResponseOpen(response)) {
458
+ done();
459
+ return;
460
+ }
461
+ try {
462
+ body.pipe(response);
463
+ }
464
+ catch {
465
+ onError();
466
+ }
467
+ });
468
+ }
103
469
  /**
104
470
  * Create a proxy handler bound to one upstream origin.
105
471
  *
@@ -129,6 +495,9 @@ export function createProxy(options) {
129
495
  if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
130
496
  throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one');
131
497
  }
498
+ if (options.trustForwardedFor === true && options.forwardClientIp !== true) {
499
+ throw new Error("[nifra/proxy] trustForwardedFor requires forwardClientIp: true");
500
+ }
132
501
  const timeoutMs = options.timeoutMs ?? 30_000;
133
502
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
134
503
  throw new Error("[nifra/proxy] timeoutMs must be a positive number");
@@ -142,7 +511,40 @@ export function createProxy(options) {
142
511
  throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`);
143
512
  }
144
513
  }
145
- return async (input) => {
514
+ // An explicit transport is used as given. Otherwise the default is resolved lazily on the first
515
+ // request and memoised: the dynamic `import` of the undici transport cannot run in this synchronous
516
+ // constructor, and doing it per request would re-pay the resolution every time.
517
+ const explicitTransport = options.transport;
518
+ let resolvedTransport = explicitTransport;
519
+ let resolving;
520
+ const getTransport = () => {
521
+ if (resolvedTransport !== undefined)
522
+ return resolvedTransport;
523
+ resolving ??= selectDefaultTransport().then((t) => {
524
+ resolvedTransport = t;
525
+ return t;
526
+ });
527
+ return resolving;
528
+ };
529
+ // The native lane is available only for the built-in undici transport. A custom transport keeps
530
+ // the documented Web contract and therefore falls back to the existing path rather than silently
531
+ // bypassing its implementation. The default is resolved lazily for the same optional-peer and
532
+ // edge-bundle reasons as `selectDefaultTransport` above.
533
+ const explicitNativeTransport = nativeTransportOf(explicitTransport);
534
+ let nativeTransport = explicitNativeTransport;
535
+ let nativeResolving;
536
+ const getNativeTransport = () => {
537
+ if (nativeTransport !== undefined)
538
+ return nativeTransport;
539
+ if (explicitTransport !== undefined)
540
+ return undefined;
541
+ nativeResolving ??= defaultNativeTransport().then((candidate) => {
542
+ nativeTransport = candidate;
543
+ return candidate;
544
+ });
545
+ return nativeResolving;
546
+ };
547
+ const proxy = async (input) => {
146
548
  const req = input instanceof Request ? input : input.req;
147
549
  const clientIp = input instanceof Request ? undefined : input.clientIp;
148
550
  const callerSignal = input instanceof Request ? input.signal : (input.signal ?? input.req.signal);
@@ -157,35 +559,166 @@ export function createProxy(options) {
157
559
  const target = new URL(base);
158
560
  target.pathname = path;
159
561
  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";
562
+ // One AbortController instead of `AbortSignal.timeout` + `AbortSignal.any`: that pair costs
563
+ // ~12x more per request on Node, and its timer is only reclaimed once GC gets to the signal.
564
+ // Here the timer is cancelled the moment the upstream answers, so a burst of in-flight requests
565
+ // cannot leave a deadline-long backlog of live timers in the wheel. The caller listener
566
+ // deliberately outlives the timer - a client that disconnects mid-body must still tear the
567
+ // upstream stream down - so it is dropped when the exchange ends instead.
568
+ const controller = new AbortController();
569
+ let timedOut = false;
570
+ const timer = setTimeout(() => {
571
+ timedOut = true;
572
+ controller.abort();
573
+ }, timeoutMs);
574
+ const abortUpstream = () => controller.abort();
575
+ // Removing the listener only matters for a signal that outlives this request: `req.signal`
576
+ // belongs to the request and is collected with it, while a caller-supplied `signal` may be
577
+ // shared by every request a process proxies, where one retained listener per exchange
578
+ // accumulates without bound. Only that case pays for the bookkeeping below.
579
+ const sharedSignal = callerSignal !== req.signal;
580
+ let releaseCaller = () => { };
581
+ if (callerSignal.aborted) {
582
+ clearTimeout(timer);
583
+ controller.abort();
172
584
  }
585
+ else {
586
+ callerSignal.addEventListener("abort", abortUpstream, { once: true });
587
+ if (sharedSignal) {
588
+ releaseCaller = () => {
589
+ callerSignal.removeEventListener("abort", abortUpstream);
590
+ };
591
+ }
592
+ }
593
+ const transport = resolvedTransport ?? (await getTransport());
173
594
  let upstream;
174
595
  try {
175
- upstream = await fetch(target, init);
596
+ upstream = await transport(target, {
597
+ method: req.method,
598
+ headers: upstreamRequestHeaders(req, clientIp, options),
599
+ body: req.method === "GET" || req.method === "HEAD" ? null : req.body,
600
+ signal: controller.signal,
601
+ });
176
602
  }
177
603
  catch (error) {
178
- if (timeout.aborted)
604
+ clearTimeout(timer);
605
+ releaseCaller();
606
+ if (timedOut)
179
607
  return flatError(504, "gateway_timeout");
180
- if (callerSignal?.aborted === true)
608
+ if (callerSignal.aborted)
181
609
  throw error;
182
610
  return flatError(502, "bad_gateway");
183
611
  }
184
- return new Response(upstream.body, {
612
+ clearTimeout(timer);
613
+ const relayed = upstream.body;
614
+ if (relayed === null)
615
+ releaseCaller();
616
+ return new Response(relayed !== null && sharedSignal ? settleTracked(relayed, releaseCaller) : relayed, {
185
617
  status: upstream.status,
186
618
  statusText: upstream.statusText,
187
- headers: relayedResponseHeaders(upstream),
619
+ headers: relayedResponseHeaders(upstream.headers, upstream.bodyEncoded === true),
188
620
  });
189
621
  };
622
+ const nativeMount = async (input, response, platform) => {
623
+ const transport = await getNativeTransport();
624
+ if (transport === undefined)
625
+ return false;
626
+ const raw = input.raw;
627
+ const controller = new AbortController();
628
+ let timedOut = false;
629
+ let cleaned = false;
630
+ const onAbort = () => controller.abort();
631
+ const cleanup = () => {
632
+ if (cleaned)
633
+ return;
634
+ cleaned = true;
635
+ raw.removeListener("aborted", onAbort);
636
+ response.removeListener("close", onAbort);
637
+ };
638
+ raw.once("aborted", onAbort);
639
+ response.once("close", onAbort);
640
+ if (raw.destroyed === true || raw.aborted === true || response.destroyed === true) {
641
+ cleanup();
642
+ return;
643
+ }
644
+ const timer = setTimeout(() => {
645
+ timedOut = true;
646
+ controller.abort();
647
+ }, timeoutMs);
648
+ const incoming = new URL(input.url);
649
+ let path = incoming.pathname;
650
+ if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
651
+ const rest = path.slice(stripPrefix.length);
652
+ path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
653
+ }
654
+ // As in the Web path, mutate only a clone of the fixed origin. The Node adapter's URL is used
655
+ // for path/query and never as a URL base, so a request path cannot redirect the dialed host.
656
+ const target = new URL(base);
657
+ target.pathname = path;
658
+ target.search = incoming.search;
659
+ try {
660
+ const upstream = await transport(target, {
661
+ method: input.method,
662
+ headers: nativeRequestHeaders(input, platform?.clientIp, options),
663
+ body: input.method === "GET" || input.method === "HEAD" ? null : input.raw,
664
+ signal: controller.signal,
665
+ headersTimeout: timeoutMs,
666
+ });
667
+ clearTimeout(timer);
668
+ if (controller.signal.aborted) {
669
+ cleanup();
670
+ return;
671
+ }
672
+ for (const [name, value] of nativeResponseHeaders(upstream.headers)) {
673
+ response.setHeader(name, value);
674
+ }
675
+ const noBody = input.method === "HEAD" ||
676
+ upstream.status === 101 ||
677
+ upstream.status === 204 ||
678
+ upstream.status === 205 ||
679
+ upstream.status === 304;
680
+ if (noBody) {
681
+ await upstream.dump();
682
+ if (!nativeResponseOpen(response)) {
683
+ cleanup();
684
+ return;
685
+ }
686
+ response.writeHead(upstream.status);
687
+ response.end();
688
+ cleanup();
689
+ return;
690
+ }
691
+ if (!nativeResponseOpen(response)) {
692
+ cleanup();
693
+ return;
694
+ }
695
+ response.writeHead(upstream.status);
696
+ await pipeNativeBody(upstream.body, response, cleanup);
697
+ return;
698
+ }
699
+ catch {
700
+ clearTimeout(timer);
701
+ cleanup();
702
+ if (controller.signal.aborted && !timedOut)
703
+ return;
704
+ if (timedOut)
705
+ nativeFlatError(response, 504, "gateway_timeout");
706
+ else
707
+ nativeFlatError(response, 502, "bad_gateway");
708
+ return;
709
+ }
710
+ };
711
+ const runtime = globalThis;
712
+ const nodeRuntime = runtime.Bun === undefined &&
713
+ runtime.Deno === undefined &&
714
+ typeof process !== "undefined" &&
715
+ process.versions?.node !== undefined;
716
+ // A caller-supplied transport without the native marker must not pay a promise-based probe on
717
+ // every Node request. The default is advertised on Node and can still fall back once if the
718
+ // optional peer is absent; all other runtimes keep the exact portable mount shape.
719
+ if (explicitNativeTransport !== undefined || (explicitTransport === undefined && nodeRuntime)) {
720
+ Object.defineProperty(proxy, NODE_NATIVE_MOUNT, { value: nativeMount });
721
+ }
722
+ return proxy;
190
723
  }
191
724
  //# sourceMappingURL=index.js.map