@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.
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
  */
@@ -240,20 +284,22 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
240
284
 
241
285
  debugLog('ssl', 'Trusting certificate via macOS security command', false)
242
286
 
243
- // Use execSudoSync which handles SUDO_PASSWORD from env
287
+ // Login keychain no sudo; Chrome/Arc often read this store on macOS.
288
+ const loginKeychain = join(homedir(), 'Library/Keychains/login.keychain-db')
244
289
  try {
245
- execSudoSync(`security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`)
246
- return true
290
+ execSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k "${loginKeychain}" "${certPath}"`, { stdio: 'ignore' })
291
+ if (await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true }))
292
+ return true
293
+ }
294
+ catch { /* fall through to system keychain */ }
295
+
296
+ // System keychain — requires sudo / SUDO_PASSWORD.
297
+ try {
298
+ execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${certPath}"`)
299
+ return await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true })
247
300
  }
248
301
  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
- }
302
+ return false
257
303
  }
258
304
  }
259
305
  catch {
@@ -262,10 +308,11 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
262
308
  }
263
309
 
264
310
  export async function generateCertificate(options: ProxyOptions): Promise<void> {
265
- if (cachedSSLConfig) {
311
+ if (cachedSSLConfig && !(options as { forceRegenerate?: boolean }).forceRegenerate) {
266
312
  debugLog('ssl', 'Using cached SSL configuration', options.verbose)
267
313
  return
268
314
  }
315
+ clearSslConfigCache()
269
316
 
270
317
  // Get all unique domains from the configuration
271
318
  const domains: string[] = isMultiProxyOptions(options)
@@ -274,15 +321,37 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
274
321
 
275
322
  debugLog('ssl', `Generating certificate for domains: ${domains.join(', ')}`, options.verbose)
276
323
 
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
324
  const hostConfig = httpsConfig(options, options.verbose)
325
+ const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
326
+ await fs.mkdir(sslDir, { recursive: true })
327
+ const rootCAPaths = getRootCAPaths(sslDir)
328
+
329
+ // Reuse the persisted Root CA when present so the user only has to trust it
330
+ // once. A fresh CA gets created (and persisted) on first run.
331
+ let caCert = await loadRootCA(rootCAPaths, options.verbose)
332
+ let caIsNew = false
333
+ if (!caCert) {
334
+ if (options.verbose)
335
+ log.info('Generating Root CA certificate (one-time)...')
336
+ caCert = await createRootCA(hostConfig)
337
+ try {
338
+ await Promise.all([
339
+ fs.writeFile(rootCAPaths.caCertPath, caCert.certificate),
340
+ fs.writeFile(rootCAPaths.caKeyPath, caCert.privateKey, { mode: 0o600 }),
341
+ ])
342
+ caIsNew = true
343
+ debugLog('ssl', `Persisted Root CA at ${rootCAPaths.caCertPath}`, options.verbose)
344
+ }
345
+ catch (err) {
346
+ debugLog('ssl', `Error saving Root CA files: ${err}`, options.verbose)
347
+ throw new Error(`Failed to save Root CA files: ${err}`)
348
+ }
349
+ }
350
+ else {
351
+ debugLog('ssl', `Reusing existing Root CA from ${rootCAPaths.caCertPath}`, options.verbose)
352
+ }
353
+
354
+ // Issue the host cert with all SANs from the (possibly reused) CA.
286
355
  if (options.verbose)
287
356
  log.info(`Generating host certificate for: ${domains.join(', ')}`)
288
357
 
@@ -294,14 +363,9 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
294
363
  },
295
364
  })
296
365
 
297
- // Save the certificate files first before trying to trust them
298
- // This prevents multiple trust attempts when files don't exist
366
+ // Persist host cert + key. Also write a copy of the CA cert at the per-domain
367
+ // `caCertPath` for back-compat with anything that still reads from there.
299
368
  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
