@stacksjs/rpx 0.11.12 → 0.11.14

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/src/daemon.ts CHANGED
@@ -16,8 +16,8 @@
16
16
  * paths are reachable without touching `~/.stacks/rpx` or :443.
17
17
  */
18
18
  /* eslint-disable no-console */
19
- import type { ProxyOptions, SSLConfig, TlsOption } from './types'
20
- import type { ProxyRoute } from './proxy-handler'
19
+ import type { ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
20
+ import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
21
21
  import { spawn as nodeSpawn } from 'node:child_process'
22
22
  import * as fsp from 'node:fs/promises'
23
23
  import { homedir } from 'node:os'
@@ -25,7 +25,10 @@ import * as path from 'node:path'
25
25
  import * as process from 'node:process'
26
26
  import { log } from './logger'
27
27
  import { checkExistingCertificates, generateCertificate } from './https'
28
- import { createProxyFetchHandler } from './proxy-handler'
28
+ import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
29
+ import { matchHost } from './host-match'
30
+ import { buildSniTlsConfig } from './sni'
31
+ import { resolveStaticRoute } from './static-files'
29
32
  import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
30
33
  import type { RegistryEntry } from './registry'
31
34
  import {
@@ -49,6 +52,12 @@ export interface DaemonOptions {
49
52
  hostname?: string
50
53
  /** TLS bootstrap options forwarded to httpsConfig. */
51
54
  https?: TlsOption
55
+ /**
56
+ * Production per-domain SNI certs (real PEMs on disk). When usable certs are
57
+ * found, the listener serves them per SNI server name instead of the dev
58
+ * self-signed shared cert.
59
+ */
60
+ productionCerts?: ProductionTlsConfig
52
61
  /** PID-GC interval in ms. Defaults to 5000. */
53
62
  gcIntervalMs?: number
54
63
  }
@@ -148,10 +157,18 @@ export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Pro
148
157
  * fetch handler. The entry's `from` is normalized to `host:port`.
149
158
  */
150
159
  function entryToRoute(entry: RegistryEntry): ProxyRoute {
151
- const fromUrl = new URL(entry.from.startsWith('http') ? entry.from : `http://${entry.from}`)
160
+ const cleanUrls = entry.cleanUrls ?? false
161
+ if (entry.static) {
162
+ return {
163
+ static: resolveStaticRoute(entry.static, cleanUrls),
164
+ cleanUrls,
165
+ }
166
+ }
167
+ const from = entry.from ?? 'localhost:1'
168
+ const fromUrl = new URL(from.startsWith('http') ? from : `http://${from}`)
152
169
  return {
153
170
  sourceHost: fromUrl.host,
154
- cleanUrls: entry.cleanUrls ?? false,
171
+ cleanUrls,
155
172
  changeOrigin: entry.changeOrigin ?? false,
156
173
  pathRewrites: entry.pathRewrites,
157
174
  }
@@ -206,6 +223,87 @@ async function bootstrapTls(opts: DaemonOptions, registryDir: string): Promise<S
206
223
  return sslConfig
207
224
  }
208
225
 
226
+ /**
227
+ * Binding :443/:80 requires root. When the daemon is launched as a normal user
228
+ * (the common case — `./buddy dev`), re-exec it through `sudo` so the elevated
229
+ * copy can bind the privileged ports. HOME/PATH are forwarded explicitly (via
230
+ * `env`) so the root daemon reads the *user's* `~/.stacks/rpx` state, certs and
231
+ * registry instead of root's home. The password is fed on stdin only — never
232
+ * placed in argv — so it can't leak via `ps`, and the root daemon doesn't need
233
+ * it (it can already sudo).
234
+ *
235
+ * Returns a launcher handle: this unprivileged process has done its job once
236
+ * the elevated daemon has written its pid, so `done` resolves immediately and
237
+ * the launcher exits, leaving the root daemon running independently (its pid
238
+ * file is how everyone else finds it).
239
+ */
240
+ async function elevateDaemonToRoot(
241
+ rpxDir: string,
242
+ httpsPort: number,
243
+ httpPort: number,
244
+ verbose: boolean,
245
+ ): Promise<DaemonHandle> {
246
+ const sudoPassword = process.env.SUDO_PASSWORD
247
+ const home = process.env.HOME ?? homedir()
248
+ const inner = [process.execPath, ...process.argv.slice(1)]
249
+ const forwardedEnv = [`HOME=${home}`, `PATH=${process.env.PATH ?? ''}`]
250
+ if (verbose)
251
+ forwardedEnv.push('RPX_VERBOSE=1')
252
+
253
+ // `sudo -S` reads the password from stdin; `-n` (no password) relies on a
254
+ // cached credential. Either way we never block on an interactive prompt.
255
+ const sudoArgs = sudoPassword
256
+ ? ['-S', '-p', '', 'env', ...forwardedEnv, ...inner]
257
+ : ['-n', 'env', ...forwardedEnv, ...inner]
258
+
259
+ debugLog('daemon', `elevating daemon via sudo for privileged ports ${httpsPort}/${httpPort}`, verbose)
260
+ const child = nodeSpawn('sudo', sudoArgs, { detached: true, stdio: ['pipe', 'ignore', 'ignore'] })
261
+
262
+ let spawnError: Error | null = null
263
+ let sudoExitCode: number | null = null
264
+ child.once('error', (err) => { spawnError = err })
265
+ child.once('exit', (code) => { sudoExitCode = code ?? 0 })
266
+
267
+ if (sudoPassword && child.stdin) {
268
+ child.stdin.write(`${sudoPassword}\n`)
269
+ child.stdin.end()
270
+ }
271
+ child.unref()
272
+
273
+ const pidPath = getDaemonPidPath(rpxDir)
274
+ const deadline = Date.now() + 15000
275
+ while (Date.now() < deadline) {
276
+ if (spawnError)
277
+ throw spawnError
278
+ const pid = await readDaemonPid(rpxDir)
279
+ if (pid !== null && isPidAlive(pid)) {
280
+ if (verbose)
281
+ log.success(`rpx daemon elevated to root (pid=${pid}, https on :${httpsPort})`)
282
+ return {
283
+ httpsPort,
284
+ httpPort,
285
+ pidPath,
286
+ done: Promise.resolve(),
287
+ stop: async () => {
288
+ // The daemon is root-owned; a normal user can't signal it. `./buddy
289
+ // dev` intentionally leaves the shared daemon running across sessions.
290
+ try { process.kill(pid, 'SIGTERM') }
291
+ catch { /* EPERM — root-owned shared daemon */ }
292
+ },
293
+ }
294
+ }
295
+ // sudo exits fast when auth fails; while the daemon runs it stays alive.
296
+ if (sudoExitCode !== null && sudoExitCode !== 0) {
297
+ throw new Error(
298
+ `rpx daemon could not elevate to bind :${httpsPort} (sudo exited ${sudoExitCode}). `
299
+ + 'Set SUDO_PASSWORD in .env or run `sudo -v` first.',
300
+ )
301
+ }
302
+ await new Promise(resolve => setTimeout(resolve, 50))
303
+ }
304
+ throw new Error(`rpx daemon failed to elevate within 15000ms (rpxDir=${rpxDir})`)
305
+ }
306
+
209
307
  /**
210
308
  * Start the daemon. Returns a handle that resolves `done` once the daemon has
211
309
  * cleanly shut down (signal received and listeners closed).
@@ -223,11 +321,21 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
223
321
  const hostname = opts.hostname ?? '0.0.0.0'
224
322
  const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
225
323
 
324
+ // Privileged ports need root. If we were launched unprivileged (the usual
325
+ // `./buddy dev` case), re-exec through sudo and hand off to the elevated
326
+ // copy — it becomes the real daemon. Tests inject high ports and so skip this.
327
+ const needsPrivilegedPort = (httpsPort > 0 && httpsPort < 1024) || (httpPort > 0 && httpPort < 1024)
328
+ const alreadyRoot = typeof process.getuid === 'function' && process.getuid() === 0
329
+ if (process.platform !== 'win32' && needsPrivilegedPort && !alreadyRoot)
330
+ return elevateDaemonToRoot(rpxDir, httpsPort, httpPort, verbose)
331
+
226
332
  const pidPath = await acquireDaemonLock(rpxDir)
227
333
 
228
334
  // Module-scoped state so the watcher and fetch handler share one routing view.
335
+ // Routing table keyed by host pattern. Lookup prefers an exact match, then
336
+ // the most-specific `*.suffix` wildcard (see `matchHost`).
229
337
  let routingTable = new Map<string, ProxyRoute>()
230
- const getRoute = (host: string): ProxyRoute | undefined => routingTable.get(host)
338
+ const getRoute = (host: string): ProxyRoute | undefined => matchHost(routingTable, host)
231
339
 
232
340
  function rebuild(entries: RegistryEntry[]): void {
233
341
  const next = new Map<string, ProxyRoute>()
@@ -251,19 +359,42 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
251
359
  debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
252
360
  })
253
361
 
254
- const sslConfig = await bootstrapTls(opts, registryDir)
362
+ // Production per-domain SNI: serve real PEM certs (e.g. Let's Encrypt) keyed
363
+ // by server name on the one listener. Falls back to the dev shared cert when
364
+ // no usable production certs are configured.
365
+ let sniTls: Array<{ serverName: string, cert: string, key: string }> = []
366
+ if (opts.productionCerts) {
367
+ sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
368
+ if (verbose && sniTls.length > 0)
369
+ log.info(`SNI: serving ${sniTls.length} real cert(s): ${sniTls.map(e => e.serverName).join(', ')}`)
370
+ }
255
371
 
256
- const httpsServer = Bun.serve({
257
- port: httpsPort,
258
- hostname,
259
- tls: {
372
+ const fetchHandler = createProxyFetchHandler(getRoute, verbose)
373
+ const wsHandler = createProxyWebSocketHandler(verbose)
374
+
375
+ let tlsConfig: unknown
376
+ if (sniTls.length > 0) {
377
+ tlsConfig = sniTls.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
378
+ }
379
+ else {
380
+ const sslConfig = await bootstrapTls(opts, registryDir)
381
+ tlsConfig = {
260
382
  key: sslConfig.key,
261
383
  cert: sslConfig.cert,
262
384
  ca: sslConfig.ca,
263
385
  requestCert: false,
264
386
  rejectUnauthorized: false,
387
+ }
388
+ }
389
+
390
+ const httpsServer = Bun.serve({
391
+ port: httpsPort,
392
+ hostname,
393
+ tls: tlsConfig as any,
394
+ fetch(req: Request, server: unknown) {
395
+ return fetchHandler(req, server as ProxyServerLike)
265
396
  },
266
- fetch: createProxyFetchHandler(getRoute, verbose),
397
+ websocket: wsHandler,
267
398
  error(err: Error) {
268
399
  debugLog('daemon', `https server error: ${err}`, verbose)
269
400
  return new Response(`Server Error: ${err.message}`, { status: 500 })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Host-based route matching with wildcard support.
3
+ *
4
+ * The routing table is keyed by host pattern. A pattern is either an exact
5
+ * hostname (`api.example.com`) or a wildcard (`*.example.com`). Lookup prefers
6
+ * an exact match, then the most-specific (deepest-suffix) wildcard.
7
+ *
8
+ * Kept dependency-free and pure so it's reusable from both the daemon and the
9
+ * in-process multi-proxy path, and trivially unit-testable.
10
+ */
11
+
12
+ export function isWildcardPattern(pattern: string): boolean {
13
+ return pattern.startsWith('*.')
14
+ }
15
+
16
+ /**
17
+ * True if `hostname` matches the wildcard `pattern` (`*.suffix`). A wildcard
18
+ * matches exactly one or more leading labels — `*.example.com` matches
19
+ * `a.example.com` and `a.b.example.com`, but NOT the bare apex `example.com`.
20
+ */
21
+ export function matchesWildcard(hostname: string, pattern: string): boolean {
22
+ if (!isWildcardPattern(pattern))
23
+ return false
24
+ const suffix = pattern.slice(1) // '*.example.com' → '.example.com'
25
+ return hostname.length > suffix.length && hostname.endsWith(suffix)
26
+ }
27
+
28
+ /**
29
+ * Find the route value for `hostname` in a host-keyed map. Exact match wins;
30
+ * otherwise the matching wildcard with the longest (most-specific) suffix wins.
31
+ * Returns `undefined` when nothing matches.
32
+ */
33
+ export function matchHost<T>(table: Map<string, T>, hostname: string): T | undefined {
34
+ const exact = table.get(hostname)
35
+ if (exact !== undefined)
36
+ return exact
37
+
38
+ let best: T | undefined
39
+ let bestLen = -1
40
+ for (const [pattern, value] of table) {
41
+ if (!isWildcardPattern(pattern))
42
+ continue
43
+ if (matchesWildcard(hostname, pattern)) {
44
+ const len = pattern.length - 1 // length of the matched suffix
45
+ if (len > bestLen) {
46
+ bestLen = len
47
+ best = value
48
+ }
49
+ }
50
+ }
51
+ return best
52
+ }
package/src/index.ts CHANGED
@@ -112,8 +112,22 @@ export type {
112
112
  StopDaemonResult,
113
113
  } from './daemon'
114
114
 
115
- export { createProxyFetchHandler } from './proxy-handler'
116
- export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
115
+ export { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
116
+ export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
117
+
118
+ export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
119
+
120
+ export {
121
+ contentTypeFor,
122
+ resolveStaticFile,
123
+ resolveStaticRoute,
124
+ safeRelativePath,
125
+ serveStaticFile,
126
+ } from './static-files'
127
+ export type { ResolvedStaticRoute, StaticResolution } from './static-files'
128
+
129
+ export { buildSniTlsConfig, serverNameFromCertFilename } from './sni'
130
+ export type { SniTlsEntry } from './sni'
117
131
 
118
132
  export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
119
133
  export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The fetch handler used by the shared :443 server. Both the in-process
2
+ * The request handlers used by the shared :443 server. Both the in-process
3
3
  * multi-proxy mode in `start.ts` and the long-running daemon delegate to this
4
4
  * module so routing semantics stay in one place.
5
5
  *
@@ -7,37 +7,108 @@
7
7
  * The callback indirection lets each caller use whatever data structure makes
8
8
  * sense (a fixed Map at startup, or a hot-swappable registry view) without
9
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`).
10
15
  */
16
+ import type { ServerWebSocket } from 'bun'
17
+ import type { ResolvedStaticRoute } from './static-files'
11
18
  import type { PathRewrite } from './types'
12
- import { debugLog } from './utils'
13
- import { resolvePathRewrite } from './utils'
19
+ import { serveStaticFile } from './static-files'
20
+ import { debugLog, resolvePathRewrite } from './utils'
14
21
 
15
22
  export interface ProxyRoute {
16
- /** Upstream `host:port` to forward requests to (e.g. `localhost:5173`). */
17
- sourceHost: string
23
+ /**
24
+ * Upstream `host:port` to forward requests to (e.g. `localhost:5173`).
25
+ * Optional when `static` is set.
26
+ */
27
+ sourceHost?: string
18
28
  /** Strip `.html` suffix and 301 to clean URLs. */
19
29
  cleanUrls?: boolean
20
30
  /** Set the `origin` header to the target. */
21
31
  changeOrigin?: boolean
22
32
  /** Per-route path rewrites (vite/nginx-style prefix routing). */
23
33
  pathRewrites?: PathRewrite[]
34
+ /** When set, serve files from a local directory instead of proxying. */
35
+ static?: ResolvedStaticRoute
24
36
  }
25
37
 
26
38
  export type GetRoute = (hostname: string) => ProxyRoute | undefined
27
39
 
28
- export type ProxyFetchHandler = (req: Request) => Promise<Response>
40
+ export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
41
+
42
+ /** Minimal shape of the Bun server needed for WebSocket upgrades. */
43
+ export interface ProxyServer {
44
+ // Loose `any` so it structurally accepts Bun's `Server<WebSocketData>` for
45
+ // any data generic (the daemon/start callers parameterize differently).
46
+ upgrade: (req: Request, options?: { data?: any, headers?: any }) => boolean
47
+ }
48
+
49
+ /** Data attached to an upgraded client socket so the ws handler can dial upstream. */
50
+ interface WsData {
51
+ targetUrl: string
52
+ forwardHeaders: Record<string, string>
53
+ }
54
+
55
+ /** Per-socket state: the upstream client + a buffer for early client frames. */
56
+ interface WsState {
57
+ upstream: WebSocket
58
+ upstreamOpen: boolean
59
+ pending: Array<string | ArrayBufferLike | Uint8Array>
60
+ }
61
+
62
+ const HOP_BY_HOP = new Set([
63
+ 'connection',
64
+ 'keep-alive',
65
+ 'proxy-authenticate',
66
+ 'proxy-authorization',
67
+ 'te',
68
+ 'trailer',
69
+ 'transfer-encoding',
70
+ 'upgrade',
71
+ 'sec-websocket-key',
72
+ 'sec-websocket-version',
73
+ 'sec-websocket-extensions',
74
+ ])
75
+
76
+ function extractHostname(req: Request): string {
77
+ const hostHeader = req.headers.get('host') || ''
78
+ // Strip port (`stacks.localhost:443` → `stacks.localhost`).
79
+ return hostHeader.split(':')[0]
80
+ }
81
+
82
+ /**
83
+ * Resolve the upstream target (`host` + `path`) for a request against a route,
84
+ * applying any matching path rewrite.
85
+ */
86
+ function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
87
+ const url = new URL(req.url)
88
+ let targetHost = route.sourceHost ?? ''
89
+ let targetPath = url.pathname
90
+
91
+ const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
92
+ if (rewriteMatch) {
93
+ targetHost = rewriteMatch.targetHost
94
+ targetPath = rewriteMatch.targetPath
95
+ debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
96
+ }
97
+
98
+ return { targetHost, targetPath, search: url.search }
99
+ }
29
100
 
30
101
  /**
31
102
  * Build a Bun.serve-compatible `fetch` handler that routes requests based on
32
103
  * the `Host` header. Returns 404 when no route matches and 502 on upstream
33
- * failures.
104
+ * failures. When a request is a WebSocket upgrade and `server` is supplied, it
105
+ * is upgraded (returns `undefined` so Bun completes the handshake) and the
106
+ * traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
34
107
  */
35
108
  export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
36
- return async (req: Request): Promise<Response> => {
109
+ return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
37
110
  const url = new URL(req.url)
38
- const hostHeader = req.headers.get('host') || ''
39
- // Strip port (`stacks.localhost:443` → `stacks.localhost`).
40
- const hostname = hostHeader.split(':')[0]
111
+ const hostname = extractHostname(req)
41
112
 
42
113
  const route = getRoute(hostname)
43
114
  if (!route) {
@@ -45,19 +116,42 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
45
116
  return new Response(`No proxy configured for ${hostname}`, { status: 404 })
46
117
  }
47
118
 
48
- let targetHost = route.sourceHost
49
- let targetPath = url.pathname
119
+ // Static file serving short-circuits everything else.
120
+ if (route.static)
121
+ return serveStaticFile(url.pathname, route.static)
122
+
123
+ // WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
124
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
125
+ if (!server || !route.sourceHost)
126
+ return new Response('WebSocket upgrade not supported here', { status: 400 })
127
+
128
+ const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
129
+ const targetUrl = `ws://${targetHost}${targetPath}${search}`
50
130
 
51
- // Per-route path rewrites: prefix preserved by default, matching Vite /
52
- // nginx / http-proxy-middleware semantics. See `resolvePathRewrite`.
53
- const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
54
- if (rewriteMatch) {
55
- targetHost = rewriteMatch.targetHost
56
- targetPath = rewriteMatch.targetPath
57
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
131
+ const forwardHeaders: Record<string, string> = {}
132
+ for (const [k, v] of req.headers) {
133
+ if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'host')
134
+ forwardHeaders[k] = v
135
+ }
136
+ forwardHeaders.host = targetHost
137
+ forwardHeaders['x-forwarded-for'] = '127.0.0.1'
138
+ forwardHeaders['x-forwarded-proto'] = 'https'
139
+ forwardHeaders['x-forwarded-host'] = hostname
140
+
141
+ const data: WsData = { targetUrl, forwardHeaders }
142
+ const ok = server.upgrade(req, { data })
143
+ if (ok) {
144
+ debugLog('ws', `upgraded ${hostname}${targetPath} → ${targetUrl}`, verbose)
145
+ return undefined
146
+ }
147
+ return new Response('WebSocket upgrade failed', { status: 400 })
58
148
  }
59
149
 
60
- const targetUrl = `http://${targetHost}${targetPath}${url.search}`
150
+ if (!route.sourceHost)
151
+ return new Response(`No upstream configured for ${hostname}`, { status: 502 })
152
+
153
+ const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
154
+ const targetUrl = `http://${targetHost}${targetPath}${search}`
61
155
 
62
156
  try {
63
157
  const headers = new Headers(req.headers)
@@ -97,3 +191,72 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
97
191
  }
98
192
  }
99
193
  }
