@stacksjs/rpx 0.11.14 → 0.11.16

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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * On-demand TLS for rpx: issue a real (Let's Encrypt, http-01) certificate for
3
+ * an unknown host the first time it's needed, gated by an `ask` callback and/or
4
+ * an `allowedSuffixes` allowlist.
5
+ *
6
+ * ## The Bun limitation this works around (verified on Bun 1.3.14 + 1.4.0)
7
+ *
8
+ * Bun.serve has **no working `SNICallback`**, and `server.reload({ tls })` does
9
+ * **NOT** update certificates at runtime. So rpx cannot mint a cert *during* the
10
+ * TLS handshake (the way Caddy's on-demand TLS does). Instead this manager
11
+ * implements on-demand as **ask-gated issuance + listener recreate**:
12
+ *
13
+ * - rpx serves the ACME `http-01` challenge from its own `:80` listener (same
14
+ * process, so the challenge token is reachable the instant we register it).
15
+ * - issuance is triggered before the HTTPS request — either reactively from
16
+ * the `:80` handler (first plaintext hit for the host), or programmatically
17
+ * via {@link OnDemandCertManager.ensureCert} (e.g. a tunnel server
18
+ * pre-warming a subdomain's cert at registration time).
19
+ * - once a cert is issued it's written to `certsDir` and added to the live SNI
20
+ * set; the manager then asks its host to rebuild the `:443` listener so the
21
+ * new cert is actually served (a sub-second `server.stop()` + re-`Bun.serve`).
22
+ *
23
+ * Concurrency: per-host in-flight de-dupe means N concurrent `ensureCert(host)`
24
+ * calls drive exactly one ACME order. Failures are logged and negatively cached
25
+ * for a short window so we don't hammer Let's Encrypt (which is rate-limited).
26
+ */
27
+ import type { Http01Store, ObtainCertificateOptions, ObtainCertificateResult } from '@stacksjs/tlsx'
28
+ import type { OnDemandTlsConfig } from './types'
29
+ import type { SniTlsEntry } from './sni'
30
+ import * as fsp from 'node:fs/promises'
31
+ import * as path from 'node:path'
32
+ import { defaultHttp01Store, obtainCertificate } from '@stacksjs/tlsx'
33
+ import { debugLog } from './utils'
34
+
35
+ /**
36
+ * The issuance function the manager calls. Defaults to tlsx's
37
+ * {@link obtainCertificate}; tests inject a stub so the suite never touches
38
+ * Let's Encrypt.
39
+ */
40
+ export type CertIssuer = (options: ObtainCertificateOptions) => Promise<ObtainCertificateResult>
41
+
42
+ export interface OnDemandCertManagerOptions {
43
+ /** Resolved on-demand config (already merged with defaults). */
44
+ config: OnDemandTlsConfig
45
+ /** Where issued PEMs are written / read. Required (resolved by the caller). */
46
+ certsDir: string
47
+ /** Initial SNI set to seed from (e.g. productionCerts already on disk). */
48
+ initial?: SniTlsEntry[]
49
+ /**
50
+ * Called after a new cert is added to the SNI set so the host can rebuild its
51
+ * `:443` listener (Bun can't hot-update tls — see file header).
52
+ */
53
+ onCertAdded?: (entries: SniTlsEntry[]) => void | Promise<void>
54
+ /** http-01 challenge store rpx's `:80` listener serves from. */
55
+ http01Store?: Http01Store
56
+ /** Inject the issuer (tests stub this). Defaults to tlsx `obtainCertificate`. */
57
+ issuer?: CertIssuer
58
+ verbose?: boolean
59
+ /** How long to negatively-cache a failed host before retrying. Default 60s. */
60
+ negativeCacheMs?: number
61
+ }
62
+
63
+ const DEFAULT_NEGATIVE_CACHE_MS = 60_000
64
+
65
+ /**
66
+ * True if `host` is covered by the `allowedSuffixes` allowlist: it equals a
67
+ * suffix, or is a subdomain of one (`a.example.com` for suffix `example.com`).
68
+ */
69
+ export function matchesAllowedSuffix(host: string, suffixes: string[] | undefined): boolean {
70
+ if (!suffixes || suffixes.length === 0)
71
+ return false
72
+ return suffixes.some((s) => {
73
+ const suffix = s.startsWith('.') ? s.slice(1) : s
74
+ return host === suffix || host.endsWith(`.${suffix}`)
75
+ })
76
+ }
77
+
78
+ /** Strict-ish hostname guard so we never feed junk Host headers into ACME. */
79
+ export function isLikelyHostname(host: string): boolean {
80
+ if (!host || host.length > 253)
81
+ return false
82
+ if (host.includes('/') || host.includes(':') || host.includes(' '))
83
+ return false
84
+ // No wildcards (http-01 can't do them) and must contain a dot (a real FQDN).
85
+ if (host.startsWith('*'))
86
+ return false
87
+ return /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(host)
88
+ }
89
+
90
+ /**
91
+ * Holds the live SNI cert set and lazily issues certs for approved hosts.
92
+ *
93
+ * The set is keyed by SNI server name; `ensureCert(host)` is the entry point for
94
+ * both the reactive `:80` path and programmatic pre-warming.
95
+ */
96
+ export class OnDemandCertManager {
97
+ private readonly config: OnDemandTlsConfig
98
+ private readonly certsDir: string
99
+ private readonly onCertAdded?: (entries: SniTlsEntry[]) => void | Promise<void>
100
+ private readonly http01Store: Http01Store
101
+ private readonly issuer: CertIssuer
102
+ private readonly verbose: boolean
103
+ private readonly negativeCacheMs: number
104
+
105
+ /** Live SNI set, keyed by server name. */
106
+ private readonly certs = new Map<string, SniTlsEntry>()
107
+ /** In-flight issuances, keyed by host — de-dupes concurrent ensureCert calls. */
108
+ private readonly inFlight = new Map<string, Promise<boolean>>()
109
+ /** host → epoch-ms until which we refuse to retry after a failure. */
110
+ private readonly negativeCache = new Map<string, number>()
111
+
112
+ constructor(opts: OnDemandCertManagerOptions) {
113
+ this.config = opts.config
114
+ this.certsDir = opts.certsDir
115
+ this.onCertAdded = opts.onCertAdded
116
+ this.http01Store = opts.http01Store ?? defaultHttp01Store
117
+ this.issuer = opts.issuer ?? obtainCertificate
118
+ this.verbose = opts.verbose ?? false
119
+ this.negativeCacheMs = opts.negativeCacheMs ?? DEFAULT_NEGATIVE_CACHE_MS
120
+ for (const e of opts.initial ?? [])
121
+ this.certs.set(e.serverName, e)
122
+ }
123
+
124
+ /** The http-01 store rpx's `:80` listener must serve challenge tokens from. */
125
+ get challengeStore(): Http01Store {
126
+ return this.http01Store
127
+ }
128
+
129
+ /** A snapshot of the current SNI set for `Bun.serve({ tls })`. */
130
+ sniEntries(): SniTlsEntry[] {
131
+ return Array.from(this.certs.values())
132
+ }
133
+
134
+ /** True if a usable cert for `host` is already loaded in the live set. */
135
+ hasCert(host: string): boolean {
136
+ return this.certs.has(host)
137
+ }
138
+
139
+ /**
140
+ * Decide whether rpx may issue a cert for `host`. A host is approved when the
141
+ * `allowedSuffixes` allowlist matches OR `ask(host)` resolves truthy. With
142
+ * neither configured, every host is refused (fail-closed, anti-abuse).
143
+ */
144
+ async isApproved(host: string): Promise<boolean> {
145
+ if (!isLikelyHostname(host))
146
+ return false
147
+ if (matchesAllowedSuffix(host, this.config.allowedSuffixes))
148
+ return true
149
+ if (this.config.ask) {
150
+ try {
151
+ return await this.config.ask(host)
152
+ }
153
+ catch (err) {
154
+ debugLog('on-demand', `ask(${host}) threw: ${(err as Error).message}`, this.verbose)
155
+ return false
156
+ }
157
+ }
158
+ return false
159
+ }
160
+
161
+ /**
162
+ * Ensure a cert exists for `host`, issuing one via ACME http-01 if needed.
163
+ *
164
+ * No-ops (resolves `true`) when a cert is already loaded. Otherwise checks
165
+ * approval, then drives issuance — de-duping concurrent calls for the same
166
+ * host so only one ACME order runs. Resolves `false` when refused or on a
167
+ * negatively-cached failure. Never throws; errors are logged + cached.
168
+ */
169
+ async ensureCert(host: string): Promise<boolean> {
170
+ if (!this.config.enabled)
171
+ return false
172
+ if (this.certs.has(host))
173
+ return true
174
+
175
+ const inFlight = this.inFlight.get(host)
176
+ if (inFlight)
177
+ return inFlight
178
+
179
+ const until = this.negativeCache.get(host)
180
+ if (until !== undefined && Date.now() < until) {
181
+ debugLog('on-demand', `${host} negatively cached for ${until - Date.now()}ms`, this.verbose)
182
+ return false
183
+ }
184
+
185
+ const promise = this.issue(host).finally(() => {
186
+ this.inFlight.delete(host)
187
+ })
188
+ this.inFlight.set(host, promise)
189
+ return promise
190
+ }
191
+
192
+ private async issue(host: string): Promise<boolean> {
193
+ // A concurrent caller may have already loaded it while we were queued.
194
+ if (this.certs.has(host))
195
+ return true
196
+
197
+ // Maybe it's already on disk (issued by a prior run) — adopt without ACME.
198
+ if (await this.loadFromDisk(host))
199
+ return true
200
+
201
+ if (!(await this.isApproved(host))) {
202
+ debugLog('on-demand', `refused issuance for ${host} (not approved)`, this.verbose)
203
+ return false
204
+ }
205
+
206
+ try {
207
+ debugLog('on-demand', `issuing cert for ${host} (staging=${this.config.staging ?? false})`, this.verbose)
208
+ const result = await this.issuer({
209
+ domains: [host],
210
+ method: 'http-01',
211
+ http01Store: this.http01Store,
212
+ email: this.config.email,
213
+ staging: this.config.staging,
214
+ })
215
+ await this.persist(host, result.fullChainPem, result.keyPem)
216
+ const entry: SniTlsEntry = { serverName: host, cert: result.fullChainPem, key: result.keyPem }
217
+ this.certs.set(host, entry)
218
+ this.negativeCache.delete(host)
219
+ debugLog('on-demand', `issued + installed cert for ${host}`, this.verbose)
220
+ await this.onCertAdded?.(this.sniEntries())
221
+ return true
222
+ }
223
+ catch (err) {
224
+ this.negativeCache.set(host, Date.now() + this.negativeCacheMs)
225
+ debugLog('on-demand', `issuance for ${host} failed: ${(err as Error).message}`, this.verbose)
226
+ return false
227
+ }
228
+ }
229
+
230
+ /** Try to load an already-present `<host>.{crt,key}` pair from `certsDir`. */
231
+ private async loadFromDisk(host: string): Promise<boolean> {
232
+ const { certPath, keyPath } = this.pathsFor(host)
233
+ try {
234
+ const [cert, key] = await Promise.all([
235
+ fsp.readFile(certPath, 'utf8'),
236
+ fsp.readFile(keyPath, 'utf8'),
237
+ ])
238
+ const entry: SniTlsEntry = { serverName: host, cert, key }
239
+ this.certs.set(host, entry)
240
+ debugLog('on-demand', `adopted existing on-disk cert for ${host}`, this.verbose)
241
+ await this.onCertAdded?.(this.sniEntries())
242
+ return true
243
+ }
244
+ catch {
245
+ return false
246
+ }
247
+ }
248
+
249
+ private pathsFor(host: string): { certPath: string, keyPath: string } {
250
+ return {
251
+ certPath: path.join(this.certsDir, `${host}.crt`),
252
+ keyPath: path.join(this.certsDir, `${host}.key`),
253
+ }
254
+ }
255
+
256
+ private async persist(host: string, certPem: string, keyPem: string): Promise<void> {
257
+ await fsp.mkdir(this.certsDir, { recursive: true }).catch(() => {})
258
+ const { certPath, keyPath } = this.pathsFor(host)
259
+ await Promise.all([
260
+ fsp.writeFile(certPath, certPem, 'utf8'),
261
+ fsp.writeFile(keyPath, keyPem, { encoding: 'utf8', mode: 0o600 }),
262
+ ])
263
+ }
264
+ }
@@ -33,9 +33,35 @@ export interface ProxyRoute {
33
33
  pathRewrites?: PathRewrite[]
34
34
  /** When set, serve files from a local directory instead of proxying. */
35
35
  static?: ResolvedStaticRoute
36
+ /**
37
+ * Path prefix this route is mounted under (e.g. `/docs`). Used together with
38
+ * {@link stripBasePathPrefix} to map request paths to the target. `/` (the
39
+ * host default) is a no-op.
40
+ */
41
+ basePath?: string
42
+ /**
43
+ * Whether to strip {@link basePath} from the request pathname before
44
+ * resolving the target.
45
+ *
46
+ * - Static routes default to `true`: a directory mounted at `/docs` serves
47
+ * its own `index.html` for `/docs` and `<root>/guide` for `/docs/guide`.
48
+ * - Proxy routes default to `false`: most apps own their namespace (an app
49
+ * mounted at `/api` expects to still see `/api/...`), matching rpx's
50
+ * `PathRewrite.stripPrefix` default and nginx `proxy_pass` (no trailing
51
+ * slash) behavior.
52
+ *
53
+ * When unset, the per-transport default above applies.
54
+ */
55
+ stripBasePathPrefix?: boolean
36
56
  }
37
57
 
38
- export type GetRoute = (hostname: string) => ProxyRoute | undefined
58
+ /**
59
+ * Resolve a route for an incoming request. `pathname` enables path-based
60
+ * routing within a host (e.g. `/api/*` → app, `/docs*` → static dir). Callers
61
+ * that only route by host can ignore the second argument — it's optional so
62
+ * existing host-only `getRoute` callbacks remain valid.
63
+ */
64
+ export type GetRoute = (hostname: string, pathname: string) => ProxyRoute | undefined
39
65
 
40
66
  export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
41
67
 
@@ -79,6 +105,24 @@ function extractHostname(req: Request): string {
79
105
  return hostHeader.split(':')[0]
80
106
  }
81
107
 
108
+ /**
109
+ * Strip the route's mount prefix (`basePath`) from a request pathname so a
110
+ * target mounted under `/docs` sees `/` for `/docs` and `/guide` for
111
+ * `/docs/guide`. A `/` (or empty) base strips nothing. The result always keeps
112
+ * a leading `/`.
113
+ */
114
+ export function stripBasePath(pathname: string, basePath?: string): string {
115
+ if (!basePath || basePath === '/')
116
+ return pathname
117
+ if (pathname === basePath)
118
+ return '/'
119
+ if (pathname.startsWith(`${basePath}/`)) {
120
+ const rest = pathname.slice(basePath.length)
121
+ return rest === '' ? '/' : rest
122
+ }
123
+ return pathname
124
+ }
125
+
82
126
  /**
83
127
  * Resolve the upstream target (`host` + `path`) for a request against a route,
84
128
  * applying any matching path rewrite.
@@ -86,9 +130,13 @@ function extractHostname(req: Request): string {
86
130
  function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
87
131
  const url = new URL(req.url)
88
132
  let targetHost = route.sourceHost ?? ''
89
- let targetPath = url.pathname
133
+ // Proxy backends preserve their mount prefix by default (most apps own their
134
+ // `/api` namespace), opting in to stripping via `stripBasePathPrefix`.
135
+ // Explicit `pathRewrites` still apply on top of this.
136
+ const stripBase = route.stripBasePathPrefix ?? false
137
+ let targetPath = stripBase ? stripBasePath(url.pathname, route.basePath) : url.pathname
90
138
 
91
- const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
139
+ const rewriteMatch = resolvePathRewrite(targetPath, route.pathRewrites)
92
140
  if (rewriteMatch) {
93
141
  targetHost = rewriteMatch.targetHost
94
142
  targetPath = rewriteMatch.targetPath
@@ -110,15 +158,20 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
110
158
  const url = new URL(req.url)
111
159
  const hostname = extractHostname(req)
112
160
 
113
- const route = getRoute(hostname)
161
+ const route = getRoute(hostname, url.pathname)
114
162
  if (!route) {
115
163
  debugLog('request', `No route found for host: ${hostname}`, verbose)
116
164
  return new Response(`No proxy configured for ${hostname}`, { status: 404 })
117
165
  }
118
166
 
119
- // Static file serving short-circuits everything else.
120
- if (route.static)
121
- return serveStaticFile(url.pathname, route.static)
167
+ // Static file serving short-circuits everything else. Strip the route's
168
+ // mount prefix (default for static) so a dir mounted at `/docs` serves its
169
+ // own root for `/docs`.
170
+ if (route.static) {
171
+ const strip = route.stripBasePathPrefix ?? true
172
+ const staticPath = strip ? stripBasePath(url.pathname, route.basePath) : url.pathname
173
+ return serveStaticFile(staticPath, route.static)
174
+ }
122
175
 
123
176
  // WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
124
177
  if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
package/src/registry.ts CHANGED
@@ -27,6 +27,14 @@ export interface RegistryEntry {
27
27
  /** Upstream `host:port`. Optional when `static` is set. */
28
28
  from?: string
29
29
  to: string
30
+ /**
31
+ * Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
32
+ * Enables several routes to share one host, each claiming a different path
33
+ * (`/api` → app, `/docs` → static dir, `/` → public). Omitted (or `'/'`)
34
+ * means the route is the host default and behaves exactly like host-only
35
+ * routing did before path routing existed.
36
+ */
37
+ path?: string
30
38
  /**
31
39
  * Optional. PID of the long-running process that owns this entry. When set,
32
40
  * the daemon's PID-GC reaps the entry the moment that process dies. Omit
@@ -96,10 +104,13 @@ function isValidEntry(value: unknown): value is RegistryEntry {
96
104
  const hasFrom = typeof e.from === 'string' && e.from.length > 0
97
105
  const hasStatic = typeof e.static === 'string'
98
106
  || (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
107
+ // path is optional; when present it must be a string.
108
+ const pathOk = e.path === undefined || typeof e.path === 'string'
99
109
  return (
100
110
  typeof e.id === 'string' && isValidId(e.id)
101
111
  && (hasFrom || hasStatic)
102
112
  && typeof e.to === 'string' && e.to.length > 0
113
+ && pathOk
103
114
  && pidOk
104
115
  && typeof e.createdAt === 'string'
105
116
  )
@@ -275,6 +286,7 @@ export function watchRegistry(
275
286
  id: entry.id,
276
287
  from: entry.from,
277
288
  to: entry.to,
289
+ path: entry.path,
278
290
  pid: entry.pid,
279
291
  pathRewrites: entry.pathRewrites,
280
292
  cleanUrls: entry.cleanUrls,
package/src/start.ts CHANGED
@@ -22,7 +22,8 @@ import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manag
22
22
  import { ProcessManager } from './process-manager'
23
23
  import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
24
24
  import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
25
- import { isWildcardPattern, matchHost } from './host-match'
25
+ import { isWildcardPattern } from './host-match'
26
+ import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
26
27
  import { resolveStaticRoute } from './static-files'
27
28
  import { debugLog, getSudoPassword, safeStringify } from './utils'
28
29
 
@@ -849,6 +850,7 @@ export function startProxy(options: ProxyOption): void {
849
850
  id: mergedOptions.id,
850
851
  from: mergedOptions.from,
851
852
  to: mergedOptions.to,
853
+ path: mergedOptions.path,
852
854
  cleanUrls: mergedOptions.cleanUrls,
853
855
  changeOrigin: mergedOptions.changeOrigin,
854
856
  pathRewrites: mergedOptions.pathRewrites,
@@ -996,6 +998,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
996
998
  id: p.id,
997
999
  from: p.from,
998
1000
  to: p.to,
1001
+ path: p.path,
999
1002
  cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls,
1000
1003
  changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin,
1001
1004
  pathRewrites: p.pathRewrites,
@@ -1004,6 +1007,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1004
1007
  id: mergedOptions.id,
1005
1008
  from: mergedOptions.from,
1006
1009
  to: mergedOptions.to,
1010
+ path: mergedOptions.path,
1007
1011
  cleanUrls: mergedOptions.cleanUrls,
1008
1012
  changeOrigin: mergedOptions.changeOrigin,
1009
1013
  pathRewrites: mergedOptions.pathRewrites,
@@ -1242,34 +1246,52 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1242
1246
  if (sslConfig && proxyOptions.length > 1) {
1243
1247
  debugLog('proxies', `Creating shared HTTPS server for ${proxyOptions.length} domains`, verbose)
1244
1248
 
1245
- const routingTable = new Map<string, ProxyRoute>()
1249
+ // Collect (host, path, route) tuples so several proxies can share one
1250
+ // domain on different paths (e.g. `/api` → app, `/docs` → static dir,
1251
+ // `/` → public). `buildHostRoutes` groups + longest-prefix-sorts them.
1252
+ const routeEntries: Array<{ host: string, path?: string, route: ProxyRoute }> = []
1253
+ const seenDomains = new Set<string>()
1246
1254
 
1247
1255
  for (const option of proxyOptions) {
1248
1256
  const domain = option.to || 'rpx.localhost'
1249
1257
  const cleanUrls = option.cleanUrls || false
1258
+ const routePath = option.path
1259
+
1260
+ const basePath = normalizePathPrefix(routePath)
1250
1261
 
1251
1262
  // Static-file route: serve a local directory instead of proxying.
1252
1263
  if (option.static) {
1253
- routingTable.set(domain, {
1254
- static: resolveStaticRoute(option.static, cleanUrls),
1255
- cleanUrls,
1264
+ routeEntries.push({
1265
+ host: domain,
1266
+ path: routePath,
1267
+ route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, basePath },
1256
1268
  })
1257
- debugLog('proxies', `Route: ${domain} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
1269
+ debugLog('proxies', `Route: ${domain}${routePath ?? ''} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
1258
1270
  }
1259
1271
  else {
1260
1272
  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,
1273
+ routeEntries.push({
1274
+ host: domain,
1275
+ path: routePath,
1276
+ route: {
1277
+ sourceHost: fromUrl.host,
1278
+ cleanUrls,
1279
+ changeOrigin: option.changeOrigin || false,
1280
+ pathRewrites: option.pathRewrites,
1281
+ basePath,
1282
+ },
1266
1283
  })
1267
- debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
1284
+ debugLog('proxies', `Route: ${domain}${routePath ?? ''} → ${fromUrl.host}`, verbose)
1268
1285
  }
1269
1286
 
1270
1287
  // Ensure hosts file entries exist for non-localhost domains. A wildcard
1271
1288
  // domain (`*.example.com`) has no single hosts entry — skip it. Skipped
1272
- // entirely when hosts management is disabled (real-server mode).
1289
+ // entirely when hosts management is disabled (real-server mode). Add each
1290
+ // domain once even when several path-routes share it.
1291
+ if (seenDomains.has(domain)) {
1292
+ continue
1293
+ }
1294
+ seenDomains.add(domain)
1273
1295
  if (hostsEnabled && !isWildcardPattern(domain) && !domain.includes('localhost') && !domain.includes('127.0.0.1')) {
1274
1296
  try {
1275
1297
  const hostsExist = await checkHosts([domain], verbose)
@@ -1300,7 +1322,11 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1300
1322
  return
1301
1323
  }
1302
1324
 
1303
- const sharedFetchHandler = createProxyFetchHandler(host => matchHost(routingTable, host), verbose)
1325
+ const routingTable = buildHostRoutes(routeEntries)
1326
+ const sharedFetchHandler = createProxyFetchHandler(
1327
+ (host, pathname) => matchHostRoute(routingTable, host, pathname),
1328
+ verbose,
1329
+ )
1304
1330
  const sharedWsHandler = createProxyWebSocketHandler(verbose)
1305
1331
 
1306
1332
  try {
package/src/types.ts CHANGED
@@ -57,6 +57,13 @@ export interface BaseProxyConfig {
57
57
  */
58
58
  from?: string // localhost:5173
59
59
  to: string // stacks.localhost
60
+ /**
61
+ * Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
62
+ * Lets multiple routes share one host, each serving a different path —
63
+ * `/api` proxied to an app, `/docs` from a static dir, `/` from another.
64
+ * The longest matching prefix wins; omit (or `'/'`) for the host default.
65
+ */
66
+ path?: string
60
67
  start?: StartOptions
61
68
  pathRewrites?: PathRewrite[]
62
69
  /**
@@ -113,6 +120,60 @@ export interface ProductionTlsConfig {
113
120
  certsDir?: string
114
121
  }
115
122
 
123
+ /**
124
+ * On-demand TLS: issue a real (Let's Encrypt, http-01) certificate for an
125
+ * unknown host the first time it's needed, gated by an `ask` callback and/or an
126
+ * `allowedSuffixes` allowlist to prevent abuse.
127
+ *
128
+ * ## Why this is "ask-gated issuance + listener recreate", not at-handshake
129
+ *
130
+ * Bun (verified on 1.3.14 + 1.4.0) has **no working SNICallback** and
131
+ * `server.reload({ tls })` does **not** update certs at runtime. So rpx cannot
132
+ * mint a cert during the TLS handshake the way Caddy's on-demand TLS does.
133
+ * Instead rpx:
134
+ * 1. Sees the first plaintext request for the host on its `:80` listener.
135
+ * 2. Asks `ask(host)` / checks `allowedSuffixes`; if approved, drives the
136
+ * ACME http-01 flow (serving the challenge from its own `:80`).
137
+ * 3. Writes the PEMs into `certsDir` and rebuilds the `:443` listener with the
138
+ * augmented SNI cert set (a sub-second `server.stop()` + re-`Bun.serve`).
139
+ * The subsequent HTTPS request then finds the freshly-issued cert.
140
+ *
141
+ * Issuance can also be triggered programmatically via the manager's
142
+ * `ensureCert(host)` (e.g. a tunnel server pre-warming a subdomain's cert on
143
+ * registration) so the cert exists before the first browser hit.
144
+ */
145
+ export interface OnDemandTlsConfig {
146
+ /** Master switch. On-demand TLS is opt-in; default `false`. */
147
+ enabled?: boolean
148
+ /**
149
+ * Gate issuance for a given hostname. Return `true` to allow rpx to obtain a
150
+ * cert, `false` to refuse. Combined with {@link allowedSuffixes} (a host is
151
+ * approved if either the suffix allowlist matches OR `ask` returns true). If
152
+ * neither is provided, on-demand issuance refuses every host.
153
+ */
154
+ ask?: (host: string) => boolean | Promise<boolean>
155
+ /**
156
+ * Allowlist of domain suffixes that may be auto-issued without consulting
157
+ * `ask`. A host matches a suffix when it equals it or ends with `.<suffix>`
158
+ * (so `example.com` allows `example.com` and `a.example.com`).
159
+ */
160
+ allowedSuffixes?: string[]
161
+ /** Contact email for the ACME account (recommended by Let's Encrypt). */
162
+ email?: string
163
+ /**
164
+ * Use Let's Encrypt **staging** (untrusted but un-rate-limited) instead of
165
+ * production. Default `false` (real, trusted, rate-limited certs).
166
+ */
167
+ staging?: boolean
168
+ /**
169
+ * Directory where issued PEMs are written (`<host>.crt` / `<host>.key`) and
170
+ * from which existing certs are loaded. Should match the SNI `certsDir` so
171
+ * issued certs survive restarts. Defaults to the daemon's productionCerts
172
+ * `certsDir` when wired through the daemon.
173
+ */
174
+ certsDir?: string
175
+ }
176
+
116
177
  export interface SharedProxyConfig {
117
178
  https: boolean | TlsOption
118
179
  cleanup: boolean | CleanupOptions
@@ -142,6 +203,11 @@ export interface SharedProxyConfig {
142
203
  * instead of the dev self-signed shared cert.
143
204
  */
144
205
  productionCerts?: ProductionTlsConfig
206
+ /**
207
+ * On-demand TLS: lazily issue a real cert for an unknown (but approved) host
208
+ * the first time it's needed. Opt-in — see {@link OnDemandTlsConfig}.
209
+ */
210
+ onDemandTls?: OnDemandTlsConfig
145
211
  }
146
212
 
147
213
  export type SharedProxyOptions = Partial<SharedProxyConfig>