@stacksjs/rpx 0.11.13 → 0.11.14

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/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 })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Static-file serving for proxy routes.
3
+ *
4
+ * A route configured with `static` serves files from a local directory instead
5
+ * of forwarding to an upstream. Path resolution is split into a pure function
6
+ * (`resolveStaticFile`) so it's trivially unit-testable, and a thin `Bun.file`
7
+ * wrapper (`serveStaticFile`) that does the actual I/O.
8
+ */
9
+ import type { PathRewriteStyle, StaticRouteConfig } from './types'
10
+ import * as path from 'node:path'
11
+
12
+ /** Normalized static-route config (shorthand string already expanded). */
13
+ export interface ResolvedStaticRoute {
14
+ dir: string
15
+ spa: boolean
16
+ pathRewriteStyle: PathRewriteStyle
17
+ maxAge: number
18
+ cleanUrls: boolean
19
+ }
20
+
21
+ export function resolveStaticRoute(
22
+ cfg: string | StaticRouteConfig,
23
+ cleanUrls: boolean,
24
+ ): ResolvedStaticRoute {
25
+ if (typeof cfg === 'string')
26
+ return { dir: cfg, spa: false, pathRewriteStyle: 'directory', maxAge: 0, cleanUrls }
27
+ return {
28
+ dir: cfg.dir,
29
+ spa: cfg.spa ?? false,
30
+ pathRewriteStyle: cfg.pathRewriteStyle ?? 'directory',
31
+ maxAge: cfg.maxAge ?? 0,
32
+ cleanUrls,
33
+ }
34
+ }
35
+
36
+ /** A minimal extension → MIME map covering the common web asset types. */
37
+ const MIME_TYPES: Record<string, string> = {
38
+ '.html': 'text/html; charset=utf-8',
39
+ '.htm': 'text/html; charset=utf-8',
40
+ '.css': 'text/css; charset=utf-8',
41
+ '.js': 'text/javascript; charset=utf-8',
42
+ '.mjs': 'text/javascript; charset=utf-8',
43
+ '.json': 'application/json; charset=utf-8',
44
+ '.map': 'application/json; charset=utf-8',
45
+ '.svg': 'image/svg+xml',
46
+ '.png': 'image/png',
47
+ '.jpg': 'image/jpeg',
48
+ '.jpeg': 'image/jpeg',
49
+ '.gif': 'image/gif',
50
+ '.webp': 'image/webp',
51
+ '.avif': 'image/avif',
52
+ '.ico': 'image/x-icon',
53
+ '.woff': 'font/woff',
54
+ '.woff2': 'font/woff2',
55
+ '.ttf': 'font/ttf',
56
+ '.otf': 'font/otf',
57
+ '.eot': 'application/vnd.ms-fontobject',
58
+ '.txt': 'text/plain; charset=utf-8',
59
+ '.xml': 'application/xml; charset=utf-8',
60
+ '.pdf': 'application/pdf',
61
+ '.wasm': 'application/wasm',
62
+ '.mp4': 'video/mp4',
63
+ '.webm': 'video/webm',
64
+ '.mp3': 'audio/mpeg',
65
+ '.wav': 'audio/wav',
66
+ }
67
+
68
+ export function contentTypeFor(filePath: string): string {
69
+ const ext = path.extname(filePath).toLowerCase()
70
+ return MIME_TYPES[ext] ?? 'application/octet-stream'
71
+ }
72
+
73
+ /**
74
+ * Decode + normalize a URL pathname into a safe relative path.
75
+ *
76
+ * Traversal safety: normalizing against a leading `/` collapses every `..`
77
+ * segment and clamps at the root, so the returned relative path never contains
78
+ * `..` and `path.join(root, rel)` can't escape `root`. Backslash, NUL and
79
+ * malformed percent-encoding are rejected outright (return `null`); the
80
+ * residual `..` guard is belt-and-suspenders.
81
+ */
82
+ export function safeRelativePath(pathname: string): string | null {
83
+ let decoded: string
84
+ try {
85
+ decoded = decodeURIComponent(pathname)
86
+ }
87
+ catch {
88
+ return null
89
+ }
90
+ // Reject NUL and backslash (Windows-style) escapes outright.
91
+ if (decoded.includes('\0') || decoded.includes('\\'))
92
+ return null
93
+
94
+ // `path.posix.normalize` collapses `..`/`.`; a leading `/` keeps it rooted so
95
+ // a normalized result that still contains `..` means traversal above root.
96
+ const normalized = path.posix.normalize(`/${decoded}`)
97
+ if (normalized.includes('..'))
98
+ return null
99
+ // Strip the leading slash to get a path relative to the static root.
100
+ return normalized.replace(/^\/+/, '')
101
+ }
102
+
103
+ export interface StaticResolution {
104
+ /** Absolute file path to attempt to serve. */
105
+ filePath: string
106
+ /** When set, the request should 301-redirect to this clean URL. */
107
+ redirectTo?: string
108
+ }
109
+
110
+ /**
111
+ * Pure resolution of an incoming request pathname to a candidate file path on
112
+ * disk. Does no I/O; the caller checks existence and may fall back (SPA).
113
+ *
114
+ * Rules:
115
+ * - A trailing `/` (or root) resolves to `index.html` in that directory.
116
+ * - `cleanUrls` + a `.html` request → 301 to the extensionless URL.
117
+ * - Extensionless paths resolve per `pathRewriteStyle`:
118
+ * - `directory`: `/about` → `about/index.html`
119
+ * - `flat`: `/about` → `about.html`
120
+ * - Paths with a real extension (`.css`, `.png`, …) map straight through.
121
+ *
122
+ * Returns `null` when the path is unsafe (traversal attempt).
123
+ */
124
+ export function resolveStaticFile(
125
+ pathname: string,
126
+ route: ResolvedStaticRoute,
127
+ ): StaticResolution | null {
128
+ const rel = safeRelativePath(pathname)
129
+ if (rel === null)
130
+ return null
131
+
132
+ const ext = path.posix.extname(rel)
133
+
134
+ // `cleanUrls`: redirect explicit `.html` requests to the clean URL.
135
+ if (route.cleanUrls && ext === '.html') {
136
+ const clean = pathname.replace(/\/index\.html$/i, '/').replace(/\.html$/i, '')
137
+ return { filePath: path.join(route.dir, rel), redirectTo: clean || '/' }
138
+ }
139
+
140
+ // Directory or root request → index.html.
141
+ if (rel === '' || pathname.endsWith('/'))
142
+ return { filePath: path.join(route.dir, rel, 'index.html') }
143
+
144
+ // Asset with a concrete extension → serve directly.
145
+ if (ext !== '')
146
+ return { filePath: path.join(route.dir, rel) }
147
+
148
+ // Extensionless route → resolve by SSG style.
149
+ if (route.pathRewriteStyle === 'flat')
150
+ return { filePath: path.join(route.dir, `${rel}.html`) }
151
+ return { filePath: path.join(route.dir, rel, 'index.html') }
152
+ }
153
+
154
+ /**
155
+ * Serve a static file for the matched route. Returns a 301 for clean-URL
156
+ * redirects, the file with the right `Content-Type`/`Cache-Control` when it
157
+ * exists, the SPA `index.html` fallback when configured, or 404.
158
+ */
159
+ export async function serveStaticFile(
160
+ pathname: string,
161
+ route: ResolvedStaticRoute,
162
+ ): Promise<Response> {
163
+ const resolution = resolveStaticFile(pathname, route)
164
+ if (!resolution)
165
+ return new Response('Forbidden', { status: 403 })
166
+
167
+ if (resolution.redirectTo)
168
+ return new Response(null, { status: 301, headers: { Location: resolution.redirectTo } })
169
+
170
+ const cacheControl = route.maxAge > 0
171
+ ? `public, max-age=${route.maxAge}`
172
+ : 'no-cache'
173
+
174
+ const file = Bun.file(resolution.filePath)
175
+ if (await file.exists()) {
176
+ return new Response(file, {
177
+ status: 200,
178
+ headers: {
179
+ 'Content-Type': contentTypeFor(resolution.filePath),
180
+ 'Cache-Control': cacheControl,
181
+ },
182
+ })
183
+ }
184
+
185
+ // SPA fallback: serve the root index.html so client-side routing works.
186
+ if (route.spa) {
187
+ const indexPath = path.join(route.dir, 'index.html')
188
+ const index = Bun.file(indexPath)
189
+ if (await index.exists()) {
190
+ return new Response(index, {
191
+ status: 200,
192
+ headers: {
193
+ 'Content-Type': 'text/html; charset=utf-8',
194
+ 'Cache-Control': 'no-cache',
195
+ },
196
+ })
197
+ }
198
+ }
199
+
200
+ return new Response('Not Found', { status: 404 })
201
+ }
package/src/types.ts CHANGED
@@ -24,11 +24,47 @@ export interface PathRewrite {
24
24
  stripPrefix?: boolean
25
25
  }