194
+
195
+ /**
196
+ * Build the `websocket` handler block for Bun.serve. It opens an upstream
197
+ * `WebSocket` per client socket, buffers client→upstream frames until the
198
+ * upstream connection is open, and pipes messages, closes and errors in both
199
+ * directions with a clean teardown.
200
+ */
201
+ export function createProxyWebSocketHandler(verbose?: boolean) {
202
+ const state = new WeakMap<ServerWebSocket<WsData>, WsState>()
203
+
204
+ return {
205
+ open(ws: ServerWebSocket<WsData>): void {
206
+ const { targetUrl, forwardHeaders } = ws.data
207
+ let upstream: WebSocket
208
+ try {
209
+ // Bun's WebSocket accepts a `headers` option (control-channel auth etc.).
210
+ upstream = new WebSocket(targetUrl, { headers: forwardHeaders } as any)
211
+ }
212
+ catch (err) {
213
+ debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
214
+ ws.close(1011, 'upstream connect failed')
215
+ return
216
+ }
217
+ upstream.binaryType = 'arraybuffer'
218
+ const st: WsState = { upstream, upstreamOpen: false, pending: [] }
219
+ state.set(ws, st)
220
+
221
+ upstream.addEventListener('open', () => {
222
+ st.upstreamOpen = true
223
+ for (const frame of st.pending)
224
+ upstream.send(frame as any)
225
+ st.pending = []
226
+ })
227
+ upstream.addEventListener('message', (ev: MessageEvent) => {
228
+ // Forward both binary (ArrayBuffer) and text frames to the client.
229
+ ws.send(ev.data as any)
230
+ })
231
+ upstream.addEventListener('close', (ev: CloseEvent) => {
232
+ try { ws.close(ev.code || 1000, ev.reason || '') }
233
+ catch { /* already closing */ }
234
+ })
235
+ upstream.addEventListener('error', () => {
236
+ debugLog('ws', `upstream error for ${targetUrl}`, verbose)
237
+ try { ws.close(1011, 'upstream error') }
238
+ catch { /* already closing */ }
239
+ })
240
+ },
241
+
242
+ message(ws: ServerWebSocket<WsData>, message: string | Buffer): void {
243
+ const st = state.get(ws)
244
+ if (!st)
245
+ return
246
+ const frame = typeof message === 'string' ? message : new Uint8Array(message)
247
+ if (st.upstreamOpen)
248
+ st.upstream.send(frame as any)
249
+ else
250
+ st.pending.push(frame)
251
+ },
252
+
253
+ close(ws: ServerWebSocket<WsData>, code: number, reason: string): void {
254
+ const st = state.get(ws)
255
+ if (!st)
256
+ return
257
+ state.delete(ws)
258
+ try { st.upstream.close(code || 1000, reason || '') }
259
+ catch { /* already closed */ }
260
+ },
261
+ }
262
+ }
package/src/registry.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * - `id` is validated against a strict charset to keep it from escaping
15
15
  * the registry directory.
