@stacksjs/rpx 0.11.18 → 0.11.20

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