@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/dist/bin/cli.js +152 -151
- package/dist/{chunk-zs1tyy8z.js → chunk-3pgh05pc.js} +1 -1
- package/dist/chunk-5ygwd93k.js +1 -0
- package/dist/{chunk-747af2w4.js → chunk-a0ddh9cv.js} +1 -1
- package/dist/chunk-tx5hnj92.js +157 -0
- package/dist/daemon-runner.d.ts +3 -2
- package/dist/daemon.d.ts +2 -1
- package/dist/host-match.d.ts +23 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +4 -4
- package/dist/proxy-handler.d.ts +18 -3
- package/dist/registry.d.ts +3 -2
- package/dist/sni.d.ts +20 -0
- package/dist/static-files.d.ts +46 -0
- package/dist/types.d.ts +33 -1
- package/package.json +1 -1
- package/src/daemon-runner.ts +12 -4
- package/src/daemon.ts +143 -12
- package/src/host-match.ts +52 -0
- package/src/index.ts +16 -2
- package/src/proxy-handler.ts +184 -21
- package/src/registry.ts +11 -3
- package/src/sni.ts +93 -0
- package/src/start.ts +66 -19
- package/src/static-files.ts +201 -0
- package/src/types.ts +79 -1
- package/dist/chunk-1j4gp3f8.js +0 -1
- package/dist/chunk-9dndasyk.js +0 -156
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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'
|
package/src/proxy-handler.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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 {
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
&&
|
|
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
|
)
|