@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.
- package/dist/bin/cli.js +119 -119
- package/dist/{chunk-kv17r01q.js → chunk-a0b9f9fs.js} +1 -1
- package/dist/{chunk-pjwm8py7.js → chunk-fndafyac.js} +1 -1
- package/dist/{chunk-hf6e07v4.js → chunk-rbgb5fyg.js} +1 -1
- package/dist/{chunk-0zdj72ps.js → chunk-zx2ghrc1.js} +62 -62
- package/dist/daemon-runner.d.ts +3 -1
- package/dist/host-routes.d.ts +43 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +7 -6
- package/dist/origin-guard.d.ts +12 -0
- package/dist/proxy-handler.d.ts +14 -1
- package/dist/registry.d.ts +2 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/daemon-runner.ts +14 -3
- package/src/daemon.ts +17 -10
- package/src/host-routes.ts +147 -0
- package/src/https.ts +2 -2
- package/src/index.ts +13 -1
- package/src/origin-guard.ts +105 -0
- package/src/proxy-handler.ts +60 -7
- package/src/registry.ts +12 -0
- package/src/start.ts +40 -14
- package/src/types.ts +7 -0
|
@@ -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
|
|
787
|
-
return isRootCaFingerprintInKeychains(certPath, { verbose: options
|
|
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
|
+
}
|
package/src/proxy-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
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
|
/**
|