@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.
- package/dist/bin/cli.js +225 -210
- package/dist/cert-inspect.d.ts +5 -3
- package/dist/chunk-0f32jmrb.js +1 -0
- package/dist/chunk-bs9e342s.js +1 -0
- package/dist/chunk-vxj1ckdm.js +176 -0
- package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
- package/dist/daemon.d.ts +17 -8
- package/dist/dns.d.ts +2 -0
- package/dist/https.d.ts +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/logger.d.ts +8 -8
- package/dist/proxy-handler.d.ts +1 -1
- package/dist/proxy-pool.d.ts +82 -0
- package/dist/start.d.ts +31 -8
- package/dist/types.d.ts +13 -0
- package/dist/utils.d.ts +15 -0
- package/package.json +11 -7
- package/dist/chunk-9njrhbd4.js +0 -1
- package/dist/chunk-a0b9f9fs.js +0 -1
- package/dist/chunk-s4tdtq37.js +0 -161
- 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 -786
- package/src/dns-state.ts +0 -116
- package/src/dns.ts +0 -509
- package/src/host-match.ts +0 -52
- package/src/host-routes.ts +0 -147
- package/src/hosts.ts +0 -278
- package/src/https.ts +0 -862
- package/src/index.ts +0 -158
- 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 -105
- package/src/port-manager.ts +0 -183
- package/src/process-manager.ts +0 -164
- package/src/proxy-handler.ts +0 -315
- 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 -256
- 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
|
-
}
|