@stacksjs/rpx 0.11.4 → 0.11.7

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/src/https.ts CHANGED
@@ -13,6 +13,50 @@ import { debugLog, execSudoSync, getPrimaryDomain, isMultiProxyConfig, isMultiPr
13
13
 
14
14
  let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null
15
15
 
16
+ // Canonical filenames for the shared Root CA. The CA is a singleton across all
17
+ // rpx-managed domains so that browsers only need to trust it once. Host certs
18
+ // for individual domains are issued from this CA on demand.
19
+ const ROOT_CA_CERT_FILENAME = 'rpx-root-ca.crt'
20
+ const ROOT_CA_KEY_FILENAME = 'rpx-root-ca.key'
21
+
22
+ export interface RootCAPaths {
23
+ caCertPath: string
24
+ caKeyPath: string
25
+ }
26
+
27
+ /**
28
+ * Returns the canonical Root CA cert + key paths inside `basePath`.
29
+ */
30
+ export function getRootCAPaths(basePath: string): RootCAPaths {
31
+ return {
32
+ caCertPath: join(basePath, ROOT_CA_CERT_FILENAME),
33
+ caKeyPath: join(basePath, ROOT_CA_KEY_FILENAME),
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Load a previously-persisted Root CA (cert + private key). Returns `null` if
39
+ * either file is missing or unreadable, signalling that a fresh CA needs to be
40
+ * created.
41
+ */
42
+ async function loadRootCA(paths: RootCAPaths, verbose?: boolean): Promise<{ certificate: string, privateKey: string } | null> {
43
+ try {
44
+ const [certificate, privateKey] = await Promise.all([
45
+ fs.readFile(paths.caCertPath, 'utf8'),
46
+ fs.readFile(paths.caKeyPath, 'utf8'),
47
+ ])
48
+ if (!certificate.includes('-----BEGIN CERTIFICATE-----') || !privateKey.includes('PRIVATE KEY-----')) {
49
+ debugLog('ssl', `Root CA files at ${paths.caCertPath} look malformed, will regenerate`, verbose)
50
+ return null
51
+ }
52
+ return { certificate, privateKey }
53
+ }
54
+ catch (err) {
55
+ debugLog('ssl', `No existing Root CA at ${paths.caCertPath} (${(err as NodeJS.ErrnoException).code || err}), will create one`, verbose)
56
+ return null
57
+ }
58
+ }
59
+
16
60
  /**
17
61
  * Resolves SSL paths based on configuration
18
62
  */
@@ -246,14 +290,7 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
246
290
  return true
247
291
  }
248
292
  catch {
249
- // If system keychain fails, try with the user's login keychain (no sudo needed)
250
- try {
251
- execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${certPath}"`)
252
- return true
253
- }
254
- catch {
255
- return false
256
- }
293
+ return false
257
294
  }
258
295
  }
259
296
  catch {
@@ -274,15 +311,37 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
274
311
 
275
312
  debugLog('ssl', `Generating certificate for domains: ${domains.join(', ')}`, options.verbose)
276
313
 
277
- // Generate Root CA first
278
- const rootCAConfig = httpsConfig(options, options.verbose)
279
-
280
- if (options.verbose)
281
- log.info('Generating Root CA certificate...')
282
- const caCert = await createRootCA(rootCAConfig)
283
-
284
- // Generate the host certificate with all domains
285
314
  const hostConfig = httpsConfig(options, options.verbose)
315
+ const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
316
+ await fs.mkdir(sslDir, { recursive: true })
317
+ const rootCAPaths = getRootCAPaths(sslDir)
318
+
319
+ // Reuse the persisted Root CA when present so the user only has to trust it
320
+ // once. A fresh CA gets created (and persisted) on first run.
321
+ let caCert = await loadRootCA(rootCAPaths, options.verbose)
322
+ let caIsNew = false
323
+ if (!caCert) {
324
+ if (options.verbose)
325
+ log.info('Generating Root CA certificate (one-time)...')
326
+ caCert = await createRootCA(hostConfig)
327
+ try {
328
+ await Promise.all([
329
+ fs.writeFile(rootCAPaths.caCertPath, caCert.certificate),
330
+ fs.writeFile(rootCAPaths.caKeyPath, caCert.privateKey, { mode: 0o600 }),
331
+ ])
332
+ caIsNew = true
333
+ debugLog('ssl', `Persisted Root CA at ${rootCAPaths.caCertPath}`, options.verbose)
334
+ }
335
+ catch (err) {
336
+ debugLog('ssl', `Error saving Root CA files: ${err}`, options.verbose)
337
+ throw new Error(`Failed to save Root CA files: ${err}`)
338
+ }
339
+ }
340
+ else {
341
+ debugLog('ssl', `Reusing existing Root CA from ${rootCAPaths.caCertPath}`, options.verbose)
342
+ }
343
+
344
+ // Issue the host cert with all SANs from the (possibly reused) CA.
286
345
  if (options.verbose)
287
346
  log.info(`Generating host certificate for: ${domains.join(', ')}`)
288
347
 
@@ -294,14 +353,9 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
294
353
  },
295
354
  })
