@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/proxy-handler.ts
DELETED
|
@@ -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
|
-
}
|