@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/src/index.ts CHANGED
@@ -10,7 +10,9 @@
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
  */
@@ -22,6 +24,101 @@ export interface ProxyContext {
22
24
  readonly signal?: AbortSignal | undefined
23
25
  }
24
26
 
27
+ /** The forwarded request, after hygiene, as handed to a {@link ProxyTransport}. */
28
+ export interface ProxyUpstreamRequest {
29
+ readonly method: string
30
+ /** Already sanitised: hop-by-hop, Connection-nominated, `Proxy-*`, `host`, and (unless opted in)
31
+ * forwarding headers are gone, and static `headers` have been applied. Send them as given. */
32
+ readonly headers: Headers
33
+ readonly body: ReadableStream<Uint8Array> | null
34
+ /** Aborts on the deadline or on caller disconnect. A transport MUST honour it. */
35
+ readonly signal: AbortSignal
36
+ }
37
+
38
+ /** What a {@link ProxyTransport} returns. Header hygiene on the way back is the proxy's job. */
39
+ export interface ProxyUpstreamResponse {
40
+ readonly status: number
41
+ /** Empty string is fine - HTTP/2 has no reason phrase, and nothing downstream depends on it. */
42
+ readonly statusText: string
43
+ readonly headers: Headers
44
+ readonly body: ReadableStream<Uint8Array> | null
45
+ /**
46
+ * Whether `body` is still exactly the bytes the upstream sent, `Content-Encoding` intact. `fetch`
47
+ * transparently decodes a compressed response, so its body is identity and the stored
48
+ * `content-encoding`/`content-length` no longer describe it - both are dropped on relay (the
49
+ * default when this is omitted). A transport that does NOT decode (undici's `request` does not)
50
+ * MUST set this `true` so the encoding and length are relayed instead, letting the client decode
51
+ * the bytes it actually receives. Getting this wrong ships a gzip body labelled identity.
52
+ */
53
+ readonly bodyEncoded?: boolean
54
+ }
55
+
56
+ /**
57
+ * How the forwarded request reaches the upstream. Defaults to the undici transport on Node when
58
+ * `undici` is installed (substantially faster there), and to `fetch` everywhere else.
59
+ *
60
+ * A transport is a security boundary, and swapping it moves three of this package's guarantees into
61
+ * your implementation. It MUST dial exactly `target` and nothing else, MUST NOT follow redirects
62
+ * (relay the 3xx as-is), and MUST leave TLS verification on. It MUST NOT add, drop, or rewrite the
63
+ * headers it is handed - they have already been sanitised, and re-adding `host` or a forwarding
64
+ * header undoes that work. If it does not decode `Content-Encoding`, it MUST set
65
+ * {@link ProxyUpstreamResponse.bodyEncoded} so the encoding is relayed rather than stripped.
66
+ *
67
+ * `@nifrajs/proxy/undici` ships the one selected by default on Node.
68
+ */
69
+ export type ProxyTransport = (
70
+ target: URL,
71
+ request: ProxyUpstreamRequest,
72
+ ) => Promise<ProxyUpstreamResponse>
73
+
74
+ /** Structural request view used only by the Node-native mount seam. The raw body stays the
75
+ * IncomingMessage; no Web Request, Headers, or Web stream is created before the transport sees it. */
76
+ export interface NodeNativeProxyRequest {
77
+ readonly method: string
78
+ /** Absolute URL assembled by the Node adapter from its trusted protocol/host policy. */
79
+ readonly url: string
80
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
81
+ readonly raw: unknown
82
+ }
83
+
84
+ /** Structural response view used by the Node-native mount seam. */
85
+ export interface NodeNativeProxyResponse {
86
+ readonly destroyed?: boolean
87
+ readonly writableEnded?: boolean
88
+ readonly writable?: boolean
89
+ readonly headersSent?: boolean
90
+ setHeader(name: string, value: string | readonly string[]): void
91
+ writeHead(status: number): void
92
+ end(body?: string | Uint8Array): void
93
+ once(event: string, listener: (...args: unknown[]) => void): unknown
94
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown
95
+ destroy(error?: unknown): unknown
96
+ }
97
+
98
+ export interface NativeProxyTransportRequest {
99
+ readonly method: string
100
+ /** Flat name/value pairs already sanitized by the proxy. */
101
+ readonly headers: readonly string[]
102
+ readonly body: unknown
103
+ readonly signal: AbortSignal
104
+ readonly headersTimeout: number
105
+ }
106
+
107
+ export interface NativeProxyUpstreamResponse {
108
+ readonly status: number
109
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
110
+ readonly body: unknown
111
+ dump(): Promise<void>
112
+ }
113
+
114
+ export type NativeProxyTransport = (
115
+ target: URL,
116
+ request: NativeProxyTransportRequest,
117
+ ) => Promise<NativeProxyUpstreamResponse>
118
+
119
+ const NODE_NATIVE_MOUNT = Symbol.for("@nifrajs/core/node-native-mount")
120
+ const NODE_NATIVE_TRANSPORT = Symbol.for("@nifrajs/proxy/node-native-transport")
121
+
25
122
  export interface ProxyOptions {
26
123
  /**
27
124
  * The upstream to forward every request to, as a **bare origin** (`https://api.internal:8443`).
@@ -34,16 +131,41 @@ export interface ProxyOptions {
34
131
  readonly stripPrefix?: string
35
132
  /**
36
133
  * Forward caller metadata upstream. Default **false**: `Forwarded` and `X-Forwarded-*` headers
37
- * are stripped, so a client-forged chain never reaches the upstream. When true, the inbound
38
- * `X-Forwarded-For` is kept with the observed caller IP appended (pass a `ProxyContext` so
39
- * `c.clientIp` - already filtered by the app's trust declaration - is what gets appended). With a
40
- * bare `Request`, forwarding metadata is suppressed rather than passed through.
134
+ * are stripped, so a client-forged chain never reaches the upstream. When true, the observed caller
135
+ * IP replaces inbound `X-Forwarded-For` by default (pass a `ProxyContext` so `c.clientIp` - already
136
+ * filtered by the app's trust declaration - is the value emitted). With a bare `Request`, forwarding
137
+ * metadata is suppressed rather than passed through.
41
138
  */
42
139
  readonly forwardClientIp?: boolean
43
- /** Upstream response deadline in milliseconds. Default `30_000`; expiry answers `504`. */
140
+ /** Emit a fixed, trusted `X-Forwarded-Host`. The inbound Host is never forwarded: it is
141
+ * attacker-controlled unless an outer adapter has already applied a host policy. */
142
+ readonly forwardedHost?: string
143
+ /**
144
+ * Preserve an inbound `X-Forwarded-For` chain before appending the observed caller IP. Set this only
145
+ * when a proxy you operate has already stripped and rebuilt the inbound chain; otherwise a directly
146
+ * reachable caller can forge the leading entries. Requires `forwardClientIp: true`.
147
+ */
148
+ readonly trustForwardedFor?: boolean
149
+ /**
150
+ * Deadline in milliseconds for the upstream to *begin* answering. Default `30_000`; expiry
151
+ * answers `504`. It covers up to the response headers, which is the only window in which a `504`
152
+ * is still sendable - once the status has been relayed the exchange cannot be turned into one. A
153
+ * body that starts and then stalls is the transport's timeout to enforce; both built-in transports
154
+ * cap the idle gap between chunks at 30s (undici's `bodyTimeout`, and an equivalent per-chunk timer
155
+ * on the portable one) and error the relayed stream past it. A custom transport owes the same
156
+ * bound. Caller disconnect still tears the upstream down at any point.
157
+ */
44
158
  readonly timeoutMs?: number
45
159
  /** Static headers to set on every forwarded request (after hygiene, so they always win). */
46
160
  readonly headers?: Readonly<Record<string, string>>
161
+ /**
162
+ * How to reach the upstream. Omit to use the default: the undici transport on Node when `undici`
163
+ * is installed (the fast path there, resolved lazily so the base package stays dependency-free),
164
+ * `fetch` on every other runtime and when `undici` is absent. See {@link ProxyTransport} - a
165
+ * transport carries security obligations. Pass one explicitly to pin the choice or tune it (e.g.
166
+ * `undiciTransport({ dispatcher })`).
167
+ */
168
+ readonly transport?: ProxyTransport
47
169
  }
