@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.
- package/dist/acme-challenge.d.ts +13 -0
- package/dist/auth.d.ts +21 -0
- package/dist/bin/cli.js +252 -200
- package/dist/cert-inspect.d.ts +5 -3
- package/dist/{chunk-83dqq28c.js → chunk-1108y1dk.js} +1 -1
- package/dist/{chunk-0f32jmrb.js → chunk-c9brkawa.js} +1 -1
- package/dist/{chunk-w888yhnp.js → chunk-cqkz06bg.js} +1 -1
- package/dist/chunk-qs36t2rf.js +224 -0
- package/dist/daemon.d.ts +4 -1
- package/dist/https.d.ts +1 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +7 -7
- package/dist/proxy-handler.d.ts +18 -1
- package/dist/proxy-pool.d.ts +5 -0
- package/dist/redirect.d.ts +40 -0
- package/dist/registry.d.ts +2 -1
- package/dist/site-resolver.d.ts +106 -0
- package/dist/site-splash.d.ts +19 -0
- package/dist/site-supervisor.d.ts +56 -0
- package/dist/start.d.ts +31 -8
- package/dist/types.d.ts +64 -0
- package/package.json +6 -7
- package/dist/chunk-rs8gqpax.js +0 -174
- package/src/cert-inspect.ts +0 -69
- package/src/colors.ts +0 -13
- package/src/config.ts +0 -45
- package/src/daemon-runner.ts +0 -180
- package/src/daemon.ts +0 -1209
- package/src/dns-state.ts +0 -116
- package/src/dns.ts +0 -568
- package/src/host-match.ts +0 -52
- package/src/host-routes.ts +0 -147
- package/src/hosts.ts +0 -283
- package/src/https.ts +0 -905
- package/src/index.ts +0 -161
- package/src/logger.ts +0 -19
- package/src/macos-trust.ts +0 -175
- package/src/on-demand.ts +0 -264
- package/src/origin-guard.ts +0 -127
- package/src/port-manager.ts +0 -183
- package/src/process-manager.ts +0 -164
- package/src/proxy-handler.ts +0 -387
- package/src/proxy-pool.ts +0 -1003
- package/src/registry.ts +0 -366
- package/src/sni.ts +0 -93
- package/src/start.ts +0 -1421
- package/src/static-files.ts +0 -201
- package/src/types.ts +0 -267
- 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
|
-
}
|