Skip to content

Commit 9718252

Browse files
committed
feat: add proxy mode for forwarding requests to an upstream server
- add src/proxy.ts with hop-by-hop header filtering and x-forwarded-* headers - add --proxy CLI flag and PROXY_TARGET env var (mutually exclusive with path) - make serve/redirect/proxy/listen async, resolving once listening - remove app.get() custom-route API - add proxy, env validation, and CLI exclusivity tests 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router)
1 parent 607efc4 commit 9718252

11 files changed

Lines changed: 294 additions & 92 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# HTTPS server running on localhost
22

3-
Run a server on localhost with a locally-trusted SSL certificate. Serve static files, define your own routes, or import it as a module in your project.
3+
Run a server on localhost with a locally-trusted SSL certificate. Serve static files, proxy an existing server, define your own routes, or import it as a module in your project.
44

55
The certificate is provided by [mkcert](https://github.com/FiloSottile/mkcert). It automatically creates and installs a local CA in the system and browser root stores, and uses it to generate certificates that your machine trusts. **These are not valid certificates, they are for development only, and will only work in your machine.**
66

@@ -25,6 +25,7 @@ Usage notes:
2525
- `-H, --host <host>`: domain for the certificate (`HOST`)
2626
- `--cert-path <path>`: custom certificate directory (`CERT_PATH`)
2727
- `--reinstall`: force certificate re-generation (`REINSTALL=true`)
28+
- `--proxy <url>`: proxy all requests to the given http(s) URL (`PROXY_TARGET`)
2829
- Specifying a port number prevents HTTP to HTTPS redirect.
2930

3031
### Standalone binaries
@@ -39,29 +40,26 @@ Install as a dependency:
3940
npm i -D https-localhost
4041
```
4142

42-
Then put in your `index.js` file:
43+
Then use it to serve static files or proxy an existing server.
4344

4445
```js
4546
import { createServer } from "https-localhost"
4647
// or, in CommonJS:
4748
// const { createServer } = require("https-localhost")
4849

4950
const app = createServer()
50-
app.listen() // optionally, specify a port
51-
app.redirect() // enable http to https
52-
app.serve(path) // serve static files
51+
await app.redirect() // enable http -> https
52+
await app.serve(path) // serve static files
5353

54-
// optionally, define your own routes (GET only)
55-
app.get("/api/hello", (req, res) => res.end("Hello, world!"))
54+
// or, instead of serve, proxy an existing server:
55+
await app.proxy("http://localhost:3000")
5656
```
5757

5858
Alternatively, you can use the certificates in your own server:
5959

6060
```js
6161
import https from "node:https"
62-
import { getCerts } from "https-localhost/certs"
63-
// or, in CommonJS:
64-
// const { getCerts } = require("https-localhost/certs")
62+
import { getCerts } from "https-localhost/certs" // or: const { getCerts } = require("https-localhost/certs")
6563

6664
const certs = await getCerts()
6765

‎src/cli.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const parsedArgs = parseArgs({
1313
host: { type: "string", short: "H" },
1414
"cert-path": { type: "string" },
1515
reinstall: { type: "boolean" },
16+
proxy: { type: "string" },
1617
help: { type: "boolean", short: "h" },
1718
},
1819
})
@@ -26,28 +27,36 @@ Options:
2627
-H, --host <host> Host/domain for SSL cert (default: localhost or HOST env)
2728
--cert-path <path> Directory to store/find certificates (default: CERT_PATH env)
2829
--reinstall Force reinstall of certificates (default: REINSTALL env)
30+
--proxy <url> Proxy requests to the given http(s) URL (default: PROXY_TARGET env)
2931
-h, --help Display help
3032
`)
3133
process.exit(0)
3234
}
3335

3436
const staticFolder = parsedArgs.positionals[0] ?? process.cwd()
3537

36-
const { port, host, "cert-path": certPath, reinstall } = parsedArgs.values
38+
const { port, host, "cert-path": certPath, reinstall, proxy } = parsedArgs.values
39+
40+
if (proxy !== undefined && parsedArgs.positionals.length > 0) {
41+
console.error("Error: --proxy and a static path are mutually exclusive.")
42+
process.exit(1)
43+
}
3744

3845
const env = getEnv({
3946
PORT: port,
4047
HOST: host,
4148
CERT_PATH: certPath,
4249
REINSTALL: reinstall,
50+
PROXY_TARGET: proxy,
4351
})
4452

4553
const app = createServer({
4654
domain: env.HOST,
4755
certPath: env.CERT_PATH,
4856
reinstall: env.REINSTALL,
4957
})
50-
app.serve(staticFolder, env.PORT)
58+
if (env.PROXY_TARGET !== undefined) app.proxy(env.PROXY_TARGET, env.PORT)
59+
else app.serve(staticFolder, env.PORT)
5160
if (env.PORT === 443) app.redirect()
5261

5362
process.on("uncaughtException", err => {

‎src/env.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export const envSchema = z.object({
77
REINSTALL: z
88
.union([z.boolean(), z.string().transform(v => v === "true" || v === "1")])
99
.default(false),
10+
PROXY_TARGET: z
11+
.url()
12+
.refine(v => v.startsWith("http:") || v.startsWith("https:"), {
13+
message: "PROXY_TARGET must be an http or https URL",
14+
})
15+
.optional(),
1016
})
1117

1218
export type Env = z.infer<typeof envSchema>

‎src/index.ts‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ import type { Server } from "node:http"
33
import https from "node:https"
44

55
import { getCerts } from "./certs.ts"
6+
import { createProxyHandler } from "./proxy.ts"
67
import { createRouter } from "./router.ts"
7-
import type { RouteHandler } from "./router.ts"
88
import { createStaticHandler } from "./static.ts"
99

1010
export type HttpsLocalhostApp = {
1111
server?: Server
1212
http?: Server
13-
get: (route: string, handler: RouteHandler) => void
1413
listen: (port?: number) => Promise<Server>
15-
redirect: (httpPort?: number, httpsPort?: number) => void
16-
serve: (staticPath?: string, port?: number) => void
14+
proxy: (target: string, port?: number) => Promise<HttpsLocalhostApp>
15+
redirect: (httpPort?: number, httpsPort?: number) => Promise<HttpsLocalhostApp>
16+
serve: (staticPath?: string, port?: number) => Promise<HttpsLocalhostApp>
1717
}
1818

1919
export function createServer({
@@ -28,34 +28,43 @@ export function createServer({
2828
const router = createRouter()
2929

3030
const app: HttpsLocalhostApp = {
31-
get(route, handler) {
32-
router.get(route, handler)
33-
},
3431
async listen(port = 443) {
35-
app.server = https
36-
.createServer(await getCerts({ domain, certPath, reinstall }), router.handleRequest)
37-
.listen(port)
32+
const certs = await getCerts({ domain, certPath, reinstall })
33+
app.server = https.createServer(certs, router.handleRequest)
34+
await new Promise<void>(resolve => {
35+
app.server?.listen(port, resolve)
36+
})
3837
console.info(`Server running on port ${port}.`)
3938
return app.server
4039
},
41-
redirect(httpPort = 80, httpsPort = 443) {
42-
app.http = http
43-
.createServer((req, res) => {
44-
const reqHost = req.headers.host ?? domain
45-
res.writeHead(301, {
46-
Location: `https://${reqHost.replace(`:${httpPort}`, "")}${
47-
httpsPort !== 443 ? `:${httpsPort}` : ""
48-
}${req.url ?? ""}`,
40+
async proxy(target, port = 443) {
41+
router.setProxyHandler(createProxyHandler(target))
42+
console.info(`Proxying to ${target}`)
43+
await app.listen(port)
44+
return app
45+
},
46+
async redirect(httpPort = 80, httpsPort = 443) {
47+
await new Promise<void>(resolve => {
48+
app.http = http
49+
.createServer((req, res) => {
50+
const reqHost = req.headers.host ?? domain
51+
res.writeHead(301, {
52+
Location: `https://${reqHost.replace(`:${httpPort}`, "")}${
53+
httpsPort !== 443 ? `:${httpsPort}` : ""
54+
}${req.url ?? ""}`,
55+
})
56+
res.end()
4957
})
50-
res.end()
51-
})
52-
.listen(httpPort)
58+
.listen(httpPort, resolve)
59+
})
5360
console.info("http to https redirection active.")
61+
return app
5462
},
55-
serve(staticPath = process.cwd(), port = 443) {
63+
async serve(staticPath = process.cwd(), port = 443) {
5664
router.setStaticHandler(createStaticHandler(staticPath))
5765
console.info(`Serving static path: ${staticPath}`)
58-
void app.listen(port)
66+
await app.listen(port)
67+
return app
5968
},
6069
}
6170

‎src/proxy.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import http from "node:http"
2+
import type {
3+
IncomingMessage,
4+
OutgoingHttpHeaders,
5+
RequestListener,
6+
ServerResponse,
7+
} from "node:http"
8+
import https from "node:https"
9+
10+
const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
11+
"connection",
12+
"keep-alive",
13+
"proxy-authenticate",
14+
"proxy-authorization",
15+
"te",
16+
"trailer",
17+
"transfer-encoding",
18+
"upgrade",
19+
])
20+
21+
function filterHeaders(headers: IncomingMessage["headers"]): OutgoingHttpHeaders {
22+
const filtered: OutgoingHttpHeaders = {}
23+
for (const [name, value] of Object.entries(headers)) {
24+
if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue
25+
filtered[name] = value
26+
}
27+
return filtered
28+
}
29+
30+
export function createProxyHandler(target: string): RequestListener {
31+
const url = new URL(target)
32+
if (url.protocol !== "http:" && url.protocol !== "https:") {
33+
throw new Error(`Unsupported proxy protocol: ${url.protocol}. Use http or https.`)
34+
}
35+
const transport = url.protocol === "https:" ? https : http
36+
const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port)
37+
38+
return function handleProxy(req: IncomingMessage, res: ServerResponse): void {
39+
const headers = filterHeaders(req.headers)
40+
headers.host = url.host
41+
if (req.socket.remoteAddress !== undefined)
42+
headers["x-forwarded-for"] = req.socket.remoteAddress
43+
headers["x-forwarded-proto"] =
44+
(req.socket as { encrypted?: boolean }).encrypted === true ? "https" : "http"
45+
if (req.headers.host !== undefined) headers["x-forwarded-host"] = req.headers.host
46+
47+
const upstream = transport.request(
48+
{
49+
protocol: url.protocol,
50+
hostname: url.hostname,
51+
port,
52+
path: req.url,
53+
method: req.method,
54+
headers,
55+
},
56+
upstreamRes => {
57+
res.writeHead(upstreamRes.statusCode ?? 502, filterHeaders(upstreamRes.headers))
58+
upstreamRes.pipe(res)
59+
},
60+
)
61+
upstream.on("error", () => {
62+
if (res.headersSent) {
63+
res.destroy()
64+
return
65+
}
66+
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" })
67+
res.end("Bad gateway.")
68+
})
69+
req.on("aborted", () => {
70+
upstream.destroy()
71+
})
72+
req.pipe(upstream)
73+
}
74+
}

‎src/router.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,18 @@ import type { IncomingMessage, RequestListener, ServerResponse } from "node:http
22

33
import { applyCors } from "./cors.ts"
44

5-
export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void
6-
75
export function createRouter(): {
86
handleRequest: RequestListener
9-
get: (route: string, handler: RouteHandler) => void
7+
setProxyHandler: (handler: RequestListener | undefined) => void
108
setStaticHandler: (handler: RequestListener | undefined) => void
119
} {
12-
const routes: Array<{ route: string; handler: RouteHandler }> = []
13-
10+
let proxyHandler: RequestListener | undefined
1411
let staticHandler: RequestListener | undefined
1512

1613
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
1714
if (applyCors(req, res)) return
18-
const route = routes.find(candidate => candidate.route === req.url?.split("?")[0])
19-
if (route !== undefined && req.method === "GET") {
20-
route.handler(req, res)
15+
if (proxyHandler !== undefined) {
16+
proxyHandler(req, res)
2117
return
2218
}
2319
if (staticHandler !== undefined) {
@@ -28,13 +24,13 @@ export function createRouter(): {
2824
res.end("Not found.")
2925
}
3026

31-
function get(route: string, handler: RouteHandler): void {
32-
routes.push({ route, handler })
27+
function setProxyHandler(handler: RequestListener | undefined): void {
28+
proxyHandler = handler
3329
}
3430

3531
function setStaticHandler(handler: RequestListener | undefined): void {
3632
staticHandler = handler
3733
}
3834

39-
return { handleRequest, get, setStaticHandler }
35+
return { handleRequest, setProxyHandler, setStaticHandler }
4036
}

‎test/cli.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ void describe("cli", () => {
3333
}
3434
})
3535

36+
void it("exits with an error when --proxy and a path are both passed", async () => {
37+
const proc = spawn("node", [cliPath, "--proxy", "http://localhost:3000", "some-path"], {
38+
env: currentEnv(),
39+
stdio: ["ignore", "pipe", "pipe"],
40+
})
41+
42+
let stderr = ""
43+
const [code] = await new Promise<[number | null]>((resolve, reject) => {
44+
proc.stderr.on("data", (data: Buffer) => {
45+
stderr += data.toString()
46+
})
47+
proc.on("close", code => resolve([code]))
48+
proc.on("error", reject)
49+
})
50+
51+
assert.strictEqual(code, 1)
52+
assert.ok(stderr.includes("mutually exclusive"))
53+
})
54+
3655
void it("prints help when --help is passed", async () => {
3756
const proc = spawn("node", [cliPath, "--help"], {
3857
env: currentEnv(),

‎test/env.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,20 @@ void describe("env", () => {
4848
assert.throws(() => envSchema.parse({ PORT: 0 }))
4949
assert.throws(() => envSchema.parse({ PORT: 70000 }))
5050
})
51+
52+
void it("rejects invalid proxy targets", () => {
53+
assert.throws(() => envSchema.parse({ PROXY_TARGET: "not-a-url" }))
54+
assert.throws(() => envSchema.parse({ PROXY_TARGET: "ftp://localhost:3000" }))
55+
})
56+
57+
void it("accepts http and https proxy targets", () => {
58+
assert.strictEqual(
59+
envSchema.parse({ PROXY_TARGET: "http://localhost:3000" }).PROXY_TARGET,
60+
"http://localhost:3000",
61+
)
62+
assert.strictEqual(
63+
envSchema.parse({ PROXY_TARGET: "https://example.com" }).PROXY_TARGET,
64+
"https://example.com",
65+
)
66+
})
5167
})

‎test/helpers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function currentEnv(): NodeJS.ProcessEnv {
1818
return env
1919
}
2020

21-
function getRootCA(): Buffer | undefined {
21+
export function getRootCA(): Buffer | undefined {
2222
try {
2323
const certDir = getAppDataPath("https-localhost")
2424
const files = fs.readdirSync(certDir)

0 commit comments

Comments
 (0)