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