@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.d.ts +128 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +579 -46
- package/dist/index.js.map +1 -1
- package/dist/node-stream.d.ts +61 -0
- package/dist/node-stream.d.ts.map +1 -0
- package/dist/node-stream.js +135 -0
- package/dist/node-stream.js.map +1 -0
- package/dist/undici.d.ts +50 -0
- package/dist/undici.d.ts.map +1 -0
- package/dist/undici.js +138 -0
- package/dist/undici.js.map +1 -0
- package/package.json +17 -2
- package/src/index.ts +792 -50
- package/src/node-stream.ts +174 -0
- package/src/undici.ts +164 -0
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
|
|
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
|
|
38
|
-
* `X-Forwarded-For`
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
270
|
+
const nominated = parseConnectionNominated(req.headers.get("connection"))
|
|
103
271
|
const out = new Headers()
|
|
104
272
|
for (const [name, value] of req.headers) {
|
|
105
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
125
|
-
const nominated =
|
|
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
|
|
128
|
-
|
|
129
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
836
|
+
const transport = resolvedTransport ?? (await getTransport())
|
|
837
|
+
let upstream: ProxyUpstreamResponse
|
|
222
838
|
try {
|
|
223
|
-
upstream = await
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
}
|