@stacksjs/rpx 0.11.13 → 0.11.15
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/bin/cli.js +178 -174
- package/dist/chunk-0zdj72ps.js +161 -0
- package/dist/{chunk-zs1tyy8z.js → chunk-hf6e07v4.js} +1 -1
- package/dist/{chunk-747af2w4.js → chunk-kv17r01q.js} +1 -1
- package/dist/chunk-pjwm8py7.js +1 -0
- package/dist/daemon-runner.d.ts +3 -2
- package/dist/daemon.d.ts +7 -1
- package/dist/host-match.d.ts +23 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +6 -6
- package/dist/on-demand.d.ts +40 -0
- package/dist/proxy-handler.d.ts +18 -3
- package/dist/registry.d.ts +3 -2
- package/dist/sni.d.ts +20 -0
- package/dist/static-files.d.ts +46 -0
- package/dist/types.d.ts +64 -1
- package/package.json +2 -2
- package/src/daemon-runner.ts +12 -4
- package/src/daemon.ts +154 -21
- package/src/host-match.ts +52 -0
- package/src/index.ts +19 -2
- package/src/on-demand.ts +264 -0
- package/src/proxy-handler.ts +184 -21
- package/src/registry.ts +11 -3
- package/src/sni.ts +93 -0
- package/src/start.ts +66 -19
- package/src/static-files.ts +201 -0
- package/src/types.ts +138 -1
- package/dist/chunk-kbnzcycw.js +0 -1
- package/dist/chunk-pncxrxde.js +0 -157
package/src/proxy-handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The request handlers used by the shared :443 server. Both the in-process
|
|
3
3
|
* multi-proxy mode in `start.ts` and the long-running daemon delegate to this
|
|
4
4
|
* module so routing semantics stay in one place.
|
|
5
5
|
*
|
|
@@ -7,37 +7,108 @@
|
|
|
7
7
|
* The callback indirection lets each caller use whatever data structure makes
|
|
8
8
|
* sense (a fixed Map at startup, or a hot-swappable registry view) without
|
|
9
9
|
* coupling this module to either.
|
|
10
|
+
*
|
|
11
|
+
* Three transports are supported per route:
|
|
12
|
+
* - HTTP(S) proxying via `fetch()` to an upstream `host:port`.
|
|
13
|
+
* - WebSocket proxying via `server.upgrade()` + an upstream `WebSocket`.
|
|
14
|
+
* - Static file serving from a local directory (`route.static`).
|
|
10
15
|
*/
|
|
16
|
+
import type { ServerWebSocket } from 'bun'
|
|
17
|
+
import type { ResolvedStaticRoute } from './static-files'
|
|
11
18
|
import type { PathRewrite } from './types'
|
|
12
|
-
import {
|
|
13
|
-
import { resolvePathRewrite } from './utils'
|
|
19
|
+
import { serveStaticFile } from './static-files'
|
|
20
|
+
import { debugLog, resolvePathRewrite } from './utils'
|
|
14
21
|
|
|
15
22
|
export interface ProxyRoute {
|
|
16
|
-
/**
|
|
17
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Upstream `host:port` to forward requests to (e.g. `localhost:5173`).
|
|
25
|
+
* Optional when `static` is set.
|
|
26
|
+
*/
|
|
27
|
+
sourceHost?: string
|
|
18
28
|
/** Strip `.html` suffix and 301 to clean URLs. */
|
|
19
29
|
cleanUrls?: boolean
|
|
20
30
|
/** Set the `origin` header to the target. */
|
|
21
31
|
changeOrigin?: boolean
|
|
22
32
|
/** Per-route path rewrites (vite/nginx-style prefix routing). */
|
|
23
33
|
pathRewrites?: PathRewrite[]
|
|
34
|
+
/** When set, serve files from a local directory instead of proxying. */
|
|
35
|
+
static?: ResolvedStaticRoute
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export type GetRoute = (hostname: string) => ProxyRoute | undefined
|
|
27
39
|
|
|
28
|
-
export type ProxyFetchHandler = (req: Request) => Promise<Response>
|
|
40
|
+
export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
|
|
41
|
+
|
|
42
|
+
/** Minimal shape of the Bun server needed for WebSocket upgrades. */
|
|
43
|
+
export interface ProxyServer {
|
|
44
|
+
// Loose `any` so it structurally accepts Bun's `Server<WebSocketData>` for
|
|
45
|
+
// any data generic (the daemon/start callers parameterize differently).
|
|
46
|
+
upgrade: (req: Request, options?: { data?: any, headers?: any }) => boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Data attached to an upgraded client socket so the ws handler can dial upstream. */
|
|
50
|
+
interface WsData {
|
|
51
|
+
targetUrl: string
|
|
52
|
+
forwardHeaders: Record<string, string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-socket state: the upstream client + a buffer for early client frames. */
|
|
56
|
+
interface WsState {
|
|
57
|
+
upstream: WebSocket
|
|
58
|
+
upstreamOpen: boolean
|
|
59
|
+
pending: Array<string | ArrayBufferLike | Uint8Array>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HOP_BY_HOP = new Set([
|
|
63
|
+
'connection',
|
|
64
|
+
'keep-alive',
|
|
65
|
+
'proxy-authenticate',
|
|
66
|
+
'proxy-authorization',
|
|
67
|
+
'te',
|
|
68
|
+
'trailer',
|
|
69
|
+
'transfer-encoding',
|
|
70
|
+
'upgrade',
|
|
71
|
+
'sec-websocket-key',
|
|
72
|
+
'sec-websocket-version',
|
|
73
|
+
'sec-websocket-extensions',
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
function extractHostname(req: Request): string {
|
|
77
|
+
const hostHeader = req.headers.get('host') || ''
|
|
78
|
+
// Strip port (`stacks.localhost:443` → `stacks.localhost`).
|
|
79
|
+
return hostHeader.split(':')[0]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the upstream target (`host` + `path`) for a request against a route,
|
|
84
|
+
* applying any matching path rewrite.
|
|
85
|
+
*/
|
|
86
|
+
function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
|
|
87
|
+
const url = new URL(req.url)
|
|
88
|
+
let targetHost = route.sourceHost ?? ''
|
|
89
|
+
let targetPath = url.pathname
|
|
90
|
+
|
|
91
|
+
const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
|
|
92
|
+
if (rewriteMatch) {
|
|
93
|
+
targetHost = rewriteMatch.targetHost
|
|
94
|
+
targetPath = rewriteMatch.targetPath
|
|
95
|
+
debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { targetHost, targetPath, search: url.search }
|
|
99
|
+
}
|
|
29
100
|
|
|
30
101
|
/**
|
|
31
102
|
* Build a Bun.serve-compatible `fetch` handler that routes requests based on
|
|
32
103
|
* the `Host` header. Returns 404 when no route matches and 502 on upstream
|
|
33
|
-
* failures.
|
|
104
|
+
* failures. When a request is a WebSocket upgrade and `server` is supplied, it
|
|
105
|
+
* is upgraded (returns `undefined` so Bun completes the handshake) and the
|
|
106
|
+
* traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
|
|
34
107
|
*/
|
|
35
108
|
export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
|
|
36
|
-
return async (req: Request): Promise<Response> => {
|
|
109
|
+
return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
|
|
37
110
|
const url = new URL(req.url)
|
|
38
|
-
const
|
|
39
|
-
// Strip port (`stacks.localhost:443` → `stacks.localhost`).
|
|
40
|
-
const hostname = hostHeader.split(':')[0]
|
|
111
|
+
const hostname = extractHostname(req)
|
|
41
112
|
|
|
42
113
|
const route = getRoute(hostname)
|
|
43
114
|
if (!route) {
|
|
@@ -45,19 +116,42 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
|
|
|
45
116
|
return new Response(`No proxy configured for ${hostname}`, { status: 404 })
|
|
46
117
|
}
|
|
47
118
|
|
|
48
|
-
|
|
49
|
-
|
|
119
|
+
// Static file serving short-circuits everything else.
|
|
120
|
+
if (route.static)
|
|
121
|
+
return serveStaticFile(url.pathname, route.static)
|
|
122
|
+
|
|
123
|
+
// WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
|
|
124
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
125
|
+
if (!server || !route.sourceHost)
|
|
126
|
+
return new Response('WebSocket upgrade not supported here', { status: 400 })
|
|
127
|
+
|
|
128
|
+
const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
|
|
129
|
+
const targetUrl = `ws://${targetHost}${targetPath}${search}`
|
|
50
130
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
131
|
+
const forwardHeaders: Record<string, string> = {}
|
|
132
|
+
for (const [k, v] of req.headers) {
|
|
133
|
+
if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'host')
|
|
134
|
+
forwardHeaders[k] = v
|
|
135
|
+
}
|
|
136
|
+
forwardHeaders.host = targetHost
|
|
137
|
+
forwardHeaders['x-forwarded-for'] = '127.0.0.1'
|
|
138
|
+
forwardHeaders['x-forwarded-proto'] = 'https'
|
|
139
|
+
forwardHeaders['x-forwarded-host'] = hostname
|
|
140
|
+
|
|
141
|
+
const data: WsData = { targetUrl, forwardHeaders }
|
|
142
|
+
const ok = server.upgrade(req, { data })
|
|
143
|
+
if (ok) {
|
|
144
|
+
debugLog('ws', `upgraded ${hostname}${targetPath} → ${targetUrl}`, verbose)
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
return new Response('WebSocket upgrade failed', { status: 400 })
|
|
58
148
|
}
|
|
59
149
|
|
|
60
|
-
|
|
150
|
+
if (!route.sourceHost)
|
|
151
|
+
return new Response(`No upstream configured for ${hostname}`, { status: 502 })
|
|
152
|
+
|
|
153
|
+
const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
|
|
154
|
+
const targetUrl = `http://${targetHost}${targetPath}${search}`
|
|
61
155
|
|
|
62
156
|
try {
|
|
63
157
|
const headers = new Headers(req.headers)
|
|
@@ -97,3 +191,72 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
|
|
|
97
191
|
}
|
|
98
192
|
}
|
|
99
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build the `websocket` handler block for Bun.serve. It opens an upstream
|
|
197
|
+
* `WebSocket` per client socket, buffers client→upstream frames until the
|
|
198
|
+
* upstream connection is open, and pipes messages, closes and errors in both
|
|
199
|
+
* directions with a clean teardown.
|
|
200
|
+
*/
|
|
201
|
+
export function createProxyWebSocketHandler(verbose?: boolean) {
|
|
202
|
+
const state = new WeakMap<ServerWebSocket<WsData>, WsState>()
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
open(ws: ServerWebSocket<WsData>): void {
|
|
206
|
+
const { targetUrl, forwardHeaders } = ws.data
|
|
207
|
+
let upstream: WebSocket
|
|
208
|
+
try {
|
|
209
|
+
// Bun's WebSocket accepts a `headers` option (control-channel auth etc.).
|
|
210
|
+
upstream = new WebSocket(targetUrl, { headers: forwardHeaders } as any)
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
|
|
214
|
+
ws.close(1011, 'upstream connect failed')
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
upstream.binaryType = 'arraybuffer'
|
|
218
|
+
const st: WsState = { upstream, upstreamOpen: false, pending: [] }
|
|
219
|
+
state.set(ws, st)
|
|
220
|
+
|
|
221
|
+
upstream.addEventListener('open', () => {
|
|
222
|
+
st.upstreamOpen = true
|
|
223
|
+
for (const frame of st.pending)
|
|
224
|
+
upstream.send(frame as any)
|
|
225
|
+
st.pending = []
|
|
226
|
+
})
|
|
227
|
+
upstream.addEventListener('message', (ev: MessageEvent) => {
|
|
228
|
+
// Forward both binary (ArrayBuffer) and text frames to the client.
|
|
229
|
+
ws.send(ev.data as any)
|
|
230
|
+
})
|
|
231
|
+
upstream.addEventListener('close', (ev: CloseEvent) => {
|
|
232
|
+
try { ws.close(ev.code || 1000, ev.reason || '') }
|
|
233
|
+
catch { /* already closing */ }
|
|
234
|
+
})
|
|
235
|
+
upstream.addEventListener('error', () => {
|
|
236
|
+
debugLog('ws', `upstream error for ${targetUrl}`, verbose)
|
|
237
|
+
try { ws.close(1011, 'upstream error') }
|
|
238
|
+
catch { /* already closing */ }
|
|
239
|
+
})
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
message(ws: ServerWebSocket<WsData>, message: string | Buffer): void {
|
|
243
|
+
const st = state.get(ws)
|
|
244
|
+
if (!st)
|
|
245
|
+
return
|
|
246
|
+
const frame = typeof message === 'string' ? message : new Uint8Array(message)
|
|
247
|
+
if (st.upstreamOpen)
|
|
248
|
+
st.upstream.send(frame as any)
|
|
249
|
+
else
|
|
250
|
+
st.pending.push(frame)
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
close(ws: ServerWebSocket<WsData>, code: number, reason: string): void {
|
|
254
|
+
const st = state.get(ws)
|
|
255
|
+
if (!st)
|
|
256
|
+
return
|
|
257
|
+
state.delete(ws)
|
|
258
|
+
try { st.upstream.close(code || 1000, reason || '') }
|
|
259
|
+
catch { /* already closed */ }
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - `id` is validated against a strict charset to keep it from escaping
|
|
15
15
|
* the registry directory.
|
|
16
16
|
*/
|
|
17
|
-
import type { PathRewrite } from './types'
|
|
17
|
+
import type { PathRewrite, StaticRouteConfig } from './types'
|
|
18
18
|
import * as fs from 'node:fs'
|
|
19
19
|
import * as fsp from 'node:fs/promises'
|
|
20
20
|
import { homedir } from 'node:os'
|
|
@@ -24,7 +24,8 @@ import { debugLog } from './utils'
|
|
|
24
24
|
|
|
25
25
|
export interface RegistryEntry {
|
|
26
26
|
id: string
|
|
27
|
-
|
|
27
|
+
/** Upstream `host:port`. Optional when `static` is set. */
|
|
28
|
+
from?: string
|
|
28
29
|
to: string
|
|
29
30
|
/**
|
|
30
31
|
* Optional. PID of the long-running process that owns this entry. When set,
|
|
@@ -38,6 +39,8 @@ export interface RegistryEntry {
|
|
|
38
39
|
pathRewrites?: PathRewrite[]
|
|
39
40
|
cleanUrls?: boolean
|
|
40
41
|
changeOrigin?: boolean
|
|
42
|
+
/** Serve a local directory for this route instead of proxying. */
|
|
43
|
+
static?: string | StaticRouteConfig
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
|
|
@@ -89,9 +92,13 @@ function isValidEntry(value: unknown): value is RegistryEntry {
|
|
|
89
92
|
// (manual entries from `rpx register`) the daemon's PID-GC skips it.
|
|
90
93
|
const pidOk = e.pid === undefined
|
|
91
94
|
|| (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
|
|
95
|
+
// A route forwards to an upstream (`from`) OR serves files (`static`).
|
|
96
|
+
const hasFrom = typeof e.from === 'string' && e.from.length > 0
|
|
97
|
+
const hasStatic = typeof e.static === 'string'
|
|
98
|
+
|| (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
|
|
92
99
|
return (
|
|
93
100
|
typeof e.id === 'string' && isValidId(e.id)
|
|
94
|
-
&&
|
|
101
|
+
&& (hasFrom || hasStatic)
|
|
95
102
|
&& typeof e.to === 'string' && e.to.length > 0
|
|
96
103
|
&& pidOk
|
|
97
104
|
&& typeof e.createdAt === 'string'
|
|
@@ -272,6 +279,7 @@ export function watchRegistry(
|
|
|
272
279
|
pathRewrites: entry.pathRewrites,
|
|
273
280
|
cleanUrls: entry.cleanUrls,
|
|
274
281
|
changeOrigin: entry.changeOrigin,
|
|
282
|
+
static: entry.static,
|
|
275
283
|
}))
|
|
276
284
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
277
285
|
)
|
package/src/sni.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Bun.serve TLS array for per-domain SNI from real PEM files on disk.
|
|
3
|
+
*
|
|
4
|
+
* Production deployments (Let's Encrypt) have one cert+key per domain. Bun's
|
|
5
|
+
* `Bun.serve({ tls: [{ serverName, cert, key }, ...] })` selects the right cert
|
|
6
|
+
* by SNI server name at handshake time, so a single listener can front many
|
|
7
|
+
* domains with their own real certs.
|
|
8
|
+
*/
|
|
9
|
+
import type { DomainCert, ProductionTlsConfig } from './types'
|
|
10
|
+
import * as fsp from 'node:fs/promises'
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
import { debugLog } from './utils'
|
|
13
|
+
|
|
14
|
+
/** One entry of the Bun.serve `tls` array. */
|
|
15
|
+
export interface SniTlsEntry {
|
|
16
|
+
serverName: string
|
|
17
|
+
cert: string
|
|
18
|
+
key: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map a PEM filename under a `certsDir` to its SNI server name. Returns `null`
|
|
23
|
+
* for files that aren't `<name>.crt`. The wildcard convention
|
|
24
|
+
* `_wildcard.<apex>.crt` maps to server name `*.<apex>`.
|
|
25
|
+
*/
|
|
26
|
+
export function serverNameFromCertFilename(filename: string): string | null {
|
|
27
|
+
if (!filename.endsWith('.crt'))
|
|
28
|
+
return null
|
|
29
|
+
const base = filename.slice(0, -'.crt'.length)
|
|
30
|
+
if (base.length === 0)
|
|
31
|
+
return null
|
|
32
|
+
if (base.startsWith('_wildcard.'))
|
|
33
|
+
return `*.${base.slice('_wildcard.'.length)}`
|
|
34
|
+
return base
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readPair(serverName: string, certPath: string, keyPath: string, verbose?: boolean): Promise<SniTlsEntry | null> {
|
|
38
|
+
try {
|
|
39
|
+
const [cert, key] = await Promise.all([
|
|
40
|
+
fsp.readFile(certPath, 'utf8'),
|
|
41
|
+
fsp.readFile(keyPath, 'utf8'),
|
|
42
|
+
])
|
|
43
|
+
return { serverName, cert, key }
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
debugLog('sni', `skipping ${serverName}: ${(err as Error).message}`, verbose)
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the SNI TLS array from a {@link ProductionTlsConfig}. Reads PEM files
|
|
53
|
+
* from an explicit `domains` map and/or a `certsDir` convention. Files that
|
|
54
|
+
* can't be read are skipped (logged in verbose mode). Returns `[]` when nothing
|
|
55
|
+
* usable is found so the caller can fall back to the dev cert flow.
|
|
56
|
+
*/
|
|
57
|
+
export async function buildSniTlsConfig(cfg: ProductionTlsConfig, verbose?: boolean): Promise<SniTlsEntry[]> {
|
|
58
|
+
const bySrvName = new Map<string, DomainCert>()
|
|
59
|
+
|
|
60
|
+
if (cfg.certsDir) {
|
|
61
|
+
let names: string[] = []
|
|
62
|
+
try {
|
|
63
|
+
names = await fsp.readdir(cfg.certsDir)
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
debugLog('sni', `certsDir read failed (${cfg.certsDir}): ${(err as Error).message}`, verbose)
|
|
67
|
+
}
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const serverName = serverNameFromCertFilename(name)
|
|
70
|
+
if (!serverName)
|
|
71
|
+
continue
|
|
72
|
+
const base = name.slice(0, -'.crt'.length)
|
|
73
|
+
bySrvName.set(serverName, {
|
|
74
|
+
certPath: path.join(cfg.certsDir, name),
|
|
75
|
+
keyPath: path.join(cfg.certsDir, `${base}.key`),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Explicit `domains` entries take precedence over `certsDir` discoveries.
|
|
81
|
+
if (cfg.domains) {
|
|
82
|
+
for (const [serverName, pair] of Object.entries(cfg.domains))
|
|
83
|
+
bySrvName.set(serverName, pair)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries: SniTlsEntry[] = []
|
|
87
|
+
for (const [serverName, pair] of bySrvName) {
|
|
88
|
+
const entry = await readPair(serverName, pair.certPath, pair.keyPath, verbose)
|
|
89
|
+
if (entry)
|
|
90
|
+
entries.push(entry)
|
|
91
|
+
}
|
|
92
|
+
return entries
|
|
93
|
+
}
|
package/src/start.ts
CHANGED
|
@@ -20,8 +20,10 @@ import { addHosts, checkHosts, removeHosts } from './hosts'
|
|
|
20
20
|
import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https'
|
|
21
21
|
import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager'
|
|
22
22
|
import { ProcessManager } from './process-manager'
|
|
23
|
-
import { createProxyFetchHandler } from './proxy-handler'
|
|
24
|
-
import type { ProxyRoute } from './proxy-handler'
|
|
23
|
+
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
24
|
+
import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
|
|
25
|
+
import { isWildcardPattern, matchHost } from './host-match'
|
|
26
|
+
import { resolveStaticRoute } from './static-files'
|
|
25
27
|
import { debugLog, getSudoPassword, safeStringify } from './utils'
|
|
26
28
|
|
|
27
29
|
const processManager = new ProcessManager()
|
|
@@ -307,7 +309,7 @@ export async function startServer(options: SingleProxyConfig): Promise<void> {
|
|
|
307
309
|
|
|
308
310
|
// Check and update hosts file for custom domains
|
|
309
311
|
const hostsToCheck = [toUrl.hostname]
|
|
310
|
-
if (!toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
|
|
312
|
+
if (isHostsManagementEnabled(options) && !toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
|
|
311
313
|
debugLog('hosts', `Checking if hosts file entry exists for: ${toUrl.hostname}`, options?.verbose)
|
|
312
314
|
|
|
313
315
|
try {
|
|
@@ -709,9 +711,11 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
|
|
|
709
711
|
// Use the global port manager if not provided
|
|
710
712
|
const portManager = options.portManager || globalPortManager
|
|
711
713
|
|
|
714
|
+
const hostsEnabled = isHostsManagementEnabled(options)
|
|
715
|
+
|
|
712
716
|
try {
|
|
713
717
|
// Add an extra check to make sure the hostname is in the hosts file
|
|
714
|
-
if (to && !to.includes('localhost') && !to.includes('127.0.0.1')) {
|
|
718
|
+
if (hostsEnabled && to && !to.includes('localhost') && !to.includes('127.0.0.1')) {
|
|
715
719
|
const hostsExist = await checkHosts([to], verbose)
|
|
716
720
|
if (!hostsExist[0]) {
|
|
717
721
|
log.warn(`The hostname ${to} isn't in your hosts file. Adding it now...`)
|
|
@@ -728,7 +732,7 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
|
|
|
728
732
|
else {
|
|
729
733
|
// On macOS, *.localhost domains resolve to 127.0.0.1 automatically (RFC 6761)
|
|
730
734
|
// so we don't need to add them to /etc/hosts
|
|
731
|
-
if (process.platform !== 'darwin' && to && to.includes('localhost') && !to.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
735
|
+
if (hostsEnabled && process.platform !== 'darwin' && to && to.includes('localhost') && !to.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
732
736
|
const hostsExist = await checkHosts([to], verbose)
|
|
733
737
|
if (!hostsExist[0]) {
|
|
734
738
|
debugLog('hosts', `${to} not found in hosts file, adding...`, verbose)
|
|
@@ -931,6 +935,23 @@ function getVerbose(options: any): boolean {
|
|
|
931
935
|
return options?.verbose || false
|
|
932
936
|
}
|
|
933
937
|
|
|
938
|
+
/**
|
|
939
|
+
* Whether rpx may read/write `/etc/hosts`. Disabled when `hostsManagement` is
|
|
940
|
+
* explicitly `false`, or when `cleanup.hosts` is `false` (or `cleanup` is
|
|
941
|
+
* `false`). Real-server deployments with real DNS should set
|
|
942
|
+
* `hostsManagement: false` so rpx never touches `/etc/hosts`.
|
|
943
|
+
*/
|
|
944
|
+
function isHostsManagementEnabled(options: any): boolean {
|
|
945
|
+
if (options?.hostsManagement === false)
|
|
946
|
+
return false
|
|
947
|
+
const cleanup = options?.cleanup
|
|
948
|
+
if (cleanup === false)
|
|
949
|
+
return false
|
|
950
|
+
if (cleanup && typeof cleanup === 'object' && cleanup.hosts === false)
|
|
951
|
+
return false
|
|
952
|
+
return true
|
|
953
|
+
}
|
|
954
|
+
|
|
934
955
|
export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
935
956
|
// Allow re-using a previous SSL config between multiple startProxies calls
|
|
936
957
|
// This is particularly important for the Vite plugin
|
|
@@ -957,8 +978,13 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
957
978
|
}
|
|
958
979
|
|
|
959
980
|
const verbose = getVerbose(mergedOptions)
|
|
981
|
+
// Master switch for /etc/hosts management. `hostsManagement: false` (real
|
|
982
|
+
// server with real DNS) or `cleanup: { hosts: false }` disables all hosts
|
|
983
|
+
// reads/writes. Defaults to enabled for backward compatibility.
|
|
984
|
+
const hostsEnabled = isHostsManagementEnabled(mergedOptions)
|
|
960
985
|
debugLog('config', `Starting with config: ${safeStringify(mergedOptions, 2)}`, verbose)
|
|
961
986
|
debugLog('config', `Is multi-proxy? ${'proxies' in mergedOptions}`, verbose)
|
|
987
|
+
debugLog('config', `Hosts management enabled? ${hostsEnabled}`, verbose)
|
|
962
988
|
|
|
963
989
|
// viaDaemon mode short-circuits before any port binding / cert work — the
|
|
964
990
|
// daemon owns all of that. We only need to register entries and block.
|
|
@@ -1072,7 +1098,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1072
1098
|
// Pre-acquire sudo credentials once so that all subsequent sudo operations
|
|
1073
1099
|
// (cert trust, hosts file, DNS resolver) reuse the cached credential
|
|
1074
1100
|
// without prompting again. `sudo -v` validates and caches for the timeout period.
|
|
1075
|
-
if (process.platform !== 'win32' && (mergedOptions.https ||
|
|
1101
|
+
if (process.platform !== 'win32' && (mergedOptions.https || hostsEnabled)) {
|
|
1076
1102
|
const sudoPassword = getSudoPassword()
|
|
1077
1103
|
if (!sudoPassword) {
|
|
1078
1104
|
try {
|
|
@@ -1151,7 +1177,10 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1151
1177
|
log.info(` Consider using reserved TLDs: .test, .localhost, or .local`)
|
|
1152
1178
|
}
|
|
1153
1179
|
|
|
1154
|
-
|
|
1180
|
+
// Local development DNS (resolver overrides + hosts entries) is a dev-only
|
|
1181
|
+
// convenience. On a real server (`hostsManagement: false`) DNS is real, so
|
|
1182
|
+
// skip it entirely — nothing under /etc should be touched.
|
|
1183
|
+
if (hostsEnabled && process.platform === 'darwin' && customDomains.length > 0) {
|
|
1155
1184
|
const { setupDevelopmentDns } = await import('./dns')
|
|
1156
1185
|
const dnsStarted = await setupDevelopmentDns({ domains: customDomains, verbose })
|
|
1157
1186
|
if (dnsStarted) {
|
|
@@ -1217,19 +1246,31 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1217
1246
|
|
|
1218
1247
|
for (const option of proxyOptions) {
|
|
1219
1248
|
const domain = option.to || 'rpx.localhost'
|
|
1220
|
-
const
|
|
1249
|
+
const cleanUrls = option.cleanUrls || false
|
|
1221
1250
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1251
|
+
// Static-file route: serve a local directory instead of proxying.
|
|
1252
|
+
if (option.static) {
|
|
1253
|
+
routingTable.set(domain, {
|
|
1254
|
+
static: resolveStaticRoute(option.static, cleanUrls),
|
|
1255
|
+
cleanUrls,
|
|
1256
|
+
})
|
|
1257
|
+
debugLog('proxies', `Route: ${domain} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
|
|
1261
|
+
routingTable.set(domain, {
|
|
1262
|
+
sourceHost: fromUrl.host,
|
|
1263
|
+
cleanUrls,
|
|
1264
|
+
changeOrigin: option.changeOrigin || false,
|
|
1265
|
+
pathRewrites: option.pathRewrites,
|
|
1266
|
+
})
|
|
1267
|
+
debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
|
|
1268
|
+
}
|
|
1230
1269
|
|
|
1231
|
-
// Ensure hosts file entries exist for non-localhost domains
|
|
1232
|
-
|
|
1270
|
+
// Ensure hosts file entries exist for non-localhost domains. A wildcard
|
|
1271
|
+
// domain (`*.example.com`) has no single hosts entry — skip it. Skipped
|
|
1272
|
+
// entirely when hosts management is disabled (real-server mode).
|
|
1273
|
+
if (hostsEnabled && !isWildcardPattern(domain) && !domain.includes('localhost') && !domain.includes('127.0.0.1')) {
|
|
1233
1274
|
try {
|
|
1234
1275
|
const hostsExist = await checkHosts([domain], verbose)
|
|
1235
1276
|
if (!hostsExist[0]) {
|
|
@@ -1259,6 +1300,9 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1259
1300
|
return
|
|
1260
1301
|
}
|
|
1261
1302
|
|
|
1303
|
+
const sharedFetchHandler = createProxyFetchHandler(host => matchHost(routingTable, host), verbose)
|
|
1304
|
+
const sharedWsHandler = createProxyWebSocketHandler(verbose)
|
|
1305
|
+
|
|
1262
1306
|
try {
|
|
1263
1307
|
const bunServer = Bun.serve({
|
|
1264
1308
|
port: listenPort,
|
|
@@ -1270,7 +1314,10 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1270
1314
|
requestCert: false,
|
|
1271
1315
|
rejectUnauthorized: false,
|
|
1272
1316
|
},
|
|
1273
|
-
fetch:
|
|
1317
|
+
fetch(req: Request, server: unknown) {
|
|
1318
|
+
return sharedFetchHandler(req, server as ProxyServerLike)
|
|
1319
|
+
},
|
|
1320
|
+
websocket: sharedWsHandler,
|
|
1274
1321
|
error(err: Error) {
|
|
1275
1322
|
debugLog('server', `Shared proxy server error: ${err}`, verbose)
|
|
1276
1323
|
return new Response(`Server Error: ${err.message}`, { status: 500 })
|