@stacksjs/rpx 0.11.18 → 0.11.20

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 (46) hide show
  1. package/dist/bin/cli.js +225 -210
  2. package/dist/cert-inspect.d.ts +5 -3
  3. package/dist/chunk-0f32jmrb.js +1 -0
  4. package/dist/chunk-bs9e342s.js +1 -0
  5. package/dist/chunk-vxj1ckdm.js +176 -0
  6. package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
  7. package/dist/daemon.d.ts +17 -8
  8. package/dist/dns.d.ts +2 -0
  9. package/dist/https.d.ts +10 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +7 -7
  12. package/dist/logger.d.ts +8 -8
  13. package/dist/proxy-handler.d.ts +1 -1
  14. package/dist/proxy-pool.d.ts +82 -0
  15. package/dist/start.d.ts +31 -8
  16. package/dist/types.d.ts +13 -0
  17. package/dist/utils.d.ts +15 -0
  18. package/package.json +11 -7
  19. package/dist/chunk-9njrhbd4.js +0 -1
  20. package/dist/chunk-a0b9f9fs.js +0 -1
  21. package/dist/chunk-s4tdtq37.js +0 -161
  22. package/src/cert-inspect.ts +0 -69
  23. package/src/colors.ts +0 -13
  24. package/src/config.ts +0 -45
  25. package/src/daemon-runner.ts +0 -180
  26. package/src/daemon.ts +0 -786
  27. package/src/dns-state.ts +0 -116
  28. package/src/dns.ts +0 -509
  29. package/src/host-match.ts +0 -52
  30. package/src/host-routes.ts +0 -147
  31. package/src/hosts.ts +0 -278
  32. package/src/https.ts +0 -862
  33. package/src/index.ts +0 -158
  34. package/src/logger.ts +0 -19
  35. package/src/macos-trust.ts +0 -175
  36. package/src/on-demand.ts +0 -264
  37. package/src/origin-guard.ts +0 -105
  38. package/src/port-manager.ts +0 -183
  39. package/src/process-manager.ts +0 -164
  40. package/src/proxy-handler.ts +0 -315
  41. package/src/registry.ts +0 -366
  42. package/src/sni.ts +0 -93
  43. package/src/start.ts +0 -1421
  44. package/src/static-files.ts +0 -201
  45. package/src/types.ts +0 -256
  46. package/src/utils.ts +0 -210
