@stacksjs/rpx 0.11.19 → 0.11.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/acme-challenge.d.ts +13 -0
  2. package/dist/auth.d.ts +21 -0
  3. package/dist/bin/cli.js +252 -200
  4. package/dist/cert-inspect.d.ts +5 -3
  5. package/dist/{chunk-83dqq28c.js → chunk-1108y1dk.js} +1 -1
  6. package/dist/{chunk-0f32jmrb.js → chunk-c9brkawa.js} +1 -1
  7. package/dist/{chunk-w888yhnp.js → chunk-cqkz06bg.js} +1 -1
  8. package/dist/chunk-qs36t2rf.js +224 -0
  9. package/dist/daemon.d.ts +4 -1
  10. package/dist/https.d.ts +1 -0
  11. package/dist/index.d.ts +33 -1
  12. package/dist/index.js +7 -7
  13. package/dist/proxy-handler.d.ts +18 -1
  14. package/dist/proxy-pool.d.ts +5 -0
  15. package/dist/redirect.d.ts +40 -0
  16. package/dist/registry.d.ts +2 -1
  17. package/dist/site-resolver.d.ts +106 -0
  18. package/dist/site-splash.d.ts +19 -0
  19. package/dist/site-supervisor.d.ts +56 -0
  20. package/dist/start.d.ts +31 -8
  21. package/dist/types.d.ts +64 -0
  22. package/package.json +6 -7
  23. package/dist/chunk-rs8gqpax.js +0 -174
  24. package/src/cert-inspect.ts +0 -69
  25. package/src/colors.ts +0 -13
  26. package/src/config.ts +0 -45
  27. package/src/daemon-runner.ts +0 -180
  28. package/src/daemon.ts +0 -1209
  29. package/src/dns-state.ts +0 -116
  30. package/src/dns.ts +0 -568
  31. package/src/host-match.ts +0 -52
  32. package/src/host-routes.ts +0 -147
  33. package/src/hosts.ts +0 -283
  34. package/src/https.ts +0 -905
  35. package/src/index.ts +0 -161
  36. package/src/logger.ts +0 -19
  37. package/src/macos-trust.ts +0 -175
  38. package/src/on-demand.ts +0 -264
  39. package/src/origin-guard.ts +0 -127
  40. package/src/port-manager.ts +0 -183
  41. package/src/process-manager.ts +0 -164
  42. package/src/proxy-handler.ts +0 -387
  43. package/src/proxy-pool.ts +0 -1003
  44. package/src/registry.ts +0 -366
  45. package/src/sni.ts +0 -93
  46. package/src/start.ts +0 -1421
  47. package/src/static-files.ts +0 -201
  48. package/src/types.ts +0 -267
  49. package/src/utils.ts +0 -243
