@stacksjs/rpx 0.11.5 → 0.11.8
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/README.md +82 -0
- package/dist/bin/cli.js +244 -8
- package/dist/chunk-6z1nzq0x.js +1 -0
- package/dist/chunk-jpf41gb9.js +49 -0
- package/dist/chunk-qcdcnadb.js +1 -0
- package/dist/daemon-runner.d.ts +32 -0
- package/dist/daemon.d.ts +99 -0
- package/dist/https.d.ts +23 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +156 -0
- package/dist/proxy-handler.d.ts +15 -0
- package/dist/registry.d.ts +74 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +11 -9
- package/src/daemon-runner.ts +161 -0
- package/src/daemon.ts +518 -0
- package/src/https.ts +151 -80
- package/src/index.ts +43 -0
- package/src/process-manager.ts +2 -2
- package/src/proxy-handler.ts +99 -0
- package/src/registry.ts +346 -0
- package/src/start.ts +66 -80
- package/src/types.ts +11 -0
- package/src/utils.ts +48 -0
- package/dist/chunk-8yenn1z8.js +0 -45
- package/dist/chunk-cvt0dqrv.js +0 -49
- package/dist/chunk-cy653fq8.js +0 -1
- package/dist/chunk-grcvjvzg.js +0 -124
- package/dist/chunk-hj5q1vd6.js +0 -1
- package/dist/chunk-sqn04kae.js +0 -2
- package/dist/chunk-wcerh8e8.js +0 -1
- package/dist/dns.d.ts +0 -21
- package/dist/src/index.js +0 -1
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
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 { ProxyOptions, SSLConfig, TlsOption } from './types'
|
|
20
|
+
import type { ProxyRoute } 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 } from './proxy-handler'
|
|
29
|
+
import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
|
|
30
|
+
import type { RegistryEntry } from './registry'
|
|
31
|
+
import { debugLog } from './utils'
|
|
32
|
+
|
|
33
|
+
export interface DaemonOptions {
|
|
34
|
+
verbose?: boolean
|
|
35
|
+
/** Override `~/.stacks/rpx`. Used by tests to avoid touching the real dir. */
|
|
36
|
+
rpxDir?: string
|
|
37
|
+
/** Override the registry directory. Defaults to `<rpxDir>/registry.d`. */
|
|
38
|
+
registryDir?: string
|
|
39
|
+
/** HTTPS listen port. Defaults to 443. */
|
|
40
|
+
httpsPort?: number
|
|
41
|
+
/** HTTP redirect port. Defaults to 80. Pass 0 to skip the redirect server. */
|
|
42
|
+
httpPort?: number
|
|
43
|
+
/** Listener bind address. Defaults to `0.0.0.0`. */
|
|
44
|
+
hostname?: string
|
|
45
|
+
/** TLS bootstrap options forwarded to httpsConfig. */
|
|
46
|
+
https?: TlsOption
|
|
47
|
+
/** PID-GC interval in ms. Defaults to 5000. */
|
|
48
|
+
gcIntervalMs?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DaemonHandle {
|
|
52
|
+
/** Stop the daemon, drain in-flight, release the lock. */
|
|
53
|
+
stop: () => Promise<void>
|
|
54
|
+
/** Resolves when the daemon has fully shut down. */
|
|
55
|
+
done: Promise<void>
|
|
56
|
+
httpsPort: number
|
|
57
|
+
httpPort: number
|
|
58
|
+
pidPath: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_GC_INTERVAL_MS = 5000
|
|
62
|
+
|
|
63
|
+
export function getDaemonRpxDir(): string {
|
|
64
|
+
return path.join(homedir(), '.stacks', 'rpx')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getDaemonPidPath(rpxDir: string = getDaemonRpxDir()): string {
|
|
68
|
+
return path.join(rpxDir, 'daemon.pid')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the PID stored in `daemon.pid`, or `null` if no file / unparseable.
|
|
73
|
+
*/
|
|
74
|
+
export async function readDaemonPid(rpxDir: string = getDaemonRpxDir()): Promise<number | null> {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await fsp.readFile(getDaemonPidPath(rpxDir), 'utf8')
|
|
77
|
+
const n = Number.parseInt(raw.trim(), 10)
|
|
78
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
79
|
+
return null
|
|
80
|
+
return n
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
|
84
|
+
return null
|
|
85
|
+
throw err
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True if `daemon.pid` points at a process that is still alive.
|
|
91
|
+
*/
|
|
92
|
+
export async function isDaemonRunning(rpxDir: string = getDaemonRpxDir()): Promise<boolean> {
|
|
93
|
+
const pid = await readDaemonPid(rpxDir)
|
|
94
|
+
return pid !== null && isPidAlive(pid)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Acquire the daemon's single-instance lock by atomically creating
|
|
99
|
+
* `daemon.pid`. If the file exists but holds a stale PID we take it over;
|
|
100
|
+
* otherwise we throw.
|
|
101
|
+
*
|
|
102
|
+
* `O_CREAT | O_EXCL` (`'wx'`) guarantees only one process wins the create
|
|
103
|
+
* race, so we don't need an external lock library.
|
|
104
|
+
*/
|
|
105
|
+
export async function acquireDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<string> {
|
|
106
|
+
await fsp.mkdir(rpxDir, { recursive: true })
|
|
107
|
+
const pidPath = getDaemonPidPath(rpxDir)
|
|
108
|
+
|
|
109
|
+
while (true) {
|
|
110
|
+
try {
|
|
111
|
+
const fh = await fsp.open(pidPath, 'wx')
|
|
112
|
+
try {
|
|
113
|
+
await fh.write(`${process.pid}\n`)
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await fh.close()
|
|
117
|
+
}
|
|
118
|
+
return pidPath
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST')
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// File exists — figure out whether it's a real owner or a stale leftover.
|
|
126
|
+
const existing = await readDaemonPid(rpxDir)
|
|
127
|
+
if (existing !== null && isPidAlive(existing))
|
|
128
|
+
throw new Error(`rpx daemon already running (pid=${existing})`)
|
|
129
|
+
|
|
130
|
+
// Stale: remove and retry. The retry loses the race iff a different
|
|
131
|
+
// process recreates the file in between, which we'll detect on the next
|
|
132
|
+
// iteration.
|
|
133
|
+
await fsp.unlink(pidPath).catch(() => {})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Promise<void> {
|
|
138
|
+
await fsp.unlink(getDaemonPidPath(rpxDir)).catch(() => {})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Translate a registry entry into the routing shape consumed by the proxy
|
|
143
|
+
* fetch handler. The entry's `from` is normalized to `host:port`.
|
|
144
|
+
*/
|
|
145
|
+
function entryToRoute(entry: RegistryEntry): ProxyRoute {
|
|
146
|
+
const fromUrl = new URL(entry.from.startsWith('http') ? entry.from : `http://${entry.from}`)
|
|
147
|
+
return {
|
|
148
|
+
sourceHost: fromUrl.host,
|
|
149
|
+
cleanUrls: entry.cleanUrls ?? false,
|
|
150
|
+
changeOrigin: entry.changeOrigin ?? false,
|
|
151
|
+
pathRewrites: entry.pathRewrites,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Bootstrap the daemon's TLS material. Reuses the persisted Root CA and any
|
|
157
|
+
* existing trusted host cert; mints fresh ones if none exist.
|
|
158
|
+
*
|
|
159
|
+
* The host cert SAN list includes every hostname in the registry (e.g.
|
|
160
|
+
* `postline.localhost`, `api.postline.localhost`). Chrome does not treat
|
|
161
|
+
* `*.localhost` as matching `<app>.localhost`, so those names must be explicit.
|
|
162
|
+
*/
|
|
163
|
+
function pickPrimaryRegistryHost(hosts: string[]): string {
|
|
164
|
+
const appHost = hosts.find(h => !/^api\./.test(h) && !/^docs\./.test(h) && !/^dashboard\./.test(h))
|
|
165
|
+
return appHost ?? hosts[0] ?? 'rpx.localhost'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<SSLConfig> {
|
|
169
|
+
const entries = await readAll(registryDir, opts.verbose)
|
|
170
|
+
const registryHosts = [...new Set(entries.map(e => e.to))]
|
|
171
|
+
const primary = pickPrimaryRegistryHost(registryHosts)
|
|
172
|
+
const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
|
|
173
|
+
|
|
174
|
+
const sslDir = path.join(homedir(), '.stacks', 'ssl')
|
|
175
|
+
const sharedCert = path.join(sslDir, 'rpx.localhost.crt')
|
|
176
|
+
|
|
177
|
+
const proxyOpts: ProxyOptions = {
|
|
178
|
+
https: typeof opts.https === 'object'
|
|
179
|
+
? { ...opts.https, certPath: sharedCert, keyPath: path.join(sslDir, 'rpx.localhost.key'), commonName: primary }
|
|
180
|
+
: {
|
|
181
|
+
certPath: sharedCert,
|
|
182
|
+
keyPath: path.join(sslDir, 'rpx.localhost.key'),
|
|
183
|
+
caCertPath: path.join(sslDir, 'rpx.localhost.ca.crt'),
|
|
184
|
+
commonName: primary,
|
|
185
|
+
},
|
|
186
|
+
verbose: opts.verbose,
|
|
187
|
+
regenerateUntrustedCerts: true,
|
|
188
|
+
...(hostnames.length > 1
|
|
189
|
+
? { proxies: hostnames.map(to => ({ from: 'localhost:1', to })) }
|
|
190
|
+
: { to: primary, from: 'localhost:1' }),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let sslConfig = await checkExistingCertificates(proxyOpts)
|
|
194
|
+
if (!sslConfig) {
|
|
195
|
+
debugLog('daemon', 'no usable cert on disk, generating one', opts.verbose)
|
|
196
|
+
await generateCertificate(proxyOpts)
|
|
197
|
+
sslConfig = await checkExistingCertificates(proxyOpts)
|
|
198
|
+
}
|
|
199
|
+
if (!sslConfig)
|
|
200
|
+
throw new Error('failed to bootstrap TLS for rpx daemon')
|
|
201
|
+
return sslConfig
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Start the daemon. Returns a handle that resolves `done` once the daemon has
|
|
206
|
+
* cleanly shut down (signal received and listeners closed).
|
|
207
|
+
*
|
|
208
|
+
* The promise itself resolves as soon as the daemon is *ready* — i.e. both
|
|
209
|
+
* listeners are bound and the initial routing table is populated. Use
|
|
210
|
+
* `handle.done` for the lifetime promise.
|
|
211
|
+
*/
|
|
212
|
+
export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
|
|
213
|
+
const verbose = opts.verbose ?? false
|
|
214
|
+
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
215
|
+
const registryDir = opts.registryDir ?? path.join(rpxDir, 'registry.d')
|
|
216
|
+
const httpsPort = opts.httpsPort ?? 443
|
|
217
|
+
const httpPort = opts.httpPort ?? 80
|
|
218
|
+
const hostname = opts.hostname ?? '0.0.0.0'
|
|
219
|
+
const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
220
|
+
|
|
221
|
+
const pidPath = await acquireDaemonLock(rpxDir)
|
|
222
|
+
|
|
223
|
+
// Module-scoped state so the watcher and fetch handler share one routing view.
|
|
224
|
+
let routingTable = new Map<string, ProxyRoute>()
|
|
225
|
+
const getRoute = (host: string): ProxyRoute | undefined => routingTable.get(host)
|
|
226
|
+
|
|
227
|
+
function rebuild(entries: RegistryEntry[]): void {
|
|
228
|
+
const next = new Map<string, ProxyRoute>()
|
|
229
|
+
for (const e of entries)
|
|
230
|
+
next.set(e.to, entryToRoute(e))
|
|
231
|
+
routingTable = next
|
|
232
|
+
debugLog('daemon', `routing table now covers ${next.size} host(s): ${Array.from(next.keys()).join(', ') || '<empty>'}`, verbose)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Initial GC + load before binding so the very first request finds a route.
|
|
236
|
+
await gcStaleEntries(registryDir, verbose).catch((err) => {
|
|
237
|
+
debugLog('daemon', `initial gc failed: ${err}`, verbose)
|
|
238
|
+
})
|
|
239
|
+
rebuild(await readAll(registryDir, verbose))
|
|
240
|
+
|
|
241
|
+
const sslConfig = await bootstrapTls(opts, registryDir)
|
|
242
|
+
|
|
243
|
+
const httpsServer = Bun.serve({
|
|
244
|
+
port: httpsPort,
|
|
245
|
+
hostname,
|
|
246
|
+
tls: {
|
|
247
|
+
key: sslConfig.key,
|
|
248
|
+
cert: sslConfig.cert,
|
|
249
|
+
ca: sslConfig.ca,
|
|
250
|
+
requestCert: false,
|
|
251
|
+
rejectUnauthorized: false,
|
|
252
|
+
},
|
|
253
|
+
fetch: createProxyFetchHandler(getRoute, verbose),
|
|
254
|
+
error(err: Error) {
|
|
255
|
+
debugLog('daemon', `https server error: ${err}`, verbose)
|
|
256
|
+
return new Response(`Server Error: ${err.message}`, { status: 500 })
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
let httpServer: ReturnType<typeof Bun.serve> | null = null
|
|
261
|
+
if (httpPort > 0) {
|
|
262
|
+
httpServer = Bun.serve({
|
|
263
|
+
port: httpPort,
|
|
264
|
+
hostname,
|
|
265
|
+
fetch(req: Request) {
|
|
266
|
+
const u = new URL(req.url)
|
|
267
|
+
const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
|
|
268
|
+
return new Response(null, {
|
|
269
|
+
status: 301,
|
|
270
|
+
headers: { Location: `https://${host}${u.pathname}${u.search}` },
|
|
271
|
+
})
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (verbose) {
|
|
277
|
+
log.success(`rpx daemon listening on https://${hostname}:${httpsPort}${httpServer ? ` (http→https on :${httpPort})` : ''}`)
|
|
278
|
+
log.info(`pid file: ${pidPath}`)
|
|
279
|
+
log.info(`registry: ${registryDir}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const watcher = watchRegistry(
|
|
283
|
+
(entries) => { rebuild(entries) },
|
|
284
|
+
{ dir: registryDir, verbose },
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const gcInterval = setInterval(() => {
|
|
288
|
+
gcStaleEntries(registryDir, verbose)
|
|
289
|
+
.then((removed) => {
|
|
290
|
+
if (removed > 0)
|
|
291
|
+
debugLog('daemon', `gc reaped ${removed} stale entries`, verbose)
|
|
292
|
+
})
|
|
293
|
+
.catch((err) => {
|
|
294
|
+
debugLog('daemon', `periodic gc failed: ${err}`, verbose)
|
|
295
|
+
})
|
|
296
|
+
}, gcIntervalMs)
|
|
297
|
+
// Don't keep the event loop alive just for GC.
|
|
298
|
+
if (typeof gcInterval.unref === 'function')
|
|
299
|
+
gcInterval.unref()
|
|
300
|
+
|
|
301
|
+
let stopped = false
|
|
302
|
+
let resolveDone!: () => void
|
|
303
|
+
const done = new Promise<void>((r) => { resolveDone = r })
|
|
304
|
+
|
|
305
|
+
async function stop(): Promise<void> {
|
|
306
|
+
if (stopped)
|
|
307
|
+
return done
|
|
308
|
+
stopped = true
|
|
309
|
+
clearInterval(gcInterval)
|
|
310
|
+
watcher.close()
|
|
311
|
+
// `stop(false)` lets in-flight requests drain before closing the listener.
|
|
312
|
+
httpsServer.stop(false)
|
|
313
|
+
httpServer?.stop(false)
|
|
314
|
+
await releaseDaemonLock(rpxDir)
|
|
315
|
+
if (verbose)
|
|
316
|
+
log.info('rpx daemon stopped')
|
|
317
|
+
resolveDone()
|
|
318
|
+
return done
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const onSignal = (sig: NodeJS.Signals) => {
|
|
322
|
+
debugLog('daemon', `received ${sig}, shutting down`, verbose)
|
|
323
|
+
stop().catch(() => {})
|
|
324
|
+
}
|
|
325
|
+
process.once('SIGINT', onSignal)
|
|
326
|
+
process.once('SIGTERM', onSignal)
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
stop,
|
|
330
|
+
done,
|
|
331
|
+
httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
|
|
332
|
+
httpPort: httpServer && typeof httpServer.port === 'number' ? httpServer.port : httpPort,
|
|
333
|
+
pidPath,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface EnsureDaemonOptions {
|
|
338
|
+
/** Override `~/.stacks/rpx`. */
|
|
339
|
+
rpxDir?: string
|
|
340
|
+
/**
|
|
341
|
+
* Argv to spawn if no daemon is running. Defaults to re-invoking the current
|
|
342
|
+
* Bun script with `daemon start`. Library consumers (e.g. `./buddy dev`)
|
|
343
|
+
* should pass an explicit command resolving to the `rpx` binary on PATH.
|
|
344
|
+
*/
|
|
345
|
+
spawnCommand?: string[]
|
|
346
|
+
/** Working directory for the spawned daemon. Defaults to `process.cwd()`. */
|
|
347
|
+
spawnCwd?: string
|
|
348
|
+
/** Extra env for the spawned daemon. Merged on top of `process.env`. */
|
|
349
|
+
spawnEnv?: Record<string, string>
|
|
350
|
+
/** Max ms to wait for the spawned daemon's pid file to appear. Default 5000. */
|
|
351
|
+
startupTimeoutMs?: number
|
|
352
|
+
/** Polling interval while waiting for the daemon to register. Default 50ms. */
|
|
353
|
+
pollIntervalMs?: number
|
|
354
|
+
verbose?: boolean
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface EnsureDaemonResult {
|
|
358
|
+
pid: number
|
|
359
|
+
/** True if we spawned a new daemon; false if one was already running. */
|
|
360
|
+
spawned: boolean
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Best-effort default for the spawn command used by lazy-spawn. Compiled
|
|
365
|
+
* binaries (`bun build --compile`) self-invoke; source-mode executions invoke
|
|
366
|
+
* the same Bun + script that's running now.
|
|
367
|
+
*
|
|
368
|
+
* Library consumers should not rely on this — pass `spawnCommand` explicitly.
|
|
369
|
+
*/
|
|
370
|
+
export function defaultDaemonSpawnCommand(): string[] {
|
|
371
|
+
const exec = process.execPath
|
|
372
|
+
const interpName = path.basename(exec).toLowerCase()
|
|
373
|
+
const isInterpreter = interpName === 'bun' || interpName === 'node' || interpName.startsWith('bun-')
|
|
374
|
+
if (isInterpreter && process.argv[1])
|
|
375
|
+
return [exec, process.argv[1], 'daemon:start']
|
|
376
|
+
return [exec, 'daemon:start']
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Make sure a daemon is running, starting one as a detached child if needed.
|
|
381
|
+
*
|
|
382
|
+
* - If the pid file exists and points at a live process, returns immediately
|
|
383
|
+
* with `spawned: false`.
|
|
384
|
+
* - Otherwise cleans any stale pid file, spawns the configured command with
|
|
385
|
+
* `detached: true` + `stdio: 'ignore'` + `unref()` so it survives the caller
|
|
386
|
+
* exiting, and polls the pid file until the new daemon registers itself.
|
|
387
|
+
*
|
|
388
|
+
* Throws if the daemon never appears within `startupTimeoutMs`.
|
|
389
|
+
*/
|
|
390
|
+
export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
|
|
391
|
+
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
392
|
+
const verbose = opts.verbose ?? false
|
|
393
|
+
|
|
394
|
+
const existingPid = await readDaemonPid(rpxDir)
|
|
395
|
+
if (existingPid !== null && isPidAlive(existingPid)) {
|
|
396
|
+
debugLog('daemon', `ensureDaemonRunning: already running pid=${existingPid}`, verbose)
|
|
397
|
+
return { pid: existingPid, spawned: false }
|
|
398
|
+
}
|
|
399
|
+
if (existingPid !== null) {
|
|
400
|
+
debugLog('daemon', `ensureDaemonRunning: clearing stale pid=${existingPid}`, verbose)
|
|
401
|
+
await releaseDaemonLock(rpxDir)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
await fsp.mkdir(rpxDir, { recursive: true })
|
|
405
|
+
|
|
406
|
+
const command = opts.spawnCommand ?? defaultDaemonSpawnCommand()
|
|
407
|
+
if (command.length === 0)
|
|
408
|
+
throw new Error('ensureDaemonRunning: spawnCommand is empty')
|
|
409
|
+
|
|
410
|
+
debugLog('daemon', `spawning daemon: ${command.join(' ')}`, verbose)
|
|
411
|
+
const child = nodeSpawn(command[0]!, command.slice(1), {
|
|
412
|
+
detached: true,
|
|
413
|
+
stdio: 'ignore',
|
|
414
|
+
cwd: opts.spawnCwd ?? process.cwd(),
|
|
415
|
+
env: opts.spawnEnv ? { ...process.env, ...opts.spawnEnv } : process.env,
|
|
416
|
+
})
|
|
417
|
+
child.unref()
|
|
418
|
+
|
|
419
|
+
// Surface synchronous spawn failures (ENOENT for the binary, etc.) so the
|
|
420
|
+
// caller doesn't have to wait the full timeout to see them.
|
|
421
|
+
let spawnError: Error | null = null
|
|
422
|
+
child.once('error', (err) => { spawnError = err })
|
|
423
|
+
|
|
424
|
+
const timeoutMs = opts.startupTimeoutMs ?? 5000
|
|
425
|
+
const pollMs = opts.pollIntervalMs ?? 50
|
|
426
|
+
const deadline = Date.now() + timeoutMs
|
|
427
|
+
|
|
428
|
+
while (Date.now() < deadline) {
|
|
429
|
+
if (spawnError)
|
|
430
|
+
throw spawnError
|
|
431
|
+
const pid = await readDaemonPid(rpxDir)
|
|
432
|
+
if (pid !== null && isPidAlive(pid)) {
|
|
433
|
+
debugLog('daemon', `daemon registered with pid=${pid}`, verbose)
|
|
434
|
+
return { pid, spawned: true }
|
|
435
|
+
}
|
|
436
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (spawnError)
|
|
440
|
+
throw spawnError
|
|
441
|
+
throw new Error(`rpx daemon failed to start within ${timeoutMs}ms (rpxDir=${rpxDir})`)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export interface StopDaemonOptions {
|
|
445
|
+
rpxDir?: string
|
|
446
|
+
/** Total ms to wait for the pid to die. Default 5000. */
|
|
447
|
+
timeoutMs?: number
|
|
448
|
+
/** Poll interval while waiting. Default 50ms. */
|
|
449
|
+
pollIntervalMs?: number
|
|
450
|
+
/** Send SIGKILL after `timeoutMs` if SIGTERM didn't take. Default true. */
|
|
451
|
+
forceAfterTimeout?: boolean
|
|
452
|
+
verbose?: boolean
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface StopDaemonResult {
|
|
456
|
+
/** True if a daemon was found and asked to stop. */
|
|
457
|
+
stopped: boolean
|
|
458
|
+
pid: number | null
|
|
459
|
+
/** True if we had to escalate to SIGKILL. */
|
|
460
|
+
forced: boolean
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Stop a running daemon by reading its pid and sending SIGTERM. Polls until
|
|
465
|
+
* the process is gone (or escalates to SIGKILL if `forceAfterTimeout`). The
|
|
466
|
+
* pid file is removed by the daemon's own SIGTERM handler — we clean up only
|
|
467
|
+
* if we had to SIGKILL.
|
|
468
|
+
*/
|
|
469
|
+
export async function stopDaemon(opts: StopDaemonOptions = {}): Promise<StopDaemonResult> {
|
|
470
|
+
const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
|
|
471
|
+
const verbose = opts.verbose ?? false
|
|
472
|
+
const timeoutMs = opts.timeoutMs ?? 5000
|
|
473
|
+
const pollMs = opts.pollIntervalMs ?? 50
|
|
474
|
+
const force = opts.forceAfterTimeout ?? true
|
|
475
|
+
|
|
476
|
+
const pid = await readDaemonPid(rpxDir)
|
|
477
|
+
if (pid === null || !isPidAlive(pid)) {
|
|
478
|
+
if (pid !== null)
|
|
479
|
+
await releaseDaemonLock(rpxDir)
|
|
480
|
+
return { stopped: false, pid, forced: false }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
process.kill(pid, 'SIGTERM')
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
488
|
+
if (code === 'ESRCH') {
|
|
489
|
+
await releaseDaemonLock(rpxDir)
|
|
490
|
+
return { stopped: false, pid, forced: false }
|
|
491
|
+
}
|
|
492
|
+
throw err
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const deadline = Date.now() + timeoutMs
|
|
496
|
+
while (Date.now() < deadline) {
|
|
497
|
+
if (!isPidAlive(pid)) {
|
|
498
|
+
debugLog('daemon', `daemon pid=${pid} stopped cleanly`, verbose)
|
|
499
|
+
return { stopped: true, pid, forced: false }
|
|
500
|
+
}
|
|
501
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!force)
|
|
505
|
+
throw new Error(`rpx daemon (pid=${pid}) did not exit within ${timeoutMs}ms`)
|
|
506
|
+
|
|
507
|
+
debugLog('daemon', `daemon pid=${pid} did not exit, escalating to SIGKILL`, verbose)
|
|
508
|
+
try {
|
|
509
|
+
process.kill(pid, 'SIGKILL')
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
if ((err as NodeJS.ErrnoException).code !== 'ESRCH')
|
|
513
|
+
throw err
|
|
514
|
+
}
|
|
515
|
+
// SIGKILL bypasses the cleanup handler, so remove the pid file ourselves.
|
|
516
|
+
await releaseDaemonLock(rpxDir)
|
|
517
|
+
return { stopped: true, pid, forced: true }
|
|
518
|
+
}
|