@stacksjs/rpx 0.11.18 → 0.11.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/bin/cli.js +225 -210
  2. package/dist/cert-inspect.d.ts +5 -3
  3. package/dist/chunk-0f32jmrb.js +1 -0
  4. package/dist/chunk-bs9e342s.js +1 -0
  5. package/dist/chunk-vxj1ckdm.js +176 -0
  6. package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
  7. package/dist/daemon.d.ts +17 -8
  8. package/dist/dns.d.ts +2 -0
  9. package/dist/https.d.ts +10 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +7 -7
  12. package/dist/logger.d.ts +8 -8
  13. package/dist/proxy-handler.d.ts +1 -1
  14. package/dist/proxy-pool.d.ts +82 -0
  15. package/dist/start.d.ts +31 -8
  16. package/dist/types.d.ts +13 -0
  17. package/dist/utils.d.ts +15 -0
  18. package/package.json +11 -7
  19. package/dist/chunk-9njrhbd4.js +0 -1
  20. package/dist/chunk-a0b9f9fs.js +0 -1
  21. package/dist/chunk-s4tdtq37.js +0 -161
  22. package/src/cert-inspect.ts +0 -69
  23. package/src/colors.ts +0 -13
  24. package/src/config.ts +0 -45
  25. package/src/daemon-runner.ts +0 -180
  26. package/src/daemon.ts +0 -786
  27. package/src/dns-state.ts +0 -116
  28. package/src/dns.ts +0 -509
  29. package/src/host-match.ts +0 -52
  30. package/src/host-routes.ts +0 -147
  31. package/src/hosts.ts +0 -278
  32. package/src/https.ts +0 -862
  33. package/src/index.ts +0 -158
  34. package/src/logger.ts +0 -19
  35. package/src/macos-trust.ts +0 -175
  36. package/src/on-demand.ts +0 -264
  37. package/src/origin-guard.ts +0 -105
  38. package/src/port-manager.ts +0 -183
  39. package/src/process-manager.ts +0 -164
  40. package/src/proxy-handler.ts +0 -315
  41. package/src/registry.ts +0 -366
  42. package/src/sni.ts +0 -93
  43. package/src/start.ts +0 -1421
  44. package/src/static-files.ts +0 -201
  45. package/src/types.ts +0 -256
  46. package/src/utils.ts +0 -210
