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