@stacksjs/rpx 0.11.3 → 0.11.4

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/start.ts ADDED
@@ -0,0 +1,1361 @@
1
+ /* eslint-disable no-console */
2
+ import type { IncomingHttpHeaders, SecureServerOptions } from 'node:http2'
3
+ import type { ServerOptions } from 'node:https'
4
+ import type { BaseProxyConfig, CleanupOptions, PathRewrite, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
5
+ import { exec, execSync } from 'node:child_process'
6
+ import * as fs from 'node:fs'
7
+ import * as http from 'node:http'
8
+ import * as http2 from 'node:http2'
9
+ import * as https from 'node:https'
10
+ import * as net from 'node:net'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
13
+ import * as process from 'node:process'
14
+ import * as tls from 'node:tls'
15
+ import { log } from './logger'
16
+ import { colors } from './colors'
17
+ import { version } from '../package.json'
18
+ import { config } from './config'
19
+ import { addHosts, checkHosts, removeHosts } from './hosts'
20
+ import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https'
21
+ import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager'
22
+ import { ProcessManager } from './process-manager'
23
+ import { debugLog, getSudoPassword } from './utils'
24
+
25
+ const processManager = new ProcessManager()
26
+ // Create a global port manager for coordinating port usage
27
+ const globalPortManager = new DefaultPortManager('0.0.0.0')
28
+
29
+ // Keep track of all running servers for cleanup
30
+ const activeServers: Set<http.Server | https.Server> = new Set()
31
+
32
+ type AnyServerType = http.Server | https.Server | http2.Http2SecureServer
33
+ type AnyIncomingMessage = http.IncomingMessage | http2.Http2ServerRequest
34
+ type AnyServerResponse = http.ServerResponse | http2.Http2ServerResponse
35
+
36
+ let isCleaningUp = false
37
+ let cleanupPromiseResolve: (() => void) | null = null
38
+ let cleanupPromise: Promise<void> | null = null
39
+
40
+ export async function cleanup(options?: CleanupOptions): Promise<void> {
41
+ if (isCleaningUp) {
42
+ debugLog('cleanup', 'Cleanup already in progress, skipping', options?.verbose)
43
+ // Return the existing cleanup promise if it exists
44
+ return cleanupPromise || Promise.resolve()
45
+ }
46
+
47
+ isCleaningUp = true
48
+ debugLog('cleanup', 'Starting cleanup process', options?.verbose)
49
+
50
+ // Create a new cleanup promise that can be returned to all callers
51
+ cleanupPromise = new Promise<void>((resolve) => {
52
+ cleanupPromiseResolve = resolve
53
+ })
54
+
55
+ try {
56
+ // Stop all watched processes first
57
+ await processManager.stopAll(options?.verbose)
58
+
59
+ log.info('Shutting down proxy servers...')
60
+
61
+ // Create an array to store all cleanup promises
62
+ const cleanupPromises: Promise<void>[] = []
63
+
64
+ // Add server closing promises
65
+ const serverClosePromises = Array.from(activeServers).map(server =>
66
+ new Promise<void>((resolve) => {
67
+ server.close(() => {
68
+ debugLog('cleanup', 'Server closed successfully', options?.verbose)
69
+ resolve()
70
+ })
71
+ }),
72
+ )
73
+ cleanupPromises.push(...serverClosePromises)
74
+
75
+ // hosts file cleanup if configured
76
+ if (options?.hosts && options.domains?.length) {
77
+ debugLog('cleanup', 'Cleaning up hosts file entries', options?.verbose)
78
+ debugLog('cleanup', `Original domains for cleanup: ${JSON.stringify(options.domains)}`, options?.verbose)
79
+
80
+ // More precise filtering to only filter actual localhost domains
81
+ // In tests, domains may contain 'test.local' which should not be filtered out
82
+ const domainsToClean = options.domains.filter((domain) => {
83
+ // Don't filter out domains in unit tests
84
+ if (domain === 'test.local')
85
+ return true
86
+
87
+ // Only filter out actual localhost domains
88
+ return domain !== 'localhost'
89
+ && !domain.startsWith('localhost.')
90
+ && domain !== '127.0.0.1'
91
+ })
92
+
93
+ debugLog('cleanup', `Filtered domains for cleanup: ${JSON.stringify(domainsToClean)}`, options?.verbose)
94
+
95
+ if (domainsToClean.length > 0) {
96
+ log.info('Cleaning up hosts file entries...')
97
+ cleanupPromises.push(
98
+ removeHosts(domainsToClean, options?.verbose)
99
+ .then(() => {
100
+ debugLog('cleanup', `Removed hosts entries for ${domainsToClean.join(', ')}`, options?.verbose)
101
+ })
102
+ .catch((err) => {
103
+ debugLog('cleanup', `Failed to remove hosts entries: ${err}`, options?.verbose)
104
+ log.warn(`Failed to clean up hosts file entries for ${domainsToClean.join(', ')}:`, err)
105
+ }),
106
+ )
107
+ }
108
+ }
109
+
110
+ // certificate cleanup if configured
111
+ if (options?.certs && options.domains?.length) {
112
+ debugLog('cleanup', 'Cleaning up SSL certificates', options?.verbose)
113
+ log.info('Cleaning up SSL certificates...')
114
+
115
+ const certCleanupPromises = options.domains.map(async (domain) => {
116
+ try {
117
+ await cleanupCertificates(domain, options?.verbose)
118
+ debugLog('cleanup', `Removed certificates for ${domain}`, options?.verbose)
119
+ }
120
+ catch (err) {
121
+ debugLog('cleanup', `Failed to remove certificates for ${domain}: ${err}`, options?.verbose)
122
+ log.warn(`Failed to clean up certificates for ${domain}:`, err)
123
+ }
124
+ })
125
+
126
+ cleanupPromises.push(...certCleanupPromises)
127
+ }
128
+
129
+ await Promise.allSettled(cleanupPromises)
130
+ debugLog('cleanup', 'All cleanup tasks completed successfully', options?.verbose)
131
+ log.success('All cleanup tasks completed successfully')
132
+ }
133
+ catch (err) {
134
+ debugLog('cleanup', `Error during cleanup: ${err}`, options?.verbose)
135
+ log.error('Error during cleanup:', err)
136
+ }
137
+ finally {
138
+ if (cleanupPromiseResolve)
139
+ cleanupPromiseResolve()
140
+ cleanupPromiseResolve = null
141
+ isCleaningUp = false
142
+
143
+ // Only exit the process if not running in a test environment
144
+ // and we're not being called from the Vite plugin
145
+ const isVitePluginCall = options && 'vitePluginUsage' in options && options.vitePluginUsage === true
146
+ if (process.env.NODE_ENV !== 'test' && process.env.BUN_ENV !== 'test' && !isVitePluginCall) {
147
+ // Use a more forceful exit to ensure all handles are closed
148
+ process.exit(0)
149
+ }
150
+ }
151
+
152
+ return cleanupPromise
153
+ }
154
+
155
+ // Register cleanup handlers
156
+ let isHandlingSignal = false
157
+
158
+ function signalHandler(signal: string) {
159
+ if (isHandlingSignal) {
160
+ // Force exit if we get a second signal
161
+ debugLog('signal', `Received second ${signal} signal, forcing exit`, true)
162
+ process.exit(1)
163
+ return
164
+ }
165
+
166
+ isHandlingSignal = true
167
+ debugLog('signal', `Received ${signal} signal, initiating cleanup`, true)
168
+
169
+ cleanup()
170
+ .catch((err) => {
171
+ debugLog('signal', `Cleanup failed after ${signal}: ${err}`, true)
172
+ process.exit(1)
173
+ })
174
+ .finally(() => {
175
+ isHandlingSignal = false
176
+ })
177
+ }
178
+
179
+ // Use a unified approach to handle signals
180
+ process.once('SIGINT', () => signalHandler('SIGINT'))
181
+ process.once('SIGTERM', () => signalHandler('SIGTERM'))
182
+ process.on('uncaughtException', (err) => {
183
+ debugLog('process', `Uncaught exception: ${err}`, true)
184
+ log.error('Uncaught exception:', err)
185
+ signalHandler('uncaughtException')
186
+ })
187
+
188
+ /**
189
+ * Test connection to a server
190
+ */
191
+ async function testConnection(hostname: string, port: number, verbose?: boolean, retries = 5): Promise<void> {
192
+ debugLog('connection', `Testing connection to ${hostname}:${port} (retries left: ${retries})`, verbose)
193
+
194
+ // Add a maximum retry timeout to prevent hanging indefinitely
195
+ const maxTestDuration = 15000 // 15 seconds maximum for the entire test process
196
+ const startTime = Date.now()
197
+
198
+ // Check if we should bypass the connection test (for special cases)
199
+ if (process.env.RPX_BYPASS_CONNECTION_TEST === 'true') {
200
+ debugLog('connection', `Bypassing connection test for ${hostname}:${port} due to RPX_BYPASS_CONNECTION_TEST flag`, verbose)
201
+ return
202
+ }
203
+
204
+ const tryConnect = () => new Promise<void>((resolve, reject) => {
205
+ const socket = net.connect({
206
+ host: hostname,
207
+ port,
208
+ timeout: 3000, // Increase timeout to 3 seconds per attempt for better reliability
209
+ })
210
+
211
+ socket.once('connect', () => {
212
+ debugLog('connection', `Successfully connected to ${hostname}:${port}`, verbose)
213
+ socket.end()
214
+ resolve()
215
+ })
216
+
217
+ socket.once('timeout', () => {
218
+ debugLog('connection', `Connection to ${hostname}:${port} timed out`, verbose)
219
+ socket.destroy()
220
+ reject(new Error('Connection timed out'))
221
+ })
222
+
223
+ socket.once('error', (err) => {
224
+ debugLog('connection', `Failed to connect to ${hostname}:${port}: ${err}`, verbose)
225
+ socket.destroy()
226
+ reject(err)
227
+ })
228
+ })
229
+
230
+ try {
231
+ await tryConnect()
232
+ }
233
+ catch (err: any) {
234
+ // Check if we've exceeded the maximum test duration
235
+ if (Date.now() - startTime > maxTestDuration) {
236
+ debugLog('connection', `Connection test timed out after ${maxTestDuration}ms, but continuing anyway`, verbose)
237
+ log.warn(`Connection test to ${hostname}:${port} timed out, but RPX will try to proceed anyway.`)
238
+ return // Continue with setup despite timeout
239
+ }
240
+
241
+ // If we're dealing with a server that takes time to start up
242
+ if (err.code === 'ECONNREFUSED' && retries > 0) {
243
+ debugLog('connection', `Connection refused, server might be starting up. Retrying in 2 seconds... (${retries} retries left)`, verbose)
244
+ await new Promise(resolve => setTimeout(resolve, 2000))
245
+ return testConnection(hostname, port, verbose, retries - 1)
246
+ }
247
+
248
+ // For other errors, retry with a different approach
249
+ if (retries > 0) {
250
+ // Try a more resilient HTTP check if traditional socket connection fails
251
+ try {
252
+ debugLog('connection', `Trying HTTP request to ${hostname}:${port}`, verbose)
253
+ await new Promise<void>((resolve, reject) => {
254
+ const req = http.request({
255
+ hostname,
256
+ port,
257
+ path: '/',
258
+ method: 'HEAD',
259
+ timeout: 5000,
260
+ }, (res) => {
261
+ // Any response is considered success (even 404, 500, etc)
262
+ debugLog('connection', `Received HTTP response with status: ${res.statusCode}`, verbose)
263
+ resolve()
264
+ })
265
+
266
+ req.on('error', e => reject(e))
267
+ req.on('timeout', () => {
268
+ req.destroy()
269
+ reject(new Error('HTTP request timed out'))
270
+ })
271
+
272
+ req.end()
273
+ })
274
+
275
+ debugLog('connection', `HTTP request to ${hostname}:${port} succeeded`, verbose)
276
+ return // HTTP request succeeded, continue with setup
277
+ }
278
+ catch (httpErr) {
279
+ debugLog('connection', `HTTP request to ${hostname}:${port} failed: ${httpErr}`, verbose)
280
+
281
+ // Still retry the regular socket connection approach
282
+ debugLog('connection', `Retrying socket connection in 2 seconds... (${retries} retries left)`, verbose)
283
+ await new Promise(resolve => setTimeout(resolve, 2000))
284
+ return testConnection(hostname, port, verbose, retries - 1)
285
+ }
286
+ }
287
+
288
+ // For production environments, we might want to be more strict
289
+ // But for typical usage, let's be permissive and just warn
290
+ const errorMessage = `Failed to connect to ${hostname}:${port} after ${5 - retries} attempts: ${err.message}`
291
+ debugLog('connection', `${errorMessage}. To bypass this check set RPX_BYPASS_CONNECTION_TEST=true`, verbose)
292
+ log.warn(errorMessage)
293
+ log.warn(`RPX will try to continue anyway. If you're sure this is correct, you can set RPX_BYPASS_CONNECTION_TEST=true to skip this check.`)
294
+ }
295
+ }
296
+
297
+ export async function startServer(options: SingleProxyConfig): Promise<void> {
298
+ debugLog('server', `Starting server with options: ${JSON.stringify(options)}`, options.verbose)
299
+
300
+ // Parse URLs early to get the hostnames
301
+ const fromUrl = new URL((options.from?.startsWith('http') ? options.from : `http://${options.from}`) || 'localhost:5173')
302
+ const toUrl = new URL((options.to?.startsWith('http') ? options.to : `http://${options.to}`) || 'rpx.localhost')
303
+ const fromPort = Number.parseInt(fromUrl.port) || (fromUrl.protocol.includes('https:') ? 443 : 80)
304
+
305
+ // Check and update hosts file for custom domains
306
+ const hostsToCheck = [toUrl.hostname]
307
+ if (!toUrl.hostname.includes('localhost') && !toUrl.hostname.includes('127.0.0.1')) {
308
+ debugLog('hosts', `Checking if hosts file entry exists for: ${toUrl.hostname}`, options?.verbose)
309
+
310
+ try {
311
+ const hostsExist = await checkHosts(hostsToCheck, options.verbose)
312
+ if (!hostsExist[0]) {
313
+ log.info(`Adding ${toUrl.hostname} to hosts file...`)
314
+ log.info('This may require sudo/administrator privileges')
315
+ try {
316
+ await addHosts(hostsToCheck, options.verbose)
317
+ }
318
+ catch (addError) {
319
+ log.error('Failed to add hosts entry:', (addError as Error).message)
320
+ log.warn('You can manually add this entry to your hosts file:')
321
+ log.warn(`127.0.0.1 ${toUrl.hostname}`)
322
+ log.warn(`::1 ${toUrl.hostname}`)
323
+
324
+ if (process.platform === 'win32') {
325
+ log.warn('On Windows:')
326
+ log.warn('1. Run notepad as administrator')
327
+ log.warn('2. Open C:\\Windows\\System32\\drivers\\etc\\hosts')
328
+ }
329
+ else {
330
+ log.warn('On Unix systems:')
331
+ log.warn('sudo nano /etc/hosts')
332
+ }
333
+ }
334
+ }
335
+ else {
336
+ debugLog('hosts', `Host entry already exists for ${toUrl.hostname}`, options.verbose)
337
+ }
338
+ }
339
+ catch (checkError) {
340
+ log.error('Failed to check hosts file:', (checkError as Error).message)
341
+ // Continue with proxy setup even if hosts check fails
342
+ }
343
+ }
344
+
345
+ // Test connection to source server before proceeding
346
+ try {
347
+ await testConnection(fromUrl.hostname, fromPort, options.verbose)
348
+ }
349
+ catch (err) {
350
+ debugLog('server', `Connection test failed: ${err}`, options.verbose)
351
+ log.error((err as Error).message)
352
+ // Don't exit process, continue with proxy setup
353
+ log.warn('Continuing with proxy setup despite connection test failure...')
354
+ log.info('If you need to bypass connection testing, set environment variable RPX_BYPASS_CONNECTION_TEST=true')
355
+ }
356
+
357
+ let sslConfig = options._cachedSSLConfig || null
358
+
359
+ if (options.https) {
360
+ try {
361
+ if (options.https === true) {
362
+ options.https = httpsConfig({
363
+ ...options,
364
+ to: toUrl.hostname,
365
+ })
366
+ }
367
+
368
+ // Always check for existing and trusted certificates
369
+ sslConfig = await checkExistingCertificates({
370
+ ...options,
371
+ to: toUrl.hostname,
372
+ https: options.https,
373
+ })
374
+
375
+ // Generate new certificates if loading failed, returned null, or not trusted
376
+ if (!sslConfig) {
377
+ debugLog('ssl', `Generating new certificates for ${toUrl.hostname}`, options.verbose)
378
+ await generateCertificate({
379
+ ...options,
380
+ from: fromUrl.toString(),
381
+ to: toUrl.hostname,
382
+ https: options.https,
383
+ })
384
+
385
+ // Try loading again after generation
386
+ sslConfig = await checkExistingCertificates({
387
+ ...options,
388
+ to: toUrl.hostname,
389
+ https: options.https,
390
+ })
391
+
392
+ if (!sslConfig) {
393
+ throw new Error(`Failed to load SSL configuration after generating certificates for ${toUrl.hostname}`)
394
+ }
395
+ }
396
+ }
397
+ catch (err) {
398
+ debugLog('server', `SSL setup failed: ${err}`, options.verbose)
399
+ throw err
400
+ }
401
+ }
402
+
403
+ debugLog('server', `Setting up reverse proxy with SSL config for ${toUrl.hostname}`, options.verbose)
404
+
405
+ await setupProxy({
406
+ ...options,
407
+ from: options.from || 'localhost:5173',
408
+ to: toUrl.hostname,
409
+ fromPort,
410
+ sourceUrl: {
411
+ hostname: fromUrl.hostname,
412
+ host: fromUrl.host,
413
+ },
414
+ ssl: sslConfig,
415
+ })
416
+ }
417
+
418
+ async function createProxyServer(
419
+ from: string,
420
+ to: string,
421
+ fromPort: number,
422
+ listenPort: number,
423
+ hostname: string,
424
+ sourceUrl: Pick<URL, 'hostname' | 'host'>,
425
+ ssl: SSLConfig | null,
426
+ vitePluginUsage?: boolean,
427
+ verbose?: boolean,
428
+ cleanUrls?: boolean,
429
+ changeOrigin?: boolean,
430
+ ): Promise<void> {
431
+ debugLog('proxy', `Creating proxy server ${from} -> ${to} with cleanUrls: ${cleanUrls}`, verbose)
432
+
433
+ // Convert HTTP/2 headers to HTTP/1 compatible format
434
+ function normalizeHeaders(headers: IncomingHttpHeaders): http.OutgoingHttpHeaders {
435
+ const normalized: http.OutgoingHttpHeaders = {}
436
+ for (const [key, value] of Object.entries(headers)) {
437
+ // Skip HTTP/2 pseudo-headers
438
+ if (!key.startsWith(':')) {
439
+ normalized[key] = value
440
+ }
441
+ }
442
+ return normalized
443
+ }
444
+
445
+ const requestHandler = (req: AnyIncomingMessage, res: AnyServerResponse) => {
446
+ debugLog('request', `Incoming request: ${req.method} ${req.url}`, verbose)
447
+
448
+ let path = req.url || '/'
449
+ let method = req.method || 'GET'
450
+
451
+ // For HTTP/2 requests, extract method and path from pseudo-headers
452
+ if (req instanceof http2.Http2ServerRequest) {
453
+ const headers = req.headers
454
+ method = (headers[':method'] as string) || method
455
+ path = (headers[':path'] as string) || path
456
+ }
457
+
458
+ // Handle clean URLs
459
+ if (cleanUrls) {
460
+ // Don't modify URLs that already have an extension
461
+ if (!path.match(/\.[a-z0-9]+$/i)) {
462
+ // If path ends with trailing slash, look for index.html
463
+ if (path.endsWith('/')) {
464
+ path = `${path}index.html`
465
+ }
466
+ // Otherwise append .html
467
+ else {
468
+ path = `${path}.html`
469
+ }
470
+ }
471
+ }
472
+
473
+ // Normalize request headers
474
+ const normalizedHeaders = normalizeHeaders(req.headers)
475
+
476
+ // Handle changeOrigin option - modify the host header to match the target
477
+ if (changeOrigin) {
478
+ normalizedHeaders.host = `${sourceUrl.hostname}:${fromPort}`
479
+ debugLog('request', `Changed origin: setting host header to ${normalizedHeaders.host}`, verbose)
480
+ }
481
+
482
+ const proxyOptions = {
483
+ hostname: sourceUrl.hostname,
484
+ port: fromPort,
485
+ path,
486
+ method,
487
+ headers: normalizedHeaders,
488
+ }
489
+
490
+ debugLog('request', `Proxy request options: ${JSON.stringify(proxyOptions)}`, verbose)
491
+
492
+ const proxyReq = http.request(proxyOptions, (proxyRes) => {
493
+ debugLog('response', `Proxy response received with status ${proxyRes.statusCode}`, verbose)
494
+
495
+ // Handle 404s for clean URLs
496
+ if (cleanUrls && proxyRes.statusCode === 404) {
497
+ // Try alternative paths for clean URLs
498
+ const alternativePaths = []
499
+
500
+ // If the path ends with .html, try without it
501
+ if (path.endsWith('.html')) {
502
+ alternativePaths.push(path.slice(0, -5))
503
+ }
504
+ // If path doesn't end with .html, try with it
505
+ else if (!path.match(/\.[a-z0-9]+$/i)) {
506
+ alternativePaths.push(`${path}.html`)
507
+ }
508
+ // If path doesn't end with /, try with /index.html
509
+ if (!path.endsWith('/')) {
510
+ alternativePaths.push(`${path}/index.html`)
511
+ }
512
+
513
+ // Try alternative paths
514
+ if (alternativePaths.length > 0) {
515
+ debugLog('cleanUrls', `Trying alternative paths: ${alternativePaths.join(', ')}`, verbose)
516
+
517
+ // Try each alternative path
518
+ const tryNextPath = (paths: string[]) => {
519
+ if (paths.length === 0) {
520
+ // If no alternatives work, send original 404
521
+ ;(res as http.ServerResponse).writeHead(proxyRes.statusCode || 404, proxyRes.headers)
522
+ proxyRes.pipe(res as http.ServerResponse)
523
+ return
524
+ }
525
+
526
+ const altPath = paths[0]
527
+ const altOptions = { ...proxyOptions, path: altPath }
528
+
529
+ const altReq = http.request(altOptions, (altRes) => {
530
+ if (altRes.statusCode === 200) {
531
+ // If we found a matching path, use it
532
+ debugLog('cleanUrls', `Found matching path: ${altPath}`, verbose)
533
+ ;(res as http.ServerResponse).writeHead(altRes.statusCode, altRes.headers)
534
+ altRes.pipe(res as http.ServerResponse)
535
+ }
536
+ else {
537
+ // Try next alternative
538
+ tryNextPath(paths.slice(1))
539
+ }
540
+ })
541
+
542
+ altReq.on('error', () => tryNextPath(paths.slice(1)))
543
+ altReq.end()
544
+ }
545
+
546
+ tryNextPath(alternativePaths)
547
+ return
548
+ }
549
+ }
550
+
551
+ // Add security headers
552
+ const headers = {
553
+ ...proxyRes.headers,
554
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
555
+ 'X-Content-Type-Options': 'nosniff',
556
+ }
557
+
558
+ ;(res as http.ServerResponse).writeHead(proxyRes.statusCode || 500, headers)
559
+ proxyRes.pipe(res as http.ServerResponse)
560
+ })
561
+
562
+ proxyReq.on('error', (err) => {
563
+ debugLog('request', `Proxy request failed: ${err}`, verbose)
564
+ log.error('Proxy request failed:', err)
565
+ ;(res as http.ServerResponse).writeHead(502)
566
+ ;(res as http.ServerResponse).end(`Proxy Error: ${err.message}`)
567
+ })
568
+
569
+ req.pipe(proxyReq)
570
+ }
571
+
572
+ debugLog('server', `Creating server with SSL config: ${!!ssl}`, verbose)
573
+
574
+ // Use Bun.serve for HTTPS as it handles TLS better than Node's https module in Bun
575
+ if (ssl) {
576
+ return new Promise<void>((resolve, reject) => {
577
+ try {
578
+ const bunServer = Bun.serve({
579
+ port: listenPort,
580
+ hostname,
581
+ tls: {
582
+ key: ssl.key,
583
+ cert: ssl.cert,
584
+ ca: ssl.ca,
585
+ // Bun's TLS options - don't request client certificates
586
+ requestCert: false,
587
+ rejectUnauthorized: false,
588
+ },
589
+ async fetch(req: Request) {
590
+ const url = new URL(req.url)
591
+ debugLog('request', `Bun.serve received: ${req.method} ${url.pathname}`, verbose)
592
+
593
+ // Build target URL from sourceUrl object
594
+ const baseUrl = `http://${sourceUrl.host}`
595
+ const targetUrl = new URL(url.pathname + url.search, baseUrl)
596
+
597
+ // Forward the request
598
+ try {
599
+ const headers = new Headers(req.headers)
600
+ headers.set('host', sourceUrl.host)
601
+ if (changeOrigin) {
602
+ headers.set('origin', baseUrl)
603
+ }
604
+ headers.set('x-forwarded-for', '127.0.0.1')
605
+ headers.set('x-forwarded-proto', 'https')
606
+ headers.set('x-forwarded-host', to)
607
+
608
+ const response = await fetch(targetUrl.toString(), {
609
+ method: req.method,
610
+ headers,
611
+ body: req.body,
612
+ redirect: 'manual',
613
+ })
614
+
615
+ // Clone response with modified headers if needed
616
+ const responseHeaders = new Headers(response.headers)
617
+
618
+ // Handle clean URLs redirect
619
+ if (cleanUrls && url.pathname.endsWith('.html')) {
620
+ const cleanPath = url.pathname.replace(/\.html$/, '')
621
+ return new Response(null, {
622
+ status: 301,
623
+ headers: { Location: cleanPath },
624
+ })
625
+ }
626
+
627
+ return new Response(response.body, {
628
+ status: response.status,
629
+ statusText: response.statusText,
630
+ headers: responseHeaders,
631
+ })
632
+ }
633
+ catch (err) {
634
+ debugLog('request', `Proxy error: ${err}`, verbose)
635
+ return new Response(`Proxy Error: ${err}`, { status: 502 })
636
+ }
637
+ },
638
+ error(err: Error) {
639
+ debugLog('server', `Bun.serve error: ${err}`, verbose)
640
+ return new Response(`Server Error: ${err.message}`, { status: 500 })
641
+ },
642
+ })
643
+
644
+ // Store reference for cleanup
645
+ activeServers.add(bunServer as unknown as http.Server)
646
+
647
+ logToConsole({
648
+ from,
649
+ to,
650
+ vitePluginUsage,
651
+ listenPort,
652
+ ssl: true,
653
+ cleanUrls,
654
+ verbose,
655
+ })
656
+
657
+ resolve()
658
+ }
659
+ catch (err) {
660
+ reject(err)
661
+ }
662
+ })
663
+ }
664
+
665
+ // For non-SSL, use Node's http.createServer
666
+ const server = http.createServer(requestHandler)
667
+
668
+ function setupServer(serverInstance: AnyServerType) {
669
+ // Use the module-level activeServers set
670
+ activeServers.add(serverInstance as http.Server | https.Server)
671
+
672
+ return new Promise<void>((resolve, reject) => {
673
+ serverInstance.listen(listenPort, hostname, () => {
674
+ debugLog('server', `Server listening on port ${listenPort}`, verbose)
675
+
676
+ logToConsole({
677
+ from,
678
+ to,
679
+ vitePluginUsage,
680
+ listenPort,
681
+ ssl: !!ssl,
682
+ cleanUrls,
683
+ verbose,
684
+ })
685
+
686
+ resolve()
687
+ })
688
+
689
+ serverInstance.on('error', (err) => {
690
+ debugLog('server', `Server error: ${err}`, verbose)
691
+ reject(err)
692
+ })
693
+ })
694
+ }
695
+
696
+ return setupServer(server)
697
+ }
698
+
699
+ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
700
+ debugLog('setup', `Setting up reverse proxy: ${JSON.stringify(options)}`, options.verbose)
701
+
702
+ const { from, to, fromPort, sourceUrl, ssl, verbose, cleanup: cleanupOptions, vitePluginUsage, changeOrigin, cleanUrls } = options
703
+ const httpPort = 80
704
+ const httpsPort = 443
705
+ const hostname = '0.0.0.0'
706
+ // Use the global port manager if not provided
707
+ const portManager = options.portManager || globalPortManager
708
+
709
+ try {
710
+ // Add an extra check to make sure the hostname is in the hosts file
711
+ if (to && !to.includes('localhost') && !to.includes('127.0.0.1')) {
712
+ const hostsExist = await checkHosts([to], verbose)
713
+ if (!hostsExist[0]) {
714
+ log.warn(`The hostname ${to} isn't in your hosts file. Adding it now...`)
715
+ try {
716
+ await addHosts([to], verbose)
717
+ log.success(`Added ${to} to your hosts file.`)
718
+ }
719
+ catch (error) {
720
+ log.error(`Failed to add ${to} to your hosts file: ${error}`)
721
+ log.info(`You may need to manually add '127.0.0.1 ${to}' to your /etc/hosts file.`)
722
+ }
723
+ }
724
+ }
725
+ else {
726
+ // On macOS, *.localhost domains resolve to 127.0.0.1 automatically (RFC 6761)
727
+ // so we don't need to add them to /etc/hosts
728
+ if (process.platform !== 'darwin' && to && to.includes('localhost') && !to.match(/^(localhost|127\.0\.0\.1)$/)) {
729
+ const hostsExist = await checkHosts([to], verbose)
730
+ if (!hostsExist[0]) {
731
+ debugLog('hosts', `${to} not found in hosts file, adding...`, verbose)
732
+ try {
733
+ await addHosts([to], verbose)
734
+ }
735
+ catch (error) {
736
+ debugLog('hosts', `Failed to add ${to} to hosts file: ${error}`, verbose)
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ // Handle HTTP redirect server only for the first proxy
743
+ if (ssl && !portManager.usedPorts.has(httpPort)) {
744
+ const isHttpPortBusy = await isPortInUse(httpPort, hostname, verbose)
745
+ if (!isHttpPortBusy) {
746
+ debugLog('setup', 'Starting HTTP redirect server', verbose)
747
+ startHttpRedirectServer(verbose)
748
+ portManager.usedPorts.add(httpPort)
749
+ }
750
+ else {
751
+ debugLog('setup', 'Port 80 is in use, skipping HTTP redirect', verbose)
752
+ if (verbose)
753
+ log.warn('Port 80 is in use, HTTP to HTTPS redirect will not be available')
754
+ }
755
+ }
756
+
757
+ const targetPort = ssl ? httpsPort : httpPort
758
+
759
+ // First check if the target port is already in use
760
+ const isTargetPortBusy = await isPortInUse(targetPort, hostname, verbose)
761
+
762
+ let finalPort: number
763
+
764
+ if (isTargetPortBusy) {
765
+ debugLog('setup', `Port ${targetPort} is already in use`, verbose)
766
+ if (verbose)
767
+ log.warn(`Port ${targetPort} is already in use. This may be another instance of rpx or another service.`)
768
+
769
+ // For port 443, we need admin/sudo privileges to use it directly
770
+ if (targetPort === 443) {
771
+ // Use the enhanced port manager with connectivity testing for more reliability
772
+ finalPort = await portManager.getNextAvailablePort(3443, true)
773
+ debugLog('setup', `Using port ${finalPort} instead of ${targetPort}`, verbose)
774
+ if (verbose)
775
+ log.info(`Using port ${finalPort} instead. Access your site at https://${to}:${finalPort}`)
776
+ }
777
+ else {
778
+ // Use the enhanced port manager with connectivity testing for more reliability
779
+ finalPort = await portManager.getNextAvailablePort(targetPort + 1000, true)
780
+ debugLog('setup', `Using port ${finalPort} instead of ${targetPort}`, verbose)
781
+ if (verbose)
782
+ log.info(`Using port ${finalPort} instead. Access your site at http://${to}:${finalPort}`)
783
+ }
784
+ }
785
+ else {
786
+ // Standard port is available, use it
787
+ finalPort = targetPort
788
+ portManager.usedPorts.add(finalPort)
789
+ debugLog('setup', `Using standard ${targetPort === 443 ? 'HTTPS' : 'HTTP'} port ${targetPort} for ${to}`, verbose)
790
+ }
791
+
792
+ await createProxyServer(from, to, fromPort, finalPort, hostname, sourceUrl, ssl, vitePluginUsage, verbose, cleanUrls, changeOrigin)
793
+ }
794
+ catch (err) {
795
+ debugLog('setup', `Setup failed: ${err}`, verbose)
796
+ log.error(`Failed to setup reverse proxy: ${(err as Error).message}`)
797
+ cleanup({
798
+ domains: [to],
799
+ hosts: typeof cleanupOptions === 'boolean' ? cleanupOptions : cleanupOptions?.hosts,
800
+ certs: typeof cleanupOptions === 'boolean' ? cleanupOptions : cleanupOptions?.certs,
801
+ verbose,
802
+ vitePluginUsage,
803
+ })
804
+ }
805
+ }
806
+
807
+ export function startHttpRedirectServer(verbose?: boolean): void {
808
+ debugLog('redirect', 'Starting HTTP redirect server', verbose)
809
+
810
+ const server = http
811
+ .createServer((req, res) => {
812
+ const host = req.headers.host || ''
813
+ debugLog('redirect', `Redirecting request from ${host}${req.url} to HTTPS`, verbose)
814
+ res.writeHead(301, {
815
+ Location: `https://${host}${req.url}`,
816
+ })
817
+ res.end()
818
+ })
819
+ .listen(80)
820
+ activeServers.add(server)
821
+ debugLog('redirect', 'HTTP redirect server started', verbose)
822
+ }
823
+
824
+ export function startProxy(options: ProxyOption): void {
825
+ const mergedOptions = {
826
+ ...config,
827
+ ...options,
828
+ }
829
+
830
+ debugLog('proxy', `Starting proxy with options: ${JSON.stringify(mergedOptions)}`, mergedOptions?.verbose)
831
+
832
+ // Start DNS server for custom domains on macOS (any domain that's not localhost/127.0.0.1)
833
+ const targetDomain = mergedOptions.to || ''
834
+ const tld = targetDomain.split('.').pop()?.toLowerCase() || ''
835
+ const isCustomDomain = process.platform === 'darwin'
836
+ && targetDomain
837
+ && !targetDomain.includes('localhost')
838
+ && !targetDomain.includes('127.0.0.1')
839
+
840
+ // TLDs that are problematic for local development (owned by Google/others with HSTS preloading)
841
+ const problematicTlds = ['dev', 'app', 'page', 'new', 'day', 'foo']
842
+ // Reserved TLDs that are safe for local development (RFC 2606 / RFC 6761)
843
+ const reservedTlds = ['test', 'localhost', 'local', 'example', 'invalid']
844
+
845
+ if (isCustomDomain && problematicTlds.includes(tld) && mergedOptions?.verbose) {
846
+ log.warn(`The .${tld} TLD may not work reliably for local development`)
847
+ log.info(` Google owns .${tld} with HSTS preloading, which can bypass local DNS`)
848
+ log.info(` Consider using a reserved TLD: .test, .localhost, or .local`)
849
+ }
850
+
851
+ if (isCustomDomain) {
852
+ import('./dns').then(({ startDnsServer, setupResolver }) => {
853
+ startDnsServer([targetDomain], mergedOptions.verbose).then((started) => {
854
+ if (started) {
855
+ setupResolver(mergedOptions.verbose, [targetDomain]).then(() => {
856
+ if (mergedOptions.verbose) {
857
+ if (reservedTlds.includes(tld)) {
858
+ log.success(`DNS server started for .${tld} domains`)
859
+ }
860
+ else {
861
+ log.success(`DNS server started for .${tld} domains (hosts file entry also added)`)
862
+ }
863
+ }
864
+ })
865
+ }
866
+ else {
867
+ debugLog('dns', `Could not start DNS server - ${targetDomain} may not resolve in browser`, mergedOptions.verbose)
868
+ }
869
+ })
870
+ }).catch((err) => {
871
+ debugLog('dns', `Failed to start DNS server: ${err}`, mergedOptions.verbose)
872
+ })
873
+ }
874
+
875
+ const serverOptions: SingleProxyConfig = {
876
+ from: mergedOptions.from,
877
+ to: mergedOptions.to,
878
+ cleanUrls: mergedOptions.cleanUrls,
879
+ https: httpsConfig(mergedOptions),
880
+ cleanup: mergedOptions.cleanup,
881
+ vitePluginUsage: mergedOptions.vitePluginUsage,
882
+ changeOrigin: mergedOptions.changeOrigin,
883
+ verbose: mergedOptions.verbose,
884
+ regenerateUntrustedCerts: mergedOptions.regenerateUntrustedCerts,
885
+ }
886
+
887
+ debugLog('proxy', `Server options: ${JSON.stringify(serverOptions)}`, mergedOptions.verbose)
888
+
889
+ startServer(serverOptions).catch((err) => {
890
+ debugLog('proxy', `Failed to start proxy: ${err}`, mergedOptions.verbose)
891
+ log.error(`Failed to start proxy: ${err.message}`)
892
+ cleanup({
893
+ domains: [mergedOptions.to],
894
+ hosts: typeof mergedOptions.cleanup === 'boolean' ? mergedOptions.cleanup : mergedOptions.cleanup?.hosts,
895
+ certs: typeof mergedOptions.cleanup === 'boolean' ? mergedOptions.cleanup : mergedOptions.cleanup?.certs,
896
+ verbose: mergedOptions.verbose,
897
+ })
898
+ })
899
+ }
900
+
901
+ // Helper function to safely get verbose flag from different config types
902
+ function getVerbose(options: any): boolean {
903
+ return options?.verbose || false
904
+ }
905
+
906
+ export async function startProxies(options?: ProxyOptions): Promise<void> {
907
+ // Allow re-using a previous SSL config between multiple startProxies calls
908
+ // This is particularly important for the Vite plugin
909
+ let mergedOptions = {
910
+ from: 'localhost:5173',
911
+ to: 'rpx.localhost',
912
+ https: false,
913
+ cleanup: {
914
+ hosts: true,
915
+ certs: false,
916
+ },
917
+ vitePluginUsage: false,
918
+ verbose: false,
919
+ cleanUrls: false,
920
+ changeOrigin: false,
921
+ regenerateUntrustedCerts: false,
922
+ } as any
923
+
924
+ if (options) {
925
+ mergedOptions = {
926
+ ...mergedOptions,
927
+ ...options,
928
+ }
929
+ }
930
+
931
+ const verbose = getVerbose(mergedOptions)
932
+ debugLog('config', `Starting with config: ${JSON.stringify(mergedOptions, null, 2)}`, verbose)
933
+ debugLog('config', `Is multi-proxy? ${'proxies' in mergedOptions}`, verbose)
934
+
935
+ // Start dev servers first if configured
936
+ if ('proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)) {
937
+ debugLog('servers', `Found ${mergedOptions.proxies.length} proxies in config`, verbose)
938
+ for (const proxy of mergedOptions.proxies) {
939
+ if (proxy.start) {
940
+ const proxyId = `${proxy.from}-${proxy.to}`
941
+ try {
942
+ debugLog('watch', `Starting command for ${proxyId} with command: ${proxy.start.command}`, verbose)
943
+ log.info(`Starting command for ${proxyId}...`)
944
+
945
+ await processManager.startProcess(proxyId, proxy.start, verbose)
946
+
947
+ // Parse the URL to get hostname and port
948
+ const fromUrl = new URL(proxy.from.startsWith('http') ? proxy.from : `http://${proxy.from}`)
949
+ const hostname = fromUrl.hostname || 'localhost'
950
+ const port = Number(fromUrl.port) || 80
951
+
952
+ // Wait for the server to be ready
953
+ try {
954
+ await testConnection(hostname, port, verbose)
955
+ debugLog('watch', `Dev server is ready at ${hostname}:${port}`, verbose)
956
+ }
957
+ catch (err) {
958
+ // Special handling for connection errors that may be recoverable
959
+ // Sometimes Vite and other dev servers can take longer to initialize
960
+ debugLog('watch', `Connection check failed, but continuing with proxy setup: ${err}`, verbose)
961
+ log.warn(`Dev server connection check failed. RPX will try to proceed anyway...`)
962
+
963
+ // Don't throw here - we'll attempt to continue even though the connection test failed
964
+ // This allows for the case where the server might become available shortly after
965
+ }
966
+ }
967
+ catch (err) {
968
+ debugLog('watch', `Failed to start command for ${proxyId}: ${err}`, verbose)
969
+ throw new Error(`Failed to start command for ${proxyId}: ${err}`)
970
+ }
971
+ }
972
+ else {
973
+ debugLog('watch', `No start command for proxy ${proxy.from} -> ${proxy.to}`, verbose)
974
+ }
975
+ }
976
+ }
977
+ else if ('start' in mergedOptions && mergedOptions.start) {
978
+ debugLog('watch', 'Found start command in single proxy config', verbose)
979
+ const proxyId = `${mergedOptions.from}-${mergedOptions.to}`
980
+ try {
981
+ if (mergedOptions.start) {
982
+ debugLog('watch', `Starting command: ${mergedOptions.start.command}`, verbose)
983
+ await processManager.startProcess(proxyId, mergedOptions.start, verbose)
984
+ }
985
+
986
+ // Parse the URL to get hostname and port
987
+ const fromUrl = new URL(mergedOptions.from?.startsWith('http') ? mergedOptions.from : `http://${mergedOptions.from}`)
988
+ const hostname = fromUrl.hostname || 'localhost'
989
+ const port = Number(fromUrl.port) || 80
990
+
991
+ // Wait for the server to be ready
992
+ try {
993
+ await testConnection(hostname, port, verbose)
994
+ debugLog('watch', `Dev server is ready at ${hostname}:${port}`, verbose)
995
+ }
996
+ catch (err) {
997
+ // Special handling for connection errors that may be recoverable
998
+ debugLog('watch', `Connection check failed, but continuing with proxy setup: ${err}`, verbose)
999
+ log.warn(`Dev server connection check failed. RPX will try to proceed anyway...`)
1000
+
1001
+ // Don't throw here - we'll attempt to continue even though the connection test failed
1002
+ }
1003
+ }
1004
+ catch (err) {
1005
+ debugLog('watch', `Failed to run start command: ${err}`, verbose)
1006
+ throw new Error(`Failed to run start command: ${err}`)
1007
+ }
1008
+ }
1009
+ else {
1010
+ debugLog('watch', 'No start command found in config', verbose)
1011
+ }
1012
+
1013
+ // Get primary domain for certificates
1014
+ const primaryDomain = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
1015
+ ? mergedOptions.proxies[0]?.to
1016
+ : ('to' in mergedOptions ? mergedOptions.to : 'rpx.localhost')
1017
+
1018
+ // Pre-acquire sudo credentials once so that all subsequent sudo operations
1019
+ // (cert trust, hosts file, DNS resolver) reuse the cached credential
1020
+ // without prompting again. `sudo -v` validates and caches for the timeout period.
1021
+ if (process.platform !== 'win32' && (mergedOptions.https || mergedOptions.cleanup?.hosts !== false)) {
1022
+ const sudoPassword = getSudoPassword()
1023
+ if (!sudoPassword) {
1024
+ try {
1025
+ debugLog('sudo', 'Pre-acquiring sudo credentials for privileged operations', verbose)
1026
+ execSync('sudo -v', { stdio: 'inherit' })
1027
+ }
1028
+ catch {
1029
+ debugLog('sudo', 'Could not pre-acquire sudo credentials', verbose)
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // Resolve SSL configuration if HTTPS is enabled
1035
+ if (mergedOptions.https) {
1036
+ let existingSSLConfig = await checkExistingCertificates(mergedOptions)
1037
+
1038
+ if (!existingSSLConfig) {
1039
+ debugLog('ssl', `No valid or trusted certificates found for ${primaryDomain}, generating new ones`, mergedOptions.verbose)
1040
+ await generateCertificate(mergedOptions)
1041
+ existingSSLConfig = await checkExistingCertificates(mergedOptions)
1042
+ if (!existingSSLConfig) {
1043
+ throw new Error(`Failed to load SSL certificates after generation for ${primaryDomain}`)
1044
+ }
1045
+ }
1046
+ else {
1047
+ debugLog('ssl', `Using existing and trusted certificates for ${primaryDomain}`, mergedOptions.verbose)
1048
+ }
1049
+ mergedOptions._cachedSSLConfig = existingSSLConfig
1050
+ }
1051
+
1052
+ // Prepare proxy configurations
1053
+ const proxyOptions = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
1054
+ ? mergedOptions.proxies.map((proxy: any) => ({
1055
+ ...proxy,
1056
+ https: mergedOptions.https,
1057
+ cleanup: mergedOptions.cleanup,
1058
+ cleanUrls: proxy.cleanUrls ?? ('cleanUrls' in mergedOptions ? mergedOptions.cleanUrls : false),
1059
+ vitePluginUsage: mergedOptions.vitePluginUsage,
1060
+ changeOrigin: proxy.changeOrigin ?? mergedOptions.changeOrigin,
1061
+ verbose,
1062
+ _cachedSSLConfig: mergedOptions._cachedSSLConfig,
1063
+ } as ProxyOption))
1064
+ : [{
1065
+ from: 'from' in mergedOptions ? mergedOptions.from : 'localhost:5173',
1066
+ to: 'to' in mergedOptions ? mergedOptions.to : 'rpx.localhost',
1067
+ cleanUrls: 'cleanUrls' in mergedOptions ? mergedOptions.cleanUrls : false,
1068
+ https: mergedOptions.https,
1069
+ cleanup: mergedOptions.cleanup,
1070
+ vitePluginUsage: mergedOptions.vitePluginUsage,
1071
+ start: ('start' in mergedOptions) ? mergedOptions.start : undefined,
1072
+ changeOrigin: mergedOptions.changeOrigin,
1073
+ verbose,
1074
+ _cachedSSLConfig: mergedOptions._cachedSSLConfig,
1075
+ } as ProxyOption]
1076
+
1077
+ // Extract domains for cleanup
1078
+ const domains = proxyOptions.map((opt: ProxyOption) => opt.to || 'rpx.localhost')
1079
+ const sslConfig = mergedOptions._cachedSSLConfig
1080
+
1081
+ // Start DNS server for custom domains on macOS (any domain that's not localhost/127.0.0.1)
1082
+ const customDomains = domains.filter((d: string) =>
1083
+ d && !d.includes('localhost') && !d.includes('127.0.0.1'),
1084
+ )
1085
+
1086
+ // TLDs that are problematic for local development (owned by Google/others with HSTS preloading)
1087
+ const problematicTlds = ['dev', 'app', 'page', 'new', 'day', 'foo']
1088
+ // Reserved TLDs that are safe for local development (RFC 2606 / RFC 6761)
1089
+ const reservedTlds = ['test', 'localhost', 'local', 'example', 'invalid']
1090
+
1091
+ // Warn about problematic TLDs
1092
+ const uniqueTlds = [...new Set(customDomains.map((d: string) => d.split('.').pop()?.toLowerCase()))]
1093
+ const problematicFound = uniqueTlds.filter((t): t is string => !!t && problematicTlds.includes(t as string))
1094
+ if (problematicFound.length > 0 && verbose) {
1095
+ log.warn(`The following TLDs may not work reliably for local development: ${problematicFound.map(t => `.${t}`).join(', ')}`)
1096
+ log.info(` These TLDs have HSTS preloading which can bypass local DNS`)
1097
+ log.info(` Consider using reserved TLDs: .test, .localhost, or .local`)
1098
+ }
1099
+
1100
+ if (process.platform === 'darwin' && customDomains.length > 0) {
1101
+ const { startDnsServer, setupResolver } = await import('./dns')
1102
+ const dnsStarted = await startDnsServer(customDomains, verbose)
1103
+ if (dnsStarted) {
1104
+ await setupResolver(verbose, customDomains)
1105
+ if (verbose) {
1106
+ const hasReservedOnly = uniqueTlds.every((t): t is string => !!t && reservedTlds.includes(t as string))
1107
+ if (hasReservedOnly) {
1108
+ log.success(`DNS server started for ${uniqueTlds.map(t => `.${t}`).join(', ')} domains`)
1109
+ }
1110
+ else {
1111
+ log.success(`DNS server started for ${uniqueTlds.map(t => `.${t}`).join(', ')} domains (hosts file entries also added)`)
1112
+ }
1113
+ }
1114
+ }
1115
+ else {
1116
+ debugLog('dns', 'Could not start DNS server - custom domains may not resolve', verbose)
1117
+ }
1118
+ }
1119
+
1120
+ // Setup cleanup handler
1121
+ const cleanupHandler = async () => {
1122
+ debugLog('cleanup', 'Starting cleanup handler', mergedOptions.verbose)
1123
+
1124
+ try {
1125
+ // Stop DNS server
1126
+ const { stopDnsServer, removeResolver } = await import('./dns')
1127
+ stopDnsServer(mergedOptions.verbose)
1128
+ await removeResolver(mergedOptions.verbose)
1129
+ }
1130
+ catch (err) {
1131
+ debugLog('cleanup', `Error stopping DNS server: ${err}`, mergedOptions.verbose)
1132
+ }
1133
+
1134
+ try {
1135
+ // Stop all watched processes first
1136
+ await processManager.stopAll(mergedOptions.verbose)
1137
+ }
1138
+ catch (err) {
1139
+ debugLog('cleanup', `Error stopping processes: ${err}`, mergedOptions.verbose)
1140
+ }
1141
+
1142
+ await cleanup({
1143
+ domains,
1144
+ hosts: typeof mergedOptions.cleanup === 'boolean' ? mergedOptions.cleanup : mergedOptions.cleanup?.hosts,
1145
+ certs: typeof mergedOptions.cleanup === 'boolean' ? mergedOptions.cleanup : mergedOptions.cleanup?.certs,
1146
+ verbose: mergedOptions.verbose || false,
1147
+ })
1148
+ }
1149
+
1150
+ // Register cleanup handlers
1151
+ process.on('SIGINT', cleanupHandler)
1152
+ process.on('SIGTERM', cleanupHandler)
1153
+ process.on('uncaughtException', (err) => {
1154
+ debugLog('process', `Uncaught exception: ${err}`, true)
1155
+ console.error('Uncaught exception:', err)
1156
+ cleanupHandler()
1157
+ })
1158
+
1159
+ // When SSL is enabled, create a single shared HTTPS server with host-based routing
1160
+ // This ensures all domains share port 443 and route by Host header
1161
+ if (sslConfig && proxyOptions.length > 1) {
1162
+ debugLog('proxies', `Creating shared HTTPS server for ${proxyOptions.length} domains`, verbose)
1163
+
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[] }>()
1166
+
1167
+ for (const option of proxyOptions) {
1168
+ const domain = option.to || 'rpx.localhost'
1169
+ const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
1170
+ const fromPort = Number.parseInt(fromUrl.port) || 80
1171
+
1172
+ routingTable.set(domain, {
1173
+ fromPort,
1174
+ sourceHost: fromUrl.host,
1175
+ cleanUrls: option.cleanUrls || false,
1176
+ changeOrigin: option.changeOrigin || false,
1177
+ pathRewrites: option.pathRewrites,
1178
+ })
1179
+
1180
+ debugLog('proxies', `Route: ${domain} → ${fromUrl.host}`, verbose)
1181
+
1182
+ // Ensure hosts file entries exist for non-localhost domains
1183
+ if (!domain.includes('localhost') && !domain.includes('127.0.0.1')) {
1184
+ try {
1185
+ const hostsExist = await checkHosts([domain], verbose)
1186
+ if (!hostsExist[0]) {
1187
+ await addHosts([domain], verbose)
1188
+ }
1189
+ }
1190
+ catch {
1191
+ debugLog('hosts', `Could not add hosts entry for ${domain}`, verbose)
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // Start HTTP redirect server if port 80 is available
1197
+ const isHttpPortBusy = await isPortInUse(80, '0.0.0.0', verbose)
1198
+ if (!isHttpPortBusy) {
1199
+ startHttpRedirectServer(verbose)
1200
+ }
1201
+
1202
+ // Create single shared Bun.serve on port 443
1203
+ const listenPort = 443
1204
+ const isPortBusy = await isPortInUse(listenPort, '0.0.0.0', verbose)
1205
+
1206
+ if (isPortBusy) {
1207
+ debugLog('proxies', `Port ${listenPort} is already in use, cannot start shared proxy`, verbose)
1208
+ if (verbose)
1209
+ log.warn(`Port ${listenPort} is in use. Shared HTTPS proxy cannot start.`)
1210
+ return
1211
+ }
1212
+
1213
+ try {
1214
+ const bunServer = Bun.serve({
1215
+ port: listenPort,
1216
+ hostname: '0.0.0.0',
1217
+ tls: {
1218
+ key: sslConfig.key,
1219
+ cert: sslConfig.cert,
1220
+ ca: sslConfig.ca,
1221
+ requestCert: false,
1222
+ rejectUnauthorized: false,
1223
+ },
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
+ if (route.pathRewrites) {
1241
+ for (const rewrite of route.pathRewrites) {
1242
+ if (url.pathname === rewrite.from || url.pathname.startsWith(`${rewrite.from}/`)) {
1243
+ targetHost = rewrite.to.startsWith('http') ? new URL(rewrite.to).host : rewrite.to
1244
+ if (rewrite.stripPrefix !== false) {
1245
+ targetPath = url.pathname.slice(rewrite.from.length) || '/'
1246
+ }
1247
+ debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
1248
+ break
1249
+ }
1250
+ }
1251
+ }
1252
+
1253
+ const targetUrl = `http://${targetHost}${targetPath}${url.search}`
1254
+
1255
+ try {
1256
+ const headers = new Headers(req.headers)
1257
+ headers.set('host', targetHost)
1258
+ if (route.changeOrigin) {
1259
+ headers.set('origin', `http://${route.sourceHost}`)
1260
+ }
1261
+ headers.set('x-forwarded-for', '127.0.0.1')
1262
+ headers.set('x-forwarded-proto', 'https')
1263
+ headers.set('x-forwarded-host', hostname)
1264
+
1265
+ const response = await fetch(targetUrl, {
1266
+ method: req.method,
1267
+ headers,
1268
+ body: req.body,
1269
+ redirect: 'manual',
1270
+ })
1271
+
1272
+ const responseHeaders = new Headers(response.headers)
1273
+
1274
+ // Handle clean URLs redirect
1275
+ if (route.cleanUrls && url.pathname.endsWith('.html')) {
1276
+ const cleanPath = url.pathname.replace(/\.html$/, '')
1277
+ return new Response(null, {
1278
+ status: 301,
1279
+ headers: { Location: cleanPath },
1280
+ })
1281
+ }
1282
+
1283
+ return new Response(response.body, {
1284
+ status: response.status,
1285
+ statusText: response.statusText,
1286
+ headers: responseHeaders,
1287
+ })
1288
+ }
1289
+ catch (err) {
1290
+ debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
1291
+ return new Response(`Proxy Error: ${err}`, { status: 502 })
1292
+ }
1293
+ },
1294
+ error(err: Error) {
1295
+ debugLog('server', `Shared proxy server error: ${err}`, verbose)
1296
+ return new Response(`Server Error: ${err.message}`, { status: 500 })
1297
+ },
1298
+ })
1299
+
1300
+ activeServers.add(bunServer as unknown as http.Server)
1301
+ debugLog('proxies', `Shared HTTPS proxy listening on port ${listenPort} for ${routingTable.size} domains`, verbose)
1302
+ }
1303
+ catch (err) {
1304
+ debugLog('proxies', `Failed to start shared proxy: ${err}`, verbose)
1305
+ console.error('Failed to start shared HTTPS proxy:', err)
1306
+ cleanupHandler()
1307
+ }
1308
+ }
1309
+ else {
1310
+ // Single proxy or no SSL — use individual servers (original behavior)
1311
+ for (const option of proxyOptions) {
1312
+ try {
1313
+ const domain = option.to || 'rpx.localhost'
1314
+ debugLog('proxy', `Starting proxy for ${domain} with SSL config: ${!!sslConfig}`, option.verbose)
1315
+
1316
+ await startServer({
1317
+ from: option.from || 'localhost:5173',
1318
+ to: domain,
1319
+ cleanUrls: option.cleanUrls || false,
1320
+ https: option.https || false,
1321
+ cleanup: option.cleanup || false,
1322
+ vitePluginUsage: option.vitePluginUsage || false,
1323
+ verbose: option.verbose || false,
1324
+ _cachedSSLConfig: sslConfig,
1325
+ changeOrigin: option.changeOrigin || false,
1326
+ })
1327
+ }
1328
+ catch (err) {
1329
+ debugLog('proxies', `Failed to start proxy for ${option.to}: ${err}`, option.verbose)
1330
+ console.error(`Failed to start proxy for ${option.to}:`, err)
1331
+ cleanupHandler()
1332
+ }
1333
+ }
1334
+ }
1335
+ }
1336
+
1337
+ interface OutputOptions {
1338
+ from?: string
1339
+ to?: string
1340
+ vitePluginUsage?: boolean
1341
+ listenPort?: number
1342
+ ssl?: boolean
1343
+ cleanUrls?: boolean
1344
+ }
1345
+
1346
+ // eslint-disable-next-line pickier/no-unused-vars
1347
+ function logToConsole(options?: OutputOptions & { verbose?: boolean }) {
1348
+ // Skip console output for Vite plugin (handles its own output) and non-verbose mode (caller handles output)
1349
+ if (options?.vitePluginUsage || !options?.verbose)
1350
+ return
1351
+
1352
+ console.log('')
1353
+ console.log(` ${colors.green(colors.bold('rpx'))} ${colors.green(`v${version}`)}`)
1354
+ console.log(` ${colors.green('➜')} ${colors.dim(options?.from ?? '')} ${colors.dim('➜')} ${colors.cyan(options?.ssl ? `https://${options?.to}` : `http://${options?.to}`)}`)
1355
+
1356
+ if (options?.listenPort !== (options?.ssl ? 443 : 80))
1357
+ console.log(` ${colors.green('➜')} Listening on port ${options?.listenPort}`)
1358
+
1359
+ if (options?.cleanUrls)
1360
+ console.log(` ${colors.green('➜')} Clean URLs enabled`)
1361
+ }