16
16
  */
17
- import type { PathRewrite } from './types'
17
+ import type { PathRewrite, StaticRouteConfig } from './types'
18
18
  import * as fs from 'node:fs'
19
19
  import * as fsp from 'node:fs/promises'
20
20
  import { homedir } from 'node:os'
@@ -24,7 +24,8 @@ import { debugLog } from './utils'
24
24
 
25
25
  export interface RegistryEntry {
26
26
  id: string
27
- from: string
27
+ /** Upstream `host:port`. Optional when `static` is set. */
28
+ from?: string
28
29
  to: string
29
30
  /**
30
31
  * Optional. PID of the long-running process that owns this entry. When set,
@@ -38,6 +39,8 @@ export interface RegistryEntry {
38
39
  pathRewrites?: PathRewrite[]
39
40
  cleanUrls?: boolean
40
41
  changeOrigin?: boolean
42
+ /** Serve a local directory for this route instead of proxying. */
43
+ static?: string | StaticRouteConfig
41
44
  }
42
45
 
43
46
  const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
@@ -89,9 +92,13 @@ function isValidEntry(value: unknown): value is RegistryEntry {
89
92
  // (manual entries from `rpx register`) the daemon's PID-GC skips it.
90
93
  const pidOk = e.pid === undefined
91
94
  || (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
95
+ // A route forwards to an upstream (`from`) OR serves files (`static`).
96
+ const hasFrom = typeof e.from === 'string' && e.from.length > 0
97
+ const hasStatic = typeof e.static === 'string'
98
+ || (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
92
99
  return (
93
100
  typeof e.id === 'string' && isValidId(e.id)
94
- && typeof e.from === 'string' && e.from.length > 0
101
+ && (hasFrom || hasStatic)
95
102
  && typeof e.to === 'string' && e.to.length > 0
96
103
  && pidOk
97
104
  && typeof e.createdAt === 'string'
@@ -272,6 +279,7 @@ export function watchRegistry(
272
279
  pathRewrites: entry.pathRewrites,
273
280
  cleanUrls: entry.cleanUrls,
274
281
  changeOrigin: entry.changeOrigin,
282
+ static: entry.static,
275
283
  }))
276
284
  .sort((a, b) => a.id.localeCompare(b.id)),
277
285
  )