@stacksjs/rpx 0.11.17 → 0.11.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -24,7 +24,15 @@ import { homedir } from 'node:os'
24
24
  import * as path from 'node:path'
25
25
  import * as process from 'node:process'
26
26
  import { log } from './logger'
27
- import { checkExistingCertificates, generateCertificate } from './https'
27
+ import {
28
+ buildRegistryTlsProxyOptions,
29
+ certIncludesSanHostnames,
30
+ checkExistingCertificates,
31
+ clearSslConfigCache,
32
+ devSslToSniEntries,
33
+ generateCertificate,
34
+ SHARED_DEV_HOST_CERT_PATH,
35
+ } from './https'
28
36
  import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
29
37
  import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
30
38
  import type { HostRoutes } from './host-routes'
@@ -38,7 +46,7 @@ import {
38
46
  syncDevelopmentDnsFromRegistry,
39
47
  tearDownDevelopmentDns,
40
48
  } from './dns'
41
- import { debugLog } from './utils'
49
+ import { debugLog, shouldReusePort } from './utils'
42
50
 
43
51
  export interface DaemonOptions {
44
52
  verbose?: boolean
@@ -68,6 +76,15 @@ export interface DaemonOptions {
68
76
  onDemandTls?: OnDemandTlsConfig
69
77
  /** PID-GC interval in ms. Defaults to 5000. */
70
78
  gcIntervalMs?: number
79
+ /**
80
+ * Run as a multi-core cluster: a coordinator owns the singletons (lock, certs,
81
+ * DNS, hosts, :80 ACME/redirect) and spawns this many worker processes that
82
+ * bind :443 with `reusePort` and serve traffic. Defaults to 1 (single process).
83
+ * Also settable via `RPX_WORKERS`. On Linux the kernel load-balances accepted
84
+ * connections across workers; on macOS `SO_REUSEPORT` doesn't, so it falls back
85
+ * to effectively one active worker (still correct, just not parallel).
86
+ */
87
+ workers?: number
71
88
  }
72
89
 
73
90
  export interface DaemonHandle {
@@ -207,33 +224,24 @@ function pickPrimaryRegistryHost(hosts: string[]): string {
207
224
 
208
225
  async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
209
226
  const entries = await readAll(registryDir, opts.verbose)
210
- const registryHosts = [...new Set(entries.map(e => e.to))]
227
+ const registryHosts = [...new Set(entries.map(e => e.to).filter(Boolean))]
211
228
  const primary = pickPrimaryRegistryHost(registryHosts)
212
229
  const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
213
230
 
214
- const sslDir = path.join(homedir(), '.stacks', 'ssl')
215
- const sharedCert = path.join(sslDir, 'rpx.localhost.crt')
216
-
217
- const proxyOpts: ProxyOptions = {
218
- https: typeof opts.https === 'object'
219
- ? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
220
- : {
221
- certPath: sharedCert,
222
- keyPath: path.join(sslDir, 'rpx.localhost.key'),
223
- caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
224
- commonName: primary,
225
- },
226
- verbose: opts.verbose,
227
- regenerateUntrustedCerts: true,
228
- ...(hostnames.length > 1
229
- ? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
230
- : { to: primary, from: 'localhost:1' }),
231
- }
231
+ const proxyOpts = buildRegistryTlsProxyOptions(registryHosts, primary, opts.verbose)
232
+ if (typeof opts.https === 'object' && typeof proxyOpts.https === 'object')
233
+ proxyOpts.https = { ...proxyOpts.https, ...opts.https }
232
234
 
233
235
  let sslConfig = await checkExistingCertificates(proxyOpts)
236
+ if (sslConfig && !certIncludesSanHostnames(SHARED_DEV_HOST_CERT_PATH, hostnames)) {
237
+ debugLog('daemon', `shared cert missing SANs for registry host(s), regenerating (${hostnames.join(', ')})`, opts.verbose)
238
+ clearSslConfigCache()
239
+ sslConfig = null
240
+ }
241
+
234
242
  if (!sslConfig) {
235
- debugLog('daemon', 'no usable cert on disk, generating one', opts.verbose)
236
- await generateCertificate(proxyOpts)
243
+ debugLog('daemon', 'no usable cert on disk, generating one via tlsx', opts.verbose)
244
+ await generateCertificate({ ...proxyOpts, forceRegenerate: true } as ProxyOptions & { forceRegenerate?: boolean })
237
245
  sslConfig = await checkExistingCertificates(proxyOpts)
238
246
  }
239
247
  if (!sslConfig)
@@ -333,10 +341,69 @@ async function elevateDaemonToRoot(
333
341
  * listeners are bound and the initial routing table is populated. Use
334
342
  * `handle.done` for the lifetime promise.
335
343
  */
344
+ let crashGuardsInstalled = false
345
+
346
+ /**
347
+ * A reverse proxy must outlive a single bad request. A stray uncaught exception
348
+ * or unhandled rejection — a malformed upstream response, a registry-watcher
349
+ * callback bug, a `void`-ed promise — would otherwise crash the whole daemon and
350
+ * drop :443 for *every* host behind it. Log and keep serving; systemd still
351
+ * restarts a genuinely fatal exit, but one bad request no longer takes the
352
+ * gateway down. Idempotent so the worker/coordinator entry points can each call
353
+ * it without stacking duplicate handlers.
354
+ */
355
+ function installDaemonCrashGuards(): void {
356
+ if (crashGuardsInstalled)
357
+ return
358
+ crashGuardsInstalled = true
359
+ process.on('uncaughtException', (err) => {
360
+ log.error(`rpx daemon: uncaught exception (continuing): ${(err as Error)?.stack ?? err}`)
361
+ })
362
+ process.on('unhandledRejection', (reason) => {
363
+ log.error(`rpx daemon: unhandled rejection (continuing): ${reason}`)
364
+ })
365
+ }
366
+
367
+ /**
368
+ * The shared `:80` handler: serve ACME http-01 challenges, kick off on-demand
369
+ * issuance for an approved-but-uncovered host, then 301 to HTTPS. The request
370
+ * target is parsed defensively — scanners constantly send malformed/relative
371
+ * targets, and a thrown `new URL` would reject the fetch handler and make Bun
372
+ * drop the connection with no response. A bad target becomes a 400 instead.
373
+ */
374
+ function handleHttpRedirect(req: Request, onDemand: OnDemandCertManager | null): Response {
375
+ let u: URL
376
+ try {
377
+ u = new URL(req.url)
378
+ }
379
+ catch {
380
+ return new Response('Bad Request', { status: 400 })
381
+ }
382
+ const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
383
+
384
+ if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
385
+ const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
386
+ if (keyAuth !== undefined)
387
+ return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
388
+ return new Response('challenge not found', { status: 404 })
389
+ }
390
+
391
+ // First plaintext hit for an approved-but-uncovered host: kick off issuance so
392
+ // the cert exists for the subsequent HTTPS request (don't block the redirect).
393
+ if (onDemand && !onDemand.hasCert(host))
394
+ onDemand.ensureCert(host).catch(() => {})
395
+
396
+ return new Response(null, {
397
+ status: 301,
398
+ headers: { Location: `https://${host}${u.pathname}${u.search}` },
399
+ })
400
+ }
401
+
336
402
  // `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
337
403
  // the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
338
404
  // eslint-disable-next-line pickier/no-unused-vars
339
405
  export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
406
+ installDaemonCrashGuards()
340
407
  const verbose = opts.verbose ?? false
341
408
  const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
342
409
  const registryDir = opts.registryDir ?? path.join(rpxDir, 'registry.d')
@@ -345,6 +412,20 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
345
412
  const hostname = opts.hostname ?? '0.0.0.0'
346
413
  const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
347
414
 
415
+ // A spawned cluster worker (RPX_DAEMON_WORKER=1) serves :443 only and is
416
+ // configured entirely via RPX_WORKER_* env (set by the coordinator). Handled
417
+ // before the privileged-port check because a worker never self-elevates — it
418
+ // inherits the coordinator's privileges and gets its port from env.
419
+ if (process.env.RPX_DAEMON_WORKER === '1') {
420
+ return runDaemonWorker({
421
+ rpxDir: process.env.RPX_WORKER_RPXDIR ?? rpxDir,
422
+ registryDir: process.env.RPX_WORKER_REGISTRYDIR ?? registryDir,
423
+ httpsPort: Number.parseInt(process.env.RPX_WORKER_HTTPSPORT ?? '', 10) || httpsPort,
424
+ hostname: process.env.RPX_WORKER_HOSTNAME ?? hostname,
425
+ verbose: process.env.RPX_WORKER_VERBOSE === '1' || verbose,
426
+ })
427
+ }
428
+
348
429
  // Privileged ports need root. If we were launched unprivileged (the usual
349
430
  // `./buddy dev` case), re-exec through sudo and hand off to the elevated
350
431
  // copy — it becomes the real daemon. Tests inject high ports and so skip this.
@@ -353,6 +434,11 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
353
434
  if (process.platform !== 'win32' && needsPrivilegedPort && !alreadyRoot)
354
435
  return elevateDaemonToRoot(rpxDir, httpsPort, httpPort, verbose)
355
436
 
437
+ // Cluster coordinator: owns the singletons and spawns N workers that bind :443.
438
+ const workers = Math.max(1, opts.workers ?? (Number.parseInt(process.env.RPX_WORKERS ?? '', 10) || 1))
439
+ if (workers > 1)
440
+ return runDaemonCoordinator(opts, { rpxDir, registryDir, httpsPort, httpPort, hostname, verbose, gcIntervalMs, workers })
441
+
356
442
  const pidPath = await acquireDaemonLock(rpxDir)
357
443
 
358
444
  // Module-scoped state so the watcher and fetch handler share one routing view.
@@ -421,7 +507,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
421
507
  : null
422
508
 
423
509
  /** Build the TLS option for Bun.serve from the current SNI set (or dev cert). */
424
- function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): unknown {
510
+ function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): Bun.TLSOptions | Bun.TLSOptions[] {
425
511
  if (entries.length > 0)
426
512
  return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
427
513
  // No real certs: fall back to the dev self-signed shared cert.
@@ -439,7 +525,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
439
525
  return Bun.serve({
440
526
  port: httpsPort,
441
527
  hostname,
442
- tls: tlsFor(entries) as any,
528
+ // Opt-in (RPX_REUSE_PORT): multi-instance :443 sharing on Linux. Off by
529
+ // default — see shouldReusePort(). rpx never spawns its own cluster.
530
+ reusePort: shouldReusePort(),
531
+ tls: tlsFor(entries),
443
532
  fetch(req: Request, server: unknown) {
444
533
  return fetchHandler(req, server as ProxyServerLike)
445
534
  },
@@ -451,7 +540,20 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
451
540
  })
452
541
  }
453
542
 
454
- let httpsServer = serveHttps(onDemand ? onDemand.sniEntries() : sniTls)
543
+ const registryHostsForTls = (entries: RegistryEntry[]) =>
544
+ [...new Set(entries.map(e => e.to).filter(Boolean))]
545
+
546
+ const devTlsEntries = (entries: RegistryEntry[]) => {
547
+ if (!devSslConfig)
548
+ return sniTls
549
+ return devSslToSniEntries([...registryHostsForTls(entries), 'rpx.localhost'], devSslConfig)
550
+ }
551
+
552
+ let httpsServer = serveHttps(
553
+ onDemand
554
+ ? onDemand.sniEntries()
555
+ : (sniTls.length > 0 ? sniTls : devTlsEntries(initialEntries)),
556
+ )
455
557
 
456
558
  /**
457
559
  * Bun has no working SNICallback and `server.reload({ tls })` does not update
@@ -488,28 +590,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
488
590
  port: httpPort,
489
591
  hostname,
490
592
  fetch(req: Request) {
491
- const u = new URL(req.url)
492
- const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
493
-
494
- // Serve ACME http-01 challenges for in-flight on-demand issuances.
495
- if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
496
- const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
497
- if (keyAuth !== undefined)
498
- return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
499
- return new Response('challenge not found', { status: 404 })
500
- }
501
-
502
- // First plaintext hit for an approved-but-uncovered host: kick off
503
- // issuance so the cert exists for the subsequent HTTPS request. We don't
504
- // block the redirect on it (the browser retries over HTTPS anyway).
505
- if (onDemand && !onDemand.hasCert(host)) {
506
- onDemand.ensureCert(host).catch(() => {})
507
- }
508
-
509
- return new Response(null, {
510
- status: 301,
511
- headers: { Location: `https://${host}${u.pathname}${u.search}` },
512
- })
593
+ return handleHttpRedirect(req, onDemand)
594
+ },
595
+ error() {
596
+ return new Response('Bad Request', { status: 400 })
513
597
  },
514
598
  })
515
599
  }
@@ -520,9 +604,23 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
520
604
  log.info(`registry: ${registryDir}`)
521
605
  }
522
606
 
607
+ async function syncDevTlsWithRegistry(entries: RegistryEntry[]): Promise<void> {
608
+ if (stopped || sniTls.length > 0 || onDemand || !devSslConfig)
609
+ return
610
+ try {
611
+ const refreshed = await bootstrapTls(opts, registryDir)
612
+ devSslConfig = refreshed
613
+ await rebuildTls(devTlsEntries(entries))
614
+ }
615
+ catch (err) {
616
+ debugLog('daemon', `TLS sync on registry change failed: ${err}`, verbose)
617
+ }
618
+ }
619
+
523
620
  const watcher = watchRegistry(
524
621
  (entries) => {
525
622
  rebuild(entries)
623
+ void syncDevTlsWithRegistry(entries)
526
624
  syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
527
625
  debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
528
626
  })
@@ -584,6 +682,331 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
584
682
  }
585
683
  }
586
684
 
685
+ // ───────────────────────── cluster: coordinator + workers ─────────────────────
686
+
687
+ interface WorkerCtx {
688
+ rpxDir: string
689
+ registryDir: string
690
+ httpsPort: number
691
+ hostname: string
692
+ verbose: boolean
693
+ }
694
+
695
+ interface CoordinatorCtx extends WorkerCtx {
696
+ httpPort: number
697
+ gcIntervalMs: number
698
+ workers: number
699
+ }
700
+
701
+ type SniEntry = { serverName: string, cert: string, key: string }
702
+ interface ClusterSni { sni: SniEntry[], dev: { key: string, cert: string, ca?: string } | null }
703
+
704
+ /** Path of the file the coordinator publishes the live SNI set to for workers. */
705
+ function clusterSniPath(rpxDir: string): string {
706
+ return path.join(rpxDir, 'cluster-sni.json')
707
+ }
708
+
709
+ /** Atomically publish the current cert material so a worker never reads a partial file. */
710
+ async function writeClusterSni(rpxDir: string, sni: SniEntry[], dev: ClusterSni['dev']): Promise<void> {
711
+ const target = clusterSniPath(rpxDir)
712
+ const tmp = `${target}.${process.pid}.tmp`
713
+ await fsp.writeFile(tmp, JSON.stringify({ sni, dev } satisfies ClusterSni), 'utf8')
714
+ await fsp.rename(tmp, target)
715
+ }
716
+
717
+ async function readClusterSni(rpxDir: string): Promise<ClusterSni> {
718
+ try {
719
+ return JSON.parse(await fsp.readFile(clusterSniPath(rpxDir), 'utf8')) as ClusterSni
720
+ }
721
+ catch {
722
+ return { sni: [], dev: null }
723
+ }
724
+ }
725
+
726
+ /** Build the Bun.serve `tls` option from a published SNI set (or the dev fallback). */
727
+ function clusterTlsFor(cfg: ClusterSni): Bun.TLSOptions | Bun.TLSOptions[] | undefined {
728
+ if (cfg.sni.length > 0)
729
+ return cfg.sni.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
730
+ if (cfg.dev)
731
+ return { key: cfg.dev.key, cert: cfg.dev.cert, ca: cfg.dev.ca, requestCert: false, rejectUnauthorized: false }
732
+ return undefined // no certs published yet; handshakes fail until the first SIGHUP reload
733
+ }
734
+
735
+ /**
736
+ * Command used to re-exec this process as a cluster worker. `RPX_WORKER_BIN`
737
+ * overrides the script path — needed when `argv[1]` isn't the rpx entrypoint
738
+ * (e.g. under a test runner, or an unusual deployment layout).
739
+ */
740
+ function workerSpawnCommand(): string[] {
741
+ const exec = process.execPath
742
+ const binOverride = process.env.RPX_WORKER_BIN
743
+ if (binOverride)
744
+ return [exec, binOverride, 'daemon:start']
745
+ const interpName = path.basename(exec).toLowerCase()
746
+ const isInterpreter = interpName === 'bun' || interpName === 'node' || interpName.startsWith('bun-')
747
+ if (isInterpreter && process.argv[1])
748
+ return [exec, process.argv[1], 'daemon:start']
749
+ return [exec, 'daemon:start']
750
+ }
751
+
752
+ /**
753
+ * A cluster worker: binds :443 with `reusePort`, serves the proxy handler, keeps
754
+ * its routing table in sync with the registry, and reloads its TLS certs from
755
+ * the coordinator-published file on `SIGHUP`. It owns none of the singletons
756
+ * (lock, DNS, hosts, :80, ACME issuance) — the coordinator does.
757
+ */
758
+ export async function runDaemonWorker(ctx: WorkerCtx): Promise<DaemonHandle> {
759
+ installDaemonCrashGuards()
760
+ const { rpxDir, registryDir, httpsPort, hostname, verbose } = ctx
761
+
762
+ let routingTable: HostRoutes<ProxyRoute> = new Map()
763
+ const getRoute = (host: string, pathname: string): ProxyRoute | undefined =>
764
+ matchHostRoute(routingTable, host, pathname)
765
+ const rebuild = (entries: RegistryEntry[]): void => {
766
+ routingTable = buildHostRoutes(entries.map(e => ({ host: e.to, path: e.path, route: entryToRoute(e) })))
767
+ }
768
+ rebuild(await readAll(registryDir, verbose))
769
+
770
+ const fetchHandler = createProxyFetchHandler(getRoute, verbose)
771
+ const wsHandler = createProxyWebSocketHandler(verbose)
772
+
773
+ let stopped = false
774
+ const serve = (cfg: ClusterSni): ReturnType<typeof Bun.serve> => Bun.serve({
775
+ port: httpsPort,
776
+ hostname,
777
+ reusePort: true, // workers share :443; the kernel load-balances (on Linux)
778
+ tls: clusterTlsFor(cfg),
779
+ fetch(req: Request, server: unknown) {
780
+ return fetchHandler(req, server as ProxyServerLike)
781
+ },
782
+ websocket: wsHandler,
783
+ error(err: Error) {
784
+ debugLog('daemon', `worker https error: ${err}`, verbose)
785
+ return new Response(`Server Error: ${err.message}`, { status: 500 })
786
+ },
787
+ })
788
+
789
+ let httpsServer = serve(await readClusterSni(rpxDir))
790
+
791
+ // Bun can't hot-swap TLS, so reload = tear down + re-bind with the new certs
792
+ // (same approach as the solo daemon's rebuildTls). reusePort keeps the rebind
793
+ // from racing sibling workers off the port.
794
+ async function reloadTls(): Promise<void> {
795
+ if (stopped)
796
+ return
797
+ const cfg = await readClusterSni(rpxDir)
798
+ httpsServer.stop(false)
799
+ for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
800
+ try {
801
+ httpsServer = serve(cfg)
802
+ return
803
+ }
804
+ catch {
805
+ await new Promise(resolve => setTimeout(resolve, 25))
806
+ }
807
+ }
808
+ }
809
+
810
+ const watcher = watchRegistry(entries => rebuild(entries), { dir: registryDir, verbose })
811
+ const onHup = (): void => { reloadTls().catch(() => {}) }
812
+ process.on('SIGHUP', onHup)
813
+
814
+ let resolveDone!: () => void
815
+ const done = new Promise<void>((r) => { resolveDone = r })
816
+ async function stop(): Promise<void> {
817
+ if (stopped)
818
+ return done
819
+ stopped = true
820
+ process.off('SIGHUP', onHup)
821
+ watcher.close()
822
+ httpsServer.stop(false)
823
+ resolveDone()
824
+ return done
825
+ }
826
+ const onSignal = (): void => { stop().then(() => process.exit(0)).catch(() => process.exit(0)) }
827
+ process.once('SIGTERM', onSignal)
828
+ process.once('SIGINT', onSignal)
829
+
830
+ if (verbose)
831
+ log.success(`rpx worker (pid ${process.pid}) serving :${httpsPort}`)
832
+
833
+ return {
834
+ stop,
835
+ done,
836
+ httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
837
+ httpPort: 0,
838
+ pidPath: '',
839
+ ensureCert: () => Promise.resolve(false),
840
+ }
841
+ }
842
+
843
+ /**
844
+ * The cluster coordinator: owns the lock, certs, DNS, hosts, registry GC, and the
845
+ * :80 listener (ACME http-01 + HTTP→HTTPS redirect). It does NOT bind :443 —
846
+ * instead it spawns {@link CoordinatorCtx.workers} worker processes that do, and
847
+ * republishes the SNI set (+ SIGHUPs the workers) whenever an on-demand cert is
848
+ * issued. Workers that crash are respawned.
849
+ */
850
+ async function runDaemonCoordinator(opts: DaemonOptions, ctx: CoordinatorCtx): Promise<DaemonHandle> {
851
+ installDaemonCrashGuards()
852
+ const { rpxDir, registryDir, httpsPort, httpPort, hostname, verbose, gcIntervalMs, workers } = ctx
853
+ const pidPath = await acquireDaemonLock(rpxDir)
854
+
855
+ // Bootstrap certs to disk + assemble the initial SNI set.
856
+ let sniTls: SniEntry[] = []
857
+ if (opts.productionCerts)
858
+ sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
859
+ let devSslConfig: SSLConfig | null = null
860
+ if (sniTls.length === 0)
861
+ devSslConfig = await bootstrapTls(opts, registryDir)
862
+ const dev: ClusterSni['dev'] = devSslConfig
863
+ ? { key: devSslConfig.key, cert: devSslConfig.cert, ca: Array.isArray(devSslConfig.ca) ? devSslConfig.ca.join('\n') : devSslConfig.ca }
864
+ : null
865
+
866
+ let stopped = false
867
+ const procs: import('bun').Subprocess[] = []
868
+ function signalWorkers(sig: NodeJS.Signals): void {
869
+ for (const p of procs) {
870
+ try { p.kill(sig) }
871
+ catch { /* already gone */ }
872
+ }
873
+ }
874
+ /** Republish certs then tell workers to reload. */
875
+ async function publishSni(entries: SniEntry[]): Promise<void> {
876
+ await writeClusterSni(rpxDir, entries, dev)
877
+ signalWorkers('SIGHUP')
878
+ }
879
+
880
+ // On-demand TLS lives on the coordinator (it owns :80 + ACME). New certs are
881
+ // republished to the workers.
882
+ const onDemandCfg = opts.onDemandTls
883
+ const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
884
+ ? new OnDemandCertManager({
885
+ config: onDemandCfg,
886
+ certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
887
+ initial: sniTls,
888
+ verbose,
889
+ onCertAdded: entries => { void publishSni(entries) },
890
+ })
891
+ : null
892
+
893
+ await writeClusterSni(rpxDir, onDemand ? onDemand.sniEntries() : sniTls, dev)
894
+
895
+ // DNS + hosts + registry GC (workers handle routing themselves).
896
+ const initialEntries = await readAll(registryDir, verbose)
897
+ await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
898
+ debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
899
+ })
900
+ await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
901
+ debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
902
+ })
903
+ await gcStaleEntries(registryDir, verbose).catch(() => {})
904
+ const watcher = watchRegistry(
905
+ (entries) => {
906
+ syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
907
+ debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
908
+ })
909
+ },
910
+ { dir: registryDir, verbose },
911
+ )
912
+ const gcInterval = setInterval(() => {
913
+ gcStaleEntries(registryDir, verbose).catch(() => {})
914
+ }, gcIntervalMs)
915
+ gcInterval.unref?.()
916
+
917
+ // :80 — ACME http-01 challenges + HTTP→HTTPS redirect (kicks off on-demand).
918
+ let httpServer: ReturnType<typeof Bun.serve> | null = null
919
+ if (httpPort > 0) {
920
+ httpServer = Bun.serve({
921
+ port: httpPort,
922
+ hostname,
923
+ fetch(req: Request) {
924
+ return handleHttpRedirect(req, onDemand)
925
+ },
926
+ error() {
927
+ return new Response('Bad Request', { status: 400 })
928
+ },
929
+ })
930
+ }
931
+
932
+ // Spawn (and keep alive) the workers.
933
+ function spawnWorker(): void {
934
+ if (stopped)
935
+ return
936
+ const env: Record<string, string> = {
937
+ ...process.env as Record<string, string>,
938
+ RPX_DAEMON_WORKER: '1',
939
+ RPX_WORKERS: '1', // the child must not recurse into coordinator mode
940
+ RPX_WORKER_RPXDIR: rpxDir,
941
+ RPX_WORKER_REGISTRYDIR: registryDir,
942
+ RPX_WORKER_HTTPSPORT: String(httpsPort),
943
+ RPX_WORKER_HOSTNAME: hostname,
944
+ RPX_WORKER_VERBOSE: verbose ? '1' : '0',
945
+ }
946
+ const proc = Bun.spawn(workerSpawnCommand(), {
947
+ env,
948
+ stdout: 'inherit',
949
+ stderr: 'inherit',
950
+ stdin: 'ignore',
951
+ onExit(_proc, code) {
952
+ if (!stopped) {
953
+ debugLog('daemon', `worker exited (code ${code}); respawning`, verbose)
954
+ spawnWorker()
955
+ }
956
+ },
957
+ })
958
+ procs.push(proc)
959
+ }
960
+ for (let i = 0; i < workers; i++)
961
+ spawnWorker()
962
+
963
+ if (verbose) {
964
+ log.success(`rpx coordinator listening on https://${hostname}:${httpsPort} via ${workers} worker(s)${httpServer ? ` (http→https on :${httpPort})` : ''}`)
965
+ log.info(`pid file: ${pidPath}`)
966
+ }
967
+
968
+ let resolveDone!: () => void
969
+ const done = new Promise<void>((r) => { resolveDone = r })
970
+ async function stop(): Promise<void> {
971
+ if (stopped)
972
+ return done
973
+ stopped = true
974
+ clearInterval(gcInterval)
975
+ watcher.close()
976
+ httpServer?.stop(false)
977
+ signalWorkers('SIGTERM')
978
+ await Promise.race([
979
+ Promise.all(procs.map(p => p.exited)),
980
+ new Promise(resolve => setTimeout(resolve, 3000)),
981
+ ])
982
+ signalWorkers('SIGKILL')
983
+ await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
984
+ debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
985
+ })
986
+ await releaseDaemonLock(rpxDir)
987
+ await fsp.unlink(clusterSniPath(rpxDir)).catch(() => {})
988
+ if (verbose)
989
+ log.info('rpx coordinator stopped')
990
+ resolveDone()
991
+ return done
992
+ }
993
+ const onSignal = (sig: NodeJS.Signals): void => {
994
+ debugLog('daemon', `coordinator received ${sig}, shutting down`, verbose)
995
+ stop().catch(() => {})
996
+ }
997
+ process.once('SIGINT', onSignal)
998
+ process.once('SIGTERM', onSignal)
999
+
1000
+ return {
1001
+ stop,
1002
+ done,
1003
+ httpsPort,
1004
+ httpPort,
1005
+ pidPath,
1006
+ ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
1007
+ }
1008
+ }
1009
+
587
1010
  export interface EnsureDaemonOptions {
588
1011
  /** Override `~/.stacks/rpx`. */
589
1012
  rpxDir?: string