@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The fetch handler used by the shared :443 server. Both the in-process
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 { debugLog } from './utils'
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
- /** Upstream `host:port` to forward requests to (e.g. `localhost:5173`). */
17
- sourceHost: string
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 hostHeader = req.headers.get('host') || ''
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
- let targetHost = route.sourceHost
49
- let targetPath = url.pathname
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
- // Per-route path rewrites: prefix preserved by default, matching Vite /
52
- // nginx / http-proxy-middleware semantics. See `resolvePathRewrite`.
53
- const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
54
- if (rewriteMatch) {
55
- targetHost = rewriteMatch.targetHost
56
- targetPath = rewriteMatch.targetPath
57
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
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
- const targetUrl = `http://${targetHost}${targetPath}${url.search}`
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
- from: string
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
- && typeof e.from === 'string' && e.from.length > 0
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 || mergedOptions.cleanup?.hosts !== false)) {
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
- if (process.platform === 'darwin' && customDomains.length > 0) {
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 fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
1249
+ const cleanUrls = option.cleanUrls || false
1221
1250
 
1222
- routingTable.set(domain, {
1223
- sourceHost: fromUrl.host,
1224
- cleanUrls: option.cleanUrls || false,
1225
- changeOrigin: option.changeOrigin || false,
1226
- pathRewrites: option.pathRewrites,
1227
- })
1228
-
1229
- debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
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
- if (!domain.includes('localhost') && !domain.includes('127.0.0.1')) {
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: createProxyFetchHandler(host => routingTable.get(host), verbose),
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 })