296
355
 
297
- // Save the certificate files first before trying to trust them
298
- // This prevents multiple trust attempts when files don't exist
356
+ // Persist host cert + key. Also write a copy of the CA cert at the per-domain
357
+ // `caCertPath` for back-compat with anything that still reads from there.
299
358
  try {
300
- // Ensure the SSL directory exists
301
- const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
302
- await fs.mkdir(sslDir, { recursive: true })
303
-
304
- // Write certificate files
305
359
  await Promise.all([
306
360
  fs.writeFile(hostConfig.certPath, hostCert.certificate),
307
361
  fs.writeFile(hostConfig.keyPath, hostCert.privateKey),
@@ -315,15 +369,19 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
315
369
  throw new Error(`Failed to save certificate files: ${err}`)
316
370
  }
317
371
 
318
- // Check if certificate is already trusted before attempting to add it
319
- // This avoids unnecessary sudo prompts
320
- const alreadyTrusted = await isCertTrusted(hostConfig.certPath, { verbose: options.verbose, regenerateUntrustedCerts: true })
321
- if (alreadyTrusted) {
322
- debugLog('ssl', 'Certificate is already trusted, skipping trust store update', options.verbose)
372
+ // The CA is the trust anchor only it needs to live in the trust store.
373
+ // Skip the install step when the CA is already trusted (a freshly minted CA
374
+ // is never trusted yet; a reused one might still need a one-time install if
375
+ // someone wiped their keychain).
376
+ const caTrusted = caIsNew
377
+ ? false
378
+ : await isCertTrusted(rootCAPaths.caCertPath, { verbose: options.verbose, regenerateUntrustedCerts: true })
379
+
380
+ if (caTrusted) {
381
+ debugLog('ssl', 'Root CA already trusted, skipping trust store update', options.verbose)
323
382
  if (options.verbose)
324
- log.success('Certificate is already trusted in system trust store')
383
+ log.success('Root CA is already trusted in system trust store')
325
384
 
326
- // Cache the SSL config for reuse
327
385
  cachedSSLConfig = {
328
386
  key: hostCert.privateKey,
329
387
  cert: hostCert.certificate,
@@ -342,50 +400,34 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
342
400
 
343
401
  let isTrusted = false
344
402
 
345
- // We'll use a stronger approach to ensure the certificate is properly trusted
403
+ // We install only the Root CA host certs derive their trust from it.
346
404
  if (process.platform === 'darwin') {
347
405
  try {
348
- // For macOS, add both CA and host certificates to system trust store in a single sudo call
349
- // Combine both certificate trust operations into a single sudo command to avoid multiple password prompts
350
- const combinedCmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.caCertPath}" && security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.certPath}"`
351
- execSudoSync(combinedCmd)
406
+ execSudoSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"`)
352
407
  if (options.verbose)
353
- log.success('Successfully added CA and host certificates to system trust store')
354
-
355
- // Also add to login keychain (no sudo needed)
356
- try {
357
- execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${hostConfig.certPath}"`, { stdio: 'pipe' })
358
- }
359
- catch {
360
- // Ignore login keychain errors - system keychain is sufficient
361
- }
408
+ log.success('Successfully added Root CA to system trust store')
362
409
 
363
410
  isTrusted = true
364
411
 
365
- // Create a simple trust-helper script for easy manual trust if needed
366
- const sslScriptDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
367
- const scriptPath = join(sslScriptDir, 'trust-rpx-cert.sh')
412
+ // Helper script for manual re-trust if the user ever wipes their keychain.
413
+ const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
368
414
  const scriptContent = `#!/bin/bash
369
- echo "Trusting RPX certificate for domains: ${domains.join(', ')}"
370
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.caCertPath}"
371
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.certPath}"
372
- echo "Certificates trusted! Please restart your browser."
415
+ echo "Trusting RPX Root CA"
416
+ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
417
+ echo "Root CA trusted! Please restart your browser."
373
418
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
374
419
  `
375
420
  await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }) // Make it executable
376
421
  }
377
422
  catch (err) {
378
423
  if (options.verbose)
379
- log.warn(`Could not add certificate to trust store automatically: ${err}`)
424
+ log.warn(`Could not add Root CA to trust store automatically: ${err}`)
380
425
 
381
- // Create a trust helper script for manual trust
382
- const sslScriptDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
383
- const scriptPath = join(sslScriptDir, 'trust-rpx-cert.sh')
426
+ const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
384
427
  const scriptContent = `#!/bin/bash
385
- echo "Trusting RPX certificate for domains: ${domains.join(', ')}"
386
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.caCertPath}"
387
- sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.certPath}"
388
- echo "Certificates trusted! Please restart your browser."
428
+ echo "Trusting RPX Root CA"
429
+ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
430
+ echo "Root CA trusted! Please restart your browser."
389
431
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
390
432
  `
391
433
  await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
@@ -404,10 +446,9 @@ echo "If you still see certificate warnings, type 'thisisunsafe' on the warning
404
446
  // Create a more reliable trust script
405
447
  const trustScript = `
406
448
  mkdir -p "${certDir}" 2>/dev/null || true
407
- cp "${hostConfig.caCertPath}" "${certDir}/"
408
- cp "${hostConfig.certPath}" "${certDir}/"
449
+ cp "${rootCAPaths.caCertPath}" "${certDir}/"
409
450
  update-ca-certificates
410
- echo "RPX certificates installed. Please restart your browser."
451
+ echo "RPX Root CA installed. Please restart your browser."
411
452
  `
412
453
  // Use a temp file for the script
413
454
  const tmpScript = join(os.tmpdir(), `rpx-trust-${Date.now()}.sh`)
@@ -444,12 +485,12 @@ echo "RPX certificates installed. Please restart your browser."
444
485
  try {
445
486
  // Windows is different - use a PowerShell approach
446
487
  const winScript = `
447
- $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${hostConfig.caCertPath.replace(/\//g, '\\')}")
488
+ $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${rootCAPaths.caCertPath.replace(/\//g, '\\')}")
448
489
  $store = New-Object System.Security.Cryptography.X509Certificates.X509Store("ROOT", "LocalMachine")
449
490
  $store.Open("ReadWrite")
450
491
  $store.Add($cert)
451
492
  $store.Close()
452
- Write-Host "Certificate trusted successfully!"
493
+ Write-Host "Root CA trusted successfully!"
453
494
  `
454
495
  const psPath = join(os.tmpdir(), 'rpx-trust.ps1')
455
496
  await fs.writeFile(psPath, winScript)
package/src/index.ts CHANGED
@@ -22,6 +22,48 @@ export {
22
22
 
23
23
  export { DefaultPortManager, findAvailablePort, isPortInUse, portManager } from './port-manager'
24
24
 
25
+ export {
26
+ gcStaleEntries,
27
+ getRegistryDir,
28
+ isPidAlive,
29
+ isValidId,
30
+ readAll,
31
+ readEntry,
32
+ removeEntry,
33
+ watchRegistry,
34
+ writeEntry,
35
+ } from './registry'
36
+
37
+ export type { RegistryEntry, WatchHandle, WatchOptions } from './registry'
38
+
39
+ export {
40
+ acquireDaemonLock,
41
+ defaultDaemonSpawnCommand,
42
+ ensureDaemonRunning,
43
+ getDaemonPidPath,
44
+ getDaemonRpxDir,
45
+ isDaemonRunning,
46
+ readDaemonPid,
47
+ releaseDaemonLock,
48
+ runDaemon,
49
+ stopDaemon,
50
+ } from './daemon'
51
+
52
+ export type {
53
+ DaemonHandle,
54
+ DaemonOptions,
55
+ EnsureDaemonOptions,
56
+ EnsureDaemonResult,
57
+ StopDaemonOptions,
58
+ StopDaemonResult,
59
+ } from './daemon'
60
+
61
+ export { createProxyFetchHandler } from './proxy-handler'
62
+ export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
63
+
64
+ export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
65
+ export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
66
+
25
67
  export { cleanup } from './start'
26
68
 
27
69
  export { startProxies, startProxy, startServer } from './start'
@@ -3,7 +3,7 @@ import type { StartOptions } from './types'
3
3
  import { spawn } from 'node:child_process'
4
4
  import * as process from 'node:process'
5
5
  import { log } from './logger'
6
- import { debugLog } from './utils'
6
+ import { debugLog, safeStringify } from './utils'
7
7
 
8
8
  export interface ManagedProcess {
9
9
  command: string
@@ -28,7 +28,7 @@ export class ProcessManager {
28
28
  debugLog('start', `Starting process ${id}:`, verbose)
29
29
  debugLog('start', ` Command: ${cmd} ${args.join(' ')}`, verbose)
30
30
  debugLog('start', ` Working directory: ${cwd}`, verbose)
31
- debugLog('start', ` Environment variables: ${JSON.stringify(options.env)}`, verbose)
31
+ debugLog('start', ` Environment variables: ${safeStringify(options.env)}`, verbose)
32
32
 
33
33
  const childProcess = spawn(cmd, args, {
34
34
  cwd,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The fetch handler used by the shared :443 server. Both the in-process
3
+ * multi-proxy mode in `start.ts` and the long-running daemon delegate to this
4
+ * module so routing semantics stay in one place.
5
+ *
6
+ * Routes are looked up via a caller-supplied `getRoute(hostname)` callback.
7
+ * The callback indirection lets each caller use whatever data structure makes
8
+ * sense (a fixed Map at startup, or a hot-swappable registry view) without
9
+ * coupling this module to either.
10
+ */
11
+ import type { PathRewrite } from './types'
12
+ import { debugLog } from './utils'
13
+ import { resolvePathRewrite } from './utils'
14
+
15
+ export interface ProxyRoute {
16
+ /** Upstream `host:port` to forward requests to (e.g. `localhost:5173`). */
17
+ sourceHost: string
18
+ /** Strip `.html` suffix and 301 to clean URLs. */
19
+ cleanUrls?: boolean
20
+ /** Set the `origin` header to the target. */
21
+ changeOrigin?: boolean
22
+ /** Per-route path rewrites (vite/nginx-style prefix routing). */
23
+ pathRewrites?: PathRewrite[]
24
+ }
25
+
26
+ export type GetRoute = (hostname: string) => ProxyRoute | undefined
27
+
28
+ export type ProxyFetchHandler = (req: Request) => Promise<Response>
29
+
30
+ /**
31
+ * Build a Bun.serve-compatible `fetch` handler that routes requests based on
32
+ * the `Host` header. Returns 404 when no route matches and 502 on upstream
33
+ * failures.
34
+ */
35
+ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
36
+ return async (req: Request): Promise<Response> => {
37
+ const url = new URL(req.url)
38
+ const hostHeader = req.headers.get('host') || ''
39
+ // Strip port (`stacks.localhost:443` → `stacks.localhost`).
40
+ const hostname = hostHeader.split(':')[0]
41
+
42
+ const route = getRoute(hostname)
43
+ if (!route) {
44
+ debugLog('request', `No route found for host: ${hostname}`, verbose)
45
+ return new Response(`No proxy configured for ${hostname}`, { status: 404 })
46
+ }
47
+
48
+ let targetHost = route.sourceHost
49
+ let targetPath = url.pathname
50
+
51
+ // Per-route path rewrites: prefix preserved by default, matching Vite /
52
+ // nginx / http-proxy-middleware semantics. See `resolvePathRewrite`.
53
+ const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
54
+ if (rewriteMatch) {
55
+ targetHost = rewriteMatch.targetHost
56
+ targetPath = rewriteMatch.targetPath
57
+ debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
58
+ }
59
+
60
+ const targetUrl = `http://${targetHost}${targetPath}${url.search}`
61
+
62
+ try {
63
+ const headers = new Headers(req.headers)
64
+ headers.set('host', targetHost)
65
+ if (route.changeOrigin)
66
+ headers.set('origin', `http://${route.sourceHost}`)
67
+ headers.set('x-forwarded-for', '127.0.0.1')
68
+ headers.set('x-forwarded-proto', 'https')
69
+ headers.set('x-forwarded-host', hostname)
70
+
71
+ const response = await fetch(targetUrl, {
72
+ method: req.method,
73
+ headers,
74
+ body: req.body,
75
+ redirect: 'manual',
76
+ })
77
+
78
+ // Strip `.html` and 301 to the clean URL when enabled.
79
+ if (route.cleanUrls && url.pathname.endsWith('.html')) {
80
+ const cleanPath = url.pathname.replace(/\.html$/, '')
81
+ return new Response(null, {
82
+ status: 301,
83
+ headers: { Location: cleanPath },
84
+ })
85
+ }
86
+
87
+ const responseHeaders = new Headers(response.headers)
88
+ return new Response(response.body, {
89
+ status: response.status,
90
+ statusText: response.statusText,
91
+ headers: responseHeaders,
92
+ })
93
+ }
94
+ catch (err) {
95
+ debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
96
+ return new Response(`Proxy Error: ${err}`, { status: 502 })
97
+ }
98
+ }
99
+ }