26
26
 
27
+ /**
28
+ * How a static-file route maps request paths to files on disk.
29
+ *
30
+ * - `directory` (default): `/about` → `<root>/about/index.html` (SSG dir style).
31
+ * - `flat`: `/about` → `<root>/about.html` (flat-file style).
32
+ */
33
+ export type PathRewriteStyle = 'directory' | 'flat'
34
+
35
+ export interface StaticRouteConfig {
36
+ /** Absolute path to the directory served for this route. */
37
+ dir: string
38
+ /**
39
+ * Single-page-app fallback: serve `index.html` for any path that doesn't
40
+ * resolve to a real file (so client-side routing works). Default: `false`.
41
+ */
42
+ spa?: boolean
43
+ /**
44
+ * Extensionless-URL resolution style for `.html` files. Default: `directory`.
45
+ */
46
+ pathRewriteStyle?: PathRewriteStyle
47
+ /**
48
+ * `Cache-Control` max-age (seconds) for served files. Default: `0`.
49
+ */
50
+ maxAge?: number
51
+ }
52
+
27
53
  export interface BaseProxyConfig {
28
- from: string // localhost:5173
54
+ /**
55
+ * Upstream `host:port` to forward to (e.g. `localhost:5173`). Optional when
56
+ * `static` is set (the route serves files from disk instead of proxying).
57
+ */
58
+ from?: string // localhost:5173
29
59
  to: string // stacks.localhost
30
60
  start?: StartOptions
31
61
  pathRewrites?: PathRewrite[]
62
+ /**
63
+ * Serve a local directory for this route instead of proxying to `from`.
64
+ * Provide an absolute directory path (shorthand) or a {@link StaticRouteConfig}.
65
+ * When set, `from` is optional; exactly one of `from`/`static` must be present.
66
+ */
67
+ static?: string | StaticRouteConfig
32
68
  /**
33
69
  * Stable id used when registering this proxy with the rpx daemon. Derived
34
70
  * from `to` if omitted. Must match `/^[a-zA-Z0-9._-]+$/` and be ≤128 chars.
@@ -48,6 +84,35 @@ export interface CleanupConfig {
48
84
 
49
85
  export type CleanupOptions = Partial<CleanupConfig>
50
86
 
87
+ /**
88
+ * A real PEM cert+key pair on disk for one SNI server name.
89
+ */
90
+ export interface DomainCert {
91
+ /** Absolute path to the PEM certificate (fullchain). */
92
+ certPath: string
93
+ /** Absolute path to the PEM private key. */
94
+ keyPath: string
95
+ }
96
+
97
+ /**
98
+ * Production TLS using real certs (e.g. Let's Encrypt) served per-domain via
99
+ * SNI on a single listener. Provide either an explicit `domains` map or a
100
+ * `certsDir` convention.
101
+ */
102
+ export interface ProductionTlsConfig {
103
+ /**
104
+ * Explicit per-domain cert/key files keyed by SNI server name. Use
105
+ * `*.example.com` for a wildcard server name.
106
+ */
107
+ domains?: Record<string, DomainCert>
108
+ /**
109
+ * Directory of PEM files following the convention `<domain>.crt` /
110
+ * `<domain>.key`. A wildcard pair `_wildcard.<apex>.crt` /
111
+ * `_wildcard.<apex>.key` is registered under server name `*.<apex>`.
112
+ */
113
+ certsDir?: string
114
+ }
115
+
51
116
  export interface SharedProxyConfig {
52
117
  https: boolean | TlsOption
53
118
  cleanup: boolean | CleanupOptions
@@ -64,6 +129,19 @@ export interface SharedProxyConfig {
64
129
  * `:443` (Valet-style). Default: `false` for backward compatibility.
65
130
  */
66
131
  viaDaemon?: boolean
132
+ /**
133
+ * Master switch for all `/etc/hosts` reads/writes. Set to `false` on a real
134
+ * server with real DNS so rpx never touches `/etc/hosts`. When omitted, the
135
+ * legacy behavior applies (driven by `cleanup.hosts`). `cleanup: { hosts:
136
+ * false }` also disables hosts management.
137
+ */
138
+ hostsManagement?: boolean
139
+ /**
140
+ * Production per-domain SNI certs (Let's Encrypt PEMs already on disk). When
141
+ * provided, the listener serves a different real cert per SNI server name
142
+ * instead of the dev self-signed shared cert.
143
+ */
144
+ productionCerts?: ProductionTlsConfig
67
145
  }
68
146
 
69
147
  export type SharedProxyOptions = Partial<SharedProxyConfig>
@@ -1 +0,0 @@
1
- import{$ as j,S as a,T as b,U as c,V as d,W as e,X as f,Y as g,Z as h,_ as i,aa as k,ba as l,ca as m,da as n}from"./chunk-pncxrxde.js";import"./chunk-zs1tyy8z.js";export{l as tearDownDevelopmentDns,k as syncDevelopmentDnsFromRegistry,d as stopDnsServer,c as startDnsServer,h as setupResolver,j as setupDevelopmentDns,f as resolverFilePath,m as removeResolver,i as removeLegacyTldResolvers,n as reconcileStaleDevelopmentDns,e as isDnsServerRunning,g as contentLooksLikeRpxResolver,b as RPX_RESOLVER_MARKER,a as DNS_PORT};