@nifrajs/proxy 2.12.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @nifrajs/proxy
2
+
3
+ Reverse proxy for nifra - stream requests to one fixed upstream origin with hop-by-hop hygiene, SSRF-proof by construction. Dependency-free.
4
+
5
+ Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @nifrajs/proxy
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ import { server } from "@nifrajs/core"
17
+ import { createProxy } from "@nifrajs/proxy"
18
+
19
+ const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
20
+ const app = server().mountFetch("/api", upstream, { stripPrefix: true })
21
+ ```
22
+
23
+ The upstream is a **bare origin** fixed at construction - the forwarded URL is built by mutating a clone of that origin, never by resolving request-derived strings, so no request input can change which host is dialed. Hop-by-hop and `Connection`-nominated headers are stripped in both directions, forwarding metadata (`X-Forwarded-*`) is dropped unless `forwardClientIp: true`, upstream redirects are never followed, and TLS verification has no off switch. Unreachable upstreams answer a flat `502`; the deadline (`timeoutMs`, default 30s) answers `504`.
24
+
25
+ ## Docs
26
+
27
+ - Reference: <https://nifra.dev/docs>
28
+ - AI-readable: <https://nifra.dev/llms.txt>
29
+
30
+ MIT
31
+
32
+ ## For AI agents
33
+
34
+ Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
35
+ one cheap read instead of the whole corpus. For the wider framework: the repo's
36
+ [`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
37
+ [`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
38
+ done-gate, or `nifra mcp` to give the agent live project tools.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Reverse proxy to ONE fixed upstream origin, secure by construction.
3
+ *
4
+ * The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
5
+ * clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
6
+ * request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
7
+ * through this proxy is unrepresentable, not filtered.
8
+ *
9
+ * Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
10
+ * by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
11
+ * the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
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
14
+ * redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
15
+ * the proxy anywhere. TLS verification is always on; there is no knob to disable it.
16
+ */
17
+ /** Structural slice of a nifra `Context` the proxy reads - a plain `Request` works too. */
18
+ export interface ProxyContext {
19
+ readonly req: Request;
20
+ readonly clientIp?: string | undefined;
21
+ readonly signal?: AbortSignal | undefined;
22
+ }
23
+ export interface ProxyOptions {
24
+ /**
25
+ * The upstream to forward every request to, as a **bare origin** (`https://api.internal:8443`).
26
+ * A path, query, fragment, or credentials in the URL throws at construction; only `http:` and
27
+ * `https:` are accepted. The forwarded URL is this origin + the incoming path and query.
28
+ */
29
+ readonly upstream: string;
30
+ /** Mount prefix to remove from the incoming path before forwarding (`/api` + request
31
+ * `/api/users` → upstream `/users`). A path outside the prefix is forwarded unchanged. */
32
+ readonly stripPrefix?: string;
33
+ /**
34
+ * Forward caller metadata upstream. Default **false**: `Forwarded` and `X-Forwarded-*` headers
35
+ * are stripped, so a client-forged chain never reaches the upstream. When true, the inbound
36
+ * `X-Forwarded-For` is kept with the observed caller IP appended (pass a `ProxyContext` so
37
+ * `c.clientIp` - already filtered by the app's trust declaration - is what gets appended). With a
38
+ * bare `Request`, forwarding metadata is suppressed rather than passed through.
39
+ */
40
+ readonly forwardClientIp?: boolean;
41
+ /** Upstream response deadline in milliseconds. Default `30_000`; expiry answers `504`. */
42
+ readonly timeoutMs?: number;
43
+ /** Static headers to set on every forwarded request (after hygiene, so they always win). */
44
+ readonly headers?: Readonly<Record<string, string>>;
45
+ }
46
+ /** Forward a request (or a nifra context) to the configured upstream. */
47
+ export type ProxyHandler = (input: Request | ProxyContext) => Promise<Response>;
48
+ /**
49
+ * Create a proxy handler bound to one upstream origin.
50
+ *
51
+ * ```ts
52
+ * const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
53
+ * app.mountFetch("/api", upstream, { stripPrefix: true })
54
+ * ```
55
+ *
56
+ * Streams both directions, preserves method/path/query/status, strips hop-by-hop and
57
+ * Connection-nominated headers both ways, never follows upstream redirects, and answers flat
58
+ * errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
59
+ * WebSocket upgrade is not proxied.
60
+ */
61
+ export declare function createProxy(options: ProxyOptions): ProxyHandler;
62
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,2FAA2F;AAC3F,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;CAC1C;AAED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;8FAC0F;IAC1F,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAA;IAClC,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACpD;AAED,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,YAAY,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AA0F/E;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAkF/D"}
package/dist/index.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Reverse proxy to ONE fixed upstream origin, secure by construction.
3
+ *
4
+ * The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
5
+ * clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
6
+ * request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
7
+ * through this proxy is unrepresentable, not filtered.
8
+ *
9
+ * Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
10
+ * by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
11
+ * the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
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
14
+ * redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
15
+ * the proxy anywhere. TLS verification is always on; there is no knob to disable it.
16
+ */
17
+ /** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
18
+ const HOP_BY_HOP = new Set([
19
+ "connection",
20
+ "keep-alive",
21
+ "proxy-authenticate",
22
+ "proxy-authorization",
23
+ "proxy-connection",
24
+ "te",
25
+ "trailer",
26
+ "transfer-encoding",
27
+ "upgrade",
28
+ ]);
29
+ /** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
30
+ const FORWARDING = new Set([
31
+ "forwarded",
32
+ "x-forwarded-for",
33
+ "x-forwarded-host",
34
+ "x-forwarded-proto",
35
+ ]);
36
+ const EMPTY = new Set();
37
+ /** Header names nominated hop-by-hop by a `Connection` header (lowercased). */
38
+ function connectionNominated(headers) {
39
+ const raw = headers.get("connection");
40
+ if (raw === null)
41
+ return EMPTY;
42
+ const out = new Set();
43
+ for (const token of raw.split(",")) {
44
+ const name = token.trim().toLowerCase();
45
+ if (name !== "")
46
+ out.add(name);
47
+ }
48
+ return out;
49
+ }
50
+ function dropHeader(name, nominated) {
51
+ return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-");
52
+ }
53
+ const flatError = (status, error) => new Response(JSON.stringify({ ok: false, error }), {
54
+ status,
55
+ headers: { "content-type": "application/json" },
56
+ });
57
+ function upstreamRequestHeaders(req, clientIp, options) {
58
+ const nominated = connectionNominated(req.headers);
59
+ const out = new Headers();
60
+ for (const [name, value] of req.headers) {
61
+ // `host` names the proxy, not the upstream - fetch derives the right one from the target URL.
62
+ if (name === "host" || dropHeader(name, nominated))
63
+ continue;
64
+ if (FORWARDING.has(name))
65
+ continue;
66
+ out.append(name, value);
67
+ }
68
+ if (options.forwardClientIp === true && clientIp !== undefined) {
69
+ const prior = req.headers.get("x-forwarded-for");
70
+ const chain = prior !== null ? `${prior}, ${clientIp}` : clientIp;
71
+ if (chain !== undefined)
72
+ out.set("x-forwarded-for", chain);
73
+ out.set("x-forwarded-proto", new URL(req.url).protocol.slice(0, -1));
74
+ const host = req.headers.get("host");
75
+ if (host !== null)
76
+ out.set("x-forwarded-host", host);
77
+ }
78
+ if (options.headers !== undefined) {
79
+ for (const [name, value] of Object.entries(options.headers))
80
+ out.set(name, value);
81
+ }
82
+ return out;
83
+ }
84
+ function relayedResponseHeaders(upstream) {
85
+ const nominated = connectionNominated(upstream.headers);
86
+ const out = new Headers();
87
+ for (const [name, value] of upstream.headers) {
88
+ if (dropHeader(name, nominated))
89
+ continue;
90
+ // fetch() already decoded the body per Content-Encoding, so the stored encoding and length no
91
+ // longer describe the bytes being relayed. (Re-compression is the compression() middleware's job.)
92
+ if (name === "content-encoding" || name === "content-length")
93
+ continue;
94
+ // Re-added below via getSetCookie() so multiple cookies survive on every runtime.
95
+ if (name === "set-cookie")
96
+ continue;
97
+ out.append(name, value);
98
+ }
99
+ for (const cookie of upstream.headers.getSetCookie())
100
+ out.append("set-cookie", cookie);
101
+ return out;
102
+ }
103
+ /**
104
+ * Create a proxy handler bound to one upstream origin.
105
+ *
106
+ * ```ts
107
+ * const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
108
+ * app.mountFetch("/api", upstream, { stripPrefix: true })
109
+ * ```
110
+ *
111
+ * Streams both directions, preserves method/path/query/status, strips hop-by-hop and
112
+ * Connection-nominated headers both ways, never follows upstream redirects, and answers flat
113
+ * errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
114
+ * WebSocket upgrade is not proxied.
115
+ */
116
+ export function createProxy(options) {
117
+ const base = new URL(options.upstream);
118
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
119
+ throw new Error(`[nifra/proxy] upstream must be http(s), got ${base.protocol}`);
120
+ }
121
+ if (base.pathname !== "/" ||
122
+ base.search !== "" ||
123
+ base.hash !== "" ||
124
+ base.username !== "" ||
125
+ base.password !== "") {
126
+ throw new Error("[nifra/proxy] upstream must be a bare origin (no path, query, fragment, or credentials)");
127
+ }
128
+ const stripPrefix = options.stripPrefix;
129
+ if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
130
+ throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one');
131
+ }
132
+ const timeoutMs = options.timeoutMs ?? 30_000;
133
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
134
+ throw new Error("[nifra/proxy] timeoutMs must be a positive number");
135
+ }
136
+ for (const name of Object.keys(options.headers ?? {})) {
137
+ const normalized = name.toLowerCase();
138
+ if (normalized === "host" ||
139
+ HOP_BY_HOP.has(normalized) ||
140
+ normalized.startsWith("proxy-") ||
141
+ FORWARDING.has(normalized)) {
142
+ throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`);
143
+ }
144
+ }
145
+ return async (input) => {
146
+ const req = input instanceof Request ? input : input.req;
147
+ const clientIp = input instanceof Request ? undefined : input.clientIp;
148
+ const callerSignal = input instanceof Request ? input.signal : (input.signal ?? input.req.signal);
149
+ const incoming = new URL(req.url);
150
+ let path = incoming.pathname;
151
+ if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
152
+ const rest = path.slice(stripPrefix.length);
153
+ path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
154
+ }
155
+ // Mutating a clone of the configured origin - never URL-resolving a request-derived string -
156
+ // means no path (e.g. `//evil.example/x`) can change the host being dialed.
157
+ const target = new URL(base);
158
+ target.pathname = path;
159
+ target.search = incoming.search;
160
+ const timeout = AbortSignal.timeout(timeoutMs);
161
+ const signal = callerSignal !== undefined ? AbortSignal.any([timeout, callerSignal]) : timeout;
162
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : req.body;
163
+ const init = {
164
+ method: req.method,
165
+ headers: upstreamRequestHeaders(req, clientIp, options),
166
+ redirect: "manual",
167
+ signal,
168
+ };
169
+ if (body !== null && body !== undefined) {
170
+ init.body = body;
171
+ init.duplex = "half";
172
+ }
173
+ let upstream;
174
+ try {
175
+ upstream = await fetch(target, init);
176
+ }
177
+ catch (error) {
178
+ if (timeout.aborted)
179
+ return flatError(504, "gateway_timeout");
180
+ if (callerSignal?.aborted === true)
181
+ throw error;
182
+ return flatError(502, "bad_gateway");
183
+ }
184
+ return new Response(upstream.body, {
185
+ status: upstream.status,
186
+ statusText: upstream.statusText,
187
+ headers: relayedResponseHeaders(upstream),
188
+ });
189
+ };
190
+ }
191
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@nifrajs/proxy",
3
+ "version": "2.12.0",
4
+ "description": "Reverse proxy for nifra - stream requests to one fixed upstream origin with hop-by-hop hygiene, SSRF-proof by construction. Dependency-free.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "proxy",
10
+ "reverse-proxy",
11
+ "http-proxy",
12
+ "streaming",
13
+ "upstream"
14
+ ],
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "bun": "./src/index.ts",
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src"
29
+ ],
30
+ "sideEffects": false,
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
33
+ },
34
+ "devDependencies": {
35
+ "@nifrajs/core": "workspace:*"
36
+ },
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/nifrajs/nifra.git",
44
+ "directory": "packages/proxy"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/nifrajs/nifra/issues"
48
+ },
49
+ "homepage": "https://github.com/nifrajs/nifra#readme"
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Reverse proxy to ONE fixed upstream origin, secure by construction.
3
+ *
4
+ * The upstream is a bare origin declared at construction; the forwarded URL is built by *mutating a
5
+ * clone of that origin's path and query*, never by URL-resolving request-derived strings - so no
6
+ * request input (protocol-relative `//host` paths included) can change which host is dialed. SSRF
7
+ * through this proxy is unrepresentable, not filtered.
8
+ *
9
+ * Hop-by-hop hygiene runs in BOTH directions: the RFC 9110 hop-by-hop set, every header nominated
10
+ * by a `Connection` header, and `Proxy-*` headers are dropped from the forwarded request and from
11
+ * the relayed response (the Connection-nominated leak is the @fastify/http-proxy CVE-2026-33805 /
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
14
+ * redirects are never followed (`redirect: "manual"`) - following one would let the upstream steer
15
+ * the proxy anywhere. TLS verification is always on; there is no knob to disable it.
16
+ */
17
+
18
+ /** Structural slice of a nifra `Context` the proxy reads - a plain `Request` works too. */
19
+ export interface ProxyContext {
20
+ readonly req: Request
21
+ readonly clientIp?: string | undefined
22
+ readonly signal?: AbortSignal | undefined
23
+ }
24
+
25
+ export interface ProxyOptions {
26
+ /**
27
+ * The upstream to forward every request to, as a **bare origin** (`https://api.internal:8443`).
28
+ * A path, query, fragment, or credentials in the URL throws at construction; only `http:` and
29
+ * `https:` are accepted. The forwarded URL is this origin + the incoming path and query.
30
+ */
31
+ readonly upstream: string
32
+ /** Mount prefix to remove from the incoming path before forwarding (`/api` + request
33
+ * `/api/users` → upstream `/users`). A path outside the prefix is forwarded unchanged. */
34
+ readonly stripPrefix?: string
35
+ /**
36
+ * 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.
41
+ */
42
+ readonly forwardClientIp?: boolean
43
+ /** Upstream response deadline in milliseconds. Default `30_000`; expiry answers `504`. */
44
+ readonly timeoutMs?: number
45
+ /** Static headers to set on every forwarded request (after hygiene, so they always win). */
46
+ readonly headers?: Readonly<Record<string, string>>
47
+ }
48
+
49
+ /** Forward a request (or a nifra context) to the configured upstream. */
50
+ export type ProxyHandler = (input: Request | ProxyContext) => Promise<Response>
51
+
52
+ /** RFC 9110 hop-by-hop headers: they describe one connection, never end-to-end semantics. */
53
+ const HOP_BY_HOP: ReadonlySet<string> = new Set([
54
+ "connection",
55
+ "keep-alive",
56
+ "proxy-authenticate",
57
+ "proxy-authorization",
58
+ "proxy-connection",
59
+ "te",
60
+ "trailer",
61
+ "transfer-encoding",
62
+ "upgrade",
63
+ ])
64
+
65
+ /** Forwarding metadata is an explicit opt-in (`forwardClientIp`), never a passthrough. */
66
+ const FORWARDING: ReadonlySet<string> = new Set([
67
+ "forwarded",
68
+ "x-forwarded-for",
69
+ "x-forwarded-host",
70
+ "x-forwarded-proto",
71
+ ])
72
+
73
+ const EMPTY: ReadonlySet<string> = new Set()
74
+
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")
78
+ if (raw === null) return EMPTY
79
+ const out = new Set<string>()
80
+ for (const token of raw.split(",")) {
81
+ const name = token.trim().toLowerCase()
82
+ if (name !== "") out.add(name)
83
+ }
84
+ return out
85
+ }
86
+
87
+ function dropHeader(name: string, nominated: ReadonlySet<string>): boolean {
88
+ return HOP_BY_HOP.has(name) || nominated.has(name) || name.startsWith("proxy-")
89
+ }
90
+
91
+ const flatError = (status: number, error: string): Response =>
92
+ new Response(JSON.stringify({ ok: false, error }), {
93
+ status,
94
+ headers: { "content-type": "application/json" },
95
+ })
96
+
97
+ function upstreamRequestHeaders(
98
+ req: Request,
99
+ clientIp: string | undefined,
100
+ options: ProxyOptions,
101
+ ): Headers {
102
+ const nominated = connectionNominated(req.headers)
103
+ const out = new Headers()
104
+ 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
108
+ out.append(name, value)
109
+ }
110
+ 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)
117
+ }
118
+ if (options.headers !== undefined) {
119
+ for (const [name, value] of Object.entries(options.headers)) out.set(name, value)
120
+ }
121
+ return out
122
+ }
123
+
124
+ function relayedResponseHeaders(upstream: Response): Headers {
125
+ const nominated = connectionNominated(upstream.headers)
126
+ 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.
133
+ if (name === "set-cookie") continue
134
+ out.append(name, value)
135
+ }
136
+ for (const cookie of upstream.headers.getSetCookie()) out.append("set-cookie", cookie)
137
+ return out
138
+ }
139
+
140
+ /**
141
+ * Create a proxy handler bound to one upstream origin.
142
+ *
143
+ * ```ts
144
+ * const upstream = createProxy({ upstream: "http://127.0.0.1:8081" })
145
+ * app.mountFetch("/api", upstream, { stripPrefix: true })
146
+ * ```
147
+ *
148
+ * Streams both directions, preserves method/path/query/status, strips hop-by-hop and
149
+ * Connection-nominated headers both ways, never follows upstream redirects, and answers flat
150
+ * errors: `502 bad_gateway` when the upstream is unreachable, `504 gateway_timeout` on deadline.
151
+ * WebSocket upgrade is not proxied.
152
+ */
153
+ export function createProxy(options: ProxyOptions): ProxyHandler {
154
+ const base = new URL(options.upstream)
155
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
156
+ throw new Error(`[nifra/proxy] upstream must be http(s), got ${base.protocol}`)
157
+ }
158
+ if (
159
+ base.pathname !== "/" ||
160
+ base.search !== "" ||
161
+ base.hash !== "" ||
162
+ base.username !== "" ||
163
+ base.password !== ""
164
+ ) {
165
+ throw new Error(
166
+ "[nifra/proxy] upstream must be a bare origin (no path, query, fragment, or credentials)",
167
+ )
168
+ }
169
+ const stripPrefix = options.stripPrefix
170
+ if (stripPrefix !== undefined && (!stripPrefix.startsWith("/") || stripPrefix.endsWith("/"))) {
171
+ throw new Error('[nifra/proxy] stripPrefix must start with "/" and not end with one')
172
+ }
173
+ const timeoutMs = options.timeoutMs ?? 30_000
174
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
175
+ throw new Error("[nifra/proxy] timeoutMs must be a positive number")
176
+ }
177
+ for (const name of Object.keys(options.headers ?? {})) {
178
+ const normalized = name.toLowerCase()
179
+ if (
180
+ normalized === "host" ||
181
+ HOP_BY_HOP.has(normalized) ||
182
+ normalized.startsWith("proxy-") ||
183
+ FORWARDING.has(normalized)
184
+ ) {
185
+ throw new TypeError(`[nifra/proxy] static header is not allowed: ${name}`)
186
+ }
187
+ }
188
+
189
+ return async (input) => {
190
+ const req = input instanceof Request ? input : input.req
191
+ const clientIp = input instanceof Request ? undefined : input.clientIp
192
+ const callerSignal =
193
+ input instanceof Request ? input.signal : (input.signal ?? input.req.signal)
194
+
195
+ const incoming = new URL(req.url)
196
+ let path = incoming.pathname
197
+ if (stripPrefix !== undefined && (path === stripPrefix || path.startsWith(`${stripPrefix}/`))) {
198
+ const rest = path.slice(stripPrefix.length)
199
+ path = rest === "" || !rest.startsWith("/") ? `/${rest}` : rest
200
+ }
201
+ // Mutating a clone of the configured origin - never URL-resolving a request-derived string -
202
+ // means no path (e.g. `//evil.example/x`) can change the host being dialed.
203
+ const target = new URL(base)
204
+ target.pathname = path
205
+ target.search = incoming.search
206
+
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"
219
+ }
220
+
221
+ let upstream: Response
222
+ try {
223
+ upstream = await fetch(target, init)
224
+ } catch (error) {
225
+ if (timeout.aborted) return flatError(504, "gateway_timeout")
226
+ if (callerSignal?.aborted === true) throw error
227
+ return flatError(502, "bad_gateway")
228
+ }
229
+ return new Response(upstream.body, {
230
+ status: upstream.status,
231
+ statusText: upstream.statusText,
232
+ headers: relayedResponseHeaders(upstream),
233
+ })
234
+ }
235
+ }