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