@stacksjs/rpx 0.11.15 → 0.11.17

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,147 @@
1
+ /**
2
+ * Path-aware routing within a single host.
3
+ *
4
+ * `host-match.ts` answers "which host pattern owns this hostname?" (exact wins,
5
+ * then most-specific wildcard). That is sufficient when every host maps to a
6
+ * single backend. But a single domain often needs to serve several things at
7
+ * once — e.g. `stacksjs.com/api/*` proxied to an app on `:3000`,
8
+ * `stacksjs.com/docs*` served from `/var/www/docs`, and `stacksjs.com/` served
9
+ * from `/var/www/public`. That requires a second routing dimension: the
10
+ * request **path**.
11
+ *
12
+ * This module layers path routing on top of host routing without disturbing
13
+ * host-only routing:
14
+ * - Each host pattern maps to a list of `(path, route)` entries.
15
+ * - Lookup first resolves the host (reusing `matchHost` semantics), then picks
16
+ * the entry whose `path` is the longest prefix of the request pathname.
17
+ * - A host with a single entry whose `path` is `'/'` (or empty) behaves
18
+ * exactly like the old host-only table — full backward compatibility.
19
+ *
20
+ * Kept dependency-free and pure so it's reusable from both the daemon and the
21
+ * in-process multi-proxy path, and trivially unit-testable.
22
+ */
23
+ import { isWildcardPattern, matchesWildcard } from './host-match'
24
+
25
+ /** One path-scoped route under a host. */
26
+ export interface PathRoute<T> {
27
+ /**
28
+ * Path prefix this route owns, e.g. `'/api'`. `'/'` (or `''`) is the host
29
+ * default that catches everything not claimed by a more specific prefix.
30
+ */
31
+ path: string
32
+ /** The route value (e.g. a {@link import('./proxy-handler').ProxyRoute}). */
33
+ route: T
34
+ }
35
+
36
+ /**
37
+ * A host-keyed routing table where each host owns an ordered set of
38
+ * path-scoped routes. Build it with {@link buildHostRoutes}.
39
+ */
40
+ export type HostRoutes<T> = Map<string, Array<PathRoute<T>>>
41
+
42
+ /**
43
+ * Normalize a path prefix to a leading-slash, no-trailing-slash form so prefix
44
+ * comparisons are predictable. `''`/`undefined`/`'/'` all normalize to `'/'`
45
+ * (the host default). `'/api/'` → `'/api'`, `'docs'` → `'/docs'`.
46
+ */
47
+ export function normalizePathPrefix(path: string | undefined): string {
48
+ if (!path || path === '/')
49
+ return '/'
50
+ let p = path.trim()
51
+ if (!p.startsWith('/'))
52
+ p = `/${p}`
53
+ // Strip trailing slashes (but keep the root '/').
54
+ p = p.replace(/\/+$/, '')
55
+ return p === '' ? '/' : p
56
+ }
57
+
58
+ /**
59
+ * True if `pathname` is matched by the prefix `prefix`. The root prefix `'/'`
60
+ * matches everything. A non-root prefix matches when the pathname equals it
61
+ * (`/api`), or continues with a `/` (`/api/x`) — so `/api` does NOT match
62
+ * `/apifoo`, only a real path-segment boundary.
63
+ */
64
+ export function pathPrefixMatches(pathname: string, prefix: string): boolean {
65
+ if (prefix === '/')
66
+ return true
67
+ if (pathname === prefix)
68
+ return true
69
+ return pathname.startsWith(`${prefix}/`)
70
+ }
71
+
72
+ /**
73
+ * Build a {@link HostRoutes} table from a flat list of entries. Entries are
74
+ * grouped by host; within each host the path-routes are sorted longest-prefix
75
+ * first so {@link matchHostRoute} can take the first match. If two entries
76
+ * collide on the same (host, path) the later one wins (matching `Map.set`).
77
+ */
78
+ export function buildHostRoutes<T>(
79
+ entries: Array<{ host: string, path?: string, route: T }>,
80
+ ): HostRoutes<T> {
81
+ const byHost = new Map<string, Map<string, T>>()
82
+ for (const e of entries) {
83
+ const prefix = normalizePathPrefix(e.path)
84
+ let paths = byHost.get(e.host)
85
+ if (!paths) {
86
+ paths = new Map<string, T>()
87
+ byHost.set(e.host, paths)
88
+ }
89
+ paths.set(prefix, e.route)
90
+ }
91
+
92
+ const table: HostRoutes<T> = new Map()
93
+ for (const [host, paths] of byHost) {
94
+ const list: Array<PathRoute<T>> = []
95
+ for (const [path, route] of paths)
96
+ list.push({ path, route })
97
+ // Longest prefix first; '/' (length 1) naturally sorts last as the default.
98
+ list.sort((a, b) => b.path.length - a.path.length)
99
+ table.set(host, list)
100
+ }
101
+ return table
102
+ }
103
+
104
+ /**
105
+ * Find the path-route list for `hostname` in a {@link HostRoutes} table. Exact
106
+ * host match wins; otherwise the most-specific (deepest-suffix) wildcard wins —
107
+ * mirroring {@link import('./host-match').matchHost}.
108
+ */
109
+ export function matchHostList<T>(table: HostRoutes<T>, hostname: string): Array<PathRoute<T>> | undefined {
110
+ const exact = table.get(hostname)
111
+ if (exact !== undefined)
112
+ return exact
113
+
114
+ let best: Array<PathRoute<T>> | undefined
115
+ let bestLen = -1
116
+ for (const [pattern, value] of table) {
117
+ if (!isWildcardPattern(pattern))
118
+ continue
119
+ if (matchesWildcard(hostname, pattern)) {
120
+ const len = pattern.length - 1
121
+ if (len > bestLen) {
122
+ bestLen = len
123
+ best = value
124
+ }
125
+ }
126
+ }
127
+ return best
128
+ }
129
+
130
+ /**
131
+ * Resolve a (hostname, pathname) pair to a single route value. First the host
132
+ * is resolved ({@link matchHostList}); then the longest matching path prefix
133
+ * within that host wins. Returns `undefined` when no host matches, or a host
134
+ * matches but no path prefix (including the `'/'` default) covers the request.
135
+ */
136
+ export function matchHostRoute<T>(table: HostRoutes<T>, hostname: string, pathname: string): T | undefined {
137
+ const list = matchHostList(table, hostname)
138
+ if (!list)
139
+ return undefined
140
+ // `list` is pre-sorted longest-prefix-first, so the first match is the most
141
+ // specific one ('/' is last and matches everything as the host default).
142
+ for (const entry of list) {
143
+ if (pathPrefixMatches(pathname, entry.path))
144
+ return entry.route
145
+ }
146
+ return undefined
147
+ }
package/src/https.ts CHANGED
@@ -783,8 +783,8 @@ export async function isCertTrusted(
783
783
  // Different check methods per platform
784
784
  if (process.platform === 'darwin') {
785
785
  if (options?.serverName)
786
- return isRootCaTrustedForSsl(certPath, options.serverName, { verbose: options.verbose })
787
- return isRootCaFingerprintInKeychains(certPath, { verbose: options.verbose })
786
+ return isRootCaTrustedForSsl(certPath, options.serverName, { verbose: options?.verbose })
787
+ return isRootCaFingerprintInKeychains(certPath, { verbose: options?.verbose })
788
788
  }
789
789
  else if (process.platform === 'win32') {
790
790
  // On Windows, use PowerShell to check the certificate store
package/src/index.ts CHANGED
@@ -112,11 +112,23 @@ export type {
112
112
  StopDaemonResult,
113
113
  } from './daemon'
114
114
 
115
- export { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
115
+ export { createProxyFetchHandler, createProxyWebSocketHandler, stripBasePath } from './proxy-handler'
116
116
  export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
117
117
 
118
118
  export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
119
119
 
120
+ export { createOriginGuard } from './origin-guard'
121
+ export type { OriginGuard, OriginGuardOptions } from './origin-guard'
122
+
123
+ export {
124
+ buildHostRoutes,
125
+ matchHostList,
126
+ matchHostRoute,
127
+ normalizePathPrefix,
128
+ pathPrefixMatches,
129
+ } from './host-routes'
130
+ export type { HostRoutes, PathRoute } from './host-routes'
131
+
120
132
  export {
121
133
  contentTypeFor,
122
134
  resolveStaticFile,
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Origin verification guard for "CDN in front of rpx" topologies.
3
+ *
4
+ * When rpx is the origin behind a CDN (e.g. CloudFront → rpx), the origin host
5
+ * is publicly resolvable, so a client could resolve it and hit rpx directly,
6
+ * bypassing the CDN's caching/WAF. The standard mitigation is a shared secret:
7
+ * the CDN injects a secret request header on the origin fetch, and the origin
8
+ * rejects any request to the protected hosts that lacks it.
9
+ *
10
+ * `createOriginGuard` returns a tiny pre-router gate you place in front of your
11
+ * fetch handler. It only guards the listed hosts (exact or `*.wildcard`) — every
12
+ * other host (e.g. apps served directly, not via the CDN) passes through
13
+ * untouched. ACME HTTP-01 challenge paths are exempt by default so cert renewal
14
+ * keeps working on the open `:80` listener.
15
+ *
16
+ * @example
17
+ * const guard = createOriginGuard({
18
+ * header: 'x-origin-verify',
19
+ * value: process.env.ORIGIN_SECRET!,
20
+ * hosts: ['stacksjs.com', 'www.stacksjs.com', 'origin.stacksjs.com'],
21
+ * })
22
+ * Bun.serve({ fetch: req => guard(req) ?? handler(req, server) })
23
+ */
24
+ import { matchesWildcard } from './host-match'
25
+
26
+ export interface OriginGuardOptions {
27
+ /** Header the CDN injects on the origin hop (case-insensitive), e.g. `x-origin-verify`. */
28
+ header: string
29
+ /** Expected secret value. Requests to protected hosts must carry `header: value`. */
30
+ value: string
31
+ /** Hosts to protect — exact (`stacksjs.com`) or wildcard (`*.stacksjs.com`). Others pass through. */
32
+ hosts: string[]
33
+ /**
34
+ * Request paths exempt from the check (prefix match). Defaults to the ACME
35
+ * HTTP-01 challenge prefix so cert issuance/renewal is never blocked.
36
+ */
37
+ exemptPaths?: string[]
38
+ /** Body returned on rejection. Defaults to a short plain-text message. */
39
+ forbiddenMessage?: string
40
+ }
41
+
42
+ export interface OriginGuard {
43
+ /** Returns a 403 Response to short-circuit, or `undefined` to let the request proceed. */
44
+ (req: Request): Response | undefined
45
+ /** Whether a given hostname is in the protected set. */
46
+ protects: (hostname: string) => boolean
47
+ }
48
+
49
+ const DEFAULT_EXEMPT = ['/.well-known/acme-challenge/']
50
+
51
+ function hostnameOf(req: Request): string {
52
+ const hostHeader = req.headers.get('host')
53
+ if (hostHeader)
54
+ return hostHeader.split(':')[0].toLowerCase()
55
+ try {
56
+ return new URL(req.url).hostname.toLowerCase()
57
+ }
58
+ catch {
59
+ return ''
60
+ }
61
+ }
62
+
63
+ export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
64
+ const header = options.header.toLowerCase()
65
+ const exact = new Set<string>()
66
+ const wildcards: string[] = []
67
+ for (const h of options.hosts) {
68
+ const host = h.toLowerCase()
69
+ if (host.startsWith('*.'))
70
+ wildcards.push(host)
71
+ else
72
+ exact.add(host)
73
+ }
74
+ const exemptPaths = options.exemptPaths ?? DEFAULT_EXEMPT
75
+ const forbidden = options.forbiddenMessage
76
+ ?? 'Forbidden: direct origin access is not allowed; requests must arrive via the CDN.\n'
77
+
78
+ const protects = (hostname: string): boolean => {
79
+ const h = hostname.toLowerCase()
80
+ return exact.has(h) || wildcards.some(w => matchesWildcard(h, w))
81
+ }
82
+
83
+ const guard: OriginGuard = ((req: Request): Response | undefined => {
84
+ const host = hostnameOf(req)
85
+ if (!protects(host))
86
+ return undefined
87
+
88
+ let pathname = '/'
89
+ try {
90
+ pathname = new URL(req.url).pathname
91
+ }
92
+ catch {
93
+ // fall through with '/'
94
+ }
95
+ if (exemptPaths.some(p => pathname.startsWith(p)))
96
+ return undefined
97
+
98
+ if (req.headers.get(header) === options.value)
99
+ return undefined
100
+
101
+ return new Response(forbidden, { status: 403, headers: { 'content-type': 'text/plain' } })
102
+ }) as OriginGuard
103
+ guard.protects = protects
104
+ return guard
105
+ }
@@ -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
  /**