package/src/daemon.ts DELETED
@@ -1,1209 +0,0 @@
1
- /**
2
- * The rpx daemon: a single long-running process that fronts :443 and :80, holds
3
- * the shared Root CA + host cert, and routes traffic per the registry.
4
- *
5
- * Lifecycle:
6
- * 1. acquireDaemonLock() — atomic create of `daemon.pid` (or take over a
7
- * stale one whose writer is gone). Bails if a healthy daemon is already
8
- * running.
9
- * 2. Bootstrap TLS (reuses the Root CA persisted by https.ts).
10
- * 3. Bun.serve :443 with the proxy fetch handler; HTTP→HTTPS redirect on :80.
11
- * 4. Watch the registry, rebuild the routing table on every change. Periodic
12
- * PID GC reaps entries from writers that died `kill -9`.
13
- * 5. SIGINT/SIGTERM → drain in-flight, release lock, exit 0.
14
- *
15
- * Tests inject a `rpxDir`/`registryDir`/non-priv ports, so all the heavy I/O
16
- * paths are reachable without touching `~/.stacks/rpx` or :443.
17
- */
18
- /* eslint-disable no-console */
19
- import type { OnDemandTlsConfig, ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
20
- import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
21
- import { spawn as nodeSpawn } from 'node:child_process'
22
- import * as fsp from 'node:fs/promises'
23
- import { homedir } from 'node:os'
24
- import * as path from 'node:path'
25
- import * as process from 'node:process'
26
- import { log } from './logger'
27
- import {
28
- buildRegistryTlsProxyOptions,
29
- certIncludesSanHostnames,
30
- checkExistingCertificates,
31
- clearSslConfigCache,
32
- devSslToSniEntries,
33
- generateCertificate,
34
- SHARED_DEV_HOST_CERT_PATH,
35
- } from './https'
36
- import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
37
- import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
38
- import type { HostRoutes } from './host-routes'
39
- import { buildSniTlsConfig } from './sni'
40
- import { OnDemandCertManager } from './on-demand'
41
- import { resolveStaticRoute } from './static-files'
42
- import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
43
- import type { RegistryEntry } from './registry'
44
- import {
45
- reconcileStaleDevelopmentDns,
46
- syncDevelopmentDnsFromRegistry,
47
- tearDownDevelopmentDns,
48
- } from './dns'
49
- import { debugLog, shouldReusePort } from './utils'
50
-
51
- export interface DaemonOptions {
52
- verbose?: boolean
53
- /** Override `~/.stacks/rpx`. Used by tests to avoid touching the real dir. */
54
- rpxDir?: string
55
- /** Override the registry directory. Defaults to `<rpxDir>/registry.d`. */
56
- registryDir?: string
57
- /** HTTPS listen port. Defaults to 443. */
58
- httpsPort?: number
59
- /** HTTP redirect port. Defaults to 80. Pass 0 to skip the redirect server. */
60
- httpPort?: number
61
- /** Listener bind address. Defaults to `0.0.0.0`. */
62
- hostname?: string
63
- /** TLS bootstrap options forwarded to httpsConfig. */
64
- https?: TlsOption
65
- /**
66
- * Production per-domain SNI certs (real PEMs on disk). When usable certs are
67
- * found, the listener serves them per SNI server name instead of the dev
68
- * self-signed shared cert.
69
- */
70
- productionCerts?: ProductionTlsConfig
71
- /**
72
- * On-demand TLS: lazily issue real certs for approved unknown hosts via ACME
73
- * http-01 (served from this daemon's `:80` listener). Opt-in via `enabled`.
74
- * Seeded with the `productionCerts`/`certsDir` certs already on disk.
75
- */
76
- onDemandTls?: OnDemandTlsConfig
77
- /** PID-GC interval in ms. Defaults to 5000. */
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
88
- }
89
-
90
- export interface DaemonHandle {
91
- /** Stop the daemon, drain in-flight, release the lock. */
92
- stop: () => Promise<void>
93
- /** Resolves when the daemon has fully shut down. */
94
- done: Promise<void>
95
- httpsPort: number
96
- httpPort: number
97
- pidPath: string
98
- /**
99
- * Pre-warm an on-demand cert for `host` (issue it now if approved & missing,
100
- * rebuilding the `:443` listener). Resolves `true` if a cert is available
101
- * afterwards. No-op resolving `false` when on-demand TLS isn't enabled. Lets a
102
- * tunnel server warm a subdomain's cert at registration time.
103
- */
104
- ensureCert: (host: string) => Promise<boolean>
105
- }
106
-
107
- const DEFAULT_GC_INTERVAL_MS = 5000
108
-
109
- export function getDaemonRpxDir(): string {
110
- return path.join(homedir(), '.stacks', 'rpx')
111
- }
112
-
113
- export function getDaemonPidPath(rpxDir: string = getDaemonRpxDir()): string {
114
- return path.join(rpxDir, 'daemon.pid')
115
- }
116
-
117
- /**
118
- * Read the PID stored in `daemon.pid`, or `null` if no file / unparseable.
119
- */
120
- export async function readDaemonPid(rpxDir: string = getDaemonRpxDir()): Promise<number | null> {
121
- try {
122
- const raw = await fsp.readFile(getDaemonPidPath(rpxDir), 'utf8')
123
- const n = Number.parseInt(raw.trim(), 10)
124
- if (!Number.isFinite(n) || n <= 0)
125
- return null
126
- return n
127
- }
128
- catch (err) {
129
- if ((err as NodeJS.ErrnoException).code === 'ENOENT')
130
- return null
131
- throw err
132
- }
133
- }
134
-
135
- /**
136
- * True if `daemon.pid` points at a process that is still alive.
137
- */
138
- export async function isDaemonRunning(rpxDir: string = getDaemonRpxDir()): Promise<boolean> {
139
- const pid = await readDaemonPid(rpxDir)
140
- return pid !== null && isPidAlive(pid)
141
- }
142
-
143
- /**
144
- * Acquire the daemon's single-instance lock by atomically creating
145
- * `daemon.pid`. If the file exists but holds a stale PID we take it over;
146
- * otherwise we throw.
147
- *
148
- * `O_CREAT | O_EXCL` (`'wx'`) guarantees only one process wins the create
149
- * race, so we don't need an external lock library.
150
- */
151
- export async function acquireDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<string> {
152
- await fsp.mkdir(rpxDir, { recursive: true })
153
- const pidPath = getDaemonPidPath(rpxDir)
154
-
155
- while (true) {
156
- try {
157
- const fh = await fsp.open(pidPath, 'wx')
158
- try {
159
- await fh.write(`${process.pid}\n`)
160
- }
161
- finally {
162
- await fh.close()
163
- }
164
- return pidPath
165
- }
166
- catch (err) {
167
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST')
168
- throw err
169
- }
170
-
171
- // File exists — figure out whether it's a real owner or a stale leftover.
172
- const existing = await readDaemonPid(rpxDir)
173
- if (existing !== null && isPidAlive(existing))
174
- throw new Error(`rpx daemon already running (pid=${existing})`)
175
-
176
- // Stale: remove and retry. The retry loses the race iff a different
177
- // process recreates the file in between, which we'll detect on the next
178
- // iteration.
179
- await fsp.unlink(pidPath).catch(() => {})
180
- }
181
- }
182
-
183
- export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<void> {
184
- await fsp.unlink(getDaemonPidPath(rpxDir)).catch(() => {})
185
- }
186
-
187
- /**
188
- * Translate a registry entry into the routing shape consumed by the proxy
189
- * fetch handler. The entry's `from` is normalized to `host:port`.
190
- */
191
- function entryToRoute(entry: RegistryEntry): ProxyRoute {
192
- const cleanUrls = entry.cleanUrls ?? false
193
- const basePath = normalizePathPrefix(entry.path)
194
- if (entry.static) {
195
- return {
196
- static: resolveStaticRoute(entry.static, cleanUrls),
197
- cleanUrls,
198
- basePath,
199
- }
200
- }
201
- const from = entry.from ?? 'localhost:1'
202
- const fromUrl = new URL(from.startsWith('http') ? from : `http://${from}`)
203
- return {
204
- sourceHost: fromUrl.host,
205
- cleanUrls,
206
- changeOrigin: entry.changeOrigin ?? false,
207
- pathRewrites: entry.pathRewrites,
208
- basePath,
209
- }
210
- }
211
-
212
- /**
213
- * Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
214
- * existing trusted host cert; mints fresh ones if none exist.
215
- *
216
- * The host cert SAN list includes every hostname in the registry (e.g.
217
- * `postline.localhost`, `api.postline.localhost`). Chrome does not treat
218
- * `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
219
- */
220
- function pickPrimaryRegistryHost(hosts: string[]): string {
221
- const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
222
- return appHost ?? hosts[0] ?? 'rpx.localhost'
223
- }
224
-
225
- async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
226
- const entries = await readAll(registryDir, opts.verbose)
227
- const registryHosts = [...new Set(entries.map(e => e.to).filter(Boolean))]
228
- const primary = pickPrimaryRegistryHost(registryHosts)
229
- const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
230
-
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 }
234
-
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
-
242
- if (!sslConfig) {
243
- debugLog('daemon', 'no usable cert on disk, generating one via tlsx', opts.verbose)
244
- await generateCertificate({ ...proxyOpts, forceRegenerate: true } as ProxyOptions & { forceRegenerate?: boolean })
245
- sslConfig = await checkExistingCertificates(proxyOpts)
246
- }
247
- if (!sslConfig)
248
- throw new Error('failed to bootstrap TLS for rpx daemon')
249
- return sslConfig
250
- }
251
-
252
- /**
253
- * Binding :443/:80 requires root. When the daemon is launched as a normal user
254
- * (the common case — `./buddy dev`), re-exec it through `sudo` so the elevated
255
- * copy can bind the privileged ports. HOME/PATH are forwarded explicitly (via
256
- * `env`) so the root daemon reads the *user's* `~/.stacks/rpx` state, certs and
257
- * registry instead of root's home. The password is fed on stdin only — never
258
- * placed in argv — so it can't leak via `ps`, and the root daemon doesn't need
259
- * it (it can already sudo).
260
- *
261
- * Returns a launcher handle: this unprivileged process has done its job once
262
- * the elevated daemon has written its pid, so `done` resolves immediately and
263
- * the launcher exits, leaving the root daemon running independently (its pid
264
- * file is how everyone else finds it).
265
- */
266
- async function elevateDaemonToRoot(
267
- rpxDir: string,
268
- httpsPort: number,
269
- httpPort: number,
270
- verbose: boolean,
271
- ): Promise<DaemonHandle> {
272
- const sudoPassword = process.env.SUDO_PASSWORD
273
- const home = process.env.HOME ?? homedir()
274
- const inner = [process.execPath, ...process.argv.slice(1)]
275
- const forwardedEnv = [`HOME=${home}`, `PATH=${process.env.PATH ?? ''}`]
276
- if (verbose)
277
- forwardedEnv.push('RPX_VERBOSE=1')
278
-
279
- // `sudo -S` reads the password from stdin; `-n` (no password) relies on a
280
- // cached credential. Either way we never block on an interactive prompt.
281
- const sudoArgs = sudoPassword
282
- ? ['-S', '-p', '', 'env', ...forwardedEnv, ...inner]
283
- : ['-n', 'env', ...forwardedEnv, ...inner]
284
-
285
- debugLog('daemon', `elevating daemon via sudo for privileged ports ${httpsPort}/${httpPort}`, verbose)
286
- const child = nodeSpawn('sudo', sudoArgs, { detached: true, stdio: ['pipe', 'ignore', 'ignore'] })
287
-
288
- let spawnError: Error | null = null
289
- let sudoExitCode: number | null = null
290
- child.once('error', (err) => { spawnError = err })
291
- child.once('exit', (code) => { sudoExitCode = code ?? 0 })
292
-
293
- if (sudoPassword && child.stdin) {
294
- child.stdin.write(`${sudoPassword}\n`)
295
- child.stdin.end()
296
- }
297
- child.unref()
298
-
299
- const pidPath = getDaemonPidPath(rpxDir)
300
- const deadline = Date.now() + 15000
301
- while (Date.now() < deadline) {
302
- if (spawnError)
303
- throw spawnError
304
- const pid = await readDaemonPid(rpxDir)
305
- if (pid !== null && isPidAlive(pid)) {
306
- if (verbose)
307
- log.success(`rpx daemon elevated to root (pid=${pid}, https on :${httpsPort})`)
308
- return {
309
- httpsPort,
310
- httpPort,
311
- pidPath,
312
- done: Promise.resolve(),
313
- stop: async () => {
314
- // The daemon is root-owned; a normal user can't signal it. `./buddy
315
- // dev` intentionally leaves the shared daemon running across sessions.
316
- try { process.kill(pid, 'SIGTERM') }
317
- catch { /* EPERM — root-owned shared daemon */ }
318
- },
319
- // On-demand issuance runs inside the elevated child's own runDaemon
320
- // handle; this caller-side stub can't reach it directly.
321
- ensureCert: () => Promise.resolve(false),
322
- }
323
- }
324
- // sudo exits fast when auth fails; while the daemon runs it stays alive.
325
- if (sudoExitCode !== null && sudoExitCode !== 0) {
326
- throw new Error(
327
- `rpx daemon could not elevate to bind :${httpsPort} (sudo exited ${sudoExitCode}). `
328
- + 'Set SUDO_PASSWORD in .env or run `sudo -v` first.',
329
- )
330
- }
331
- await new Promise(resolve => setTimeout(resolve, 50))
332
- }
333
- throw new Error(`rpx daemon failed to elevate within 15000ms (rpxDir=${rpxDir})`)
334
- }
335
-
336
- /**
337
- * Start the daemon. Returns a handle that resolves `done` once the daemon has
338
- * cleanly shut down (signal received and listeners closed).
339
- *
340
- * The promise itself resolves as soon as the daemon is *ready* — i.e. both
341
- * listeners are bound and the initial routing table is populated. Use
342
- * `handle.done` for the lifetime promise.
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
-
402
- // `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
403
- // the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
404
- // eslint-disable-next-line pickier/no-unused-vars
405
- export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
406
- installDaemonCrashGuards()
407
- const verbose = opts.verbose ?? false
408
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
409
- const registryDir = opts.registryDir ?? path.join(rpxDir, 'registry.d')
410
- const httpsPort = opts.httpsPort ?? 443
411
- const httpPort = opts.httpPort ?? 80
412
- const hostname = opts.hostname ?? '0.0.0.0'
413
- const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
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
-
429
- // Privileged ports need root. If we were launched unprivileged (the usual
430
- // `./buddy dev` case), re-exec through sudo and hand off to the elevated
431
- // copy — it becomes the real daemon. Tests inject high ports and so skip this.
432
- const needsPrivilegedPort = (httpsPort > 0 && httpsPort < 1024) || (httpPort > 0 && httpPort < 1024)
433
- const alreadyRoot = typeof process.getuid === 'function' && process.getuid() === 0
434
- if (process.platform !== 'win32' && needsPrivilegedPort && !alreadyRoot)
435
- return elevateDaemonToRoot(rpxDir, httpsPort, httpPort, verbose)
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
-
442
- const pidPath = await acquireDaemonLock(rpxDir)
443
-
444
- // Module-scoped state so the watcher and fetch handler share one routing view.
445
- // Routing table keyed by host pattern; each host owns an ordered list of
446
- // path-scoped routes. Lookup prefers an exact host match, then the
447
- // most-specific `*.suffix` wildcard (see `matchHostList`); within a host the
448
- // longest matching path prefix wins (see `matchHostRoute`).
449
- let routingTable: HostRoutes<ProxyRoute> = new Map()
450
- const getRoute = (host: string, pathname: string): ProxyRoute | undefined =>
451
- matchHostRoute(routingTable, host, pathname)
452
-
453
- function rebuild(entries: RegistryEntry[]): void {
454
- routingTable = buildHostRoutes(
455
- entries.map(e => ({ host: e.to, path: e.path, route: entryToRoute(e) })),
456
- )
457
- const hosts = Array.from(routingTable.keys())
458
- debugLog('daemon', `routing table now covers ${hosts.length} host(s): ${hosts.join(', ') || '<empty>'}`, verbose)
459
- }
460
-
461
- // Initial GC + load before binding so the very first request finds a route.
462
- await gcStaleEntries(registryDir, verbose).catch((err) => {
463
- debugLog('daemon', `initial gc failed: ${err}`, verbose)
464
- })
465
- const initialEntries = await readAll(registryDir, verbose)
466
- rebuild(initialEntries)
467
-
468
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
469
- debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
470
- })
471
- await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
472
- debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
473
- })
474
-
475
- // Production per-domain SNI: serve real PEM certs (e.g. Let's Encrypt) keyed
476
- // by server name on the one listener. Falls back to the dev shared cert when
477
- // no usable production certs are configured.
478
- let sniTls: Array<{ serverName: string, cert: string, key: string }> = []
479
- if (opts.productionCerts) {
480
- sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
481
- if (verbose && sniTls.length > 0)
482
- log.info(`SNI: serving ${sniTls.length} real cert(s): ${sniTls.map(e => e.serverName).join(', ')}`)
483
- }
484
-
485
- const fetchHandler = createProxyFetchHandler(getRoute, verbose)
486
- const wsHandler = createProxyWebSocketHandler(verbose)
487
-
488
- // Bootstrap the dev shared cert once when there's no real SNI set, so a single
489
- // SNI listener with on-demand can still answer hosts that aren't covered yet.
490
- let devSslConfig: SSLConfig | null = null
491
- if (sniTls.length === 0)
492
- devSslConfig = await bootstrapTls(opts, registryDir)
493
-
494
- // On-demand TLS manager (opt-in). Holds the live SNI set; lazily issues real
495
- // certs for approved unknown hosts via ACME http-01 served from our :80
496
- // listener (Bun can't issue at handshake time — see on-demand.ts header).
497
- const onDemandCfg = opts.onDemandTls
498
- const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
499
- ? new OnDemandCertManager({
500
- config: onDemandCfg,
501
- certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
502
- initial: sniTls,
503
- verbose,
504
- // A new cert was issued/adopted — rebuild :443 with the augmented set.
505
- onCertAdded: (entries) => { void rebuildTls(entries) },
506
- })
507
- : null
508
-
509
- /** Build the TLS option for Bun.serve from the current SNI set (or dev cert). */
510
- function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): Bun.TLSOptions | Bun.TLSOptions[] {
511
- if (entries.length > 0)
512
- return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
513
- // No real certs: fall back to the dev self-signed shared cert.
514
- return {
515
- key: devSslConfig!.key,
516
- cert: devSslConfig!.cert,
517
- ca: devSslConfig!.ca,
518
- requestCert: false,
519
- rejectUnauthorized: false,
520
- }
521
- }
522
-
523
- /** (Re)create the :443 listener. Factored so on-demand can rebuild it. */
524
- function serveHttps(entries: Array<{ serverName: string, cert: string, key: string }>): ReturnType<typeof Bun.serve> {
525
- return Bun.serve({
526
- port: httpsPort,
527
- hostname,
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),
532
- fetch(req: Request, server: unknown) {
533
- return fetchHandler(req, server as ProxyServerLike)
534
- },
535
- websocket: wsHandler,
536
- error(err: Error) {
537
- debugLog('daemon', `https server error: ${err}`, verbose)
538
- return new Response(`Server Error: ${err.message}`, { status: 500 })
539
- },
540
- })
541
- }
542
-
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
- )
557
-
558
- /**
559
- * Bun has no working SNICallback and `server.reload({ tls })` does not update
560
- * certs at runtime (verified Bun 1.3.14/1.4.0). So to serve a freshly-issued
561
- * cert we tear the old listener down and re-bind with the augmented SNI set.
562
- * The rebind is sub-second; if the OS hasn't freed the port yet we retry on a
563
- * short async backoff. In-flight requests on the old listener drain
564
- * (`stop(false)`). Only ever invoked from the (async) issuance callback.
565
- */
566
- async function rebuildTls(entries: Array<{ serverName: string, cert: string, key: string }>): Promise<void> {
567
- if (stopped)
568
- return
569
- debugLog('daemon', `rebuilding :443 with ${entries.length} SNI cert(s)`, verbose)
570
- httpsServer.stop(false)
571
- let lastErr: unknown
572
- for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
573
- try {
574
- httpsServer = serveHttps(entries)
575
- return
576
- }
577
- catch (err) {
578
- // EADDRINUSE while the old socket releases — back off briefly, retry.
579
- lastErr = err
580
- await new Promise(resolve => setTimeout(resolve, 25))
581
- }
582
- }
583
- // Could not rebind: the old listener is already down. Surface the failure.
584
- log.error(`rpx: failed to rebuild :443 after issuing cert: ${(lastErr as Error)?.message}`)
585
- }
586
-
587
- let httpServer: ReturnType<typeof Bun.serve> | null = null
588
- if (httpPort > 0) {
589
- httpServer = Bun.serve({
590
- port: httpPort,
591
- hostname,
592
- fetch(req: Request) {
593
- return handleHttpRedirect(req, onDemand)
594
- },
595
- error() {
596
- return new Response('Bad Request', { status: 400 })
597
- },
598
- })
599
- }
600
-
601
- if (verbose) {
602
- log.success(`rpx daemon listening on https://${hostname}:${httpsPort}${httpServer ? ` (http→https on :${httpPort})` : ''}`)
603
- log.info(`pid file: ${pidPath}`)
604
- log.info(`registry: ${registryDir}`)
605
- }
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
-
620
- const watcher = watchRegistry(
621
- (entries) => {
622
- rebuild(entries)
623
- void syncDevTlsWithRegistry(entries)
624
- syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
625
- debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
626
- })
627
- },
628
- { dir: registryDir, verbose },
629
- )
630
-
631
- const gcInterval = setInterval(() => {
632
- gcStaleEntries(registryDir, verbose)
633
- .then((removed) => {
634
- if (removed > 0)
635
- debugLog('daemon', `gc reaped ${removed} stale entries`, verbose)
636
- })
637
- .catch((err) => {
638
- debugLog('daemon', `periodic gc failed: ${err}`, verbose)
639
- })
640
- }, gcIntervalMs)
641
- // Don't keep the event loop alive just for GC.
642
- if (typeof gcInterval.unref === 'function')
643
- gcInterval.unref()
644
-
645
- let stopped = false
646
- let resolveDone!: () => void
647
- const done = new Promise<void>((r) => { resolveDone = r })
648
-
649
- async function stop(): Promise<void> {
650
- if (stopped)
651
- return done
652
- stopped = true
653
- clearInterval(gcInterval)
654
- watcher.close()
655
- // `stop(false)` lets in-flight requests drain before closing the listener.
656
- httpsServer.stop(false)
657
- httpServer?.stop(false)
658
- await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
659
- debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
660
- })
661
- await releaseDaemonLock(rpxDir)
662
- if (verbose)
663
- log.info('rpx daemon stopped')
664
- resolveDone()
665
- return done
666
- }
667
-
668
- const onSignal = (sig: NodeJS.Signals) => {
669
- debugLog('daemon', `received ${sig}, shutting down`, verbose)
670
- stop().catch(() => {})
671
- }
672
- process.once('SIGINT', onSignal)
673
- process.once('SIGTERM', onSignal)
674
-
675
- return {
676
- stop,
677
- done,
678
- httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
679
- httpPort: httpServer && typeof httpServer.port === 'number' ? httpServer.port : httpPort,
680
- pidPath,
681
- ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
682
- }
683
- }
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
-
1010
- export interface EnsureDaemonOptions {
1011
- /** Override `~/.stacks/rpx`. */
1012
- rpxDir?: string
1013
- /**
1014
- * Argv to spawn if no daemon is running. Defaults to re-invoking the current
1015
- * Bun script with `daemon start`. Library consumers (e.g. `./buddy dev`)
1016
- * should pass an explicit command resolving to the `rpx` binary on PATH.
1017
- */
1018
- spawnCommand?: string[]
1019
- /** Working directory for the spawned daemon. Defaults to `process.cwd()`. */
1020
- spawnCwd?: string
1021
- /** Extra env for the spawned daemon. Merged on top of `process.env`. */
1022
- spawnEnv?: Record<string, string>
1023
- /** Max ms to wait for the spawned daemon's pid file to appear. Default 5000. */
1024
- startupTimeoutMs?: number
1025
- /** Polling interval while waiting for the daemon to register. Default 50ms. */
1026
- pollIntervalMs?: number
1027
- verbose?: boolean
1028
- }
1029
-
1030
- export interface EnsureDaemonResult {
1031
- pid: number
1032
- /** True if we spawned a new daemon; false if one was already running. */
1033
- spawned: boolean
1034
- }
1035
-
1036
- /**
1037
- * Best-effort default for the spawn command used by lazy-spawn. Compiled
1038
- * binaries (`bun build --compile`) self-invoke; source-mode executions invoke
1039
- * the same Bun + script that's running now.
1040
- *
1041
- * Library consumers should not rely on this — pass `spawnCommand` explicitly.
1042
- */
1043
- export function defaultDaemonSpawnCommand(): string[] {
1044
- const exec = process.execPath
1045
- const interpName = path.basename(exec).toLowerCase()
1046
- const isInterpreter = interpName === 'bun' || interpName === 'node' || interpName.startsWith('bun-')
1047
- if (isInterpreter && process.argv[1])
1048
- return [exec, process.argv[1], 'daemon:start']
1049
- return [exec, 'daemon:start']
1050
- }
1051
-
1052
- /**
1053
- * Make sure a daemon is running, starting one as a detached child if needed.
1054
- *
1055
- * - If the pid file exists and points at a live process, returns immediately
1056
- * with `spawned: false`.
1057
- * - Otherwise cleans any stale pid file, spawns the configured command with
1058
- * `detached: true` + `stdio: 'ignore'` + `unref()` so it survives the caller
1059
- * exiting, and polls the pid file until the new daemon registers itself.
1060
- *
1061
- * Throws if the daemon never appears within `startupTimeoutMs`.
1062
- */
1063
- export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
1064
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
1065
- const verbose = opts.verbose ?? false
1066
-
1067
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
1068
- debugLog('daemon', `DNS reconcile before ensureDaemonRunning: ${err}`, verbose)
1069
- })
1070
-
1071
- const existingPid = await readDaemonPid(rpxDir)
1072
- if (existingPid !== null && isPidAlive(existingPid)) {
1073
- debugLog('daemon', `ensureDaemonRunning: already running pid=${existingPid}`, verbose)
1074
- return { pid: existingPid, spawned: false }
1075
- }
1076
- if (existingPid !== null) {
1077
- debugLog('daemon', `ensureDaemonRunning: clearing stale pid=${existingPid}`, verbose)
1078
- await releaseDaemonLock(rpxDir)
1079
- }
1080
-
1081
- await fsp.mkdir(rpxDir, { recursive: true })
1082
-
1083
- const command = opts.spawnCommand ?? defaultDaemonSpawnCommand()
1084
- if (command.length === 0)
1085
- throw new Error('ensureDaemonRunning: spawnCommand is empty')
1086
-
1087
- debugLog('daemon', `spawning daemon: ${command.join(' ')}`, verbose)
1088
- const child = nodeSpawn(command[0]!, command.slice(1), {
1089
- detached: true,
1090
- stdio: 'ignore',
1091
- cwd: opts.spawnCwd ?? process.cwd(),
1092
- env: opts.spawnEnv ? { ...process.env, ...opts.spawnEnv } : process.env,
1093
- })
1094
- child.unref()
1095
-
1096
- // Surface synchronous spawn failures (ENOENT for the binary, etc.) so the
1097
- // caller doesn't have to wait the full timeout to see them.
1098
- let spawnError: Error | null = null
1099
- child.once('error', (err) => { spawnError = err })
1100
-
1101
- const timeoutMs = opts.startupTimeoutMs ?? 5000
1102
- const pollMs = opts.pollIntervalMs ?? 50
1103
- const deadline = Date.now() + timeoutMs
1104
-
1105
- while (Date.now() < deadline) {
1106
- if (spawnError)
1107
- throw spawnError
1108
- const pid = await readDaemonPid(rpxDir)
1109
- if (pid !== null && isPidAlive(pid)) {
1110
- debugLog('daemon', `daemon registered with pid=${pid}`, verbose)
1111
- return { pid, spawned: true }
1112
- }
1113
- await new Promise(resolve => setTimeout(resolve, pollMs))
1114
- }
1115
-
1116
- if (spawnError)
1117
- throw spawnError
1118
- throw new Error(`rpx daemon failed to start within ${timeoutMs}ms (rpxDir=${rpxDir})`)
1119
- }
1120
-
1121
- export interface StopDaemonOptions {
1122
- rpxDir?: string
1123
- /** Total ms to wait for the pid to die. Default 5000. */
1124
- timeoutMs?: number
1125
- /** Poll interval while waiting. Default 50ms. */
1126
- pollIntervalMs?: number
1127
- /** Send SIGKILL after `timeoutMs` if SIGTERM didn't take. Default true. */
1128
- forceAfterTimeout?: boolean
1129
- verbose?: boolean
1130
- }
1131
-
1132
- export interface StopDaemonResult {
1133
- /** True if a daemon was found and asked to stop. */
1134
- stopped: boolean
1135
- pid: number | null
1136
- /** True if we had to escalate to SIGKILL. */
1137
- forced: boolean
1138
- }
1139
-
1140
- /**
1141
- * Stop a running daemon by reading its pid and sending SIGTERM. Polls until
1142
- * the process is gone (or escalates to SIGKILL if `forceAfterTimeout`). The
1143
- * pid file is removed by the daemon's own SIGTERM handler — we clean up only
1144
- * if we had to SIGKILL.
1145
- */
1146
- export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaemonResult> {
1147
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
1148
- const verbose = opts.verbose ?? false
1149
- const timeoutMs = opts.timeoutMs ?? 5000
1150
- const pollMs = opts.pollIntervalMs ?? 50
1151
- const force = opts.forceAfterTimeout ?? true
1152
-
1153
- const pid = await readDaemonPid(rpxDir)
1154
- if (pid === null || !isPidAlive(pid)) {
1155
- if (pid !== null)
1156
- await releaseDaemonLock(rpxDir)
1157
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch(() => {})
1158
- return { stopped: false, pid, forced: false }
1159
- }
1160
-
1161
- try {
1162
- process.kill(pid, 'SIGTERM')
1163
- }
1164
- catch (err) {
1165
- const code = (err as NodeJS.ErrnoException).code
1166
- if (code === 'ESRCH') {
1167
- await releaseDaemonLock(rpxDir)
1168
- return { stopped: false, pid, forced: false }
1169
- }
1170
- throw err
1171
- }
1172
-
1173
- const deadline = Date.now() + timeoutMs
1174
- while (Date.now() < deadline) {
1175
- if (!isPidAlive(pid)) {
1176
- debugLog('daemon', `daemon pid=${pid} stopped cleanly`, verbose)
1177
- return { stopped: true, pid, forced: false }
1178
- }
1179
- await new Promise(resolve => setTimeout(resolve, pollMs))
1180
- }
1181
-
1182
- if (!force)
1183
- throw new Error(`rpx daemon (pid=${pid}) did not exit within ${timeoutMs}ms`)
1184
-
1185
- debugLog('daemon', `daemon pid=${pid} did not exit, escalating to SIGKILL`, verbose)
1186
- try {
1187
- process.kill(pid, 'SIGKILL')
1188
- }
1189
- catch (err) {
1190
- if ((err as NodeJS.ErrnoException).code !== 'ESRCH')
1191
- throw err
1192
- }
1193
- // SIGKILL bypasses the cleanup handler, so remove the pid file ourselves.
1194
- await releaseDaemonLock(rpxDir)
1195
- await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
1196
- debugLog('daemon', `DNS teardown after SIGKILL: ${err}`, verbose)
1197
- })
1198
- return { stopped: true, pid, forced: true }
1199
- }
1200
-
1201
- /**
1202
- * When the daemon is not running, ensure no stale macOS resolver overrides remain.
1203
- */
1204
- export async function reconcileDevelopmentDnsOnIdle(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
1205
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
1206
- if (await isDaemonRunning(rpxDir))
1207
- return
1208
- await reconcileStaleDevelopmentDns({ rpxDir, verbose: opts.verbose })
1209
- }