@stacksjs/rpx 0.11.12 → 0.11.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +152 -151
- package/dist/{chunk-zs1tyy8z.js → chunk-3pgh05pc.js} +1 -1
- package/dist/chunk-5ygwd93k.js +1 -0
- package/dist/{chunk-747af2w4.js → chunk-a0ddh9cv.js} +1 -1
- package/dist/chunk-tx5hnj92.js +157 -0
- package/dist/daemon-runner.d.ts +3 -2
- package/dist/daemon.d.ts +2 -1
- package/dist/host-match.d.ts +23 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +4 -4
- package/dist/proxy-handler.d.ts +18 -3
- package/dist/registry.d.ts +3 -2
- package/dist/sni.d.ts +20 -0
- package/dist/static-files.d.ts +46 -0
- package/dist/types.d.ts +33 -1
- package/package.json +1 -1
- package/src/daemon-runner.ts +12 -4
- package/src/daemon.ts +143 -12
- package/src/host-match.ts +52 -0
- package/src/index.ts +16 -2
- package/src/proxy-handler.ts +184 -21
- package/src/registry.ts +11 -3
- package/src/sni.ts +93 -0
- package/src/start.ts +66 -19
- package/src/static-files.ts +201 -0
- package/src/types.ts +79 -1
- package/dist/chunk-1j4gp3f8.js +0 -1
- package/dist/chunk-9dndasyk.js +0 -156
package/src/sni.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Bun.serve TLS array for per-domain SNI from real PEM files on disk.
|
|
3
|
+
*
|
|
4
|
+
* Production deployments (Let's Encrypt) have one cert+key per domain. Bun's
|
|
5
|
+
* `Bun.serve({ tls: [{ serverName, cert, key }, ...] })` selects the right cert
|
|
6
|
+
* by SNI server name at handshake time, so a single listener can front many
|
|
7
|
+
* domains with their own real certs.
|
|
8
|
+
*/
|
|
9
|
+
import type { DomainCert, ProductionTlsConfig } from './types'
|
|
10
|
+
import * as fsp from 'node:fs/promises'
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
import { debugLog } from './utils'
|
|
13
|
+
|
|
14
|
+
/** One entry of the Bun.serve `tls` array. */
|
|
15
|
+
export interface SniTlsEntry {
|
|
16
|
+
serverName: string
|
|
17
|
+
cert: string
|
|
18
|
+
key: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map a PEM filename under a `certsDir` to its SNI server name. Returns `null`
|
|
23
|
+
* for files that aren't `<name>.crt`. The wildcard convention
|
|
24
|
+
* `_wildcard.<apex>.crt` maps to server name `*.<apex>`.
|
|
25
|
+
*/
|
|
26
|
+
export function serverNameFromCertFilename(filename: string): string | null {
|
|
27
|
+
if (!filename.endsWith('.crt'))
|
|
28
|
+
return null
|
|
29
|
+
const base = filename.slice(0, -'.crt'.length)
|
|
30
|
+
if (base.length === 0)
|
|
31
|
+
return null
|
|
32
|
+
if (base.startsWith('_wildcard.'))
|
|
33
|
+
return `*.${base.slice('_wildcard.'.length)}`
|
|
34
|
+
return base
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readPair(serverName: string, certPath: string, keyPath: string, verbose?: boolean): Promise<SniTlsEntry | null> {
|
|
38
|
+
try {
|
|
39
|
+
const [cert, key] = await Promise.all([
|
|
40
|
+
fsp.readFile(certPath, 'utf8'),
|
|
41
|
+
fsp.readFile(keyPath, 'utf8'),
|
|
42
|
+
])
|
|
43
|
+
return { serverName, cert, key }
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
debugLog('sni', `skipping ${serverName}: ${(err as Error).message}`, verbose)
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the SNI TLS array from a {@link ProductionTlsConfig}. Reads PEM files
|
|
53
|
+
* from an explicit `domains` map and/or a `certsDir` convention. Files that
|
|
54
|
+
* can't be read are skipped (logged in verbose mode). Returns `[]` when nothing
|
|
55
|
+
* usable is found so the caller can fall back to the dev cert flow.
|
|
56
|
+
*/
|
|
57
|
+
export async function buildSniTlsConfig(cfg: ProductionTlsConfig, verbose?: boolean): Promise<SniTlsEntry[]> {
|
|
58
|
+
const bySrvName = new Map<string, DomainCert>()
|
|
59
|
+
|
|
60
|
+
if (cfg.certsDir) {
|
|
61
|
+
let names: string[] = []
|
|
62
|
+
try {
|
|
63
|
+
names = await fsp.readdir(cfg.certsDir)
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
debugLog('sni', `certsDir read failed (${cfg.certsDir}): ${(err as Error).message}`, verbose)
|
|
67
|
+
}
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const serverName = serverNameFromCertFilename(name)
|
|
70
|
+
if (!serverName)
|
|
71
|
+
continue
|
|
72
|
+
const base = name.slice(0, -'.crt'.length)
|
|
73
|
+
bySrvName.set(serverName, {
|
|
74
|
+
certPath: path.join(cfg.certsDir, name),
|
|
75
|
+
keyPath: path.join(cfg.certsDir, `${base}.key`),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Explicit `domains` entries take precedence over `certsDir` discoveries.
|
|
81
|
+
if (cfg.domains) {
|
|
82
|
+
for (const [serverName, pair] of Object.entries(cfg.domains))
|
|
83
|
+
bySrvName.set(serverName, pair)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries: SniTlsEntry[] = []
|
|
87
|
+
for (const [serverName, pair] of bySrvName) {
|
|
88
|
+
const entry = await readPair(serverName, pair.certPath, pair.keyPath, verbose)
|
|
89
|
+
if (entry)
|
|
90
|
+
entries.push(entry)
|
|
91
|
+
}
|
|
92
|
+
return entries
|
|
93
|
+
}
|
package/src/start.ts
CHANGED
|
@@ -20,8 +20,10 @@ import { addHosts, checkHosts, removeHosts } from './hosts'
|
|
|
20
20
|
import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https'
|
|
21
21
|
import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager'
|
|
22
22
|
import { ProcessManager } from './process-manager'
|
|
23
|
-
import { createProxyFetchHandler } from './proxy-handler'
|
|
24
|
-
import type { ProxyRoute } from './proxy-handler'
|
|
23
|
+
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
24
|
+
import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
|
|
25
|
+
import { isWildcardPattern, matchHost } from './host-match'
|
|
26
|
+
import { resolveStaticRoute } from './static-files'
|
|
25
27
|
import { debugLog, getSudoPassword, safeStringify } from './utils'
|
|
26
28
|
|
|
27
29
|
const processManager = new ProcessManager()
|
|
@@ -307,7 +309,7 @@ export async function startServer(options: SingleProxyConfig): Promise<void> {
|
|
|
307
309
|
|
|
308
310
|
// Check and update hosts file for custom domains
|
|
309
311
|
const hostsToCheck = [toUrl.hostname]
|
|
310
|
-
if (!toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
|
|
312
|
+
if (isHostsManagementEnabled(options) && !toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
|
|
311
313
|
debugLog('hosts', `Checking if hosts file entry exists for: ${toUrl.hostname}`, options?.verbose)
|
|
312
314
|
|
|
313
315
|
try {
|
|
@@ -709,9 +711,11 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
|
|
|
709
711
|
// Use the global port manager if not provided
|
|
710
712
|
const portManager = options.portManager || globalPortManager
|
|
711
713
|
|
|
714
|
+
const hostsEnabled = isHostsManagementEnabled(options)
|
|
715
|
+
|
|
712
716
|
try {
|
|
713
717
|
// Add an extra check to make sure the hostname is in the hosts file
|
|
714
|
-
if (to && !to.includes('localhost') && !to.includes('127.0.0.1')) {
|
|
718
|
+
if (hostsEnabled && to && !to.includes('localhost') && !to.includes('127.0.0.1')) {
|
|
715
719
|
const hostsExist = await checkHosts([to], verbose)
|
|
716
720
|
if (!hostsExist[0]) {
|
|
717
721
|
log.warn(`The hostname ${to} isn't in your hosts file. Adding it now...`)
|
|
@@ -728,7 +732,7 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
|
|
|
728
732
|
else {
|
|
729
733
|
// On macOS, *.localhost domains resolve to 127.0.0.1 automatically (RFC 6761)
|
|
730
734
|
// so we don't need to add them to /etc/hosts
|
|
731
|
-
if (process.platform !== 'darwin' && to && to.includes('localhost') && !to.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
735
|
+
if (hostsEnabled && process.platform !== 'darwin' && to && to.includes('localhost') && !to.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
732
736
|
const hostsExist = await checkHosts([to], verbose)
|
|
733
737
|
if (!hostsExist[0]) {
|
|
734
738
|
debugLog('hosts', `${to} not found in hosts file, adding...`, verbose)
|
|
@@ -931,6 +935,23 @@ function getVerbose(options: any): boolean {
|
|
|
931
935
|
return options?.verbose || false
|
|
932
936
|
}
|
|
933
937
|
|
|
938
|
+
/**
|
|
939
|
+
* Whether rpx may read/write `/etc/hosts`. Disabled when `hostsManagement` is
|
|
940
|
+
* explicitly `false`, or when `cleanup.hosts` is `false` (or `cleanup` is
|
|
941
|
+
* `false`). Real-server deployments with real DNS should set
|
|
942
|
+
* `hostsManagement: false` so rpx never touches `/etc/hosts`.
|
|
943
|
+
*/
|
|
944
|
+
function isHostsManagementEnabled(options: any): boolean {
|
|
945
|
+
if (options?.hostsManagement === false)
|
|
946
|
+
return false
|
|
947
|
+
const cleanup = options?.cleanup
|
|
948
|
+
if (cleanup === false)
|
|
949
|
+
return false
|
|
950
|
+
if (cleanup && typeof cleanup === 'object' && cleanup.hosts === false)
|
|
951
|
+
return false
|
|
952
|
+
return true
|
|
953
|
+
}
|
|
954
|
+
|
|
934
955
|
export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
935
956
|
// Allow re-using a previous SSL config between multiple startProxies calls
|
|
936
957
|
// This is particularly important for the Vite plugin
|
|
@@ -957,8 +978,13 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
957
978
|
}
|
|
958
979
|
|
|
959
980
|
const verbose = getVerbose(mergedOptions)
|
|
981
|
+
// Master switch for /etc/hosts management. `hostsManagement: false` (real
|
|
982
|
+
// server with real DNS) or `cleanup: { hosts: false }` disables all hosts
|
|
983
|
+
// reads/writes. Defaults to enabled for backward compatibility.
|
|
984
|
+
const hostsEnabled = isHostsManagementEnabled(mergedOptions)
|
|
960
985
|
debugLog('config', `Starting with config: ${safeStringify(mergedOptions, 2)}`, verbose)
|
|
961
986
|
debugLog('config', `Is multi-proxy? ${'proxies' in mergedOptions}`, verbose)
|
|
987
|
+
debugLog('config', `Hosts management enabled? ${hostsEnabled}`, verbose)
|
|
962
988
|
|
|
963
989
|
// viaDaemon mode short-circuits before any port binding / cert work — the
|
|
964
990
|
// daemon owns all of that. We only need to register entries and block.
|
|
@@ -1072,7 +1098,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1072
1098
|
// Pre-acquire sudo credentials once so that all subsequent sudo operations
|
|
1073
1099
|
// (cert trust, hosts file, DNS resolver) reuse the cached credential
|
|
1074
1100
|
// without prompting again. `sudo -v` validates and caches for the timeout period.
|
|
1075
|
-
if (process.platform !== 'win32' && (mergedOptions.https ||
|
|
1101
|
+
if (process.platform !== 'win32' && (mergedOptions.https || hostsEnabled)) {
|
|
1076
1102
|
const sudoPassword = getSudoPassword()
|
|
1077
1103
|
if (!sudoPassword) {
|
|
1078
1104
|
try {
|
|
@@ -1151,7 +1177,10 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1151
1177
|
log.info(` Consider using reserved TLDs: .test, .localhost, or .local`)
|
|
1152
1178
|
}
|
|
1153
1179
|
|
|
1154
|
-
|
|
1180
|
+
// Local development DNS (resolver overrides + hosts entries) is a dev-only
|
|
1181
|
+
// convenience. On a real server (`hostsManagement: false`) DNS is real, so
|
|
1182
|
+
// skip it entirely — nothing under /etc should be touched.
|
|
1183
|
+
if (hostsEnabled && process.platform === 'darwin' && customDomains.length > 0) {
|
|
1155
1184
|
const { setupDevelopmentDns } = await import('./dns')
|
|
1156
1185
|
const dnsStarted = await setupDevelopmentDns({ domains: customDomains, verbose })
|
|
1157
1186
|
if (dnsStarted) {
|
|
@@ -1217,19 +1246,31 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1217
1246
|
|
|
1218
1247
|
for (const option of proxyOptions) {
|
|
1219
1248
|
const domain = option.to || 'rpx.localhost'
|
|
1220
|
-
const
|
|
1249
|
+
const cleanUrls = option.cleanUrls || false
|
|
1221
1250
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1251
|
+
// Static-file route: serve a local directory instead of proxying.
|
|
1252
|
+
if (option.static) {
|
|
1253
|
+
routingTable.set(domain, {
|
|
1254
|
+
static: resolveStaticRoute(option.static, cleanUrls),
|
|
1255
|
+
cleanUrls,
|
|
1256
|
+
})
|
|
1257
|
+
debugLog('proxies', `Route: ${domain} → static ${typeof option.static === 'string' ? option.static : option.static.dir}`, verbose)
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
|
|
1261
|
+
routingTable.set(domain, {
|
|
1262
|
+
sourceHost: fromUrl.host,
|
|
1263
|
+
cleanUrls,
|
|
1264
|
+
changeOrigin: option.changeOrigin || false,
|
|
1265
|
+
pathRewrites: option.pathRewrites,
|
|
1266
|
+
})
|
|
1267
|
+
debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
|
|
1268
|
+
}
|
|
1230
1269
|
|
|
1231
|
-
// Ensure hosts file entries exist for non-localhost domains
|
|
1232
|
-
|
|
1270
|
+
// Ensure hosts file entries exist for non-localhost domains. A wildcard
|
|
1271
|
+
// domain (`*.example.com`) has no single hosts entry — skip it. Skipped
|
|
1272
|
+
// entirely when hosts management is disabled (real-server mode).
|
|
1273
|
+
if (hostsEnabled && !isWildcardPattern(domain) && !domain.includes('localhost') && !domain.includes('127.0.0.1')) {
|
|
1233
1274
|
try {
|
|
1234
1275
|
const hostsExist = await checkHosts([domain], verbose)
|
|
1235
1276
|
if (!hostsExist[0]) {
|
|
@@ -1259,6 +1300,9 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1259
1300
|
return
|
|
1260
1301
|
}
|
|
1261
1302
|
|
|
1303
|
+
const sharedFetchHandler = createProxyFetchHandler(host => matchHost(routingTable, host), verbose)
|
|
1304
|
+
const sharedWsHandler = createProxyWebSocketHandler(verbose)
|
|
1305
|
+
|
|
1262
1306
|
try {
|
|
1263
1307
|
const bunServer = Bun.serve({
|
|
1264
1308
|
port: listenPort,
|
|
@@ -1270,7 +1314,10 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1270
1314
|
requestCert: false,
|
|
1271
1315
|
rejectUnauthorized: false,
|
|
1272
1316
|
},
|
|
1273
|
-
fetch:
|
|
1317
|
+
fetch(req: Request, server: unknown) {
|
|
1318
|
+
return sharedFetchHandler(req, server as ProxyServerLike)
|
|
1319
|
+
},
|
|
1320
|
+
websocket: sharedWsHandler,
|
|
1274
1321
|
error(err: Error) {
|
|
1275
1322
|
debugLog('server', `Shared proxy server error: ${err}`, verbose)
|
|
1276
1323
|
return new Response(`Server Error: ${err.message}`, { status: 500 })
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-file serving for proxy routes.
|
|
3
|
+
*
|
|
4
|
+
* A route configured with `static` serves files from a local directory instead
|
|
5
|
+
* of forwarding to an upstream. Path resolution is split into a pure function
|
|
6
|
+
* (`resolveStaticFile`) so it's trivially unit-testable, and a thin `Bun.file`
|
|
7
|
+
* wrapper (`serveStaticFile`) that does the actual I/O.
|
|
8
|
+
*/
|
|
9
|
+
import type { PathRewriteStyle, StaticRouteConfig } from './types'
|
|
10
|
+
import * as path from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** Normalized static-route config (shorthand string already expanded). */
|
|
13
|
+
export interface ResolvedStaticRoute {
|
|
14
|
+
dir: string
|
|
15
|
+
spa: boolean
|
|
16
|
+
pathRewriteStyle: PathRewriteStyle
|
|
17
|
+
maxAge: number
|
|
18
|
+
cleanUrls: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveStaticRoute(
|
|
22
|
+
cfg: string | StaticRouteConfig,
|
|
23
|
+
cleanUrls: boolean,
|
|
24
|
+
): ResolvedStaticRoute {
|
|
25
|
+
if (typeof cfg === 'string')
|
|
26
|
+
return { dir: cfg, spa: false, pathRewriteStyle: 'directory', maxAge: 0, cleanUrls }
|
|
27
|
+
return {
|
|
28
|
+
dir: cfg.dir,
|
|
29
|
+
spa: cfg.spa ?? false,
|
|
30
|
+
pathRewriteStyle: cfg.pathRewriteStyle ?? 'directory',
|
|
31
|
+
maxAge: cfg.maxAge ?? 0,
|
|
32
|
+
cleanUrls,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A minimal extension → MIME map covering the common web asset types. */
|
|
37
|
+
const MIME_TYPES: Record<string, string> = {
|
|
38
|
+
'.html': 'text/html; charset=utf-8',
|
|
39
|
+
'.htm': 'text/html; charset=utf-8',
|
|
40
|
+
'.css': 'text/css; charset=utf-8',
|
|
41
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
42
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
43
|
+
'.json': 'application/json; charset=utf-8',
|
|
44
|
+
'.map': 'application/json; charset=utf-8',
|
|
45
|
+
'.svg': 'image/svg+xml',
|
|
46
|
+
'.png': 'image/png',
|
|
47
|
+
'.jpg': 'image/jpeg',
|
|
48
|
+
'.jpeg': 'image/jpeg',
|
|
49
|
+
'.gif': 'image/gif',
|
|
50
|
+
'.webp': 'image/webp',
|
|
51
|
+
'.avif': 'image/avif',
|
|
52
|
+
'.ico': 'image/x-icon',
|
|
53
|
+
'.woff': 'font/woff',
|
|
54
|
+
'.woff2': 'font/woff2',
|
|
55
|
+
'.ttf': 'font/ttf',
|
|
56
|
+
'.otf': 'font/otf',
|
|
57
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
58
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
59
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
60
|
+
'.pdf': 'application/pdf',
|
|
61
|
+
'.wasm': 'application/wasm',
|
|
62
|
+
'.mp4': 'video/mp4',
|
|
63
|
+
'.webm': 'video/webm',
|
|
64
|
+
'.mp3': 'audio/mpeg',
|
|
65
|
+
'.wav': 'audio/wav',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function contentTypeFor(filePath: string): string {
|
|
69
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
70
|
+
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decode + normalize a URL pathname into a safe relative path.
|
|
75
|
+
*
|
|
76
|
+
* Traversal safety: normalizing against a leading `/` collapses every `..`
|
|
77
|
+
* segment and clamps at the root, so the returned relative path never contains
|
|
78
|
+
* `..` and `path.join(root, rel)` can't escape `root`. Backslash, NUL and
|
|
79
|
+
* malformed percent-encoding are rejected outright (return `null`); the
|
|
80
|
+
* residual `..` guard is belt-and-suspenders.
|
|
81
|
+
*/
|
|
82
|
+
export function safeRelativePath(pathname: string): string | null {
|
|
83
|
+
let decoded: string
|
|
84
|
+
try {
|
|
85
|
+
decoded = decodeURIComponent(pathname)
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
// Reject NUL and backslash (Windows-style) escapes outright.
|
|
91
|
+
if (decoded.includes('\0') || decoded.includes('\\'))
|
|
92
|
+
return null
|
|
93
|
+
|
|
94
|
+
// `path.posix.normalize` collapses `..`/`.`; a leading `/` keeps it rooted so
|
|
95
|
+
// a normalized result that still contains `..` means traversal above root.
|
|
96
|
+
const normalized = path.posix.normalize(`/${decoded}`)
|
|
97
|
+
if (normalized.includes('..'))
|
|
98
|
+
return null
|
|
99
|
+
// Strip the leading slash to get a path relative to the static root.
|
|
100
|
+
return normalized.replace(/^\/+/, '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface StaticResolution {
|
|
104
|
+
/** Absolute file path to attempt to serve. */
|
|
105
|
+
filePath: string
|
|
106
|
+
/** When set, the request should 301-redirect to this clean URL. */
|
|
107
|
+
redirectTo?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pure resolution of an incoming request pathname to a candidate file path on
|
|
112
|
+
* disk. Does no I/O; the caller checks existence and may fall back (SPA).
|
|
113
|
+
*
|
|
114
|
+
* Rules:
|
|
115
|
+
* - A trailing `/` (or root) resolves to `index.html` in that directory.
|
|
116
|
+
* - `cleanUrls` + a `.html` request → 301 to the extensionless URL.
|
|
117
|
+
* - Extensionless paths resolve per `pathRewriteStyle`:
|
|
118
|
+
* - `directory`: `/about` → `about/index.html`
|
|
119
|
+
* - `flat`: `/about` → `about.html`
|
|
120
|
+
* - Paths with a real extension (`.css`, `.png`, …) map straight through.
|
|
121
|
+
*
|
|
122
|
+
* Returns `null` when the path is unsafe (traversal attempt).
|
|
123
|
+
*/
|
|
124
|
+
export function resolveStaticFile(
|
|
125
|
+
pathname: string,
|
|
126
|
+
route: ResolvedStaticRoute,
|
|
127
|
+
): StaticResolution | null {
|
|
128
|
+
const rel = safeRelativePath(pathname)
|
|
129
|
+
if (rel === null)
|
|
130
|
+
return null
|
|
131
|
+
|
|
132
|
+
const ext = path.posix.extname(rel)
|
|
133
|
+
|
|
134
|
+
// `cleanUrls`: redirect explicit `.html` requests to the clean URL.
|
|
135
|
+
if (route.cleanUrls && ext === '.html') {
|
|
136
|
+
const clean = pathname.replace(/\/index\.html$/i, '/').replace(/\.html$/i, '')
|
|
137
|
+
return { filePath: path.join(route.dir, rel), redirectTo: clean || '/' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Directory or root request → index.html.
|
|
141
|
+
if (rel === '' || pathname.endsWith('/'))
|
|
142
|
+
return { filePath: path.join(route.dir, rel, 'index.html') }
|
|
143
|
+
|
|
144
|
+
// Asset with a concrete extension → serve directly.
|
|
145
|
+
if (ext !== '')
|
|
146
|
+
return { filePath: path.join(route.dir, rel) }
|
|
147
|
+
|
|
148
|
+
// Extensionless route → resolve by SSG style.
|
|
149
|
+
if (route.pathRewriteStyle === 'flat')
|
|
150
|
+
return { filePath: path.join(route.dir, `${rel}.html`) }
|
|
151
|
+
return { filePath: path.join(route.dir, rel, 'index.html') }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Serve a static file for the matched route. Returns a 301 for clean-URL
|
|
156
|
+
* redirects, the file with the right `Content-Type`/`Cache-Control` when it
|
|
157
|
+
* exists, the SPA `index.html` fallback when configured, or 404.
|
|
158
|
+
*/
|
|
159
|
+
export async function serveStaticFile(
|
|
160
|
+
pathname: string,
|
|
161
|
+
route: ResolvedStaticRoute,
|
|
162
|
+
): Promise<Response> {
|
|
163
|
+
const resolution = resolveStaticFile(pathname, route)
|
|
164
|
+
if (!resolution)
|
|
165
|
+
return new Response('Forbidden', { status: 403 })
|
|
166
|
+
|
|
167
|
+
if (resolution.redirectTo)
|
|
168
|
+
return new Response(null, { status: 301, headers: { Location: resolution.redirectTo } })
|
|
169
|
+
|
|
170
|
+
const cacheControl = route.maxAge > 0
|
|
171
|
+
? `public, max-age=${route.maxAge}`
|
|
172
|
+
: 'no-cache'
|
|
173
|
+
|
|
174
|
+
const file = Bun.file(resolution.filePath)
|
|
175
|
+
if (await file.exists()) {
|
|
176
|
+
return new Response(file, {
|
|
177
|
+
status: 200,
|
|
178
|
+
headers: {
|
|
179
|
+
'Content-Type': contentTypeFor(resolution.filePath),
|
|
180
|
+
'Cache-Control': cacheControl,
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// SPA fallback: serve the root index.html so client-side routing works.
|
|
186
|
+
if (route.spa) {
|
|
187
|
+
const indexPath = path.join(route.dir, 'index.html')
|
|
188
|
+
const index = Bun.file(indexPath)
|
|
189
|
+
if (await index.exists()) {
|
|
190
|
+
return new Response(index, {
|
|
191
|
+
status: 200,
|
|
192
|
+
headers: {
|
|
193
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
194
|
+
'Cache-Control': 'no-cache',
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return new Response('Not Found', { status: 404 })
|
|
201
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -24,11 +24,47 @@ export interface PathRewrite {
|
|
|
24
24
|
stripPrefix?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How a static-file route maps request paths to files on disk.
|
|
29
|
+
*
|
|
30
|
+
* - `directory` (default): `/about` → `<root>/about/index.html` (SSG dir style).
|
|
31
|
+
* - `flat`: `/about` → `<root>/about.html` (flat-file style).
|
|
32
|
+
*/
|
|
33
|
+
export type PathRewriteStyle = 'directory' | 'flat'
|
|
34
|
+
|
|
35
|
+
export interface StaticRouteConfig {
|
|
36
|
+
/** Absolute path to the directory served for this route. */
|
|
37
|
+
dir: string
|
|
38
|
+
/**
|
|
39
|
+
* Single-page-app fallback: serve `index.html` for any path that doesn't
|
|
40
|
+
* resolve to a real file (so client-side routing works). Default: `false`.
|
|
41
|
+
*/
|
|
42
|
+
spa?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* Extensionless-URL resolution style for `.html` files. Default: `directory`.
|
|
45
|
+
*/
|
|
46
|
+
pathRewriteStyle?: PathRewriteStyle
|
|
47
|
+
/**
|
|
48
|
+
* `Cache-Control` max-age (seconds) for served files. Default: `0`.
|
|
49
|
+
*/
|
|
50
|
+
maxAge?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
export interface BaseProxyConfig {
|
|
28
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Upstream `host:port` to forward to (e.g. `localhost:5173`). Optional when
|
|
56
|
+
* `static` is set (the route serves files from disk instead of proxying).
|
|
57
|
+
*/
|
|
58
|
+
from?: string // localhost:5173
|
|
29
59
|
to: string // stacks.localhost
|
|
30
60
|
start?: StartOptions
|
|
31
61
|
pathRewrites?: PathRewrite[]
|
|
62
|
+
/**
|
|
63
|
+
* Serve a local directory for this route instead of proxying to `from`.
|
|
64
|
+
* Provide an absolute directory path (shorthand) or a {@link StaticRouteConfig}.
|
|
65
|
+
* When set, `from` is optional; exactly one of `from`/`static` must be present.
|
|
66
|
+
*/
|
|
67
|
+
static?: string | StaticRouteConfig
|
|
32
68
|
/**
|
|
33
69
|
* Stable id used when registering this proxy with the rpx daemon. Derived
|
|
34
70
|
* from `to` if omitted. Must match `/^[a-zA-Z0-9._-]+$/` and be ≤128 chars.
|
|
@@ -48,6 +84,35 @@ export interface CleanupConfig {
|
|
|
48
84
|
|
|
49
85
|
export type CleanupOptions = Partial<CleanupConfig>
|
|
50
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A real PEM cert+key pair on disk for one SNI server name.
|
|
89
|
+
*/
|
|
90
|
+
export interface DomainCert {
|
|
91
|
+
/** Absolute path to the PEM certificate (fullchain). */
|
|
92
|
+
certPath: string
|
|
93
|
+
/** Absolute path to the PEM private key. */
|
|
94
|
+
keyPath: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Production TLS using real certs (e.g. Let's Encrypt) served per-domain via
|
|
99
|
+
* SNI on a single listener. Provide either an explicit `domains` map or a
|
|
100
|
+
* `certsDir` convention.
|
|
101
|
+
*/
|
|
102
|
+
export interface ProductionTlsConfig {
|
|
103
|
+
/**
|
|
104
|
+
* Explicit per-domain cert/key files keyed by SNI server name. Use
|
|
105
|
+
* `*.example.com` for a wildcard server name.
|
|
106
|
+
*/
|
|
107
|
+
domains?: Record<string, DomainCert>
|
|
108
|
+
/**
|
|
109
|
+
* Directory of PEM files following the convention `<domain>.crt` /
|
|
110
|
+
* `<domain>.key`. A wildcard pair `_wildcard.<apex>.crt` /
|
|
111
|
+
* `_wildcard.<apex>.key` is registered under server name `*.<apex>`.
|
|
112
|
+
*/
|
|
113
|
+
certsDir?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
51
116
|
export interface SharedProxyConfig {
|
|
52
117
|
https: boolean | TlsOption
|
|
53
118
|
cleanup: boolean | CleanupOptions
|
|
@@ -64,6 +129,19 @@ export interface SharedProxyConfig {
|
|
|
64
129
|
* `:443` (Valet-style). Default: `false` for backward compatibility.
|
|
65
130
|
*/
|
|
66
131
|
viaDaemon?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* Master switch for all `/etc/hosts` reads/writes. Set to `false` on a real
|
|
134
|
+
* server with real DNS so rpx never touches `/etc/hosts`. When omitted, the
|
|
135
|
+
* legacy behavior applies (driven by `cleanup.hosts`). `cleanup: { hosts:
|
|
136
|
+
* false }` also disables hosts management.
|
|
137
|
+
*/
|
|
138
|
+
hostsManagement?: boolean
|
|
139
|
+
/**
|
|
140
|
+
* Production per-domain SNI certs (Let's Encrypt PEMs already on disk). When
|
|
141
|
+
* provided, the listener serves a different real cert per SNI server name
|
|
142
|
+
* instead of the dev self-signed shared cert.
|
|
143
|
+
*/
|
|
144
|
+
productionCerts?: ProductionTlsConfig
|
|
67
145
|
}
|
|
68
146
|
|
|
69
147
|
export type SharedProxyOptions = Partial<SharedProxyConfig>
|
package/dist/chunk-1j4gp3f8.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{$ as j,S as a,T as b,U as c,V as d,W as e,X as f,Y as g,Z as h,_ as i,aa as k,ba as l,ca as m,da as n}from"./chunk-9dndasyk.js";import"./chunk-zs1tyy8z.js";export{l as tearDownDevelopmentDns,k as syncDevelopmentDnsFromRegistry,d as stopDnsServer,c as startDnsServer,h as setupResolver,j as setupDevelopmentDns,f as resolverFilePath,m as removeResolver,i as removeLegacyTldResolvers,n as reconcileStaleDevelopmentDns,e as isDnsServerRunning,g as contentLooksLikeRpxResolver,b as RPX_RESOLVER_MARKER,a as DNS_PORT};
|