@stacksjs/rpx 0.11.5 → 0.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/bin/cli.js +244 -8
- package/dist/chunk-6z1nzq0x.js +1 -0
- package/dist/chunk-jpf41gb9.js +49 -0
- package/dist/chunk-qcdcnadb.js +1 -0
- package/dist/daemon-runner.d.ts +32 -0
- package/dist/daemon.d.ts +99 -0
- package/dist/https.d.ts +23 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +156 -0
- package/dist/proxy-handler.d.ts +15 -0
- package/dist/registry.d.ts +74 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +11 -9
- package/src/daemon-runner.ts +161 -0
- package/src/daemon.ts +518 -0
- package/src/https.ts +151 -80
- package/src/index.ts +43 -0
- package/src/process-manager.ts +2 -2
- package/src/proxy-handler.ts +99 -0
- package/src/registry.ts +346 -0
- package/src/start.ts +66 -80
- package/src/types.ts +11 -0
- package/src/utils.ts +48 -0
- package/dist/chunk-8yenn1z8.js +0 -45
- package/dist/chunk-cvt0dqrv.js +0 -49
- package/dist/chunk-cy653fq8.js +0 -1
- package/dist/chunk-grcvjvzg.js +0 -124
- package/dist/chunk-hj5q1vd6.js +0 -1
- package/dist/chunk-sqn04kae.js +0 -2
- package/dist/chunk-wcerh8e8.js +0 -1
- package/dist/dns.d.ts +0 -21
- package/dist/src/index.js +0 -1
package/src/https.ts
CHANGED
|
@@ -13,6 +13,50 @@ import { debugLog, execSudoSync, getPrimaryDomain, isMultiProxyConfig, isMultiPr
|
|
|
13
13
|
|
|
14
14
|
let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null
|
|
15
15
|
|
|
16
|
+
// Canonical filenames for the shared Root CA. The CA is a singleton across all
|
|
17
|
+
// rpx-managed domains so that browsers only need to trust it once. Host certs
|
|
18
|
+
// for individual domains are issued from this CA on demand.
|
|
19
|
+
const ROOT_CA_CERT_FILENAME = 'rpx-root-ca.crt'
|
|
20
|
+
const ROOT_CA_KEY_FILENAME = 'rpx-root-ca.key'
|
|
21
|
+
|
|
22
|
+
export interface RootCAPaths {
|
|
23
|
+
caCertPath: string
|
|
24
|
+
caKeyPath: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the canonical Root CA cert + key paths inside `basePath`.
|
|
29
|
+
*/
|
|
30
|
+
export function getRootCAPaths(basePath: string): RootCAPaths {
|
|
31
|
+
return {
|
|
32
|
+
caCertPath: join(basePath, ROOT_CA_CERT_FILENAME),
|
|
33
|
+
caKeyPath: join(basePath, ROOT_CA_KEY_FILENAME),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load a previously-persisted Root CA (cert + private key). Returns `null` if
|
|
39
|
+
* either file is missing or unreadable, signalling that a fresh CA needs to be
|
|
40
|
+
* created.
|
|
41
|
+
*/
|
|
42
|
+
async function loadRootCA(paths: RootCAPaths, verbose?: boolean): Promise<{ certificate: string, privateKey: string } | null> {
|
|
43
|
+
try {
|
|
44
|
+
const [certificate, privateKey] = await Promise.all([
|
|
45
|
+
fs.readFile(paths.caCertPath, 'utf8'),
|
|
46
|
+
fs.readFile(paths.caKeyPath, 'utf8'),
|
|
47
|
+
])
|
|
48
|
+
if (!certificate.includes('-----BEGIN CERTIFICATE-----') || !privateKey.includes('PRIVATE KEY-----')) {
|
|
49
|
+
debugLog('ssl', `Root CA files at ${paths.caCertPath} look malformed, will regenerate`, verbose)
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
return { certificate, privateKey }
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
debugLog('ssl', `No existing Root CA at ${paths.caCertPath} (${(err as NodeJS.ErrnoException).code || err}), will create one`, verbose)
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
16
60
|
/**
|
|
17
61
|
* Resolves SSL paths based on configuration
|
|
18
62
|
*/
|
|
@@ -240,20 +284,22 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
|
|
|
240
284
|
|
|
241
285
|
debugLog('ssl', 'Trusting certificate via macOS security command', false)
|
|
242
286
|
|
|
243
|
-
//
|
|
287
|
+
// Login keychain — no sudo; Chrome/Arc often read this store on macOS.
|
|
288
|
+
const loginKeychain = join(homedir(), 'Library/Keychains/login.keychain-db')
|
|
244
289
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
290
|
+
execSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k "${loginKeychain}" "${certPath}"`, { stdio: 'ignore' })
|
|
291
|
+
if (await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true }))
|
|
292
|
+
return true
|
|
293
|
+
}
|
|
294
|
+
catch { /* fall through to system keychain */ }
|
|
295
|
+
|
|
296
|
+
// System keychain — requires sudo / SUDO_PASSWORD.
|
|
297
|
+
try {
|
|
298
|
+
execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${certPath}"`)
|
|
299
|
+
return await isCertTrusted(certPath, { verbose: false, regenerateUntrustedCerts: true })
|
|
247
300
|
}
|
|
248
301
|
catch {
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${certPath}"`)
|
|
252
|
-
return true
|
|
253
|
-
}
|
|
254
|
-
catch {
|
|
255
|
-
return false
|
|
256
|
-
}
|
|
302
|
+
return false
|
|
257
303
|
}
|
|
258
304
|
}
|
|
259
305
|
catch {
|
|
@@ -262,10 +308,11 @@ async function forceTrustCertificateMacOS(certPath: string): Promise<boolean> {
|
|
|
262
308
|
}
|
|
263
309
|
|
|
264
310
|
export async function generateCertificate(options: ProxyOptions): Promise<void> {
|
|
265
|
-
if (cachedSSLConfig) {
|
|
311
|
+
if (cachedSSLConfig && !(options as { forceRegenerate?: boolean }).forceRegenerate) {
|
|
266
312
|
debugLog('ssl', 'Using cached SSL configuration', options.verbose)
|
|
267
313
|
return
|
|
268
314
|
}
|
|
315
|
+
clearSslConfigCache()
|
|
269
316
|
|
|
270
317
|
// Get all unique domains from the configuration
|
|
271
318
|
const domains: string[] = isMultiProxyOptions(options)
|
|
@@ -274,15 +321,37 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
274
321
|
|
|
275
322
|
debugLog('ssl', `Generating certificate for domains: ${domains.join(', ')}`, options.verbose)
|
|
276
323
|
|
|
277
|
-
// Generate Root CA first
|
|
278
|
-
const rootCAConfig = httpsConfig(options, options.verbose)
|
|
279
|
-
|
|
280
|
-
if (options.verbose)
|
|
281
|
-
log.info('Generating Root CA certificate...')
|
|
282
|
-
const caCert = await createRootCA(rootCAConfig)
|
|
283
|
-
|
|
284
|
-
// Generate the host certificate with all domains
|
|
285
324
|
const hostConfig = httpsConfig(options, options.verbose)
|
|
325
|
+
const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
|
|
326
|
+
await fs.mkdir(sslDir, { recursive: true })
|
|
327
|
+
const rootCAPaths = getRootCAPaths(sslDir)
|
|
328
|
+
|
|
329
|
+
// Reuse the persisted Root CA when present so the user only has to trust it
|
|
330
|
+
// once. A fresh CA gets created (and persisted) on first run.
|
|
331
|
+
let caCert = await loadRootCA(rootCAPaths, options.verbose)
|
|
332
|
+
let caIsNew = false
|
|
333
|
+
if (!caCert) {
|
|
334
|
+
if (options.verbose)
|
|
335
|
+
log.info('Generating Root CA certificate (one-time)...')
|
|
336
|
+
caCert = await createRootCA(hostConfig)
|
|
337
|
+
try {
|
|
338
|
+
await Promise.all([
|
|
339
|
+
fs.writeFile(rootCAPaths.caCertPath, caCert.certificate),
|
|
340
|
+
fs.writeFile(rootCAPaths.caKeyPath, caCert.privateKey, { mode: 0o600 }),
|
|
341
|
+
])
|
|
342
|
+
caIsNew = true
|
|
343
|
+
debugLog('ssl', `Persisted Root CA at ${rootCAPaths.caCertPath}`, options.verbose)
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
debugLog('ssl', `Error saving Root CA files: ${err}`, options.verbose)
|
|
347
|
+
throw new Error(`Failed to save Root CA files: ${err}`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
debugLog('ssl', `Reusing existing Root CA from ${rootCAPaths.caCertPath}`, options.verbose)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Issue the host cert with all SANs from the (possibly reused) CA.
|
|
286
355
|
if (options.verbose)
|
|
287
356
|
log.info(`Generating host certificate for: ${domains.join(', ')}`)
|
|
288
357
|
|
|
@@ -294,14 +363,9 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
294
363
|
},
|
|
295
364
|
})
|
|
296
365
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
366
|
+
// Persist host cert + key. Also write a copy of the CA cert at the per-domain
|
|
367
|
+
// `caCertPath` for back-compat with anything that still reads from there.
|
|
299
368
|
try {
|
|
300
|
-
// Ensure the SSL directory exists
|
|
301
|
-
const sslDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
|
|
302
|
-
await fs.mkdir(sslDir, { recursive: true })
|
|
303
|
-
|
|
304
|
-
// Write certificate files
|
|
305
369
|
await Promise.all([
|
|
306
370
|
fs.writeFile(hostConfig.certPath, hostCert.certificate),
|
|
307
371
|
fs.writeFile(hostConfig.keyPath, hostCert.privateKey),
|
|
@@ -315,15 +379,19 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
315
379
|
throw new Error(`Failed to save certificate files: ${err}`)
|
|
316
380
|
}
|
|
317
381
|
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
382
|
+
// The CA is the trust anchor — only it needs to live in the trust store.
|
|
383
|
+
// Skip the install step when the CA is already trusted (a freshly minted CA
|
|
384
|
+
// is never trusted yet; a reused one might still need a one-time install if
|
|
385
|
+
// someone wiped their keychain).
|
|
386
|
+
const caTrusted = caIsNew
|
|
387
|
+
? false
|
|
388
|
+
: await isCertTrusted(rootCAPaths.caCertPath, { verbose: options.verbose, regenerateUntrustedCerts: true })
|
|
389
|
+
|
|
390
|
+
if (caTrusted) {
|
|
391
|
+
debugLog('ssl', 'Root CA already trusted, skipping trust store update', options.verbose)
|
|
323
392
|
if (options.verbose)
|
|
324
|
-
log.success('
|
|
393
|
+
log.success('Root CA is already trusted in system trust store')
|
|
325
394
|
|
|
326
|
-
// Cache the SSL config for reuse
|
|
327
395
|
cachedSSLConfig = {
|
|
328
396
|
key: hostCert.privateKey,
|
|
329
397
|
cert: hostCert.certificate,
|
|
@@ -342,50 +410,34 @@ export async function generateCertificate(options: ProxyOptions): Promise<void>
|
|
|
342
410
|
|
|
343
411
|
let isTrusted = false
|
|
344
412
|
|
|
345
|
-
// We
|
|
413
|
+
// We install only the Root CA — host certs derive their trust from it.
|
|
346
414
|
if (process.platform === 'darwin') {
|
|
347
415
|
try {
|
|
348
|
-
|
|
349
|
-
// Combine both certificate trust operations into a single sudo command to avoid multiple password prompts
|
|
350
|
-
const combinedCmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.caCertPath}" && security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${hostConfig.certPath}"`
|
|
351
|
-
execSudoSync(combinedCmd)
|
|
416
|
+
execSudoSync(`security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"`)
|
|
352
417
|
if (options.verbose)
|
|
353
|
-
log.success('Successfully added CA
|
|
354
|
-
|
|
355
|
-
// Also add to login keychain (no sudo needed)
|
|
356
|
-
try {
|
|
357
|
-
execSync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${hostConfig.certPath}"`, { stdio: 'pipe' })
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
// Ignore login keychain errors - system keychain is sufficient
|
|
361
|
-
}
|
|
418
|
+
log.success('Successfully added Root CA to system trust store')
|
|
362
419
|
|
|
363
420
|
isTrusted = true
|
|
364
421
|
|
|
365
|
-
//
|
|
366
|
-
const
|
|
367
|
-
const scriptPath = join(sslScriptDir, 'trust-rpx-cert.sh')
|
|
422
|
+
// Helper script for manual re-trust if the user ever wipes their keychain.
|
|
423
|
+
const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
|
|
368
424
|
const scriptContent = `#!/bin/bash
|
|
369
|
-
echo "Trusting RPX
|
|
370
|
-
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${
|
|
371
|
-
|
|
372
|
-
echo "Certificates trusted! Please restart your browser."
|
|
425
|
+
echo "Trusting RPX Root CA"
|
|
426
|
+
sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
|
|
427
|
+
echo "Root CA trusted! Please restart your browser."
|
|
373
428
|
echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
|
|
374
429
|
`
|
|
375
430
|
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }) // Make it executable
|
|
376
431
|
}
|
|
377
432
|
catch (err) {
|
|
378
433
|
if (options.verbose)
|
|
379
|
-
log.warn(`Could not add
|
|
434
|
+
log.warn(`Could not add Root CA to trust store automatically: ${err}`)
|
|
380
435
|
|
|
381
|
-
|
|
382
|
-
const sslScriptDir = hostConfig.basePath || join(homedir(), '.stacks', 'ssl')
|
|
383
|
-
const scriptPath = join(sslScriptDir, 'trust-rpx-cert.sh')
|
|
436
|
+
const scriptPath = join(sslDir, 'trust-rpx-cert.sh')
|
|
384
437
|
const scriptContent = `#!/bin/bash
|
|
385
|
-
echo "Trusting RPX
|
|
386
|
-
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${
|
|
387
|
-
|
|
388
|
-
echo "Certificates trusted! Please restart your browser."
|
|
438
|
+
echo "Trusting RPX Root CA"
|
|
439
|
+
sudo security add-trusted-cert -d -r trustRoot -p ssl -p basic -k /Library/Keychains/System.keychain "${rootCAPaths.caCertPath}"
|
|
440
|
+
echo "Root CA trusted! Please restart your browser."
|
|
389
441
|
echo "If you still see certificate warnings, type 'thisisunsafe' on the warning page in Chrome/Arc browsers."
|
|
390
442
|
`
|
|
391
443
|
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
|
|
@@ -404,10 +456,9 @@ echo "If you still see certificate warnings, type 'thisisunsafe' on the warning
|
|
|
404
456
|
// Create a more reliable trust script
|
|
405
457
|
const trustScript = `
|
|
406
458
|
mkdir -p "${certDir}" 2>/dev/null || true
|
|
407
|
-
cp "${
|
|
408
|
-
cp "${hostConfig.certPath}" "${certDir}/"
|
|
459
|
+
cp "${rootCAPaths.caCertPath}" "${certDir}/"
|
|
409
460
|
update-ca-certificates
|
|
410
|
-
echo "RPX
|
|
461
|
+
echo "RPX Root CA installed. Please restart your browser."
|
|
411
462
|
`
|
|
412
463
|
// Use a temp file for the script
|
|
413
464
|
const tmpScript = join(os.tmpdir(), `rpx-trust-${Date.now()}.sh`)
|
|
@@ -444,12 +495,12 @@ echo "RPX certificates installed. Please restart your browser."
|
|
|
444
495
|
try {
|
|
445
496
|
// Windows is different - use a PowerShell approach
|
|
446
497
|
const winScript = `
|
|
447
|
-
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${
|
|
498
|
+
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("${rootCAPaths.caCertPath.replace(/\//g, '\\')}")
|
|
448
499
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("ROOT", "LocalMachine")
|
|
449
500
|
$store.Open("ReadWrite")
|
|
450
501
|
$store.Add($cert)
|
|
451
502
|
$store.Close()
|
|
452
|
-
Write-Host "
|
|
503
|
+
Write-Host "Root CA trusted successfully!"
|
|
453
504
|
`
|
|
454
505
|
const psPath = join(os.tmpdir(), 'rpx-trust.ps1')
|
|
455
506
|
await fs.writeFile(psPath, winScript)
|
|
@@ -498,6 +549,11 @@ export function getSSLConfig(): { key: string, cert: string, ca?: string } | nul
|
|
|
498
549
|
return cachedSSLConfig
|
|
499
550
|
}
|
|
500
551
|
|
|
552
|
+
/** Clear in-process TLS cache so the next generate/load picks up new files on disk. */
|
|
553
|
+
export function clearSslConfigCache(): void {
|
|
554
|
+
cachedSSLConfig = null
|
|
555
|
+
}
|
|
556
|
+
|
|
501
557
|
// needs to accept the options
|
|
502
558
|
export async function checkExistingCertificates(options?: ProxyOptions): Promise<SSLConfig | null> {
|
|
503
559
|
if (!options)
|
|
@@ -528,10 +584,15 @@ export async function checkExistingCertificates(options?: ProxyOptions): Promise
|
|
|
528
584
|
const flagValue = options.regenerateUntrustedCerts
|
|
529
585
|
const shouldCheckTrust = hasFlag ? flagValue !== false : true
|
|
530
586
|
debugLog('ssl', `Trust check: hasFlag=${hasFlag}, flagValue=${flagValue}, shouldCheckTrust=${shouldCheckTrust}`, options.verbose)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
587
|
+
// Trust anchor is the shared Root CA — host certs are not installed individually.
|
|
588
|
+
const sslDir = sslConfig.basePath || join(homedir(), '.stacks', 'ssl')
|
|
589
|
+
const rootCAPaths = getRootCAPaths(sslDir)
|
|
590
|
+
const caIsTrusted = shouldCheckTrust
|
|
591
|
+
? await isCertTrusted(rootCAPaths.caCertPath, options)
|
|
592
|
+
: true
|
|
593
|
+
|
|
594
|
+
if (!caIsTrusted) {
|
|
595
|
+
debugLog('ssl', 'Root CA exists but is not trusted, will regenerate', options.verbose)
|
|
535
596
|
// Don't attempt to trust here - let generateCertificate handle it in one place
|
|
536
597
|
// This avoids multiple sudo prompts
|
|
537
598
|
return null
|
|
@@ -680,23 +741,33 @@ export async function isCertTrusted(certPath: string, options?: { verbose?: bool
|
|
|
680
741
|
try {
|
|
681
742
|
// Get certificate fingerprint
|
|
682
743
|
const certFingerprint = execSync(`openssl x509 -noout -fingerprint -sha256 -in "${certPath}"`).toString().trim()
|
|
683
|
-
const
|
|
744
|
+
const normalize = (raw: string) => raw.split('=').pop()!.replace(/SHA-256\s+hash:\s*/gi, '').replace(/:/g, '').trim().toUpperCase()
|
|
745
|
+
const fingerprintValue = normalize(certFingerprint)
|
|
684
746
|
|
|
685
747
|
if (!fingerprintValue) {
|
|
686
748
|
debugLog('ssl', 'Could not extract certificate fingerprint', options?.verbose)
|
|
687
749
|
return false
|
|
688
750
|
}
|
|
689
751
|
|
|
690
|
-
|
|
691
|
-
|
|
752
|
+
const keychains = [
|
|
753
|
+
'/Library/Keychains/System.keychain',
|
|
754
|
+
join(homedir(), 'Library/Keychains/login.keychain-db'),
|
|
755
|
+
]
|
|
692
756
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
757
|
+
for (const keychain of keychains) {
|
|
758
|
+
try {
|
|
759
|
+
const listing = execSync(`security find-certificate -a -Z "${keychain}" 2>/dev/null || true`).toString()
|
|
760
|
+
for (const line of listing.split('\n')) {
|
|
761
|
+
if (line.toUpperCase().includes('SHA-256') && normalize(line) === fingerprintValue) {
|
|
762
|
+
debugLog('ssl', `Certificate fingerprint found in ${keychain}`, options?.verbose)
|
|
763
|
+
return true
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch { /* try next keychain */ }
|
|
697
768
|
}
|
|
698
769
|
|
|
699
|
-
debugLog('ssl', 'Certificate fingerprint not found in system
|
|
770
|
+
debugLog('ssl', 'Certificate fingerprint not found in system keychains', options?.verbose)
|
|
700
771
|
return false
|
|
701
772
|
}
|
|
702
773
|
catch (error) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export {
|
|
|
13
13
|
export {
|
|
14
14
|
checkExistingCertificates,
|
|
15
15
|
cleanupCertificates,
|
|
16
|
+
clearSslConfigCache,
|
|
16
17
|
forceTrustCertificate,
|
|
17
18
|
generateCertificate,
|
|
18
19
|
httpsConfig,
|
|
@@ -22,6 +23,48 @@ export {
|
|
|
22
23
|
|
|
23
24
|
export { DefaultPortManager, findAvailablePort, isPortInUse, portManager } from './port-manager'
|
|
24
25
|
|
|
26
|
+
export {
|
|
27
|
+
gcStaleEntries,
|
|
28
|
+
getRegistryDir,
|
|
29
|
+
isPidAlive,
|
|
30
|
+
isValidId,
|
|
31
|
+
readAll,
|
|
32
|
+
readEntry,
|
|
33
|
+
removeEntry,
|
|
34
|
+
watchRegistry,
|
|
35
|
+
writeEntry,
|
|
36
|
+
} from './registry'
|
|
37
|
+
|
|
38
|
+
export type { RegistryEntry, WatchHandle, WatchOptions } from './registry'
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
acquireDaemonLock,
|
|
42
|
+
defaultDaemonSpawnCommand,
|
|
43
|
+
ensureDaemonRunning,
|
|
44
|
+
getDaemonPidPath,
|
|
45
|
+
getDaemonRpxDir,
|
|
46
|
+
isDaemonRunning,
|
|
47
|
+
readDaemonPid,
|
|
48
|
+
releaseDaemonLock,
|
|
49
|
+
runDaemon,
|
|
50
|
+
stopDaemon,
|
|
51
|
+
} from './daemon'
|
|
52
|
+
|
|
53
|
+
export type {
|
|
54
|
+
DaemonHandle,
|
|
55
|
+
DaemonOptions,
|
|
56
|
+
EnsureDaemonOptions,
|
|
57
|
+
EnsureDaemonResult,
|
|
58
|
+
StopDaemonOptions,
|
|
59
|
+
StopDaemonResult,
|
|
60
|
+
} from './daemon'
|
|
61
|
+
|
|
62
|
+
export { createProxyFetchHandler } from './proxy-handler'
|
|
63
|
+
export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
|
|
64
|
+
|
|
65
|
+
export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
|
|
66
|
+
export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
|
|
67
|
+
|
|
25
68
|
export { cleanup } from './start'
|
|
26
69
|
|
|
27
70
|
export { startProxies, startProxy, startServer } from './start'
|
package/src/process-manager.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { StartOptions } from './types'
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import * as process from 'node:process'
|
|
5
5
|
import { log } from './logger'
|
|
6
|
-
import { debugLog } from './utils'
|
|
6
|
+
import { debugLog, safeStringify } from './utils'
|
|
7
7
|
|
|
8
8
|
export interface ManagedProcess {
|
|
9
9
|
command: string
|
|
@@ -28,7 +28,7 @@ export class ProcessManager {
|
|
|
28
28
|
debugLog('start', `Starting process ${id}:`, verbose)
|
|
29
29
|
debugLog('start', ` Command: ${cmd} ${args.join(' ')}`, verbose)
|
|
30
30
|
debugLog('start', ` Working directory: ${cwd}`, verbose)
|
|
31
|
-
debugLog('start', ` Environment variables: ${
|
|
31
|
+
debugLog('start', ` Environment variables: ${safeStringify(options.env)}`, verbose)
|
|
32
32
|
|
|
33
33
|
const childProcess = spawn(cmd, args, {
|
|
34
34
|
cwd,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fetch handler used by the shared :443 server. Both the in-process
|
|
3
|
+
* multi-proxy mode in `start.ts` and the long-running daemon delegate to this
|
|
4
|
+
* module so routing semantics stay in one place.
|
|
5
|
+
*
|
|
6
|
+
* Routes are looked up via a caller-supplied `getRoute(hostname)` callback.
|
|
7
|
+
* The callback indirection lets each caller use whatever data structure makes
|
|
8
|
+
* sense (a fixed Map at startup, or a hot-swappable registry view) without
|
|
9
|
+
* coupling this module to either.
|
|
10
|
+
*/
|
|
11
|
+
import type { PathRewrite } from './types'
|
|
12
|
+
import { debugLog } from './utils'
|
|
13
|
+
import { resolvePathRewrite } from './utils'
|
|
14
|
+
|
|
15
|
+
export interface ProxyRoute {
|
|
16
|
+
/** Upstream `host:port` to forward requests to (e.g. `localhost:5173`). */
|
|
17
|
+
sourceHost: string
|
|
18
|
+
/** Strip `.html` suffix and 301 to clean URLs. */
|
|
19
|
+
cleanUrls?: boolean
|
|
20
|
+
/** Set the `origin` header to the target. */
|
|
21
|
+
changeOrigin?: boolean
|
|
22
|
+
/** Per-route path rewrites (vite/nginx-style prefix routing). */
|
|
23
|
+
pathRewrites?: PathRewrite[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type GetRoute = (hostname: string) => ProxyRoute | undefined
|
|
27
|
+
|
|
28
|
+
export type ProxyFetchHandler = (req: Request) => Promise<Response>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build a Bun.serve-compatible `fetch` handler that routes requests based on
|
|
32
|
+
* the `Host` header. Returns 404 when no route matches and 502 on upstream
|
|
33
|
+
* failures.
|
|
34
|
+
*/
|
|
35
|
+
export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
|
|
36
|
+
return async (req: Request): Promise<Response> => {
|
|
37
|
+
const url = new URL(req.url)
|
|
38
|
+
const hostHeader = req.headers.get('host') || ''
|
|
39
|
+
// Strip port (`stacks.localhost:443` → `stacks.localhost`).
|
|
40
|
+
const hostname = hostHeader.split(':')[0]
|
|
41
|
+
|
|
42
|
+
const route = getRoute(hostname)
|
|
43
|
+
if (!route) {
|
|
44
|
+
debugLog('request', `No route found for host: ${hostname}`, verbose)
|
|
45
|
+
return new Response(`No proxy configured for ${hostname}`, { status: 404 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let targetHost = route.sourceHost
|
|
49
|
+
let targetPath = url.pathname
|
|
50
|
+
|
|
51
|
+
// Per-route path rewrites: prefix preserved by default, matching Vite /
|
|
52
|
+
// nginx / http-proxy-middleware semantics. See `resolvePathRewrite`.
|
|
53
|
+
const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
|
|
54
|
+
if (rewriteMatch) {
|
|
55
|
+
targetHost = rewriteMatch.targetHost
|
|
56
|
+
targetPath = rewriteMatch.targetPath
|
|
57
|
+
debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const targetUrl = `http://${targetHost}${targetPath}${url.search}`
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const headers = new Headers(req.headers)
|
|
64
|
+
headers.set('host', targetHost)
|
|
65
|
+
if (route.changeOrigin)
|
|
66
|
+
headers.set('origin', `http://${route.sourceHost}`)
|
|
67
|
+
headers.set('x-forwarded-for', '127.0.0.1')
|
|
68
|
+
headers.set('x-forwarded-proto', 'https')
|
|
69
|
+
headers.set('x-forwarded-host', hostname)
|
|
70
|
+
|
|
71
|
+
const response = await fetch(targetUrl, {
|
|
72
|
+
method: req.method,
|
|
73
|
+
headers,
|
|
74
|
+
body: req.body,
|
|
75
|
+
redirect: 'manual',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Strip `.html` and 301 to the clean URL when enabled.
|
|
79
|
+
if (route.cleanUrls && url.pathname.endsWith('.html')) {
|
|
80
|
+
const cleanPath = url.pathname.replace(/\.html$/, '')
|
|
81
|
+
return new Response(null, {
|
|
82
|
+
status: 301,
|
|
83
|
+
headers: { Location: cleanPath },
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const responseHeaders = new Headers(response.headers)
|
|
88
|
+
return new Response(response.body, {
|
|
89
|
+
status: response.status,
|
|
90
|
+
statusText: response.statusText,
|
|
91
|
+
headers: responseHeaders,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
|
|
96
|
+
return new Response(`Proxy Error: ${err}`, { status: 502 })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|