package/src/daemon.ts DELETED
@@ -1,786 +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 { checkExistingCertificates, generateCertificate } from './https'
28
- import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
29
- import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
30
- import type { HostRoutes } from './host-routes'
31
- import { buildSniTlsConfig } from './sni'
32
- import { OnDemandCertManager } from './on-demand'
33
- import { resolveStaticRoute } from './static-files'
34
- import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
35
- import type { RegistryEntry } from './registry'
36
- import {
37
- reconcileStaleDevelopmentDns,
38
- syncDevelopmentDnsFromRegistry,
39
- tearDownDevelopmentDns,
40
- } from './dns'
41
- import { debugLog } from './utils'
42
-
43
- export interface DaemonOptions {
44
- verbose?: boolean
45
- /** Override `~/.stacks/rpx`. Used by tests to avoid touching the real dir. */
46
- rpxDir?: string
47
- /** Override the registry directory. Defaults to `<rpxDir>/registry.d`. */
48
- registryDir?: string
49
- /** HTTPS listen port. Defaults to 443. */
50
- httpsPort?: number
51
- /** HTTP redirect port. Defaults to 80. Pass 0 to skip the redirect server. */
52
- httpPort?: number
53
- /** Listener bind address. Defaults to `0.0.0.0`. */
54
- hostname?: string
55
- /** TLS bootstrap options forwarded to httpsConfig. */
56
- https?: TlsOption
57
- /**
58
- * Production per-domain SNI certs (real PEMs on disk). When usable certs are
59
- * found, the listener serves them per SNI server name instead of the dev
60
- * self-signed shared cert.
61
- */
62
- productionCerts?: ProductionTlsConfig
63
- /**
64
- * On-demand TLS: lazily issue real certs for approved unknown hosts via ACME
65
- * http-01 (served from this daemon's `:80` listener). Opt-in via `enabled`.
66
- * Seeded with the `productionCerts`/`certsDir` certs already on disk.
67
- */
68
- onDemandTls?: OnDemandTlsConfig
69
- /** PID-GC interval in ms. Defaults to 5000. */
70
- gcIntervalMs?: number
71
- }
72
-
73
- export interface DaemonHandle {
74
- /** Stop the daemon, drain in-flight, release the lock. */
75
- stop: () => Promise<void>
76
- /** Resolves when the daemon has fully shut down. */
77
- done: Promise<void>
78
- httpsPort: number
79
- httpPort: number
80
- pidPath: string
81
- /**
82
- * Pre-warm an on-demand cert for `host` (issue it now if approved & missing,
83
- * rebuilding the `:443` listener). Resolves `true` if a cert is available
84
- * afterwards. No-op resolving `false` when on-demand TLS isn't enabled. Lets a
85
- * tunnel server warm a subdomain's cert at registration time.
86
- */
87
- ensureCert: (host: string) => Promise<boolean>
88
- }
89
-
90
- const DEFAULT_GC_INTERVAL_MS = 5000
91
-
92
- export function getDaemonRpxDir(): string {
93
- return path.join(homedir(), '.stacks', 'rpx')
94
- }
95
-
96
- export function getDaemonPidPath(rpxDir: string = getDaemonRpxDir()): string {
97
- return path.join(rpxDir, 'daemon.pid')
98
- }
99
-
100
- /**
101
- * Read the PID stored in `daemon.pid`, or `null` if no file / unparseable.
102
- */
103
- export async function readDaemonPid(rpxDir: string = getDaemonRpxDir()): Promise<number | null> {
104
- try {
105
- const raw = await fsp.readFile(getDaemonPidPath(rpxDir), 'utf8')
106
- const n = Number.parseInt(raw.trim(), 10)
107
- if (!Number.isFinite(n) || n <= 0)
108
- return null
109
- return n
110
- }
111
- catch (err) {
112
- if ((err as NodeJS.ErrnoException).code === 'ENOENT')
113
- return null
114
- throw err
115
- }
116
- }
117
-
118
- /**
119
- * True if `daemon.pid` points at a process that is still alive.
120
- */
121
- export async function isDaemonRunning(rpxDir: string = getDaemonRpxDir()): Promise<boolean> {
122
- const pid = await readDaemonPid(rpxDir)
123
- return pid !== null && isPidAlive(pid)
124
- }
125
-
126
- /**
127
- * Acquire the daemon's single-instance lock by atomically creating
128
- * `daemon.pid`. If the file exists but holds a stale PID we take it over;
129
- * otherwise we throw.
130
- *
131
- * `O_CREAT | O_EXCL` (`'wx'`) guarantees only one process wins the create
132
- * race, so we don't need an external lock library.
133
- */
134
- export async function acquireDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<string> {
135
- await fsp.mkdir(rpxDir, { recursive: true })
136
- const pidPath = getDaemonPidPath(rpxDir)
137
-
138
- while (true) {
139
- try {
140
- const fh = await fsp.open(pidPath, 'wx')
141
- try {
142
- await fh.write(`${process.pid}\n`)
143
- }
144
- finally {
145
- await fh.close()
146
- }
147
- return pidPath
148
- }
149
- catch (err) {
150
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST')
151
- throw err
152
- }
153
-
154
- // File exists — figure out whether it's a real owner or a stale leftover.
155
- const existing = await readDaemonPid(rpxDir)
156
- if (existing !== null && isPidAlive(existing))
157
- throw new Error(`rpx daemon already running (pid=${existing})`)
158
-
159
- // Stale: remove and retry. The retry loses the race iff a different
160
- // process recreates the file in between, which we'll detect on the next
161
- // iteration.
162
- await fsp.unlink(pidPath).catch(() => {})
163
- }
164
- }
165
-
166
- export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<void> {
167
- await fsp.unlink(getDaemonPidPath(rpxDir)).catch(() => {})
168
- }
169
-
170
- /**
171
- * Translate a registry entry into the routing shape consumed by the proxy
172
- * fetch handler. The entry's `from` is normalized to `host:port`.
173
- */
174
- function entryToRoute(entry: RegistryEntry): ProxyRoute {
175
- const cleanUrls = entry.cleanUrls ?? false
176
- const basePath = normalizePathPrefix(entry.path)
177
- if (entry.static) {
178
- return {
179
- static: resolveStaticRoute(entry.static, cleanUrls),
180
- cleanUrls,
181
- basePath,
182
- }
183
- }
184
- const from = entry.from ?? 'localhost:1'
185
- const fromUrl = new URL(from.startsWith('http') ? from : `http://${from}`)
186
- return {
187
- sourceHost: fromUrl.host,
188
- cleanUrls,
189
- changeOrigin: entry.changeOrigin ?? false,
190
- pathRewrites: entry.pathRewrites,
191
- basePath,
192
- }
193
- }
194
-
195
- /**
196
- * Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
197
- * existing trusted host cert; mints fresh ones if none exist.
198
- *
199
- * The host cert SAN list includes every hostname in the registry (e.g.
200
- * `postline.localhost`, `api.postline.localhost`). Chrome does not treat
201
- * `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
202
- */
203
- function pickPrimaryRegistryHost(hosts: string[]): string {
204
- const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
205
- return appHost ?? hosts[0] ?? 'rpx.localhost'
206
- }
207
-
208
- async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
209
- const entries = await readAll(registryDir, opts.verbose)
210
- const registryHosts = [...new Set(entries.map(e => e.to))]
211
- const primary = pickPrimaryRegistryHost(registryHosts)
212
- const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
213
-
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
- }
232
-
233
- let sslConfig = await checkExistingCertificates(proxyOpts)
234
- if (!sslConfig) {
235
- debugLog('daemon', 'no usable cert on disk, generating one', opts.verbose)
236
- await generateCertificate(proxyOpts)
237
- sslConfig = await checkExistingCertificates(proxyOpts)
238
- }
239
- if (!sslConfig)
240
- throw new Error('failed to bootstrap TLS for rpx daemon')
241
- return sslConfig
242
- }
243
-
244
- /**
245
- * Binding :443/:80 requires root. When the daemon is launched as a normal user
246
- * (the common case — `./buddy dev`), re-exec it through `sudo` so the elevated
247
- * copy can bind the privileged ports. HOME/PATH are forwarded explicitly (via
248
- * `env`) so the root daemon reads the *user's* `~/.stacks/rpx` state, certs and
249
- * registry instead of root's home. The password is fed on stdin only — never
250
- * placed in argv — so it can't leak via `ps`, and the root daemon doesn't need
251
- * it (it can already sudo).
252
- *
253
- * Returns a launcher handle: this unprivileged process has done its job once
254
- * the elevated daemon has written its pid, so `done` resolves immediately and
255
- * the launcher exits, leaving the root daemon running independently (its pid
256
- * file is how everyone else finds it).
257
- */
258
- async function elevateDaemonToRoot(
259
- rpxDir: string,
260
- httpsPort: number,
261
- httpPort: number,
262
- verbose: boolean,
263
- ): Promise<DaemonHandle> {
264
- const sudoPassword = process.env.SUDO_PASSWORD
265
- const home = process.env.HOME ?? homedir()
266
- const inner = [process.execPath, ...process.argv.slice(1)]
267
- const forwardedEnv = [`HOME=${home}`, `PATH=${process.env.PATH ?? ''}`]
268
- if (verbose)
269
- forwardedEnv.push('RPX_VERBOSE=1')
270
-
271
- // `sudo -S` reads the password from stdin; `-n` (no password) relies on a
272
- // cached credential. Either way we never block on an interactive prompt.
273
- const sudoArgs = sudoPassword
274
- ? ['-S', '-p', '', 'env', ...forwardedEnv, ...inner]
275
- : ['-n', 'env', ...forwardedEnv, ...inner]
276
-
277
- debugLog('daemon', `elevating daemon via sudo for privileged ports ${httpsPort}/${httpPort}`, verbose)
278
- const child = nodeSpawn('sudo', sudoArgs, { detached: true, stdio: ['pipe', 'ignore', 'ignore'] })
279
-
280
- let spawnError: Error | null = null
281
- let sudoExitCode: number | null = null
282
- child.once('error', (err) => { spawnError = err })
283
- child.once('exit', (code) => { sudoExitCode = code ?? 0 })
284
-
285
- if (sudoPassword && child.stdin) {
286
- child.stdin.write(`${sudoPassword}\n`)
287
- child.stdin.end()
288
- }
289
- child.unref()
290
-
291
- const pidPath = getDaemonPidPath(rpxDir)
292
- const deadline = Date.now() + 15000
293
- while (Date.now() < deadline) {
294
- if (spawnError)
295
- throw spawnError
296
- const pid = await readDaemonPid(rpxDir)
297
- if (pid !== null && isPidAlive(pid)) {
298
- if (verbose)
299
- log.success(`rpx daemon elevated to root (pid=${pid}, https on :${httpsPort})`)
300
- return {
301
- httpsPort,
302
- httpPort,
303
- pidPath,
304
- done: Promise.resolve(),
305
- stop: async () => {
306
- // The daemon is root-owned; a normal user can't signal it. `./buddy
307
- // dev` intentionally leaves the shared daemon running across sessions.
308
- try { process.kill(pid, 'SIGTERM') }
309
- catch { /* EPERM — root-owned shared daemon */ }
310
- },
311
- // On-demand issuance runs inside the elevated child's own runDaemon
312
- // handle; this caller-side stub can't reach it directly.
313
- ensureCert: () => Promise.resolve(false),
314
- }
315
- }
316
- // sudo exits fast when auth fails; while the daemon runs it stays alive.
317
- if (sudoExitCode !== null && sudoExitCode !== 0) {
318
- throw new Error(
319
- `rpx daemon could not elevate to bind :${httpsPort} (sudo exited ${sudoExitCode}). `
320
- + 'Set SUDO_PASSWORD in .env or run `sudo -v` first.',
321
- )
322
- }
323
- await new Promise(resolve => setTimeout(resolve, 50))
324
- }
325
- throw new Error(`rpx daemon failed to elevate within 15000ms (rpxDir=${rpxDir})`)
326
- }
327
-
328
- /**
329
- * Start the daemon. Returns a handle that resolves `done` once the daemon has
330
- * cleanly shut down (signal received and listeners closed).
331
- *
332
- * The promise itself resolves as soon as the daemon is *ready* — i.e. both
333
- * listeners are bound and the initial routing table is populated. Use
334
- * `handle.done` for the lifetime promise.
335
- */
336
- // `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
337
- // the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
338
- // eslint-disable-next-line pickier/no-unused-vars
339
- export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
340
- const verbose = opts.verbose ?? false
341
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
342
- const registryDir = opts.registryDir ?? path.join(rpxDir, 'registry.d')
343
- const httpsPort = opts.httpsPort ?? 443
344
- const httpPort = opts.httpPort ?? 80
345
- const hostname = opts.hostname ?? '0.0.0.0'
346
- const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
347
-
348
- // Privileged ports need root. If we were launched unprivileged (the usual
349
- // `./buddy dev` case), re-exec through sudo and hand off to the elevated
350
- // copy — it becomes the real daemon. Tests inject high ports and so skip this.
351
- const needsPrivilegedPort = (httpsPort > 0 && httpsPort < 1024) || (httpPort > 0 && httpPort < 1024)
352
- const alreadyRoot = typeof process.getuid === 'function' && process.getuid() === 0
353
- if (process.platform !== 'win32' && needsPrivilegedPort && !alreadyRoot)
354
- return elevateDaemonToRoot(rpxDir, httpsPort, httpPort, verbose)
355
-
356
- const pidPath = await acquireDaemonLock(rpxDir)
357
-
358
- // Module-scoped state so the watcher and fetch handler share one routing view.
359
- // Routing table keyed by host pattern; each host owns an ordered list of
360
- // path-scoped routes. Lookup prefers an exact host match, then the
361
- // most-specific `*.suffix` wildcard (see `matchHostList`); within a host the
362
- // longest matching path prefix wins (see `matchHostRoute`).
363
- let routingTable: HostRoutes<ProxyRoute> = new Map()
364
- const getRoute = (host: string, pathname: string): ProxyRoute | undefined =>
365
- matchHostRoute(routingTable, host, pathname)
366
-
367
- function rebuild(entries: RegistryEntry[]): void {
368
- routingTable = buildHostRoutes(
369
- entries.map(e => ({ host: e.to, path: e.path, route: entryToRoute(e) })),
370
- )
371
- const hosts = Array.from(routingTable.keys())
372
- debugLog('daemon', `routing table now covers ${hosts.length} host(s): ${hosts.join(', ') || '<empty>'}`, verbose)
373
- }
374
-
375
- // Initial GC + load before binding so the very first request finds a route.
376
- await gcStaleEntries(registryDir, verbose).catch((err) => {
377
- debugLog('daemon', `initial gc failed: ${err}`, verbose)
378
- })
379
- const initialEntries = await readAll(registryDir, verbose)
380
- rebuild(initialEntries)
381
-
382
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
383
- debugLog('daemon', `DNS reconcile on start failed: ${err}`, verbose)
384
- })
385
- await syncDevelopmentDnsFromRegistry(initialEntries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
386
- debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
387
- })
388
-
389
- // Production per-domain SNI: serve real PEM certs (e.g. Let's Encrypt) keyed
390
- // by server name on the one listener. Falls back to the dev shared cert when
391
- // no usable production certs are configured.
392
- let sniTls: Array<{ serverName: string, cert: string, key: string }> = []
393
- if (opts.productionCerts) {
394
- sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
395
- if (verbose && sniTls.length > 0)
396
- log.info(`SNI: serving ${sniTls.length} real cert(s): ${sniTls.map(e => e.serverName).join(', ')}`)
397
- }
398
-
399
- const fetchHandler = createProxyFetchHandler(getRoute, verbose)
400
- const wsHandler = createProxyWebSocketHandler(verbose)
401
-
402
- // Bootstrap the dev shared cert once when there's no real SNI set, so a single
403
- // SNI listener with on-demand can still answer hosts that aren't covered yet.
404
- let devSslConfig: SSLConfig | null = null
405
- if (sniTls.length === 0)
406
- devSslConfig = await bootstrapTls(opts, registryDir)
407
-
408
- // On-demand TLS manager (opt-in). Holds the live SNI set; lazily issues real
409
- // certs for approved unknown hosts via ACME http-01 served from our :80
410
- // listener (Bun can't issue at handshake time — see on-demand.ts header).
411
- const onDemandCfg = opts.onDemandTls
412
- const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
413
- ? new OnDemandCertManager({
414
- config: onDemandCfg,
415
- certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
416
- initial: sniTls,
417
- verbose,
418
- // A new cert was issued/adopted — rebuild :443 with the augmented set.
419
- onCertAdded: (entries) => { void rebuildTls(entries) },
420
- })
421
- : null
422
-
423
- /** 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 {
425
- if (entries.length > 0)
426
- return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
427
- // No real certs: fall back to the dev self-signed shared cert.
428
- return {
429
- key: devSslConfig!.key,
430
- cert: devSslConfig!.cert,
431
- ca: devSslConfig!.ca,
432
- requestCert: false,
433
- rejectUnauthorized: false,
434
- }
435
- }
436
-
437
- /** (Re)create the :443 listener. Factored so on-demand can rebuild it. */
438
- function serveHttps(entries: Array<{ serverName: string, cert: string, key: string }>): ReturnType<typeof Bun.serve> {
439
- return Bun.serve({
440
- port: httpsPort,
441
- hostname,
442
- tls: tlsFor(entries) as any,
443
- fetch(req: Request, server: unknown) {
444
- return fetchHandler(req, server as ProxyServerLike)
445
- },
446
- websocket: wsHandler,
447
- error(err: Error) {
448
- debugLog('daemon', `https server error: ${err}`, verbose)
449
- return new Response(`Server Error: ${err.message}`, { status: 500 })
450
- },
451
- })
452
- }
453
-
454
- let httpsServer = serveHttps(onDemand ? onDemand.sniEntries() : sniTls)
455
-
456
- /**
457
- * Bun has no working SNICallback and `server.reload({ tls })` does not update
458
- * certs at runtime (verified Bun 1.3.14/1.4.0). So to serve a freshly-issued
459
- * cert we tear the old listener down and re-bind with the augmented SNI set.
460
- * The rebind is sub-second; if the OS hasn't freed the port yet we retry on a
461
- * short async backoff. In-flight requests on the old listener drain
462
- * (`stop(false)`). Only ever invoked from the (async) issuance callback.
463
- */
464
- async function rebuildTls(entries: Array<{ serverName: string, cert: string, key: string }>): Promise<void> {
465
- if (stopped)
466
- return
467
- debugLog('daemon', `rebuilding :443 with ${entries.length} SNI cert(s)`, verbose)
468
- httpsServer.stop(false)
469
- let lastErr: unknown
470
- for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
471
- try {
472
- httpsServer = serveHttps(entries)
473
- return
474
- }
475
- catch (err) {
476
- // EADDRINUSE while the old socket releases — back off briefly, retry.
477
- lastErr = err
478
- await new Promise(resolve => setTimeout(resolve, 25))
479
- }
480
- }
481
- // Could not rebind: the old listener is already down. Surface the failure.
482
- log.error(`rpx: failed to rebuild :443 after issuing cert: ${(lastErr as Error)?.message}`)
483
- }
484
-
485
- let httpServer: ReturnType<typeof Bun.serve> | null = null
486
- if (httpPort > 0) {
487
- httpServer = Bun.serve({
488
- port: httpPort,
489
- hostname,
490
- 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
- })
513
- },
514
- })
515
- }
516
-
517
- if (verbose) {
518
- log.success(`rpx daemon listening on https://${hostname}:${httpsPort}${httpServer ? ` (http→https on :${httpPort})` : ''}`)
519
- log.info(`pid file: ${pidPath}`)
520
- log.info(`registry: ${registryDir}`)
521
- }
522
-
523
- const watcher = watchRegistry(
524
- (entries) => {
525
- rebuild(entries)
526
- syncDevelopmentDnsFromRegistry(entries, { rpxDir, verbose, ownerPid: process.pid }).catch((err) => {
527
- debugLog('daemon', `DNS sync on registry change failed: ${err}`, verbose)
528
- })
529
- },
530
- { dir: registryDir, verbose },
531
- )
532
-
533
- const gcInterval = setInterval(() => {
534
- gcStaleEntries(registryDir, verbose)
535
- .then((removed) => {
536
- if (removed > 0)
537
- debugLog('daemon', `gc reaped ${removed} stale entries`, verbose)
538
- })
539
- .catch((err) => {
540
- debugLog('daemon', `periodic gc failed: ${err}`, verbose)
541
- })
542
- }, gcIntervalMs)
543
- // Don't keep the event loop alive just for GC.
544
- if (typeof gcInterval.unref === 'function')
545
- gcInterval.unref()
546
-
547
- let stopped = false
548
- let resolveDone!: () => void
549
- const done = new Promise<void>((r) => { resolveDone = r })
550
-
551
- async function stop(): Promise<void> {
552
- if (stopped)
553
- return done
554
- stopped = true
555
- clearInterval(gcInterval)
556
- watcher.close()
557
- // `stop(false)` lets in-flight requests drain before closing the listener.
558
- httpsServer.stop(false)
559
- httpServer?.stop(false)
560
- await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
561
- debugLog('daemon', `DNS teardown failed: ${err}`, verbose)
562
- })
563
- await releaseDaemonLock(rpxDir)
564
- if (verbose)
565
- log.info('rpx daemon stopped')
566
- resolveDone()
567
- return done
568
- }
569
-
570
- const onSignal = (sig: NodeJS.Signals) => {
571
- debugLog('daemon', `received ${sig}, shutting down`, verbose)
572
- stop().catch(() => {})
573
- }
574
- process.once('SIGINT', onSignal)
575
- process.once('SIGTERM', onSignal)
576
-
577
- return {
578
- stop,
579
- done,
580
- httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
581
- httpPort: httpServer && typeof httpServer.port === 'number' ? httpServer.port : httpPort,
582
- pidPath,
583
- ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
584
- }
585
- }
586
-
587
- export interface EnsureDaemonOptions {
588
- /** Override `~/.stacks/rpx`. */
589
- rpxDir?: string
590
- /**
591
- * Argv to spawn if no daemon is running. Defaults to re-invoking the current
592
- * Bun script with `daemon start`. Library consumers (e.g. `./buddy dev`)
593
- * should pass an explicit command resolving to the `rpx` binary on PATH.
594
- */
595
- spawnCommand?: string[]
596
- /** Working directory for the spawned daemon. Defaults to `process.cwd()`. */
597
- spawnCwd?: string
598
- /** Extra env for the spawned daemon. Merged on top of `process.env`. */
599
- spawnEnv?: Record<string, string>
600
- /** Max ms to wait for the spawned daemon's pid file to appear. Default 5000. */
601
- startupTimeoutMs?: number
602
- /** Polling interval while waiting for the daemon to register. Default 50ms. */
603
- pollIntervalMs?: number
604
- verbose?: boolean
605
- }
606
-
607
- export interface EnsureDaemonResult {
608
- pid: number
609
- /** True if we spawned a new daemon; false if one was already running. */
610
- spawned: boolean
611
- }
612
-
613
- /**
614
- * Best-effort default for the spawn command used by lazy-spawn. Compiled
615
- * binaries (`bun build --compile`) self-invoke; source-mode executions invoke
616
- * the same Bun + script that's running now.
617
- *
618
- * Library consumers should not rely on this — pass `spawnCommand` explicitly.
619
- */
620
- export function defaultDaemonSpawnCommand(): string[] {
621
- const exec = process.execPath
622
- const interpName = path.basename(exec).toLowerCase()
623
- const isInterpreter = interpName === 'bun' || interpName === 'node' || interpName.startsWith('bun-')
624
- if (isInterpreter && process.argv[1])
625
- return [exec, process.argv[1], 'daemon:start']
626
- return [exec, 'daemon:start']
627
- }
628
-
629
- /**
630
- * Make sure a daemon is running, starting one as a detached child if needed.
631
- *
632
- * - If the pid file exists and points at a live process, returns immediately
633
- * with `spawned: false`.
634
- * - Otherwise cleans any stale pid file, spawns the configured command with
635
- * `detached: true` + `stdio: 'ignore'` + `unref()` so it survives the caller
636
- * exiting, and polls the pid file until the new daemon registers itself.
637
- *
638
- * Throws if the daemon never appears within `startupTimeoutMs`.
639
- */
640
- export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
641
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
642
- const verbose = opts.verbose ?? false
643
-
644
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch((err) => {
645
- debugLog('daemon', `DNS reconcile before ensureDaemonRunning: ${err}`, verbose)
646
- })
647
-
648
- const existingPid = await readDaemonPid(rpxDir)
649
- if (existingPid !== null && isPidAlive(existingPid)) {
650
- debugLog('daemon', `ensureDaemonRunning: already running pid=${existingPid}`, verbose)
651
- return { pid: existingPid, spawned: false }
652
- }
653
- if (existingPid !== null) {
654
- debugLog('daemon', `ensureDaemonRunning: clearing stale pid=${existingPid}`, verbose)
655
- await releaseDaemonLock(rpxDir)
656
- }
657
-
658
- await fsp.mkdir(rpxDir, { recursive: true })
659
-
660
- const command = opts.spawnCommand ?? defaultDaemonSpawnCommand()
661
- if (command.length === 0)
662
- throw new Error('ensureDaemonRunning: spawnCommand is empty')
663
-
664
- debugLog('daemon', `spawning daemon: ${command.join(' ')}`, verbose)
665
- const child = nodeSpawn(command[0]!, command.slice(1), {
666
- detached: true,
667
- stdio: 'ignore',
668
- cwd: opts.spawnCwd ?? process.cwd(),
669
- env: opts.spawnEnv ? { ...process.env, ...opts.spawnEnv } : process.env,
670
- })
671
- child.unref()
672
-
673
- // Surface synchronous spawn failures (ENOENT for the binary, etc.) so the
674
- // caller doesn't have to wait the full timeout to see them.
675
- let spawnError: Error | null = null
676
- child.once('error', (err) => { spawnError = err })
677
-
678
- const timeoutMs = opts.startupTimeoutMs ?? 5000
679
- const pollMs = opts.pollIntervalMs ?? 50
680
- const deadline = Date.now() + timeoutMs
681
-
682
- while (Date.now() < deadline) {
683
- if (spawnError)
684
- throw spawnError
685
- const pid = await readDaemonPid(rpxDir)
686
- if (pid !== null && isPidAlive(pid)) {
687
- debugLog('daemon', `daemon registered with pid=${pid}`, verbose)
688
- return { pid, spawned: true }
689
- }
690
- await new Promise(resolve => setTimeout(resolve, pollMs))
691
- }
692
-
693
- if (spawnError)
694
- throw spawnError
695
- throw new Error(`rpx daemon failed to start within ${timeoutMs}ms (rpxDir=${rpxDir})`)
696
- }
697
-
698
- export interface StopDaemonOptions {
699
- rpxDir?: string
700
- /** Total ms to wait for the pid to die. Default 5000. */
701
- timeoutMs?: number
702
- /** Poll interval while waiting. Default 50ms. */
703
- pollIntervalMs?: number
704
- /** Send SIGKILL after `timeoutMs` if SIGTERM didn't take. Default true. */
705
- forceAfterTimeout?: boolean
706
- verbose?: boolean
707
- }
708
-
709
- export interface StopDaemonResult {
710
- /** True if a daemon was found and asked to stop. */
711
- stopped: boolean
712
- pid: number | null
713
- /** True if we had to escalate to SIGKILL. */
714
- forced: boolean
715
- }
716
-
717
- /**
718
- * Stop a running daemon by reading its pid and sending SIGTERM. Polls until
719
- * the process is gone (or escalates to SIGKILL if `forceAfterTimeout`). The
720
- * pid file is removed by the daemon's own SIGTERM handler — we clean up only
721
- * if we had to SIGKILL.
722
- */
723
- export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaemonResult> {
724
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
725
- const verbose = opts.verbose ?? false
726
- const timeoutMs = opts.timeoutMs ?? 5000
727
- const pollMs = opts.pollIntervalMs ?? 50
728
- const force = opts.forceAfterTimeout ?? true
729
-
730
- const pid = await readDaemonPid(rpxDir)
731
- if (pid === null || !isPidAlive(pid)) {
732
- if (pid !== null)
733
- await releaseDaemonLock(rpxDir)
734
- await reconcileStaleDevelopmentDns({ rpxDir, verbose }).catch(() => {})
735
- return { stopped: false, pid, forced: false }
736
- }
737
-
738
- try {
739
- process.kill(pid, 'SIGTERM')
740
- }
741
- catch (err) {
742
- const code = (err as NodeJS.ErrnoException).code
743
- if (code === 'ESRCH') {
744
- await releaseDaemonLock(rpxDir)
745
- return { stopped: false, pid, forced: false }
746
- }
747
- throw err
748
- }
749
-
750
- const deadline = Date.now() + timeoutMs
751
- while (Date.now() < deadline) {
752
- if (!isPidAlive(pid)) {
753
- debugLog('daemon', `daemon pid=${pid} stopped cleanly`, verbose)
754
- return { stopped: true, pid, forced: false }
755
- }
756
- await new Promise(resolve => setTimeout(resolve, pollMs))
757
- }
758
-
759
- if (!force)
760
- throw new Error(`rpx daemon (pid=${pid}) did not exit within ${timeoutMs}ms`)
761
-
762
- debugLog('daemon', `daemon pid=${pid} did not exit, escalating to SIGKILL`, verbose)
763
- try {
764
- process.kill(pid, 'SIGKILL')
765
- }
766
- catch (err) {
767
- if ((err as NodeJS.ErrnoException).code !== 'ESRCH')
768
- throw err
769
- }
770
- // SIGKILL bypasses the cleanup handler, so remove the pid file ourselves.
771
- await releaseDaemonLock(rpxDir)
772
- await tearDownDevelopmentDns({ rpxDir, verbose }).catch((err) => {
773
- debugLog('daemon', `DNS teardown after SIGKILL: ${err}`, verbose)
774
- })
775
- return { stopped: true, pid, forced: true }
776
- }
777
-
778
- /**
779
- * When the daemon is not running, ensure no stale macOS resolver overrides remain.
780
- */
781
- export async function reconcileDevelopmentDnsOnIdle(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
782
- const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
783
- if (await isDaemonRunning(rpxDir))
784
- return
785
- await reconcileStaleDevelopmentDns({ rpxDir, verbose: opts.verbose })
786
- }