369
  await Promise.all([
306
370
  fs.writeFile(hostConfig.certPath, hostCert.certificate),
307
371
  fs.writeFile(hostConfig.keyPath, hostCert.privateKey),
@@ -315,15 +379,19 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
315
379
  throw new Error(`Failed to save certificate files: ${err}`)
316
380
  }
317
381
 
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)
382
+ // The CA is the trust anchor only it needs to live in the trust store.
383
+ // Skip the install step when the CA is already trusted (a freshly minted CA
384
+ // is never trusted yet; a reused one might still need a one-time install if
385
+ // someone wiped their keychain).
386
+ const caTrusted = caIsNew
387
+ ? false
388
+ : await isCertTrusted(rootCAPaths.caCertPath, { verbose: options.verbose, regenerateUntrustedCerts: true })
389
+
390
+ if (caTrusted) {
391
+ debugLog('ssl', 'Root CA already trusted, skipping trust store update', options.verbose)
323
392
  if (options.verbose)
324
- log.success('Certificate is already trusted in system trust store')
393
+ log.success('Root CA is already trusted in system trust store')
325
394
 
326
- // Cache the SSL config for reuse
327
395
  cachedSSLConfig = {
328
396
  key: hostCert.privateKey,
329
397
  cert: hostCert.certificate,
@@ -342,50 +410,34 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
342
410
 
343
411
  let isTrusted = false
344
412
 
345
- // We'll use a stronger approach to ensure the certificate is properly trusted
413
+ // We install only the Root CA host certs derive their trust from it.
346
414
  if (process.platform === 'darwin') {
347
415
  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)
416
+ execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"`)
352
417
  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
- }
418
+ log.success('Successfully added Root CA to system trust store')
362
419
 
363
420
  isTrusted = true
364
421
 
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')
422
+ // Helper script for manual re-trust if the user ever wipes their keychain.
423
+ const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
368
424
  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."
425
+ echo "Trusting RPX Root CA"
426
+ sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
427
+ echo "Root CA trusted! Please restart your browser."
373
428
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
374
429
  `
375
430
  await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }) // Make it executable
376
431
  }
377
432
  catch (err) {
378
433
  if (options.verbose)
379
- log.warn(`Could not add certificate to trust store automatically: ${err}`)
434
+ log.warn(`Could not add Root CA to trust store automatically: ${err}`)
380
435
 
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')
436
+ const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
384
437
  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."
438
+ echo "Trusting RPX Root CA"
439
+ sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
440
+ echo "Root CA trusted! Please restart your browser."
389
441
  echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
390
442
  `
391
443
  await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
@@ -404,10 +456,9 @@ echo "If you still see certificate warnings, type 'thisisunsafe' on the warning
404
456
  // Create a more reliable trust script
405
457
  const trustScript = `
406
458
  mkdir -p "${certDir}" 2>/dev/null || true
407
- cp "${hostConfig.caCertPath}" "${certDir}/"
408
- cp "${hostConfig.certPath}" "${certDir}/"
459
+ cp "${rootCAPaths.caCertPath}" "${certDir}/"
409
460
  update-ca-certificates
410
- echo "RPX certificates installed. Please restart your browser."
461
+ echo "RPX Root CA installed. Please restart your browser."
411
462
  `
412
463
  // Use a temp file for the script
413
464
  const tmpScript = join(os.tmpdir(), `rpx-trust-${Date.now()}.sh`)
@@ -444,12 +495,12 @@ echo "RPX certificates installed. Please restart your browser."
444
495
  try {
445
496
  // Windows is different - use a PowerShell approach
446
497
  const winScript = `
447
- $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${hostConfig.caCertPath.replace(/\//g, '\\')}")
498
+ $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${rootCAPaths.caCertPath.replace(/\//g, '\\')}")
448
499
  $store = New-Object System.Security.Cryptography.X509Certificates.X509Store("ROOT", "LocalMachine")
449
500
  $store.Open("ReadWrite")
450
501
  $store.Add($cert)
451
502
  $store.Close()
452
- Write-Host "Certificate trusted successfully!"
503
+ Write-Host "Root CA trusted successfully!"
453
504
  `
454
505
  const psPath = join(os.tmpdir(), 'rpx-trust.ps1')
455
506
  await fs.writeFile(psPath, winScript)
@@ -498,6 +549,11 @@ export function getSSLConfig(): { key: string, cert: string, ca?: string } | nul
498
549
  return cachedSSLConfig
499
550
  }
500
551
 
552
+ /** Clear in-process TLS cache so the next generate/load picks up new files on disk. */
553
+ export function clearSslConfigCache(): void {
554
+ cachedSSLConfig = null
555
+ }
556
+
501
557
  // needs to accept the options
502
558
  export async function checkExistingCertificates(options?: ProxyOptions): Promise<SSLConfig | null> {
503
559
  if (!options)
@@ -528,10 +584,15 @@ export async function checkExistingCertificates(options?: ProxyOptions): Promise
528
584
  const flagValue = options.regenerateUntrustedCerts
529
585
  const shouldCheckTrust = hasFlag ? flagValue !== false : true
530
586
  debugLog('ssl', `Trust check: hasFlag=${hasFlag}, flagValue=${flagValue}, shouldCheckTrust=${shouldCheckTrust}`, options.verbose)
531
- const certIsTrusted = shouldCheckTrust ? await isCertTrusted(sslConfig.certPath, options) : true
532
-
533
- if (!certIsTrusted) {
534
- debugLog('ssl', 'Certificate exists but is not trusted, will regenerate', options.verbose)
587
+ // Trust anchor is the shared Root CA host certs are not installed individually.
588
+ const sslDir = sslConfig.basePath || join(homedir(), '.stacks', 'ssl')
589
+ const rootCAPaths = getRootCAPaths(sslDir)
590
+ const caIsTrusted = shouldCheckTrust
591
+ ? await isCertTrusted(rootCAPaths.caCertPath, options)
592
+ : true
593
+
594
+ if (!caIsTrusted) {
595
+ debugLog('ssl', 'Root CA exists but is not trusted, will regenerate', options.verbose)
535
596
  // Don't attempt to trust here - let generateCertificate handle it in one place
536
597
  // This avoids multiple sudo prompts
537
598
  return null
@@ -680,23 +741,33 @@ export async function isCertTrusted(certPath: string, options?: { verbose?: bool
680
741
  try {
681
742
  // Get certificate fingerprint
682
743
  const certFingerprint = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`).toString().trim()
683
- const fingerprintValue = certFingerprint.split('=')[1]?.trim() || ''
744
+ const normalize = (raw: string) => raw.split('=').pop()!.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
745
+ const fingerprintValue = normalize(certFingerprint)
684
746
 
685
747
  if (!fingerprintValue) {
686
748
  debugLog('ssl', 'Could not extract certificate fingerprint', options?.verbose)
687
749
  return false
688
750
  }
689
751
 
690
- // Check if the fingerprint exists in the system keychain and is trusted
691
- const keychainOutput = execSync(`security find-certificate -a -Z -p | openssl x509 -noout -fingerprint -sha256`).toString()
752
+ const keychains = [
753
+ '/Library/Keychains/System.keychain',
754
+ join(homedir(), 'Library/Keychains/login.keychain-db'),
755
+ ]
692
756
 
693
- // If the fingerprint is found in the trusted certs, consider it trusted
694
- if (keychainOutput.includes(fingerprintValue)) {
695
- debugLog('ssl', 'Certificate fingerprint found in system keychain', options?.verbose)
696
- return true
757
+ for (const keychain of keychains) {
758
+ try {
759
+ const listing = execSync(`security find-certificate -a -Z "${keychain}" 2>/dev/null || true`).toString()
760
+ for (const line of listing.split('\n')) {
761
+ if (line.toUpperCase().includes('SHA-256') && normalize(line) === fingerprintValue) {
762
+ debugLog('ssl', `Certificate fingerprint found in ${keychain}`, options?.verbose)
763
+ return true
764
+ }
765
+ }
766
+ }
767
+ catch { /* try next keychain */ }
697
768
  }
698
769
 
699
- debugLog('ssl', 'Certificate fingerprint not found in system keychain', options?.verbose)
770
+ debugLog('ssl', 'Certificate fingerprint not found in system keychains', options?.verbose)
700
771
  return false
701
772
  }
702
773
  catch (error) {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  checkExistingCertificates,
15
15
  cleanupCertificates,
16
+ clearSslConfigCache,
16
17
  forceTrustCertificate,
17
18
  generateCertificate,
18
19
  httpsConfig,
@@ -22,6 +23,48 @@ export {
22
23
 
23
24
  export { DefaultPortManager, findAvailablePort, isPortInUse, portManager } from './port-manager'
24
25
 
26
+ export {
27
+ gcStaleEntries,
28
+ getRegistryDir,
29
+ isPidAlive,
30
+ isValidId,
31
+ readAll,
32
+ readEntry,
33
+ removeEntry,
34
+ watchRegistry,
35
+ writeEntry,
36
+ } from './registry'
37
+
38
+ export type { RegistryEntry, WatchHandle, WatchOptions } from './registry'
39
+
40
+ export {
41
+ acquireDaemonLock,
42
+ defaultDaemonSpawnCommand,
43
+ ensureDaemonRunning,
44
+ getDaemonPidPath,
45
+ getDaemonRpxDir,
46
+ isDaemonRunning,
47
+ readDaemonPid,
48
+ releaseDaemonLock,
49
+ runDaemon,
50
+ stopDaemon,
51
+ } from './daemon'
52
+
53
+ export type {
54
+ DaemonHandle,
55
+ DaemonOptions,
56
+ EnsureDaemonOptions,
57
+ EnsureDaemonResult,
58
+ StopDaemonOptions,
59
+ StopDaemonResult,
60
+ } from './daemon'
61
+
62
+ export { createProxyFetchHandler } from './proxy-handler'
63
+ export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
64
+
65
+ export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
66
+ export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
67
+
25
68
  export { cleanup } from './start'
26
69
 
27
70
  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
+ }