@stacksjs/rpx 0.11.19 → 0.11.21

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