48
170
 
49
171
  /** Forward a request (or a nifra context) to the configured upstream. */
@@ -62,7 +184,7 @@ const HOP_BY_HOP: ReadonlySet<string> = new Set([
62
184
  "upgrade",
63
185
  ])
64
186
 
65
- /** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
187
+ /** Forwarding metadata is an explicit opt-in (`forwardClientIp` / `forwardedHost`), never passthrough. */
66
188
  const FORWARDING: ReadonlySet<string> = new Set([
67
189
  "forwarded",
68
190
  "x-forwarded-for",
@@ -72,9 +194,14 @@ const FORWARDING: ReadonlySet<string> = new Set([
72
194
 
73
195
  const EMPTY: ReadonlySet<string> = new Set()
74
196
 
75
- /** Header names nominated hop-by-hop by a `Connection` header (lowercased). */
76
- function connectionNominated(headers: Headers): ReadonlySet<string> {
77
- const raw = headers.get("connection")
197
+ /**
198
+ * Header names nominated hop-by-hop by a `Connection` header value (lowercased).
199
+ *
200
+ * Takes the raw value, not a header container, so the Web lane (`Headers`) and the Node-native lane
201
+ * (a header record) resolve `connection` their own way and share this one parse - the hop-by-hop
202
+ * decision is security-critical and must not exist in two copies that can drift.
203
+ */
204
+ function parseConnectionNominated(raw: string | null): ReadonlySet<string> {
78
205
  if (raw === null) return EMPTY
79
206
  const out = new Set<string>()
80
207
  for (const token of raw.split(",")) {
@@ -88,6 +215,48 @@ function dropHeader(name: string, nominated: ReadonlySet<string>): boolean {
88
215
  return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-")
89
216
  }
90
217
 
218
+ /**
219
+ * A request header dropped before the hop: it names the proxy (`host`, which the transport derives
220
+ * from the target), is hop-by-hop / Connection-nominated / `proxy-*`, or is forwarding metadata that
221
+ * only explicit proxy options may reintroduce. The single predicate both lanes filter through.
222
+ */
223
+ function isDroppedRequestHeader(name: string, nominated: ReadonlySet<string>): boolean {
224
+ return name === "host" || dropHeader(name, nominated) || FORWARDING.has(name)
225
+ }
226
+
227
+ /**
228
+ * A response header kept on relay. Drops hop-by-hop / Connection-nominated, and - only when the
229
+ * transport decoded the body, so the stored `Content-Encoding` and length describe bytes the client
230
+ * will never see - drops those two as well. The undici lanes pass bytes through untouched
231
+ * (`bodyEncoded` true) and keep them; the `fetch` lane decoded and drops them.
232
+ */
233
+ function isKeptResponseHeader(
234
+ name: string,
235
+ nominated: ReadonlySet<string>,
236
+ bodyEncoded: boolean,
237
+ ): boolean {
238
+ if (dropHeader(name, nominated)) return false
239
+ if (!bodyEncoded && (name === "content-encoding" || name === "content-length")) return false
240
+ return true
241
+ }
242
+
243
+ /**
244
+ * The caller-IP `x-forwarded-*` overrides to SET when `forwardClientIp` is on. Shared so the trust
245
+ * decision - the one place that may preserve inbound `x-forwarded-for` - cannot diverge between lanes.
246
+ * Host is intentionally absent: only the fixed `forwardedHost` option may emit it.
247
+ */
248
+ function forwardedClientHeaders(
249
+ priorXff: string | null,
250
+ requestUrl: string,
251
+ clientIp: string,
252
+ trustInbound: boolean,
253
+ ): Array<[string, string]> {
254
+ return [
255
+ ["x-forwarded-for", trustInbound && priorXff !== null ? `${priorXff}, ${clientIp}` : clientIp],
256
+ ["x-forwarded-proto", new URL(requestUrl).protocol.slice(0, -1)],
257
+ ]
258
+ }
259
+
91
260
  const flatError = (status: number, error: string): Response =>
92
261
  new Response(JSON.stringify({ ok: false, error }), {
93
262
  status,
@@ -99,44 +268,437 @@ function upstreamRequestHeaders(
99
268
  clientIp: string | undefined,
100
269
  options: ProxyOptions,
101
270
  ): Headers {
102
- const nominated = connectionNominated(req.headers)
271
+ const nominated = parseConnectionNominated(req.headers.get("connection"))
103
272
  const out = new Headers()
104
273
  for (const [name, value] of req.headers) {
105
- // `host` names the proxy, not the upstream - fetch derives the right one from the target URL.
106
- if (name === "host" || dropHeader(name, nominated)) continue
107
- if (FORWARDING.has(name)) continue
274
+ if (isDroppedRequestHeader(name, nominated)) continue
108
275
  out.append(name, value)
109
276
  }
110
277
  if (options.forwardClientIp === true && clientIp !== undefined) {
111
- const prior = req.headers.get("x-forwarded-for")
112
- const chain = prior !== null ? `${prior}, ${clientIp}` : clientIp
113
- if (chain !== undefined) out.set("x-forwarded-for", chain)
114
- out.set("x-forwarded-proto", new URL(req.url).protocol.slice(0, -1))
115
- const host = req.headers.get("host")
116
- if (host !== null) out.set("x-forwarded-host", host)
278
+ for (const [name, value] of forwardedClientHeaders(
279
+ req.headers.get("x-forwarded-for"),
280
+ req.url,
281
+ clientIp,
282
+ options.trustForwardedFor === true,
283
+ )) {
284
+ out.set(name, value)
285
+ }
117
286
  }
287
+ if (options.forwardedHost !== undefined) out.set("x-forwarded-host", options.forwardedHost)
118
288
  if (options.headers !== undefined) {
119
289
  for (const [name, value] of Object.entries(options.headers)) out.set(name, value)
120
290
  }
121
291
  return out
122
292
  }
123
293
 
124
- function relayedResponseHeaders(upstream: Response): Headers {
125
- const nominated = connectionNominated(upstream.headers)
294
+ function relayedResponseHeaders(upstreamHeaders: Headers, bodyEncoded: boolean): Headers {
295
+ const nominated = parseConnectionNominated(upstreamHeaders.get("connection"))
126
296
  const out = new Headers()
127
- for (const [name, value] of upstream.headers) {
128
- if (dropHeader(name, nominated)) continue
129
- // fetch() already decoded the body per Content-Encoding, so the stored encoding and length no
130
- // longer describe the bytes being relayed. (Re-compression is the compression() middleware's job.)
131
- if (name === "content-encoding" || name === "content-length") continue
132
- // Re-added below via getSetCookie() so multiple cookies survive on every runtime.
297
+ for (const [name, value] of upstreamHeaders) {
298
+ // Re-added below via getSetCookie() so multiple cookies survive on every runtime - a `Headers`
299
+ // iteration would hand back one comma-joined value that no longer parses as separate cookies.
133
300
  if (name === "set-cookie") continue
301
+ if (!isKeptResponseHeader(name, nominated, bodyEncoded)) continue
134
302
  out.append(name, value)
135
303
  }
136
- for (const cookie of upstream.headers.getSetCookie()) out.append("set-cookie", cookie)
304
+ for (const cookie of upstreamHeaders.getSetCookie()) out.append("set-cookie", cookie)
305
+ return out
306
+ }
307
+
308
+ /** Options for {@link fetchTransport}. */
309
+ export interface FetchTransportOptions {
310
+ /**
311
+ * Milliseconds of silence tolerated *within* the response body before the relayed stream is
312
+ * errored and the upstream read cancelled. `createProxy`'s own `timeoutMs` only covers the wait
313
+ * for response headers - after the status is relayed a `504` is no longer sendable - so this is
314
+ * what protects against a body that starts and then stalls. Default `30_000` (undici's
315
+ * `bodyTimeout` default, so the choice of transport does not change the bound); `0` disables it.
316
+ */
317
+ readonly bodyTimeoutMs?: number
318
+ }
319
+
320
+ /**
321
+ * Bound the gap *between* response body chunks. `timeoutMs` only covers up to the response headers -
322
+ * the sole window a 504 is still sendable - and `fetch` has no body-side deadline of its own, so an
323
+ * upstream that answers and then stops sending would otherwise pin the caller's connection and this
324
+ * proxy's socket open indefinitely. The timer is armed per read and cleared the moment bytes land,
325
+ * so a healthy stream pays one setTimeout/clearTimeout pair per chunk and nothing else. On expiry
326
+ * the upstream read is cancelled and the relayed stream errors, which is the only signal left once
327
+ * the status line has already been sent.
328
+ */
329
+ function idleBoundedBody<T>(body: ReadableStream<T>, ms: number): ReadableStream<T> {
330
+ const reader = body.getReader()
331
+ let timer: ReturnType<typeof setTimeout> | undefined
332
+ const disarm = (): void => {
333
+ if (timer !== undefined) {
334
+ clearTimeout(timer)
335
+ timer = undefined
336
+ }
337
+ }
338
+ return new ReadableStream<T>({
339
+ async pull(controller) {
340
+ // Rejects only while the read below is still outstanding: `disarm()` runs before this pull
341
+ // settles either way, so the timer can never fire against an already-answered read and leave
342
+ // an unobserved rejection behind.
343
+ const stalled = new Promise<never>((_, reject) => {
344
+ timer = setTimeout(() => {
345
+ reject(new Error(`[nifra/proxy] upstream response body stalled for ${ms}ms`))
346
+ }, ms)
347
+ })
348
+ try {
349
+ const result = await Promise.race([reader.read(), stalled])
350
+ if (result.done) {
351
+ controller.close()
352
+ return
353
+ }
354
+ controller.enqueue(result.value)
355
+ } catch (error) {
356
+ // Tear the upstream down as well - erroring only the relayed stream would leave the
357
+ // upstream socket reading into nothing.
358
+ void reader.cancel(error).catch(() => {})
359
+ throw error
360
+ } finally {
361
+ disarm()
362
+ }
363
+ },
364
+ cancel(reason) {
365
+ disarm()
366
+ return reader.cancel(reason)
367
+ },
368
+ })
369
+ }
370
+
371
+ /**
372
+ * The Node-stream claim seam, by value rather than by import: `node-stream.ts` pulls `node:stream`,
373
+ * and this file has to stay loadable on every runtime. Only the key and the shape below are the
374
+ * contract - see that module for what claiming means and why it is one-shot.
375
+ */
376
+ const NODE_STREAM_CLAIM = Symbol.for("nifra.node.stream-claim")
377
+
378
+ /** The part of the claim holder this file touches; the claimed value is a Node `Readable`. */
379
+ interface StreamClaimHolder {
380
+ claim(): { once(event: string, listener: () => void): unknown } | null
381
+ }
382
+
383
+ /**
384
+ * Run `onSettled` once the relayed body is finished with - closed, errored, or cancelled.
385
+ *
386
+ * A body that arrives claimable (the undici transport hands the upstream's Node stream through a Web
387
+ * view that `@nifrajs/node` can trade back in) keeps its claim: the wrapper forwards it, and when the
388
+ * claim is taken the callback moves to the Node stream's `close` instead. Replacing the stream
389
+ * outright would strip the claim and put every proxied byte back through a Web round trip.
390
+ *
391
+ * `highWaterMark: 0` for the same reason it is zero in `claimableWebStream`: a default strategy pulls
392
+ * speculatively at construction, which would read the source before anyone asked and make the claim
393
+ * unavailable in every case.
394
+ */
395
+ function settleTracked(
396
+ body: ReadableStream<Uint8Array>,
397
+ onSettled: () => void,
398
+ ): ReadableStream<Uint8Array> {
399
+ let settled = false
400
+ const settle = (): void => {
401
+ if (settled) return
402
+ settled = true
403
+ onSettled()
404
+ }
405
+ const reader = body.getReader()
406
+ const tracked = new ReadableStream<Uint8Array>(
407
+ {
408
+ async pull(controller) {
409
+ try {
410
+ const result = await reader.read()
411
+ if (result.done) {
412
+ controller.close()
413
+ settle()
414
+ return
415
+ }
416
+ controller.enqueue(result.value)
417
+ } catch (error) {
418
+ settle()
419
+ throw error
420
+ }
421
+ },
422
+ cancel(reason) {
423
+ settle()
424
+ return reader.cancel(reason)
425
+ },
426
+ },
427
+ { highWaterMark: 0 },
428
+ )
429
+ const holder = (body as { readonly [NODE_STREAM_CLAIM]?: StreamClaimHolder })[NODE_STREAM_CLAIM]
430
+ if (holder !== undefined && typeof holder.claim === "function") {
431
+ const forwarded: StreamClaimHolder = {
432
+ claim() {
433
+ const claimed = holder.claim()
434
+ if (claimed === null) return null
435
+ // The wrapper is bypassed from here on, so the Node stream's own end is the settle point.
436
+ claimed.once("close", settle)
437
+ return claimed
438
+ },
439
+ }
440
+ Object.defineProperty(tracked, NODE_STREAM_CLAIM, { value: forwarded, enumerable: false })
441
+ }
442
+ return tracked
443
+ }
444
+
445
+ /**
446
+ * Create the portable `fetch`-backed {@link ProxyTransport} - the default off Node, and the fallback
447
+ * everywhere the undici transport cannot be used. `redirect: "manual"` is not a preference: following
448
+ * an upstream redirect would let the upstream choose the proxy's next destination, so it is pinned
449
+ * here and a 3xx is relayed to the caller untouched.
450
+ *
451
+ * Pass it explicitly only to tune {@link FetchTransportOptions.bodyTimeoutMs} or to pin the choice
452
+ * on Node; leaving `transport` unset picks the right one per runtime.
453
+ */
454
+ export function fetchTransport(options: FetchTransportOptions = {}): ProxyTransport {
455
+ const bodyTimeout = options.bodyTimeoutMs ?? 30_000
456
+ if (!Number.isFinite(bodyTimeout) || bodyTimeout < 0) {
457
+ throw new Error("[nifra/proxy] bodyTimeoutMs must be a non-negative number")
458
+ }
459
+ return async (target, request) => {
460
+ const init: RequestInit & { duplex?: "half" } = {
461
+ method: request.method,
462
+ headers: request.headers,
463
+ redirect: "manual",
464
+ signal: request.signal,
465
+ }
466
+ if (request.body !== null) {
467
+ init.body = request.body
468
+ init.duplex = "half"
469
+ }
470
+ const response = await fetch(target, init)
471
+ const body = response.body
472
+ return {
473
+ status: response.status,
474
+ statusText: response.statusText,
475
+ headers: response.headers,
476
+ body: body === null || bodyTimeout === 0 ? body : idleBoundedBody(body, bodyTimeout),
477
+ // fetch decoded any Content-Encoding, so the relayed body is identity.
478
+ bodyEncoded: false,
479
+ }
480
+ }
481
+ }
482
+
483
+ /** The portable transport with its defaults - built once, not per request. */
484
+ const defaultFetchTransport = fetchTransport()
485
+
486
+ /**
487
+ * Pick the transport when the caller named none. On Node, `undici`'s dispatcher is ~2.5x `fetch` on
488
+ * GET and ~2.2x on POST for this workload, so it is the default there when installed - resolved
489
+ * lazily through a dynamic `import` so the base package keeps no static dependency on `undici` and
490
+ * stays loadable on runtimes that do not ship it. Everywhere else `fetch` is both the portable
491
+ * choice and the fast one: under Bun the `undici` specifier is a shim `undiciTransport` refuses, and
492
+ * Bun/Deno/edge `fetch` already measures level with a raw client. A Node install without the
493
+ * optional `undici` peer falls back to `fetch` rather than failing.
494
+ */
495
+ async function selectDefaultTransport(): Promise<ProxyTransport> {
496
+ const runtime = globalThis as { readonly Bun?: unknown; readonly Deno?: unknown }
497
+ const onNode =
498
+ runtime.Bun === undefined &&
499
+ runtime.Deno === undefined &&
500
+ typeof process !== "undefined" &&
501
+ process.versions?.node !== undefined
502
+ if (!onNode) return defaultFetchTransport
503
+ try {
504
+ // The specifier is a variable, not a literal, on purpose: a bundler targeting an edge runtime
505
+ // must NOT follow this into `undici` (which pulls `node:*` builtins), and this branch only ever
506
+ // executes on Node. `import()` of a non-literal is left as a genuine runtime import, so `undici`
507
+ // enters the graph only for a Node consumer that actually reaches here. The `.js` names the
508
+ // emitted file directly (tsc does not resolve a variable specifier); the cast restores its type.
509
+ const undiciModule = "./undici.js"
510
+ const mod = (await import(undiciModule)) as typeof import("./undici.ts")
511
+ return mod.undiciTransport()
512
+ } catch {
513
+ // `undici` is an optional peer - absent, we simply keep the portable transport.
514
+ return defaultFetchTransport
515
+ }
516
+ }
517
+
518
+ function nativeTransportOf(value: unknown): NativeProxyTransport | undefined {
519
+ if (typeof value !== "function") return undefined
520
+ const candidate = (value as unknown as Record<symbol, unknown>)[NODE_NATIVE_TRANSPORT]
521
+ return typeof candidate === "function" ? (candidate as NativeProxyTransport) : undefined
522
+ }
523
+
524
+ /** Load the optional client directly for a bundled Node consumer. A variable specifier keeps
525
+ * `undici` out of edge bundles; unlike a relative dynamic import, Node-targeted single-file bundlers
526
+ * leave this as the real package lookup rather than emitting a sibling `undici.js` that does not
527
+ * exist next to the one-file application bundle. */
528
+ async function defaultNativeTransport(): Promise<NativeProxyTransport | undefined> {
529
+ try {
530
+ const packageName = "undici"
531
+ const mod = (await import(packageName)) as {
532
+ readonly request?: (
533
+ target: URL,
534
+ options: {
535
+ readonly method: string
536
+ readonly headers: string[]
537
+ readonly body: unknown
538
+ readonly signal: AbortSignal
539
+ readonly headersTimeout: number
540
+ readonly bodyTimeout: number
541
+ },
542
+ ) => Promise<{
543
+ readonly statusCode: number
544
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
545
+ readonly body: {
546
+ dump(): Promise<void>
547
+ }
548
+ }>
549
+ }
550
+ if (typeof mod.request !== "function") return undefined
551
+ const request = mod.request
552
+ return async (target, input) => {
553
+ const upstream = await request(target, {
554
+ method: input.method,
555
+ headers: Array.from(input.headers),
556
+ body: input.body,
557
+ signal: input.signal,
558
+ headersTimeout: input.headersTimeout,
559
+ bodyTimeout: 30_000,
560
+ })
561
+ return {
562
+ status: upstream.statusCode,
563
+ headers: upstream.headers,
564
+ body: upstream.body,
565
+ dump: () => upstream.body.dump(),
566
+ }
567
+ }
568
+ } catch {
569
+ return undefined
570
+ }
571
+ }
572
+
573
+ function nativeHeaderValue(
574
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>,
575
+ name: string,
576
+ ): string | null {
577
+ const value = headers[name]
578
+ if (value === undefined) return null
579
+ return typeof value === "string" ? value : Array.from(value).join(", ")
580
+ }
581
+
582
+ function nativeHeaderValues(value: string | readonly string[] | undefined): readonly string[] {
583
+ if (value === undefined) return []
584
+ return typeof value === "string" ? [value] : value
585
+ }
586
+
587
+ function nativeRequestHeaders(
588
+ request: NodeNativeProxyRequest,
589
+ clientIp: string | undefined,
590
+ options: ProxyOptions,
591
+ ): string[] {
592
+ const nominated = parseConnectionNominated(nativeHeaderValue(request.headers, "connection"))
593
+ const entries: string[] = []
594
+ for (const [name, raw] of Object.entries(request.headers)) {
595
+ if (raw === undefined) continue
596
+ if (isDroppedRequestHeader(name, nominated)) continue
597
+ for (const value of nativeHeaderValues(raw)) entries.push(name, value)
598
+ }
599
+ const replace = (name: string, value: string): void => {
600
+ for (let i = entries.length - 2; i >= 0; i -= 2) {
601
+ if (entries[i] === name) entries.splice(i, 2)
602
+ }
603
+ entries.push(name, value)
604
+ }
605
+ if (options.forwardClientIp === true && clientIp !== undefined) {
606
+ for (const [name, value] of forwardedClientHeaders(
607
+ nativeHeaderValue(request.headers, "x-forwarded-for"),
608
+ request.url,
609
+ clientIp,
610
+ options.trustForwardedFor === true,
611
+ )) {
612
+ replace(name, value)
613
+ }
614
+ }
615
+ if (options.forwardedHost !== undefined) replace("x-forwarded-host", options.forwardedHost)
616
+ if (options.headers !== undefined) {
617
+ for (const [name, value] of Object.entries(options.headers)) replace(name.toLowerCase(), value)
618
+ }
619
+ return entries
620
+ }
621
+
622
+ function nativeResponseHeaders(
623
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>,
624
+ ): Array<[string, string | readonly string[]]> {
625
+ const nominated = parseConnectionNominated(nativeHeaderValue(headers, "connection"))
626
+ const out: Array<[string, string | readonly string[]]> = []
627
+ for (const [name, value] of Object.entries(headers)) {
628
+ // The native lane is always the undici passthrough transport, which does not decode the body,
629
+ // so the stored content-encoding/length still describe the relayed bytes: `bodyEncoded` true.
630
+ if (value === undefined || !isKeptResponseHeader(name, nominated, true)) continue
631
+ out.push([name, value])
632
+ }
137
633
  return out
138
634
  }
139
635
 
636
+ interface NativeBodyStream {
637
+ readonly destroyed?: boolean
638
+ on(event: string, listener: (...args: unknown[]) => void): unknown
639
+ once(event: string, listener: (...args: unknown[]) => void): unknown
640
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown
641
+ destroy(error?: unknown): unknown
642
+ pipe(destination: unknown): unknown
643
+ }
644
+
645
+ function nativeResponseOpen(response: NodeNativeProxyResponse): boolean {
646
+ return (
647
+ response.destroyed !== true && response.writableEnded !== true && response.writable !== false
648
+ )
649
+ }
650
+
651
+ function nativeFlatError(
652
+ response: NodeNativeProxyResponse,
653
+ statusCode: number,
654
+ error: string,
655
+ ): void {
656
+ if (!nativeResponseOpen(response)) return
657
+ const body = JSON.stringify({ ok: false, error })
658
+ response.setHeader("content-type", "application/json")
659
+ response.setHeader("content-length", String(new TextEncoder().encode(body).byteLength))
660
+ response.writeHead(statusCode)
661
+ response.end(body)
662
+ }
663
+
664
+ function pipeNativeBody(
665
+ body: NativeBodyStream,
666
+ response: NodeNativeProxyResponse,
667
+ cleanup: () => void,
668
+ ): Promise<void> {
669
+ return new Promise((resolve) => {
670
+ let settled = false
671
+ const done = (): void => {
672
+ if (settled) return
673
+ settled = true
674
+ response.removeListener("close", onClose)
675
+ response.removeListener("finish", onFinish)
676
+ body.removeListener("error", onError)
677
+ cleanup()
678
+ if (body.destroyed !== true) body.destroy()
679
+ resolve()
680
+ }
681
+ const onClose = (): void => done()
682
+ const onFinish = (): void => done()
683
+ const onError = (): void => {
684
+ if (!settled && nativeResponseOpen(response)) response.destroy()
685
+ done()
686
+ }
687
+ body.on("error", onError)
688
+ response.once("close", onClose)
689
+ response.once("finish", onFinish)
690
+ if (!nativeResponseOpen(response)) {
691
+ done()
692
+ return
693
+ }
694
+ try {
695
+ body.pipe(response)
696
+ } catch {
697
+ onError()
698
+ }
699
+ })
700
+ }
701
+
140
702
  /**
141
703
  * Create a proxy handler bound to one upstream origin.
142
704
  *
@@ -170,6 +732,29 @@ export function createProxy(options: ProxyOptions): ProxyHandler {
170
732
  if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
171
733
  throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one')
172
734
  }
735
+ if (options.trustForwardedFor === true && options.forwardClientIp !== true) {
736
+ throw new Error("[nifra/proxy] trustForwardedFor requires forwardClientIp: true")
737
+ }
738
+ if (options.forwardedHost !== undefined) {
739
+ let parsed: URL
740
+ try {
741
+ parsed = new URL(`http://${options.forwardedHost}`)
742
+ } catch {
743
+ throw new TypeError("[nifra/proxy] forwardedHost must be a bare host authority")
744
+ }
745
+ if (
746
+ options.forwardedHost === "" ||
747
+ parsed.host === "" ||
748
+ parsed.username !== "" ||
749
+ parsed.password !== "" ||
750
+ parsed.pathname !== "/" ||
751
+ parsed.search !== "" ||
752
+ parsed.hash !== "" ||
753
+ /[\r\n\s]/.test(options.forwardedHost)
754
+ ) {
755
+ throw new TypeError("[nifra/proxy] forwardedHost must be a bare host authority")
756
+ }
757
+ }
173
758
  const timeoutMs = options.timeoutMs ?? 30_000
174
759
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
175
760
  throw new Error("[nifra/proxy] timeoutMs must be a positive number")
@@ -185,8 +770,42 @@ export function createProxy(options: ProxyOptions): ProxyHandler {
185
770
  throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`)
186
771
  }
187
772
  }
773
+ // An explicit transport is used as given. Otherwise the default is resolved lazily on the first
774
+ // request and memoised: the dynamic `import` of the undici transport cannot run in this synchronous
775
+ // constructor, and doing it per request would re-pay the resolution every time.
776
+ const explicitTransport = options.transport
777
+ let resolvedTransport = explicitTransport
778
+ let resolving: Promise<ProxyTransport> | undefined
779
+ const getTransport = (): ProxyTransport | Promise<ProxyTransport> => {
780
+ if (resolvedTransport !== undefined) return resolvedTransport
781
+ resolving ??= selectDefaultTransport().then((t) => {
782
+ resolvedTransport = t
783
+ return t
784
+ })
785
+ return resolving
786
+ }
188
787
 
189
- return async (input) => {
788
+ // The native lane is available only for the built-in undici transport. A custom transport keeps
789
+ // the documented Web contract and therefore falls back to the existing path rather than silently
790
+ // bypassing its implementation. The default is resolved lazily for the same optional-peer and
791
+ // edge-bundle reasons as `selectDefaultTransport` above.
792
+ const explicitNativeTransport = nativeTransportOf(explicitTransport)
793
+ let nativeTransport = explicitNativeTransport
794
+ let nativeResolving: Promise<NativeProxyTransport | undefined> | undefined
795
+ const getNativeTransport = ():
796
+ | NativeProxyTransport
797
+ | Promise<NativeProxyTransport | undefined>
798
+ | undefined => {
799
+ if (nativeTransport !== undefined) return nativeTransport
800
+ if (explicitTransport !== undefined) return undefined
801
+ nativeResolving ??= defaultNativeTransport().then((candidate) => {
802
+ nativeTransport = candidate
803
+ return candidate
804
+ })
805
+ return nativeResolving
806
+ }
807
+
808
+ const proxy = async (input: Request | ProxyContext): Promise<Response> => {
190
809
  const req = input instanceof Request ? input : input.req
191
810
  const clientIp = input instanceof Request ? undefined : input.clientIp
192
811
  const callerSignal =
@@ -204,32 +823,176 @@ export function createProxy(options: ProxyOptions): ProxyHandler {
204
823
  target.pathname = path
205
824
  target.search = incoming.search
206
825
 
207
- const timeout = AbortSignal.timeout(timeoutMs)
208
- const signal = callerSignal !== undefined ? AbortSignal.any([timeout, callerSignal]) : timeout
209
- const body = req.method === "GET" || req.method === "HEAD" ? undefined : req.body
210
- const init: RequestInit & { duplex?: "half" } = {
211
- method: req.method,
212
- headers: upstreamRequestHeaders(req, clientIp, options),
213
- redirect: "manual",
214
- signal,
215
- }
216
- if (body !== null && body !== undefined) {
217
- init.body = body
218
- init.duplex = "half"
826
+ // One AbortController instead of `AbortSignal.timeout` + `AbortSignal.any`: that pair costs
827
+ // ~12x more per request on Node, and its timer is only reclaimed once GC gets to the signal.
828
+ // Here the timer is cancelled the moment the upstream answers, so a burst of in-flight requests
829
+ // cannot leave a deadline-long backlog of live timers in the wheel. The caller listener
830
+ // deliberately outlives the timer - a client that disconnects mid-body must still tear the
831
+ // upstream stream down - so it is dropped when the exchange ends instead.
832
+ const controller = new AbortController()
833
+ let timedOut = false
834
+ const timer = setTimeout(() => {
835
+ timedOut = true
836
+ controller.abort()
837
+ }, timeoutMs)
838
+ const abortUpstream = (): void => controller.abort()
839
+ // Removing the listener only matters for a signal that outlives this request: `req.signal`
840
+ // belongs to the request and is collected with it, while a caller-supplied `signal` may be
841
+ // shared by every request a process proxies, where one retained listener per exchange
842
+ // accumulates without bound. Only that case pays for the bookkeeping below.
843
+ const sharedSignal = callerSignal !== req.signal
844
+ let releaseCaller = (): void => {}
845
+ if (callerSignal.aborted) {
846
+ clearTimeout(timer)
847
+ controller.abort()
848
+ } else {
849
+ callerSignal.addEventListener("abort", abortUpstream, { once: true })
850
+ if (sharedSignal) {
851
+ releaseCaller = () => {
852
+ callerSignal.removeEventListener("abort", abortUpstream)
853
+ }
854
+ }
219
855
  }
220
856
 
221
- let upstream: Response
857
+ const transport = resolvedTransport ?? (await getTransport())
858
+ let upstream: ProxyUpstreamResponse
222
859
  try {
223
- upstream = await fetch(target, init)
860
+ upstream = await transport(target, {
861
+ method: req.method,
862
+ headers: upstreamRequestHeaders(req, clientIp, options),
863
+ body: req.method === "GET" || req.method === "HEAD" ? null : req.body,
864
+ signal: controller.signal,
865
+ })
224
866
  } catch (error) {
225
- if (timeout.aborted) return flatError(504, "gateway_timeout")
226
- if (callerSignal?.aborted === true) throw error
867
+ clearTimeout(timer)
868
+ releaseCaller()
869
+ if (timedOut) return flatError(504, "gateway_timeout")
870
+ if (callerSignal.aborted) throw error
227
871
  return flatError(502, "bad_gateway")
228
872
  }
229
- return new Response(upstream.body, {
230
- status: upstream.status,
231
- statusText: upstream.statusText,
232
- headers: relayedResponseHeaders(upstream),
233
- })
873
+ clearTimeout(timer)
874
+ const relayed = upstream.body
875
+ if (relayed === null) releaseCaller()
876
+ return new Response(
877
+ relayed !== null && sharedSignal ? settleTracked(relayed, releaseCaller) : relayed,
878
+ {
879
+ status: upstream.status,
880
+ statusText: upstream.statusText,
881
+ headers: relayedResponseHeaders(upstream.headers, upstream.bodyEncoded === true),
882
+ },
883
+ )
884
+ }
885
+
886
+ const nativeMount = async (
887
+ input: NodeNativeProxyRequest,
888
+ response: NodeNativeProxyResponse,
889
+ platform?: { readonly clientIp?: string },
890
+ ): Promise<undefined | false> => {
891
+ const transport = await getNativeTransport()
892
+ if (transport === undefined) return false
893
+
894
+ const raw = input.raw as {
895
+ readonly destroyed?: boolean
896
+ readonly aborted?: boolean
897
+ once(event: string, listener: (...args: unknown[]) => void): unknown
898
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown
899
+ }
900
+ const controller = new AbortController()
901
+ let timedOut = false
902
+ let cleaned = false
903
+ const onAbort = (): void => controller.abort()
904
+ const cleanup = (): void => {
905
+ if (cleaned) return
906
+ cleaned = true
907
+ raw.removeListener("aborted", onAbort)
908
+ response.removeListener("close", onAbort)
909
+ }
910
+ raw.once("aborted", onAbort)
911
+ response.once("close", onAbort)
912
+ if (raw.destroyed === true || raw.aborted === true || response.destroyed === true) {
913
+ cleanup()
914
+ return
915
+ }
916
+
917
+ const timer = setTimeout(() => {
918
+ timedOut = true
919
+ controller.abort()
920
+ }, timeoutMs)
921
+ const incoming = new URL(input.url)
922
+ let path = incoming.pathname
923
+ if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
924
+ const rest = path.slice(stripPrefix.length)
925
+ path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest
926
+ }
927
+ // As in the Web path, mutate only a clone of the fixed origin. The Node adapter's URL is used
928
+ // for path/query and never as a URL base, so a request path cannot redirect the dialed host.
929
+ const target = new URL(base)
930
+ target.pathname = path
931
+ target.search = incoming.search
932
+
933
+ try {
934
+ const upstream = await transport(target, {
935
+ method: input.method,
936
+ headers: nativeRequestHeaders(input, platform?.clientIp, options),
937
+ body: input.method === "GET" || input.method === "HEAD" ? null : input.raw,
938
+ signal: controller.signal,
939
+ headersTimeout: timeoutMs,
940
+ })
941
+ clearTimeout(timer)
942
+ if (controller.signal.aborted) {
943
+ cleanup()
944
+ return
945
+ }
946
+ for (const [name, value] of nativeResponseHeaders(upstream.headers)) {
947
+ response.setHeader(name, value)
948
+ }
949
+
950
+ const noBody =
951
+ input.method === "HEAD" ||
952
+ upstream.status === 101 ||
953
+ upstream.status === 204 ||
954
+ upstream.status === 205 ||
955
+ upstream.status === 304
956
+ if (noBody) {
957
+ await upstream.dump()
958
+ if (!nativeResponseOpen(response)) {
959
+ cleanup()
960
+ return
961
+ }
962
+ response.writeHead(upstream.status)
963
+ response.end()
964
+ cleanup()
965
+ return
966
+ }
967
+
968
+ if (!nativeResponseOpen(response)) {
969
+ cleanup()
970
+ return
971
+ }
972
+ response.writeHead(upstream.status)
973
+ await pipeNativeBody(upstream.body as NativeBodyStream, response, cleanup)
974
+ return
975
+ } catch {
976
+ clearTimeout(timer)
977
+ cleanup()
978
+ if (controller.signal.aborted && !timedOut) return
979
+ if (timedOut) nativeFlatError(response, 504, "gateway_timeout")
980
+ else nativeFlatError(response, 502, "bad_gateway")
981
+ return
982
+ }
983
+ }
984
+
985
+ const runtime = globalThis as { readonly Bun?: unknown; readonly Deno?: unknown }
986
+ const nodeRuntime =
987
+ runtime.Bun === undefined &&
988
+ runtime.Deno === undefined &&
989
+ typeof process !== "undefined" &&
990
+ process.versions?.node !== undefined
991
+ // A caller-supplied transport without the native marker must not pay a promise-based probe on
992
+ // every Node request. The default is advertised on Node and can still fall back once if the
993
+ // optional peer is absent; all other runtimes keep the exact portable mount shape.
994
+ if (explicitNativeTransport !== undefined || (explicitTransport === undefined && nodeRuntime)) {
995
+ Object.defineProperty(proxy, NODE_NATIVE_MOUNT, { value: nativeMount })
234
996
  }
997
+ return proxy
235
998
  }