@stacksjs/rpx 0.11.18 → 0.11.20
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 +225 -210
- package/dist/cert-inspect.d.ts +5 -3
- package/dist/chunk-0f32jmrb.js +1 -0
- package/dist/chunk-bs9e342s.js +1 -0
- package/dist/chunk-vxj1ckdm.js +176 -0
- package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
- package/dist/daemon.d.ts +17 -8
- package/dist/dns.d.ts +2 -0
- package/dist/https.d.ts +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/logger.d.ts +8 -8
- package/dist/proxy-handler.d.ts +1 -1
- package/dist/proxy-pool.d.ts +82 -0
- package/dist/start.d.ts +31 -8
- package/dist/types.d.ts +13 -0
- package/dist/utils.d.ts +15 -0
- package/package.json +11 -7
- package/dist/chunk-9njrhbd4.js +0 -1
- package/dist/chunk-a0b9f9fs.js +0 -1
- package/dist/chunk-s4tdtq37.js +0 -161
- package/src/cert-inspect.ts +0 -69
- package/src/colors.ts +0 -13
- package/src/config.ts +0 -45
- package/src/daemon-runner.ts +0 -180
- package/src/daemon.ts +0 -786
- package/src/dns-state.ts +0 -116
- package/src/dns.ts +0 -509
- package/src/host-match.ts +0 -52
- package/src/host-routes.ts +0 -147
- package/src/hosts.ts +0 -278
- package/src/https.ts +0 -862
- package/src/index.ts +0 -158
- package/src/logger.ts +0 -19
- package/src/macos-trust.ts +0 -175
- package/src/on-demand.ts +0 -264
- package/src/origin-guard.ts +0 -105
- package/src/port-manager.ts +0 -183
- package/src/process-manager.ts +0 -164
- package/src/proxy-handler.ts +0 -315
- package/src/registry.ts +0 -366
- package/src/sni.ts +0 -93
- package/src/start.ts +0 -1421
- package/src/static-files.ts +0 -201
- package/src/types.ts +0 -256
- package/src/utils.ts +0 -210
package/src/static-files.ts
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import type { TlsConfig, TlsOption } from '@stacksjs/tlsx'
|
|
2
|
-
import type { OriginGuardOptions } from './origin-guard'
|
|
3
|
-
|
|
4
|
-
export interface StartOptions {
|
|
5
|
-
command: string
|
|
6
|
-
cwd?: string
|
|
7
|
-
env?: Record<string, string>
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface PathRewrite {
|
|
11
|
-
/** Path prefix to match, e.g. '/api' */
|
|
12
|
-
from: string
|
|
13
|
-
/** Target backend to route to, e.g. 'localhost:3008' */
|
|
14
|
-
to: string
|
|
15
|
-
/**
|
|
16
|
-
* Strip the matched prefix before forwarding. Default: `false` (preserve path).
|
|
17
|
-
*
|
|
18
|
-
* Matches the behavior of Vite's `server.proxy`, nginx `proxy_pass http://host:port`
|
|
19
|
-
* (no trailing slash), and http-proxy-middleware's default. Most upstreams that own
|
|
20
|
-
* a `/api` namespace expect the prefix to remain on the request URL.
|
|
21
|
-
*
|
|
22
|
-
* Set to `true` only when the upstream registers routes WITHOUT the prefix
|
|
23
|
-
* (e.g., upstream serves `/cart/add` and you want `/api/cart/add` to reach it).
|
|
24
|
-
*/
|
|
25
|
-
stripPrefix?: boolean
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* How a static-file route maps request paths to files on disk.
|
|
30
|
-
*
|
|
31
|
-
* - `directory` (default): `/about` → `<root>/about/index.html` (SSG dir style).
|
|
32
|
-
* - `flat`: `/about` → `<root>/about.html` (flat-file style).
|
|
33
|
-
*/
|
|
34
|
-
export type PathRewriteStyle = 'directory' | 'flat'
|
|
35
|
-
|
|
36
|
-
export interface StaticRouteConfig {
|
|
37
|
-
/** Absolute path to the directory served for this route. */
|
|
38
|
-
dir: string
|
|
39
|
-
/**
|
|
40
|
-
* Single-page-app fallback: serve `index.html` for any path that doesn't
|
|
41
|
-
* resolve to a real file (so client-side routing works). Default: `false`.
|
|
42
|
-
*/
|
|
43
|
-
spa?: boolean
|
|
44
|
-
/**
|
|
45
|
-
* Extensionless-URL resolution style for `.html` files. Default: `directory`.
|
|
46
|
-
*/
|
|
47
|
-
pathRewriteStyle?: PathRewriteStyle
|
|
48
|
-
/**
|
|
49
|
-
* `Cache-Control` max-age (seconds) for served files. Default: `0`.
|
|
50
|
-
*/
|
|
51
|
-
maxAge?: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface BaseProxyConfig {
|
|
55
|
-
/**
|
|
56
|
-
* Upstream `host:port` to forward to (e.g. `localhost:5173`). Optional when
|
|
57
|
-
* `static` is set (the route serves files from disk instead of proxying).
|
|
58
|
-
*/
|
|
59
|
-
from?: string // localhost:5173
|
|
60
|
-
to: string // stacks.localhost
|
|
61
|
-
/**
|
|
62
|
-
* Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
|
|
63
|
-
* Lets multiple routes share one host, each serving a different path —
|
|
64
|
-
* `/api` proxied to an app, `/docs` from a static dir, `/` from another.
|
|
65
|
-
* The longest matching prefix wins; omit (or `'/'`) for the host default.
|
|
66
|
-
*/
|
|
67
|
-
path?: string
|
|
68
|
-
start?: StartOptions
|
|
69
|
-
pathRewrites?: PathRewrite[]
|
|
70
|
-
/**
|
|
71
|
-
* Serve a local directory for this route instead of proxying to `from`.
|
|
72
|
-
* Provide an absolute directory path (shorthand) or a {@link StaticRouteConfig}.
|
|
73
|
-
* When set, `from` is optional; exactly one of `from`/`static` must be present.
|
|
74
|
-
*/
|
|
75
|
-
static?: string | StaticRouteConfig
|
|
76
|
-
/**
|
|
77
|
-
* Stable id used when registering this proxy with the rpx daemon. Derived
|
|
78
|
-
* from `to` if omitted. Must match `/^[a-zA-Z0-9._-]+$/` and be ≤128 chars.
|
|
79
|
-
*/
|
|
80
|
-
id?: string
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export type BaseProxyOptions = Partial<BaseProxyConfig>
|
|
84
|
-
|
|
85
|
-
export interface CleanupConfig {
|
|
86
|
-
domains: string[] // default: [], if only specific domain/s should be cleaned up
|
|
87
|
-
hosts: boolean // default: true, if hosts file should be cleaned up
|
|
88
|
-
certs: boolean // default: false, if certificates should be cleaned up
|
|
89
|
-
verbose: boolean // default: false
|
|
90
|
-
vitePluginUsage?: boolean // default: false, if cleanup was initiated by the Vite plugin
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export type CleanupOptions = Partial<CleanupConfig>
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* A real PEM cert+key pair on disk for one SNI server name.
|
|
97
|
-
*/
|
|
98
|
-
export interface DomainCert {
|
|
99
|
-
/** Absolute path to the PEM certificate (fullchain). */
|
|
100
|
-
certPath: string
|
|
101
|
-
/** Absolute path to the PEM private key. */
|
|
102
|
-
keyPath: string
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Production TLS using real certs (e.g. Let's Encrypt) served per-domain via
|
|
107
|
-
* SNI on a single listener. Provide either an explicit `domains` map or a
|
|
108
|
-
* `certsDir` convention.
|
|
109
|
-
*/
|
|
110
|
-
export interface ProductionTlsConfig {
|
|
111
|
-
/**
|
|
112
|
-
* Explicit per-domain cert/key files keyed by SNI server name. Use
|
|
113
|
-
* `*.example.com` for a wildcard server name.
|
|
114
|
-
*/
|
|
115
|
-
domains?: Record<string, DomainCert>
|
|
116
|
-
/**
|
|
117
|
-
* Directory of PEM files following the convention `<domain>.crt` /
|
|
118
|
-
* `<domain>.key`. A wildcard pair `_wildcard.<apex>.crt` /
|
|
119
|
-
* `_wildcard.<apex>.key` is registered under server name `*.<apex>`.
|
|
120
|
-
*/
|
|
121
|
-
certsDir?: string
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* On-demand TLS: issue a real (Let's Encrypt, http-01) certificate for an
|
|
126
|
-
* unknown host the first time it's needed, gated by an `ask` callback and/or an
|
|
127
|
-
* `allowedSuffixes` allowlist to prevent abuse.
|
|
128
|
-
*
|
|
129
|
-
* ## Why this is "ask-gated issuance + listener recreate", not at-handshake
|
|
130
|
-
*
|
|
131
|
-
* Bun (verified on 1.3.14 + 1.4.0) has **no working SNICallback** and
|
|
132
|
-
* `server.reload({ tls })` does **not** update certs at runtime. So rpx cannot
|
|
133
|
-
* mint a cert during the TLS handshake the way Caddy's on-demand TLS does.
|
|
134
|
-
* Instead rpx:
|
|
135
|
-
* 1. Sees the first plaintext request for the host on its `:80` listener.
|
|
136
|
-
* 2. Asks `ask(host)` / checks `allowedSuffixes`; if approved, drives the
|
|
137
|
-
* ACME http-01 flow (serving the challenge from its own `:80`).
|
|
138
|
-
* 3. Writes the PEMs into `certsDir` and rebuilds the `:443` listener with the
|
|
139
|
-
* augmented SNI cert set (a sub-second `server.stop()` + re-`Bun.serve`).
|
|
140
|
-
* The subsequent HTTPS request then finds the freshly-issued cert.
|
|
141
|
-
*
|
|
142
|
-
* Issuance can also be triggered programmatically via the manager's
|
|
143
|
-
* `ensureCert(host)` (e.g. a tunnel server pre-warming a subdomain's cert on
|
|
144
|
-
* registration) so the cert exists before the first browser hit.
|
|
145
|
-
*/
|
|
146
|
-
export interface OnDemandTlsConfig {
|
|
147
|
-
/** Master switch. On-demand TLS is opt-in; default `false`. */
|
|
148
|
-
enabled?: boolean
|
|
149
|
-
/**
|
|
150
|
-
* Gate issuance for a given hostname. Return `true` to allow rpx to obtain a
|
|
151
|
-
* cert, `false` to refuse. Combined with {@link allowedSuffixes} (a host is
|
|
152
|
-
* approved if either the suffix allowlist matches OR `ask` returns true). If
|
|
153
|
-
* neither is provided, on-demand issuance refuses every host.
|
|
154
|
-
*/
|
|
155
|
-
ask?: (host: string) => boolean | Promise<boolean>
|
|
156
|
-
/**
|
|
157
|
-
* Allowlist of domain suffixes that may be auto-issued without consulting
|
|
158
|
-
* `ask`. A host matches a suffix when it equals it or ends with `.<suffix>`
|
|
159
|
-
* (so `example.com` allows `example.com` and `a.example.com`).
|
|
160
|
-
*/
|
|
161
|
-
allowedSuffixes?: string[]
|
|
162
|
-
/** Contact email for the ACME account (recommended by Let's Encrypt). */
|
|
163
|
-
email?: string
|
|
164
|
-
/**
|
|
165
|
-
* Use Let's Encrypt **staging** (untrusted but un-rate-limited) instead of
|
|
166
|
-
* production. Default `false` (real, trusted, rate-limited certs).
|
|
167
|
-
*/
|
|
168
|
-
staging?: boolean
|
|
169
|
-
/**
|
|
170
|
-
* Directory where issued PEMs are written (`<host>.crt` / `<host>.key`) and
|
|
171
|
-
* from which existing certs are loaded. Should match the SNI `certsDir` so
|
|
172
|
-
* issued certs survive restarts. Defaults to the daemon's productionCerts
|
|
173
|
-
* `certsDir` when wired through the daemon.
|
|
174
|
-
*/
|
|
175
|
-
certsDir?: string
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface SharedProxyConfig {
|
|
179
|
-
https: boolean | TlsOption
|
|
180
|
-
cleanup: boolean | CleanupOptions
|
|
181
|
-
vitePluginUsage: boolean
|
|
182
|
-
verbose: boolean
|
|
183
|
-
_cachedSSLConfig?: SSLConfig | null
|
|
184
|
-
start?: StartOptions
|
|
185
|
-
cleanUrls: boolean
|
|
186
|
-
changeOrigin?: boolean // default: false - changes the origin of the host header to the target URL
|
|
187
|
-
regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system.
|
|
188
|
-
/**
|
|
189
|
-
* Route this proxy through the long-running rpx daemon instead of binding
|
|
190
|
-
* its own :443. Lets multiple `rpx start` invocations coexist on shared
|
|
191
|
-
* `:443` (Valet-style). Default: `false` for backward compatibility.
|
|
192
|
-
*/
|
|
193
|
-
viaDaemon?: boolean
|
|
194
|
-
/**
|
|
195
|
-
* Master switch for all `/etc/hosts` reads/writes. Set to `false` on a real
|
|
196
|
-
* server with real DNS so rpx never touches `/etc/hosts`. When omitted, the
|
|
197
|
-
* legacy behavior applies (driven by `cleanup.hosts`). `cleanup: { hosts:
|
|
198
|
-
* false }` also disables hosts management.
|
|
199
|
-
*/
|
|
200
|
-
hostsManagement?: boolean
|
|
201
|
-
/**
|
|
202
|
-
* Production per-domain SNI certs (Let's Encrypt PEMs already on disk). When
|
|
203
|
-
* provided, the listener serves a different real cert per SNI server name
|
|
204
|
-
* instead of the dev self-signed shared cert.
|
|
205
|
-
*/
|
|
206
|
-
productionCerts?: ProductionTlsConfig
|
|
207
|
-
/**
|
|
208
|
-
* On-demand TLS: lazily issue a real cert for an unknown (but approved) host
|
|
209
|
-
* the first time it's needed. Opt-in — see {@link OnDemandTlsConfig}.
|
|
210
|
-
*/
|
|
211
|
-
onDemandTls?: OnDemandTlsConfig
|
|
212
|
-
/**
|
|
213
|
-
* Origin lockdown for "CDN in front of rpx" setups. When set, the shared
|
|
214
|
-
* HTTPS handler rejects requests to the listed hosts that lack the CDN's
|
|
215
|
-
* shared-secret header — so the publicly-resolvable origin can't be used to
|
|
216
|
-
* bypass the CDN. See {@link createOriginGuard}.
|
|
217
|
-
*/
|
|
218
|
-
originGuard?: OriginGuardOptions
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export type SharedProxyOptions = Partial<SharedProxyConfig>
|
|
222
|
-
|
|
223
|
-
export interface SingleProxyConfig extends BaseProxyConfig, SharedProxyConfig {}
|
|
224
|
-
|
|
225
|
-
export interface MultiProxyConfig extends SharedProxyConfig {
|
|
226
|
-
proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[] }>
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export type ProxyConfig = SingleProxyConfig
|
|
230
|
-
export type ProxyConfigs = SingleProxyConfig | MultiProxyConfig
|
|
231
|
-
|
|
232
|
-
export type BaseProxyOption = Partial<BaseProxyConfig>
|
|
233
|
-
export type ProxyOption = Partial<SingleProxyConfig>
|
|
234
|
-
export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig>
|
|
235
|
-
|
|
236
|
-
export interface SSLConfig {
|
|
237
|
-
key: string
|
|
238
|
-
cert: string
|
|
239
|
-
ca?: string | string[]
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export interface ProxySetupOptions extends Omit<ProxyOption, 'from'> {
|
|
243
|
-
fromPort: number
|
|
244
|
-
sourceUrl: Pick<URL, 'hostname' | 'host'>
|
|
245
|
-
ssl: SSLConfig | null
|
|
246
|
-
from: string
|
|
247
|
-
to: string
|
|
248
|
-
portManager?: PortManager
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export interface PortManager {
|
|
252
|
-
usedPorts: Set<number>
|
|
253
|
-
getNextAvailablePort: (startPort: number) => Promise<number>
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export type { TlsConfig, TlsOption }
|
package/src/utils.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import type { MultiProxyConfig, PathRewrite, ProxyConfigs, ProxyOption, ProxyOptions, SingleProxyConfig } from './types'
|
|
2
|
-
import { execSync } from 'node:child_process'
|
|
3
|
-
import * as fs from 'node:fs/promises'
|
|
4
|
-
import { Logger } from '@stacksjs/clarity'
|
|
5
|
-
|
|
6
|
-
const logger = new Logger('rpx', {
|
|
7
|
-
showTags: false,
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Get sudo password from environment variable if set
|
|
12
|
-
*/
|
|
13
|
-
export function getSudoPassword(): string | undefined {
|
|
14
|
-
return process.env.SUDO_PASSWORD
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Execute a command with sudo, using SUDO_PASSWORD if available
|
|
19
|
-
*/
|
|
20
|
-
export function execSudoSync(command: string): string {
|
|
21
|
-
const sudoPassword = getSudoPassword()
|
|
22
|
-
const escaped = command.replace(/'/g, `'\\''`)
|
|
23
|
-
|
|
24
|
-
if (sudoPassword) {
|
|
25
|
-
return execSync(`echo '${sudoPassword}' | sudo -S sh -c '${escaped}' 2>/dev/null`, {
|
|
26
|
-
encoding: 'utf-8',
|
|
27
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Never block on an interactive password prompt — dev servers must start headlessly.
|
|
32
|
-
try {
|
|
33
|
-
return execSync(`sudo -n sh -c '${escaped}'`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
throw new Error('sudo required but no cached credentials (set SUDO_PASSWORD in .env or run sudo -v)')
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function debugLog(category: string, message: string, verbose?: boolean): void {
|
|
41
|
-
if (verbose)
|
|
42
|
-
logger.debug(`[rpx:${category}] ${message}`)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const REDACTED = '[redacted]'
|
|
46
|
-
const SENSITIVE_KEYS = new Set([
|
|
47
|
-
'certificate',
|
|
48
|
-
'privatekey',
|
|
49
|
-
'key',
|
|
50
|
-
'cert',
|
|
51
|
-
'ca',
|
|
52
|
-
'rootca',
|
|
53
|
-
'password',
|
|
54
|
-
'sudo_password',
|
|
55
|
-
])
|
|
56
|
-
const PEM_BLOCK_RE = /-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/
|
|
57
|
-
|
|
58
|
-
function isSensitiveKey(key: string): boolean {
|
|
59
|
-
const normalized = key.toLowerCase()
|
|
60
|
-
return SENSITIVE_KEYS.has(normalized)
|
|
61
|
-
|| normalized.endsWith('password')
|
|
62
|
-
|| normalized.includes('secret')
|
|
63
|
-
|| normalized.includes('token')
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function redactSensitive(value: unknown): unknown {
|
|
67
|
-
if (Array.isArray(value))
|
|
68
|
-
return value.map(item => redactSensitive(item))
|
|
69
|
-
|
|
70
|
-
if (typeof value === 'string')
|
|
71
|
-
return PEM_BLOCK_RE.test(value) ? REDACTED : value
|
|
72
|
-
|
|
73
|
-
if (!value || typeof value !== 'object')
|
|
74
|
-
return value
|
|
75
|
-
|
|
76
|
-
const output: Record<string, unknown> = {}
|
|
77
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
78
|
-
if (isSensitiveKey(key)) {
|
|
79
|
-
output[key] = REDACTED
|
|
80
|
-
continue
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
output[key] = redactSensitive(nested)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return output
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function safeStringify(value: unknown, space?: number): string {
|
|
90
|
-
return JSON.stringify(redactSensitive(value), null, space)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Extracts hostnames from proxy configuration
|
|
95
|
-
*/
|
|
96
|
-
export function extractHostname(options: ProxyOption | ProxyOptions): string[] {
|
|
97
|
-
if (isMultiProxyOptions(options)) {
|
|
98
|
-
return options.proxies.map((proxy) => {
|
|
99
|
-
const domain = proxy.to || 'stacks.localhost'
|
|
100
|
-
return domain.startsWith('http') ? new URL(domain).hostname : domain
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (isSingleProxyOptions(options)) {
|
|
105
|
-
const domain = options.to || 'stacks.localhost'
|
|
106
|
-
return [domain.startsWith('http') ? new URL(domain).hostname : domain]
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return ['stacks.localhost']
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface RootCA {
|
|
113
|
-
certificate: string
|
|
114
|
-
privateKey: string
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function isValidRootCA(value: unknown): value is RootCA {
|
|
118
|
-
return (
|
|
119
|
-
typeof value === 'object'
|
|
120
|
-
&& value !== null
|
|
121
|
-
&& 'certificate' in value
|
|
122
|
-
&& 'privateKey' in value
|
|
123
|
-
&& typeof (value as RootCA).certificate === 'string'
|
|
124
|
-
&& typeof (value as RootCA).privateKey === 'string'
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function getPrimaryDomain(options?: ProxyOption | ProxyOptions): string {
|
|
129
|
-
if (!options)
|
|
130
|
-
return 'stacks.localhost'
|
|
131
|
-
|
|
132
|
-
if (isMultiProxyOptions(options) && options.proxies.length > 0)
|
|
133
|
-
return options.proxies[0].to || 'stacks.localhost'
|
|
134
|
-
|
|
135
|
-
if (isSingleProxyOptions(options))
|
|
136
|
-
return options.to || 'stacks.localhost'
|
|
137
|
-
|
|
138
|
-
return 'stacks.localhost'
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Type guard for multi-proxy configuration
|
|
143
|
-
*/
|
|
144
|
-
export function isMultiProxyConfig(options: ProxyConfigs | ProxyOptions): options is MultiProxyConfig {
|
|
145
|
-
return !!(options && 'proxies' in options && Array.isArray((options as MultiProxyConfig).proxies))
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Type guard to check if options are for multi-proxy configuration
|
|
150
|
-
*/
|
|
151
|
-
export function isMultiProxyOptions(options: ProxyOption | ProxyOptions): options is MultiProxyConfig {
|
|
152
|
-
return 'proxies' in options && Array.isArray((options as MultiProxyConfig).proxies)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Type guard to check if options are for single-proxy configuration
|
|
157
|
-
*/
|
|
158
|
-
export function isSingleProxyOptions(options: ProxyOption | ProxyOptions): options is SingleProxyConfig {
|
|
159
|
-
return 'to' in options && typeof (options as SingleProxyConfig).to === 'string'
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function isSingleProxyConfig(options: ProxyConfigs | ProxyOptions): options is SingleProxyConfig {
|
|
163
|
-
return !!(options && 'to' in options && !('proxies' in options))
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Resolve a path against a list of `pathRewrites`.
|
|
168
|
-
*
|
|
169
|
-
* Returns `null` if no rewrite matches; otherwise returns `{ targetHost, targetPath }`
|
|
170
|
-
* with the prefix preserved by default (or stripped when `stripPrefix === true`).
|
|
171
|
-
*
|
|
172
|
-
* Matching rule: rewrite matches if `pathname` is exactly `from` OR starts with
|
|
173
|
-
* `from + '/'`. So `/api` matches `/api`, `/api/`, `/api/cart` — but not `/apidocs`.
|
|
174
|
-
*/
|
|
175
|
-
export function resolvePathRewrite(
|
|
176
|
-
pathname: string,
|
|
177
|
-
rewrites: PathRewrite[] | undefined,
|
|
178
|
-
): { targetHost: string, targetPath: string } | null {
|
|
179
|
-
if (!rewrites || rewrites.length === 0)
|
|
180
|
-
return null
|
|
181
|
-
|
|
182
|
-
for (const rewrite of rewrites) {
|
|
183
|
-
if (pathname === rewrite.from || pathname.startsWith(`${rewrite.from}/`)) {
|
|
184
|
-
const targetHost = rewrite.to.startsWith('http') ? new URL(rewrite.to).host : rewrite.to
|
|
185
|
-
const targetPath = rewrite.stripPrefix === true
|
|
186
|
-
? (pathname.slice(rewrite.from.length) || '/')
|
|
187
|
-
: pathname
|
|
188
|
-
return { targetHost, targetPath }
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return null
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Safely delete a file if it exists
|
|
197
|
-
*/
|
|
198
|
-
export async function safeDeleteFile(filePath: string, verbose?: boolean): Promise<void> {
|
|
199
|
-
try {
|
|
200
|
-
// Try to delete the file directly without checking existence first
|
|
201
|
-
await fs.unlink(filePath)
|
|
202
|
-
debugLog('certificates', `Successfully deleted: ${filePath}`, verbose)
|
|
203
|
-
}
|
|
204
|
-
catch (err) {
|
|
205
|
-
// Ignore errors where file doesn't exist
|
|
206
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
207
|
-
debugLog('certificates', `Warning: Could not delete ${filePath}: ${err}`, verbose)
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|