@stacksjs/rpx 0.11.5 → 0.11.8

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,346 @@
1
+ /**
2
+ * Registry of currently-active rpx proxies.
3
+ *
4
+ * Each running upstream (e.g. a `./buddy dev` invocation) writes a small JSON
5
+ * file into `~/.stacks/rpx/registry.d/<id>.json` describing where to forward
6
+ * traffic. The rpx daemon watches this directory and rebuilds its routing
7
+ * table whenever entries appear, change, or disappear.
8
+ *
9
+ * Design choices worth knowing about:
10
+ * - One file per entry → no shared-file locking, no merge conflicts.
11
+ * - Atomic write via temp file + rename → readers never see partial JSON.
12
+ * - Each entry carries the writer's PID so the daemon can GC files left
13
+ * behind by a writer that was killed -9.
14
+ * - `id` is validated against a strict charset to keep it from escaping
15
+ * the registry directory.
16
+ */
17
+ import type { PathRewrite } from './types'
18
+ import * as fs from 'node:fs'
19
+ import * as fsp from 'node:fs/promises'
20
+ import { homedir } from 'node:os'
21
+ import * as path from 'node:path'
22
+ import * as process from 'node:process'
23
+ import { debugLog } from './utils'
24
+
25
+ export interface RegistryEntry {
26
+ id: string
27
+ from: string
28
+ to: string
29
+ /**
30
+ * Optional. PID of the long-running process that owns this entry. When set,
31
+ * the daemon's PID-GC reaps the entry the moment that process dies. Omit
32
+ * (or set to `undefined`) for manually-managed entries created via
33
+ * `rpx register` — those persist until explicit `rpx unregister`.
34
+ */
35
+ pid?: number
36
+ cwd?: string
37
+ createdAt: string
38
+ pathRewrites?: PathRewrite[]
39
+ cleanUrls?: boolean
40
+ changeOrigin?: boolean
41
+ }
42
+
43
+ const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
44
+
45
+ /**
46
+ * Default location for the registry directory. The daemon's PID file and log
47
+ * sit alongside it under `~/.stacks/rpx/`.
48
+ */
49
+ export function getRegistryDir(): string {
50
+ return path.join(homedir(), '.stacks', 'rpx', 'registry.d')
51
+ }
52
+
53
+ /**
54
+ * Validate an entry id. Rejects anything that could escape the registry dir
55
+ * (path traversal, slashes) or that would round-trip oddly through a filename.
56
+ */
57
+ export function isValidId(id: string): boolean {
58
+ return typeof id === 'string' && id.length > 0 && id.length <= 128 && ID_PATTERN.test(id)
59
+ }
60
+
61
+ function entryPath(dir: string, id: string): string {
62
+ if (!isValidId(id))
63
+ throw new Error(`invalid registry id: ${JSON.stringify(id)}`)
64
+ return path.join(dir, `${id}.json`)
65
+ }
66
+
67
+ /**
68
+ * Check whether a PID is alive. `kill(pid, 0)` returns without sending a
69
+ * signal but throws ESRCH if the process is gone — exactly the probe we need.
70
+ * EPERM means the process exists but we don't own it; treat as alive.
71
+ */
72
+ export function isPidAlive(pid: number): boolean {
73
+ if (!Number.isInteger(pid) || pid <= 0)
74
+ return false
75
+ try {
76
+ process.kill(pid, 0)
77
+ return true
78
+ }
79
+ catch (err) {
80
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
81
+ }
82
+ }
83
+
84
+ function isValidEntry(value: unknown): value is RegistryEntry {
85
+ if (!value || typeof value !== 'object')
86
+ return false
87
+ const e = value as Partial<RegistryEntry>
88
+ // pid is optional. When present it must be a positive integer; when absent
89
+ // (manual entries from `rpx register`) the daemon's PID-GC skips it.
90
+ const pidOk = e.pid === undefined
91
+ || (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
92
+ return (
93
+ typeof e.id === 'string' && isValidId(e.id)
94
+ && typeof e.from === 'string' && e.from.length > 0
95
+ && typeof e.to === 'string' && e.to.length > 0
96
+ && pidOk
97
+ && typeof e.createdAt === 'string'
98
+ )
99
+ }
100
+
101
+ async function ensureDir(dir: string): Promise<void> {
102
+ await fsp.mkdir(dir, { recursive: true })
103
+ }
104
+
105
+ /**
106
+ * Atomically write an entry to disk.
107
+ *
108
+ * Writes to a temp file in the same directory, then renames into place. POSIX
109
+ * rename within the same filesystem is atomic, so a concurrent reader either
110
+ * sees the old file or the new file — never a half-written one.
111
+ */
112
+ export async function writeEntry(entry: RegistryEntry, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
113
+ if (!isValidEntry(entry))
114
+ throw new Error(`invalid registry entry: ${JSON.stringify(entry)}`)
115
+
116
+ await ensureDir(dir)
117
+ const finalPath = entryPath(dir, entry.id)
118
+ const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`
119
+ const json = JSON.stringify(entry, null, 2)
120
+
121
+ try {
122
+ await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o644 })
123
+ await fsp.rename(tmpPath, finalPath)
124
+ debugLog('registry', `wrote entry ${entry.id} → ${finalPath}`, verbose)
125
+ }
126
+ catch (err) {
127
+ // Best-effort cleanup of the temp file if the rename never landed.
128
+ await fsp.unlink(tmpPath).catch(() => {})
129
+ throw err
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Remove an entry by id. No-op if the file is already gone.
135
+ */
136
+ export async function removeEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
137
+ const target = entryPath(dir, id)
138
+ try {
139
+ await fsp.unlink(target)
140
+ debugLog('registry', `removed entry ${id}`, verbose)
141
+ }
142
+ catch (err) {
143
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
144
+ throw err
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Read a single entry by id. Returns `null` if missing or malformed (malformed
150
+ * files are deleted so they don't keep poisoning subsequent reads).
151
+ */
152
+ export async function readEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry | null> {
153
+ const target = entryPath(dir, id)
154
+ try {
155
+ const raw = await fsp.readFile(target, 'utf8')
156
+ const parsed = JSON.parse(raw)
157
+ if (!isValidEntry(parsed)) {
158
+ debugLog('registry', `entry ${id} failed validation, removing`, verbose)
159
+ await fsp.unlink(target).catch(() => {})
160
+ return null
161
+ }
162
+ return parsed
163
+ }
164
+ catch (err) {
165
+ const code = (err as NodeJS.ErrnoException).code
166
+ if (code === 'ENOENT')
167
+ return null
168
+ if (err instanceof SyntaxError) {
169
+ debugLog('registry', `entry ${id} has invalid JSON, removing`, verbose)
170
+ await fsp.unlink(target).catch(() => {})
171
+ return null
172
+ }
173
+ throw err
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Read all entries from the registry directory. Malformed files are pruned.
179
+ * This does NOT GC stale PIDs — call `gcStaleEntries` for that explicitly.
180
+ */
181
+ export async function readAll(dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry[]> {
182
+ let names: string[]
183
+ try {
184
+ names = await fsp.readdir(dir)
185
+ }
186
+ catch (err) {
187
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT')
188
+ return []
189
+ throw err
190
+ }
191
+
192
+ const out: RegistryEntry[] = []
193
+ for (const name of names) {
194
+ if (!name.endsWith('.json'))
195
+ continue
196
+ const id = name.slice(0, -'.json'.length)
197
+ if (!isValidId(id))
198
+ continue
199
+ const entry = await readEntry(id, dir, verbose)
200
+ if (entry)
201
+ out.push(entry)
202
+ }
203
+ return out
204
+ }
205
+
206
+ /**
207
+ * Remove entries whose writer PID is no longer alive. Returns the count of
208
+ * entries removed. Safe to call repeatedly; intended to run on daemon startup
209
+ * and on a slow timer (e.g. every 5s) while the daemon is up.
210
+ */
211
+ export async function gcStaleEntries(dir: string = getRegistryDir(), verbose?: boolean): Promise<number> {
212
+ const entries = await readAll(dir, verbose)
213
+ let removed = 0
214
+ for (const entry of entries) {
215
+ // Manually-managed entries (no pid) opt out of PID-GC. The user is
216
+ // responsible for `rpx unregister` when they're done.
217
+ if (entry.pid === undefined)
218
+ continue
219
+ if (!isPidAlive(entry.pid)) {
220
+ debugLog('registry', `GC: pid ${entry.pid} for ${entry.id} is dead, removing`, verbose)
221
+ await removeEntry(entry.id, dir, verbose).catch(() => {})
222
+ removed++
223
+ }
224
+ }
225
+ return removed
226
+ }
227
+
228
+ export interface WatchHandle {
229
+ close: () => void
230
+ }
231
+
232
+ export interface WatchOptions {
233
+ debounceMs?: number
234
+ pollMs?: number
235
+ verbose?: boolean
236
+ }
237
+
238
+ /**
239
+ * Watch the registry directory and invoke `onChange` with the full current
240
+ * entry list whenever something changes. Events are debounced so a flurry of
241
+ * rapid writes (e.g. several `./buddy dev` invocations starting in parallel)
242
+ * triggers at most one rebuild.
243
+ *
244
+ * The watcher tolerates a missing directory at startup — it creates the dir
245
+ * before opening the watch, so the first `writeEntry` doesn't race the daemon.
246
+ */
247
+ export function watchRegistry(
248
+ onChange: (entries: RegistryEntry[]) => void | Promise<void>,
249
+ opts: WatchOptions & { dir?: string } = {},
250
+ ): WatchHandle {
251
+ const dir = opts.dir ?? getRegistryDir()
252
+ const debounceMs = opts.debounceMs ?? 100
253
+ const pollMs = opts.pollMs ?? Math.max(debounceMs * 2, 250)
254
+ const verbose = opts.verbose
255
+
256
+ // Create the dir up front so fs.watch has something to attach to.
257
+ fs.mkdirSync(dir, { recursive: true })
258
+
259
+ let pending: ReturnType<typeof setTimeout> | null = null
260
+ let closed = false
261
+ let lastSignature: string | null = null
262
+ let pollInFlight = false
263
+
264
+ const signatureFor = (entries: RegistryEntry[]): string => {
265
+ return JSON.stringify(
266
+ entries
267
+ .map(entry => ({
268
+ id: entry.id,
269
+ from: entry.from,
270
+ to: entry.to,
271
+ pid: entry.pid,
272
+ pathRewrites: entry.pathRewrites,
273
+ cleanUrls: entry.cleanUrls,
274
+ changeOrigin: entry.changeOrigin,
275
+ }))
276
+ .sort((a, b) => a.id.localeCompare(b.id)),
277
+ )
278
+ }
279
+
280
+ const fire = () => {
281
+ pending = null
282
+ if (closed)
283
+ return
284
+ readAll(dir, verbose)
285
+ .then((entries) => {
286
+ lastSignature = signatureFor(entries)
287
+ return onChange(entries)
288
+ })
289
+ .catch((err) => {
290
+ debugLog('registry', `watcher onChange failed: ${err}`, verbose)
291
+ })
292
+ }
293
+
294
+ const schedule = () => {
295
+ if (closed)
296
+ return
297
+ if (pending)
298
+ clearTimeout(pending)
299
+ pending = setTimeout(fire, debounceMs)
300
+ }
301
+
302
+ const poll = () => {
303
+ if (closed || pollInFlight)
304
+ return
305
+
306
+ pollInFlight = true
307
+ readAll(dir, verbose)
308
+ .then((entries) => {
309
+ const signature = signatureFor(entries)
310
+ if (signature !== lastSignature)
311
+ schedule()
312
+ })
313
+ .catch((err) => {
314
+ debugLog('registry', `watcher poll failed: ${err}`, verbose)
315
+ })
316
+ .finally(() => {
317
+ pollInFlight = false
318
+ })
319
+ }
320
+
321
+ const pollInterval = setInterval(poll, pollMs)
322
+
323
+ const watcher = fs.watch(dir, { persistent: true }, (_eventType, filename) => {
324
+ // Ignore temp files from our own atomic-write protocol.
325
+ if (filename && /\.tmp\.\d+\.\d+$/.test(filename))
326
+ return
327
+ schedule()
328
+ })
329
+
330
+ watcher.on('error', (err) => {
331
+ debugLog('registry', `watcher error: ${err}`, verbose)
332
+ })
333
+
334
+ // Fire once on startup so the daemon picks up entries that already exist.
335
+ schedule()
336
+
337
+ return {
338
+ close: () => {
339
+ closed = true
340
+ if (pending)
341
+ clearTimeout(pending)
342
+ clearInterval(pollInterval)
343
+ watcher.close()
344
+ },
345
+ }
346
+ }
package/src/start.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable no-console */
2
2
  import type { IncomingHttpHeaders, SecureServerOptions } from 'node:http2'
3
3
  import type { ServerOptions } from 'node:https'
4
- import type { BaseProxyConfig, CleanupOptions, PathRewrite, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
4
+ import type { BaseProxyConfig, CleanupOptions, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
5
5
  import { exec, execSync } from 'node:child_process'
6
6
  import * as fs from 'node:fs'
7
7
  import * as http from 'node:http'
@@ -14,15 +14,18 @@ import * as process from 'node:process'
14
14
  import * as tls from 'node:tls'
15
15
  import { log } from './logger'
16
16
  import { colors } from './colors'
17
- import { version } from '../package.json'
18
17
  import { config } from './config'
18
+ import { runViaDaemon } from './daemon-runner'
19
19
  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 { debugLog, getSudoPassword, resolvePathRewrite } from './utils'
23
+ import { createProxyFetchHandler } from './proxy-handler'
24
+ import type { ProxyRoute } from './proxy-handler'
25
+ import { debugLog, getSudoPassword, safeStringify } from './utils'
24
26
 
25
27
  const processManager = new ProcessManager()
28
+ const version = '0.12.0'
26
29
  // Create a global port manager for coordinating port usage
27
30
  const globalPortManager = new DefaultPortManager('0.0.0.0')
28
31
 
@@ -295,7 +298,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
295
298
  }
296
299
 
297
300
  export async function startServer(options: SingleProxyConfig): Promise<void> {
298
- debugLog('server', `Starting server with options: ${JSON.stringify(options)}`, options.verbose)
301
+ debugLog('server', `Starting server with options: ${safeStringify(options)}`, options.verbose)
299
302
 
300
303
  // Parse URLs early to get the hostnames
301
304
  const fromUrl = new URL((options.from?.startsWith('http') ? options.from : `http://${options.from}`) || 'localhost:5173')
@@ -487,7 +490,7 @@ async function createProxyServer(
487
490
  headers: normalizedHeaders,
488
491
  }
489
492
 
490
- debugLog('request', `Proxy request options: ${JSON.stringify(proxyOptions)}`, verbose)
493
+ debugLog('request', `Proxy request options: ${safeStringify(proxyOptions)}`, verbose)
491
494
 
492
495
  const proxyReq = http.request(proxyOptions, (proxyRes) => {
493
496
  debugLog('response', `Proxy response received with status ${proxyRes.statusCode}`, verbose)
@@ -697,7 +700,7 @@ async function createProxyServer(
697
700
  }
698
701
 
699
702
  export async function setupProxy(options: ProxySetupOptions): Promise<void> {
700
- debugLog('setup', `Setting up reverse proxy: ${JSON.stringify(options)}`, options.verbose)
703
+ debugLog('setup', `Setting up reverse proxy: ${safeStringify(options)}`, options.verbose)
701
704
 
702
705
  const { from, to, fromPort, sourceUrl, ssl, verbose, cleanup: cleanupOptions, vitePluginUsage, changeOrigin, cleanUrls } = options
703
706
  const httpPort = 80
@@ -827,7 +830,32 @@ export function startProxy(options: ProxyOption): void {
827
830
  ...options,
828
831
  }
829
832
 
830
- debugLog('proxy', `Starting proxy with options: ${JSON.stringify(mergedOptions)}`, mergedOptions?.verbose)
833
+ debugLog('proxy', `Starting proxy with options: ${safeStringify(mergedOptions)}`, mergedOptions?.verbose)
834
+
835
+ // viaDaemon: register with the long-running daemon instead of binding our
836
+ // own :443. The daemon owns TLS termination and host-header routing for
837
+ // every concurrent `rpx start` on this machine.
838
+ if (mergedOptions.viaDaemon) {
839
+ if (!mergedOptions.from || !mergedOptions.to) {
840
+ log.error('viaDaemon mode requires both `from` and `to`')
841
+ return
842
+ }
843
+ runViaDaemon({
844
+ proxies: [{
845
+ id: mergedOptions.id,
846
+ from: mergedOptions.from,
847
+ to: mergedOptions.to,
848
+ cleanUrls: mergedOptions.cleanUrls,
849
+ changeOrigin: mergedOptions.changeOrigin,
850
+ pathRewrites: mergedOptions.pathRewrites,
851
+ }],
852
+ verbose: mergedOptions.verbose,
853
+ }).catch((err) => {
854
+ log.error(`Failed to register with rpx daemon: ${err.message}`)
855
+ process.exit(1)
856
+ })
857
+ return
858
+ }
831
859
 
832
860
  // Start DNS server for custom domains on macOS (any domain that's not localhost/127.0.0.1)
833
861
  const targetDomain = mergedOptions.to || ''
@@ -884,7 +912,7 @@ export function startProxy(options: ProxyOption): void {
884
912
  regenerateUntrustedCerts: mergedOptions.regenerateUntrustedCerts,
885
913
  }
886
914
 
887
- debugLog('proxy', `Server options: ${JSON.stringify(serverOptions)}`, mergedOptions.verbose)
915
+ debugLog('proxy', `Server options: ${safeStringify(serverOptions)}`, mergedOptions.verbose)
888
916
 
889
917
  startServer(serverOptions).catch((err) => {
890
918
  debugLog('proxy', `Failed to start proxy: ${err}`, mergedOptions.verbose)
@@ -918,7 +946,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
918
946
  verbose: false,
919
947
  cleanUrls: false,
920
948
  changeOrigin: false,
921
- regenerateUntrustedCerts: false,
949
+ regenerateUntrustedCerts: true,
922
950
  } as any
923
951
 
924
952
  if (options) {
@@ -929,9 +957,35 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
929
957
  }
930
958
 
931
959
  const verbose = getVerbose(mergedOptions)
932
- debugLog('config', `Starting with config: ${JSON.stringify(mergedOptions, null, 2)}`, verbose)
960
+ debugLog('config', `Starting with config: ${safeStringify(mergedOptions, 2)}`, verbose)
933
961
  debugLog('config', `Is multi-proxy? ${'proxies' in mergedOptions}`, verbose)
934
962
 
963
+ // viaDaemon mode short-circuits before any port binding / cert work — the
964
+ // daemon owns all of that. We only need to register entries and block.
965
+ if (mergedOptions.viaDaemon) {
966
+ const isMulti = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
967
+ const proxies = isMulti
968
+ ? (mergedOptions.proxies as Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean }>)
969
+ .map(p => ({
970
+ id: p.id,
971
+ from: p.from,
972
+ to: p.to,
973
+ cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls,
974
+ changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin,
975
+ pathRewrites: p.pathRewrites,
976
+ }))
977
+ : [{
978
+ id: mergedOptions.id,
979
+ from: mergedOptions.from,
980
+ to: mergedOptions.to,
981
+ cleanUrls: mergedOptions.cleanUrls,
982
+ changeOrigin: mergedOptions.changeOrigin,
983
+ pathRewrites: mergedOptions.pathRewrites,
984
+ }]
985
+ await runViaDaemon({ proxies, verbose })
986
+ return
987
+ }
988
+
935
989
  // Start dev servers first if configured
936
990
  if ('proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)) {
937
991
  debugLog('servers', `Found ${mergedOptions.proxies.length} proxies in config`, verbose)
@@ -1161,16 +1215,13 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1161
1215
  if (sslConfig && proxyOptions.length > 1) {
1162
1216
  debugLog('proxies', `Creating shared HTTPS server for ${proxyOptions.length} domains`, verbose)
1163
1217
 
1164
- // Build routing table: domain → { fromPort, sourceHost, cleanUrls, changeOrigin, pathRewrites }
1165
- const routingTable = new Map<string, { fromPort: number, sourceHost: string, cleanUrls: boolean, changeOrigin: boolean, pathRewrites?: PathRewrite[] }>()
1218
+ const routingTable = new Map<string, ProxyRoute>()
1166
1219
 
1167
1220
  for (const option of proxyOptions) {
1168
1221
  const domain = option.to || 'rpx.localhost'
1169
1222
  const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
1170
- const fromPort = Number.parseInt(fromUrl.port) || 80
1171
1223
 
1172
1224
  routingTable.set(domain, {
1173
- fromPort,
1174
1225
  sourceHost: fromUrl.host,
1175
1226
  cleanUrls: option.cleanUrls || false,
1176
1227
  changeOrigin: option.changeOrigin || false,
@@ -1221,72 +1272,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1221
1272
  requestCert: false,
1222
1273
  rejectUnauthorized: false,
1223
1274
  },
1224
- async fetch(req: Request) {
1225
- const url = new URL(req.url)
1226
- const hostHeader = req.headers.get('host') || ''
1227
- // Strip port from Host header (e.g., "stacks.localhost:443" → "stacks.localhost")
1228
- const hostname = hostHeader.split(':')[0]
1229
-
1230
- const route = routingTable.get(hostname)
1231
- if (!route) {
1232
- debugLog('request', `No route found for host: ${hostname}`, verbose)
1233
- return new Response(`No proxy configured for ${hostname}`, { status: 404 })
1234
- }
1235
-
1236
- let targetHost = route.sourceHost
1237
- let targetPath = url.pathname
1238
-
1239
- // Check path rewrites — route specific path prefixes to different backends.
1240
- // By default the prefix is preserved (matches Vite/nginx/http-proxy-middleware
1241
- // semantics); set `stripPrefix: true` per-rewrite to strip.
1242
- const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
1243
- if (rewriteMatch) {
1244
- targetHost = rewriteMatch.targetHost
1245
- targetPath = rewriteMatch.targetPath
1246
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
1247
- }
1248
-
1249
- const targetUrl = `http://${targetHost}${targetPath}${url.search}`
1250
-
1251
- try {
1252
- const headers = new Headers(req.headers)
1253
- headers.set('host', targetHost)
1254
- if (route.changeOrigin) {
1255
- headers.set('origin', `http://${route.sourceHost}`)
1256
- }
1257
- headers.set('x-forwarded-for', '127.0.0.1')
1258
- headers.set('x-forwarded-proto', 'https')
1259
- headers.set('x-forwarded-host', hostname)
1260
-
1261
- const response = await fetch(targetUrl, {
1262
- method: req.method,
1263
- headers,
1264
- body: req.body,
1265
- redirect: 'manual',
1266
- })
1267
-
1268
- const responseHeaders = new Headers(response.headers)
1269
-
1270
- // Handle clean URLs redirect
1271
- if (route.cleanUrls && url.pathname.endsWith('.html')) {
1272
- const cleanPath = url.pathname.replace(/\.html$/, '')
1273
- return new Response(null, {
1274
- status: 301,
1275
- headers: { Location: cleanPath },
1276
- })
1277
- }
1278
-
1279
- return new Response(response.body, {
1280
- status: response.status,
1281
- statusText: response.statusText,
1282
- headers: responseHeaders,
1283
- })
1284
- }
1285
- catch (err) {
1286
- debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
1287
- return new Response(`Proxy Error: ${err}`, { status: 502 })
1288
- }
1289
- },
1275
+ fetch: createProxyFetchHandler(host => routingTable.get(host), verbose),
1290
1276
  error(err: Error) {
1291
1277
  debugLog('server', `Shared proxy server error: ${err}`, verbose)
1292
1278
  return new Response(`Server Error: ${err.message}`, { status: 500 })
package/src/types.ts CHANGED
@@ -29,6 +29,11 @@ export interface BaseProxyConfig {
29
29
  to: string // stacks.localhost
30
30
  start?: StartOptions
31
31
  pathRewrites?: PathRewrite[]
32
+ /**
33
+ * Stable id used when registering this proxy with the rpx daemon. Derived
34
+ * from `to` if omitted. Must match `/^[a-zA-Z0-9._-]+$/` and be ≤128 chars.
35
+ */
36
+ id?: string
32
37
  }
33
38
 
34
39
  export type BaseProxyOptions = Partial<BaseProxyConfig>
@@ -53,6 +58,12 @@ export interface SharedProxyConfig {
53
58
  cleanUrls: boolean
54
59
  changeOrigin?: boolean // default: false - changes the origin of the host header to the target URL
55
60
  regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system.
61
+ /**
62
+ * Route this proxy through the long-running rpx daemon instead of binding
63
+ * its own :443. Lets multiple `rpx start` invocations coexist on shared
64
+ * `:443` (Valet-style). Default: `false` for backward compatibility.
65
+ */
66
+ viaDaemon?: boolean
56
67
  }
57
68
 
58
69
  export type SharedProxyOptions = Partial<SharedProxyConfig>
package/src/utils.ts CHANGED
@@ -36,6 +36,54 @@ export function debugLog(category: string, message: string, verbose?: boolean):
36
36
  logger.debug(`[rpx:${category}] ${message}`)
37
37
  }
38
38
 
39
+ const REDACTED = '[redacted]'
40
+ const SENSITIVE_KEYS = new Set([
41
+ 'certificate',
42
+ 'privatekey',
43
+ 'key',
44
+ 'cert',
45
+ 'ca',
46
+ 'rootca',
47
+ 'password',
48
+ 'sudo_password',
49
+ ])
50
+ const PEM_BLOCK_RE = /-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/
51
+
52
+ function isSensitiveKey(key: string): boolean {
53
+ const normalized = key.toLowerCase()
54
+ return SENSITIVE_KEYS.has(normalized)
55
+ || normalized.endsWith('password')
56
+ || normalized.includes('secret')
57
+ || normalized.includes('token')
58
+ }
59
+
60
+ export function redactSensitive(value: unknown): unknown {
61
+ if (Array.isArray(value))
62
+ return value.map(item => redactSensitive(item))
63
+
64
+ if (typeof value === 'string')
65
+ return PEM_BLOCK_RE.test(value) ? REDACTED : value
66
+
67
+ if (!value || typeof value !== 'object')
68
+ return value
69
+
70
+ const output: Record<string, unknown> = {}
71
+ for (const [key, nested] of Object.entries(value)) {
72
+ if (isSensitiveKey(key)) {
73
+ output[key] = REDACTED
74
+ continue
75
+ }
76
+
77
+ output[key] = redactSensitive(nested)
78
+ }
79
+
80
+ return output
81
+ }
82
+
83
+ export function safeStringify(value: unknown, space?: number): string {
84
+ return JSON.stringify(redactSensitive(value), null, space)
85
+ }
86
+
39
87
  /**
40
88
  * Extracts hostnames from proxy configuration
41
89
  */