@@ -1,315 +0,0 @@
1
- /**
2
- * The request handlers used by the shared :443 server. Both the in-process
3
- * multi-proxy mode in `start.ts` and the long-running daemon delegate to this
4
- * module so routing semantics stay in one place.
5
- *
6
- * Routes are looked up via a caller-supplied `getRoute(hostname)` callback.
7
- * The callback indirection lets each caller use whatever data structure makes
8
- * sense (a fixed Map at startup, or a hot-swappable registry view) without
9
- * coupling this module to either.
10
- *
11
- * Three transports are supported per route:
12
- * - HTTP(S) proxying via `fetch()` to an upstream `host:port`.
13
- * - WebSocket proxying via `server.upgrade()` + an upstream `WebSocket`.
14
- * - Static file serving from a local directory (`route.static`).
15
- */
16
- import type { ServerWebSocket } from 'bun'
17
- import type { ResolvedStaticRoute } from './static-files'
18
- import type { PathRewrite } from './types'
19
- import { serveStaticFile } from './static-files'
20
- import { debugLog, resolvePathRewrite } from './utils'
21
-
22
- export interface ProxyRoute {
23
- /**
24
- * Upstream `host:port` to forward requests to (e.g. `localhost:5173`).
25
- * Optional when `static` is set.
26
- */
27
- sourceHost?: string
28
- /** Strip `.html` suffix and 301 to clean URLs. */
29
- cleanUrls?: boolean
30
- /** Set the `origin` header to the target. */
31
- changeOrigin?: boolean
32
- /** Per-route path rewrites (vite/nginx-style prefix routing). */
33
- pathRewrites?: PathRewrite[]
34
- /** When set, serve files from a local directory instead of proxying. */
35
- static?: ResolvedStaticRoute
36
- /**
37
- * Path prefix this route is mounted under (e.g. `/docs`). Used together with
38
- * {@link stripBasePathPrefix} to map request paths to the target. `/` (the
39
- * host default) is a no-op.
40
- */
41
- basePath?: string
42
- /**
43
- * Whether to strip {@link basePath} from the request pathname before
44
- * resolving the target.
45
- *
46
- * - Static routes default to `true`: a directory mounted at `/docs` serves
47
- * its own `index.html` for `/docs` and `<root>/guide` for `/docs/guide`.
48
- * - Proxy routes default to `false`: most apps own their namespace (an app
49
- * mounted at `/api` expects to still see `/api/...`), matching rpx's
50
- * `PathRewrite.stripPrefix` default and nginx `proxy_pass` (no trailing
51
- * slash) behavior.
52
- *
53
- * When unset, the per-transport default above applies.
54
- */
55
- stripBasePathPrefix?: boolean
56
- }
57
-
58
- /**
59
- * Resolve a route for an incoming request. `pathname` enables path-based
60
- * routing within a host (e.g. `/api/*` → app, `/docs*` → static dir). Callers
61
- * that only route by host can ignore the second argument — it's optional so
62
- * existing host-only `getRoute` callbacks remain valid.
63
- */
64
- export type GetRoute = (hostname: string, pathname: string) => ProxyRoute | undefined
65
-
66
- export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
67
-
68
- /** Minimal shape of the Bun server needed for WebSocket upgrades. */
69
- export interface ProxyServer {
70
- // Loose `any` so it structurally accepts Bun's `Server<WebSocketData>` for
71
- // any data generic (the daemon/start callers parameterize differently).
72
- upgrade: (req: Request, options?: { data?: any, headers?: any }) => boolean
73
- }
74
-
75
- /** Data attached to an upgraded client socket so the ws handler can dial upstream. */
76
- interface WsData {
77
- targetUrl: string
78
- forwardHeaders: Record<string, string>
79
- }
80
-
81
- /** Per-socket state: the upstream client + a buffer for early client frames. */
82
- interface WsState {
83
- upstream: WebSocket
84
- upstreamOpen: boolean
85
- pending: Array<string | ArrayBufferLike | Uint8Array>
86
- }
87
-
88
- const HOP_BY_HOP = new Set([
89
- 'connection',
90
- 'keep-alive',
91
- 'proxy-authenticate',
92
- 'proxy-authorization',
93
- 'te',
94
- 'trailer',
95
- 'transfer-encoding',
96
- 'upgrade',
97
- 'sec-websocket-key',
98
- 'sec-websocket-version',
99
- 'sec-websocket-extensions',
100
- ])
101
-
102
- function extractHostname(req: Request): string {
103
- const hostHeader = req.headers.get('host') || ''
104
- // Strip port (`stacks.localhost:443` → `stacks.localhost`).
105
- return hostHeader.split(':')[0]
106
- }
107
-
108
- /**
109
- * Strip the route's mount prefix (`basePath`) from a request pathname so a
110
- * target mounted under `/docs` sees `/` for `/docs` and `/guide` for
111
- * `/docs/guide`. A `/` (or empty) base strips nothing. The result always keeps
112
- * a leading `/`.
113
- */
114
- export function stripBasePath(pathname: string, basePath?: string): string {
115
- if (!basePath || basePath === '/')
116
- return pathname
117
- if (pathname === basePath)
118
- return '/'
119
- if (pathname.startsWith(`${basePath}/`)) {
120
- const rest = pathname.slice(basePath.length)
121
- return rest === '' ? '/' : rest
122
- }
123
- return pathname
124
- }
125
-
126
- /**
127
- * Resolve the upstream target (`host` + `path`) for a request against a route,
128
- * applying any matching path rewrite.
129
- */
130
- function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
131
- const url = new URL(req.url)
132
- let targetHost = route.sourceHost ?? ''
133
- // Proxy backends preserve their mount prefix by default (most apps own their
134
- // `/api` namespace), opting in to stripping via `stripBasePathPrefix`.
135
- // Explicit `pathRewrites` still apply on top of this.
136
- const stripBase = route.stripBasePathPrefix ?? false
137
- let targetPath = stripBase ? stripBasePath(url.pathname, route.basePath) : url.pathname
138
-
139
- const rewriteMatch = resolvePathRewrite(targetPath, route.pathRewrites)
140
- if (rewriteMatch) {
141
- targetHost = rewriteMatch.targetHost
142
- targetPath = rewriteMatch.targetPath
143
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
144
- }
145
-
146
- return { targetHost, targetPath, search: url.search }
147
- }
148
-
149
- /**
150
- * Build a Bun.serve-compatible `fetch` handler that routes requests based on
151
- * the `Host` header. Returns 404 when no route matches and 502 on upstream
152
- * failures. When a request is a WebSocket upgrade and `server` is supplied, it
153
- * is upgraded (returns `undefined` so Bun completes the handshake) and the
154
- * traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
155
- */
156
- export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
157
- return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
158
- const url = new URL(req.url)
159
- const hostname = extractHostname(req)
160
-
161
- const route = getRoute(hostname, url.pathname)
162
- if (!route) {
163
- debugLog('request', `No route found for host: ${hostname}`, verbose)
164
- return new Response(`No proxy configured for ${hostname}`, { status: 404 })
165
- }
166
-
167
- // Static file serving short-circuits everything else. Strip the route's
168
- // mount prefix (default for static) so a dir mounted at `/docs` serves its
169
- // own root for `/docs`.
170
- if (route.static) {
171
- const strip = route.stripBasePathPrefix ?? true
172
- const staticPath = strip ? stripBasePath(url.pathname, route.basePath) : url.pathname
173
- return serveStaticFile(staticPath, route.static)
174
- }
175
-
176
- // WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
177
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
178
- if (!server || !route.sourceHost)
179
- return new Response('WebSocket upgrade not supported here', { status: 400 })
180
-
181
- const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
182
- const targetUrl = `ws://${targetHost}${targetPath}${search}`
183
-
184
- const forwardHeaders: Record<string, string> = {}
185
- for (const [k, v] of req.headers) {
186
- if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'host')
187
- forwardHeaders[k] = v
188
- }
189
- forwardHeaders.host = targetHost
190
- forwardHeaders['x-forwarded-for'] = '127.0.0.1'
191
- forwardHeaders['x-forwarded-proto'] = 'https'
192
- forwardHeaders['x-forwarded-host'] = hostname
193
-
194
- const data: WsData = { targetUrl, forwardHeaders }
195
- const ok = server.upgrade(req, { data })
196
- if (ok) {
197
- debugLog('ws', `upgraded ${hostname}${targetPath} → ${targetUrl}`, verbose)
198
- return undefined
199
- }
200
- return new Response('WebSocket upgrade failed', { status: 400 })
201
- }
202
-
203
- if (!route.sourceHost)
204
- return new Response(`No upstream configured for ${hostname}`, { status: 502 })
205
-
206
- const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
207
- const targetUrl = `http://${targetHost}${targetPath}${search}`
208
-
209
- try {
210
- const headers = new Headers(req.headers)
211
- headers.set('host', targetHost)
212
- if (route.changeOrigin)
213
- headers.set('origin', `http://${route.sourceHost}`)
214
- headers.set('x-forwarded-for', '127.0.0.1')
215
- headers.set('x-forwarded-proto', 'https')
216
- headers.set('x-forwarded-host', hostname)
217
-
218
- const response = await fetch(targetUrl, {
219
- method: req.method,
220
- headers,
221
- body: req.body,
222
- redirect: 'manual',
223
- })
224
-
225
- // Strip `.html` and 301 to the clean URL when enabled.
226
- if (route.cleanUrls && url.pathname.endsWith('.html')) {
227
- const cleanPath = url.pathname.replace(/\.html$/, '')
228
- return new Response(null, {
229
- status: 301,
230
- headers: { Location: cleanPath },
231
- })
232
- }
233
-
234
- const responseHeaders = new Headers(response.headers)
235
- return new Response(response.body, {
236
- status: response.status,
237
- statusText: response.statusText,
238
- headers: responseHeaders,
239
- })
240
- }
241
- catch (err) {
242
- debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
243
- return new Response(`Proxy Error: ${err}`, { status: 502 })
244
- }
245
- }
246
- }
247
-
248
- /**
249
- * Build the `websocket` handler block for Bun.serve. It opens an upstream
250
- * `WebSocket` per client socket, buffers client→upstream frames until the
251
- * upstream connection is open, and pipes messages, closes and errors in both
252
- * directions with a clean teardown.
253
- */
254
- export function createProxyWebSocketHandler(verbose?: boolean) {
255
- const state = new WeakMap<ServerWebSocket<WsData>, WsState>()
256
-
257
- return {
258
- open(ws: ServerWebSocket<WsData>): void {
259
- const { targetUrl, forwardHeaders } = ws.data
260
- let upstream: WebSocket
261
- try {
262
- // Bun's WebSocket accepts a `headers` option (control-channel auth etc.).
263
- upstream = new WebSocket(targetUrl, { headers: forwardHeaders } as any)
264
- }
265
- catch (err) {
266
- debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
267
- ws.close(1011, 'upstream connect failed')
268
- return
269
- }
270
- upstream.binaryType = 'arraybuffer'
271
- const st: WsState = { upstream, upstreamOpen: false, pending: [] }
272
- state.set(ws, st)
273
-
274
- upstream.addEventListener('open', () => {
275
- st.upstreamOpen = true
276
- for (const frame of st.pending)
277
- upstream.send(frame as any)
278
- st.pending = []
279
- })
280
- upstream.addEventListener('message', (ev: MessageEvent) => {
281
- // Forward both binary (ArrayBuffer) and text frames to the client.
282
- ws.send(ev.data as any)
283
- })
284
- upstream.addEventListener('close', (ev: CloseEvent) => {
285
- try { ws.close(ev.code || 1000, ev.reason || '') }
286
- catch { /* already closing */ }
287
- })
288
- upstream.addEventListener('error', () => {
289
- debugLog('ws', `upstream error for ${targetUrl}`, verbose)
290
- try { ws.close(1011, 'upstream error') }
291
- catch { /* already closing */ }
292
- })
293
- },
294
-
295
- message(ws: ServerWebSocket<WsData>, message: string | Buffer): void {
296
- const st = state.get(ws)
297
- if (!st)
298
- return
299
- const frame = typeof message === 'string' ? message : new Uint8Array(message)
300
- if (st.upstreamOpen)
301
- st.upstream.send(frame as any)
302
- else
303
- st.pending.push(frame)
304
- },
305
-
306
- close(ws: ServerWebSocket<WsData>, code: number, reason: string): void {
307
- const st = state.get(ws)
308
- if (!st)
309
- return
310
- state.delete(ws)
311
- try { st.upstream.close(code || 1000, reason || '') }
312
- catch { /* already closed */ }
313
- },
314
- }
315
- }
package/src/registry.ts DELETED
@@ -1,366 +0,0 @@
1
- /**
2
- * Registry of currently-active rpx proxies.
3
- *
4
- * Each running upstream (e.g. a `./buddy dev` invocation) writes a small JSON
5
- * file into `~/.stacks/rpx/registry.d/<id>.json` describing where to forward
6
- * traffic. The rpx daemon watches this directory and rebuilds its routing
7
- * table whenever entries appear, change, or disappear.
8
- *
9
- * Design choices worth knowing about:
10
- * - One file per entry → no shared-file locking, no merge conflicts.
11
- * - Atomic write via temp file + rename → readers never see partial JSON.
12
- * - Each entry carries the writer's PID so the daemon can GC files left
13
- * behind by a writer that was killed -9.
14
- * - `id` is validated against a strict charset to keep it from escaping
15
- * the registry directory.
16
- */
17
- import type { PathRewrite, StaticRouteConfig } from './types'
18
- import * as fs from 'node:fs'
19
- import * as fsp from 'node:fs/promises'
20
- import { homedir } from 'node:os'
21
- import * as path from 'node:path'
22
- import * as process from 'node:process'
23
- import { debugLog } from './utils'
24
-
25
- export interface RegistryEntry {
26
- id: string
27
- /** Upstream `host:port`. Optional when `static` is set. */
28
- from?: string
29
- to: string
30
- /**
31
- * Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
32
- * Enables several routes to share one host, each claiming a different path
33
- * (`/api` → app, `/docs` → static dir, `/` → public). Omitted (or `'/'`)
34
- * means the route is the host default and behaves exactly like host-only
35
- * routing did before path routing existed.
36
- */
37
- path?: string
38
- /**
39
- * Optional. PID of the long-running process that owns this entry. When set,
40
- * the daemon's PID-GC reaps the entry the moment that process dies. Omit
41
- * (or set to `undefined`) for manually-managed entries created via
42
- * `rpx register` — those persist until explicit `rpx unregister`.
43
- */
44
- pid?: number
45
- cwd?: string
46
- createdAt: string
47
- pathRewrites?: PathRewrite[]
48
- cleanUrls?: boolean
49
- changeOrigin?: boolean
50
- /** Serve a local directory for this route instead of proxying. */
51
- static?: string | StaticRouteConfig
52
- }
53
-
54
- const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
55
-
56
- /**
57
- * Default location for the registry directory. The daemon's PID file and log
58
- * sit alongside it under `~/.stacks/rpx/`.
59
- */
60
- export function getRegistryDir(): string {
61
- return path.join(homedir(), '.stacks', 'rpx', 'registry.d')
62
- }
63
-
64
- /**
65
- * Validate an entry id. Rejects anything that could escape the registry dir
66
- * (path traversal, slashes) or that would round-trip oddly through a filename.
67
- */
68
- export function isValidId(id: string): boolean {
69
- return typeof id === 'string' && id.length > 0 && id.length <= 128 && ID_PATTERN.test(id)
70
- }
71
-
72
- function entryPath(dir: string, id: string): string {
73
- if (!isValidId(id))
74
- throw new Error(`invalid registry id: ${JSON.stringify(id)}`)
75
- return path.join(dir, `${id}.json`)
76
- }
77
-
78
- /**
79
- * Check whether a PID is alive. `kill(pid, 0)` returns without sending a
80
- * signal but throws ESRCH if the process is gone — exactly the probe we need.
81
- * EPERM means the process exists but we don't own it; treat as alive.
82
- */
83
- export function isPidAlive(pid: number): boolean {
84
- if (!Number.isInteger(pid) || pid <= 0)
85
- return false
86
- try {
87
- process.kill(pid, 0)
88
- return true
89
- }
90
- catch (err) {
91
- return (err as NodeJS.ErrnoException).code === 'EPERM'
92
- }
93
- }
94
-
95
- function isValidEntry(value: unknown): value is RegistryEntry {
96
- if (!value || typeof value !== 'object')
97
- return false
98
- const e = value as Partial<RegistryEntry>
99
- // pid is optional. When present it must be a positive integer; when absent
100
- // (manual entries from `rpx register`) the daemon's PID-GC skips it.
101
- const pidOk = e.pid === undefined
102
- || (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
103
- // A route forwards to an upstream (`from`) OR serves files (`static`).
104
- const hasFrom = typeof e.from === 'string' && e.from.length > 0
105
- const hasStatic = typeof e.static === 'string'
106
- || (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
107
- // path is optional; when present it must be a string.
108
- const pathOk = e.path === undefined || typeof e.path === 'string'
109
- return (
110
- typeof e.id === 'string' && isValidId(e.id)
111
- && (hasFrom || hasStatic)
112
- && typeof e.to === 'string' && e.to.length > 0
113
- && pathOk
114
- && pidOk
115
- && typeof e.createdAt === 'string'
116
- )
117
- }
118
-
119
- async function ensureDir(dir: string): Promise<void> {
120
- await fsp.mkdir(dir, { recursive: true })
121
- }
122
-
123
- /**
124
- * Atomically write an entry to disk.
125
- *
126
- * Writes to a temp file in the same directory, then renames into place. POSIX
127
- * rename within the same filesystem is atomic, so a concurrent reader either
128
- * sees the old file or the new file — never a half-written one.
129
- */
130
- export async function writeEntry(entry: RegistryEntry, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
131
- if (!isValidEntry(entry))
132
- throw new Error(`invalid registry entry: ${JSON.stringify(entry)}`)
133
-
134
- await ensureDir(dir)
135
- const finalPath = entryPath(dir, entry.id)
136
- const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`
137
- const json = JSON.stringify(entry, null, 2)
138
-
139
- try {
140
- await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o644 })
141
- await fsp.rename(tmpPath, finalPath)
142
- debugLog('registry', `wrote entry ${entry.id} → ${finalPath}`, verbose)
143
- }
144
- catch (err) {
145
- // Best-effort cleanup of the temp file if the rename never landed.
146
- await fsp.unlink(tmpPath).catch(() => {})
147
- throw err
148
- }
149
- }
150
-
151
- /**
152
- * Remove an entry by id. No-op if the file is already gone.
153
- */
154
- export async function removeEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
155
- const target = entryPath(dir, id)
156
- try {
157
- await fsp.unlink(target)
158
- debugLog('registry', `removed entry ${id}`, verbose)
159
- }
160
- catch (err) {
161
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
162
- throw err
163
- }
164
- }
165
-
166
- /**
167
- * Read a single entry by id. Returns `null` if missing or malformed (malformed
168
- * files are deleted so they don't keep poisoning subsequent reads).
169
- */
170
- export async function readEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry | null> {
171
- const target = entryPath(dir, id)
172
- try {
173
- const raw = await fsp.readFile(target, 'utf8')
174
- const parsed = JSON.parse(raw)
175
- if (!isValidEntry(parsed)) {
176
- debugLog('registry', `entry ${id} failed validation, removing`, verbose)
177
- await fsp.unlink(target).catch(() => {})
178
- return null
179
- }
180
- return parsed
181
- }
182
- catch (err) {
183
- const code = (err as NodeJS.ErrnoException).code
184
- if (code === 'ENOENT')
185
- return null
186
- if (err instanceof SyntaxError) {
187
- debugLog('registry', `entry ${id} has invalid JSON, removing`, verbose)
188
- await fsp.unlink(target).catch(() => {})
189
- return null
190
- }
191
- throw err
192
- }
193
- }
194
-
195
- /**
196
- * Read all entries from the registry directory. Malformed files are pruned.
197
- * This does NOT GC stale PIDs — call `gcStaleEntries` for that explicitly.
198
- */
199
- export async function readAll(dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry[]> {
200
- let names: string[]
201
- try {
202
- names = await fsp.readdir(dir)
203
- }
204
- catch (err) {
205
- if ((err as NodeJS.ErrnoException).code === 'ENOENT')
206
- return []
207
- throw err
208
- }
209
-
210
- const out: RegistryEntry[] = []
211
- for (const name of names) {
212
- if (!name.endsWith('.json'))
213
- continue
214
- const id = name.slice(0, -'.json'.length)
215
- if (!isValidId(id))
216
- continue
217
- const entry = await readEntry(id, dir, verbose)
218
- if (entry)
219
- out.push(entry)
220
- }
221
- return out
222
- }
223
-
224
- /**
225
- * Remove entries whose writer PID is no longer alive. Returns the count of
226
- * entries removed. Safe to call repeatedly; intended to run on daemon startup
227
- * and on a slow timer (e.g. every 5s) while the daemon is up.
228
- */
229
- export async function gcStaleEntries(dir: string = getRegistryDir(), verbose?: boolean): Promise<number> {
230
- const entries = await readAll(dir, verbose)
231
- let removed = 0
232
- for (const entry of entries) {
233
- // Manually-managed entries (no pid) opt out of PID-GC. The user is
234
- // responsible for `rpx unregister` when they're done.
235
- if (entry.pid === undefined)
236
- continue
237
- if (!isPidAlive(entry.pid)) {
238
- debugLog('registry', `GC: pid ${entry.pid} for ${entry.id} is dead, removing`, verbose)
239
- await removeEntry(entry.id, dir, verbose).catch(() => {})
240
- removed++
241
- }
242
- }
243
- return removed
244
- }
245
-
246
- export interface WatchHandle {
247
- close: () => void
248
- }
249
-
250
- export interface WatchOptions {
251
- debounceMs?: number
252
- pollMs?: number
253
- verbose?: boolean
254
- }
255
-
256
- /**
257
- * Watch the registry directory and invoke `onChange` with the full current
258
- * entry list whenever something changes. Events are debounced so a flurry of
259
- * rapid writes (e.g. several `./buddy dev` invocations starting in parallel)
260
- * triggers at most one rebuild.
261
- *
262
- * The watcher tolerates a missing directory at startup — it creates the dir
263
- * before opening the watch, so the first `writeEntry` doesn't race the daemon.
264
- */
265
- export function watchRegistry(
266
- onChange: (entries: RegistryEntry[]) => void | Promise<void>,
267
- opts: WatchOptions & { dir?: string } = {},
268
- ): WatchHandle {
269
- const dir = opts.dir ?? getRegistryDir()
270
- const debounceMs = opts.debounceMs ?? 100
271
- const pollMs = opts.pollMs ?? Math.max(debounceMs * 2, 250)
272
- const verbose = opts.verbose
273
-
274
- // Create the dir up front so fs.watch has something to attach to.
275
- fs.mkdirSync(dir, { recursive: true })
276
-
277
- let pending: ReturnType<typeof setTimeout> | null = null
278
- let closed = false
279
- let lastSignature: string | null = null
280
- let pollInFlight = false
281
-
282
- const signatureFor = (entries: RegistryEntry[]): string => {
283
- return JSON.stringify(
284
- entries
285
- .map(entry => ({
286
- id: entry.id,
287
- from: entry.from,
288
- to: entry.to,
289
- path: entry.path,
290
- pid: entry.pid,
291
- pathRewrites: entry.pathRewrites,
292
- cleanUrls: entry.cleanUrls,
293
- changeOrigin: entry.changeOrigin,
294
- static: entry.static,
295
- }))
296
- .sort((a, b) => a.id.localeCompare(b.id)),
297
- )
298
- }
299
-
300
- const fire = () => {
301
- pending = null
302
- if (closed)
303
- return
304
- readAll(dir, verbose)
305
- .then((entries) => {
306
- lastSignature = signatureFor(entries)
307
- return onChange(entries)
308
- })
309
- .catch((err) => {
310
- debugLog('registry', `watcher onChange failed: ${err}`, verbose)
311
- })
312
- }
313
-
314
- const schedule = () => {
315
- if (closed)
316
- return
317
- if (pending)
318
- clearTimeout(pending)
319
- pending = setTimeout(fire, debounceMs)
320
- }
321
-
322
- const poll = () => {
323
- if (closed || pollInFlight)
324
- return
325
-
326
- pollInFlight = true
327
- readAll(dir, verbose)
328
- .then((entries) => {
329
- const signature = signatureFor(entries)
330
- if (signature !== lastSignature)
331
- schedule()
332
- })
333
- .catch((err) => {
334
- debugLog('registry', `watcher poll failed: ${err}`, verbose)
335
- })
336
- .finally(() => {
337
- pollInFlight = false
338
- })
339
- }
340
-
341
- const pollInterval = setInterval(poll, pollMs)
342
-
343
- const watcher = fs.watch(dir, { persistent: true }, (_eventType, filename) => {
344
- // Ignore temp files from our own atomic-write protocol.
345
- if (filename && /\.tmp\.\d+\.\d+$/.test(filename))
346
- return
347
- schedule()
348
- })
349
-
350
- watcher.on('error', (err) => {
351
- debugLog('registry', `watcher error: ${err}`, verbose)
352
- })
353
-
354
- // Fire once on startup so the daemon picks up entries that already exist.
355
- schedule()
356
-
357
- return {
358
- close: () => {
359
- closed = true
360
- if (pending)
361
- clearTimeout(pending)
362
- clearInterval(pollInterval)
363
- watcher.close()
364
- },
365
- }
366
- }