@stacksjs/rpx 0.11.13 → 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 -152
- 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 +54 -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-kbnzcycw.js +0 -1
- package/dist/chunk-pncxrxde.js +0 -157
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
|
}
|
|
@@ -315,8 +332,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
315
332
|
const pidPath = await acquireDaemonLock(rpxDir)
|
|
316
333
|
|
|
317
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`).
|
|
318
337
|
let routingTable = new Map<string, ProxyRoute>()
|
|
319
|
-
const getRoute = (host: string): ProxyRoute | undefined => routingTable
|
|
338
|
+
const getRoute = (host: string): ProxyRoute | undefined => matchHost(routingTable, host)
|
|
320
339
|
|
|
321
340
|
function rebuild(entries: RegistryEntry[]): void {
|
|
322
341
|
const next = new Map<string, ProxyRoute>()
|
|
@@ -340,19 +359,42 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
|
|
|
340
359
|
debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
|
|
341
360
|
})
|
|
342
361
|
|
|
343
|
-
|
|
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
|
+
}
|
|
344
371
|
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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 = {
|
|
349
382
|
key: sslConfig.key,
|
|
350
383
|
cert: sslConfig.cert,
|
|
351
384
|
ca: sslConfig.ca,
|
|
352
385
|
requestCert: false,
|
|
353
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)
|
|
354
396
|
},
|
|
355
|
-
|
|
397
|
+
websocket: wsHandler,
|
|
356
398
|
error(err: Error) {
|
|
357
399
|
debugLog('daemon', `https server error: ${err}`, verbose)
|
|
358
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
|
)
|
package/src/sni.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a Bun.serve TLS array for per-domain SNI from real PEM files on disk.
|
|
3
|
+
*
|
|
4
|
+
* Production deployments (Let's Encrypt) have one cert+key per domain. Bun's
|
|
5
|
+
* `Bun.serve({ tls: [{ serverName, cert, key }, ...] })` selects the right cert
|
|
6
|
+
* by SNI server name at handshake time, so a single listener can front many
|
|
7
|
+
* domains with their own real certs.
|
|
8
|
+
*/
|
|
9
|
+
import type { DomainCert, ProductionTlsConfig } from './types'
|
|
10
|
+
import * as fsp from 'node:fs/promises'
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
import { debugLog } from './utils'
|
|
13
|
+
|
|
14
|
+
/** One entry of the Bun.serve `tls` array. */
|
|
15
|
+
export interface SniTlsEntry {
|
|
16
|
+
serverName: string
|
|
17
|
+
cert: string
|
|
18
|
+
key: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map a PEM filename under a `certsDir` to its SNI server name. Returns `null`
|
|
23
|
+
* for files that aren't `<name>.crt`. The wildcard convention
|
|
24
|
+
* `_wildcard.<apex>.crt` maps to server name `*.<apex>`.
|
|
25
|
+
*/
|
|
26
|
+
export function serverNameFromCertFilename(filename: string): string | null {
|
|
27
|
+
if (!filename.endsWith('.crt'))
|
|
28
|
+
return null
|
|
29
|
+
const base = filename.slice(0, -'.crt'.length)
|
|
30
|
+
if (base.length === 0)
|
|
31
|
+
return null
|
|
32
|
+
if (base.startsWith('_wildcard.'))
|
|
33
|
+
return `*.${base.slice('_wildcard.'.length)}`
|
|
34
|
+
return base
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readPair(serverName: string, certPath: string, keyPath: string, verbose?: boolean): Promise<SniTlsEntry | null> {
|
|
38
|
+
try {
|
|
39
|
+
const [cert, key] = await Promise.all([
|
|
40
|
+
fsp.readFile(certPath, 'utf8'),
|
|
41
|
+
fsp.readFile(keyPath, 'utf8'),
|
|
42
|
+
])
|
|
43
|
+
return { serverName, cert, key }
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
debugLog('sni', `skipping ${serverName}: ${(err as Error).message}`, verbose)
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the SNI TLS array from a {@link ProductionTlsConfig}. Reads PEM files
|
|
53
|
+
* from an explicit `domains` map and/or a `certsDir` convention. Files that
|
|
54
|
+
* can't be read are skipped (logged in verbose mode). Returns `[]` when nothing
|
|
55
|
+
* usable is found so the caller can fall back to the dev cert flow.
|
|
56
|
+
*/
|
|
57
|
+
export async function buildSniTlsConfig(cfg: ProductionTlsConfig, verbose?: boolean): Promise<SniTlsEntry[]> {
|
|
58
|
+
const bySrvName = new Map<string, DomainCert>()
|
|
59
|
+
|
|
60
|
+
if (cfg.certsDir) {
|
|
61
|
+
let names: string[] = []
|
|
62
|
+
try {
|
|
63
|
+
names = await fsp.readdir(cfg.certsDir)
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
debugLog('sni', `certsDir read failed (${cfg.certsDir}): ${(err as Error).message}`, verbose)
|
|
67
|
+
}
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const serverName = serverNameFromCertFilename(name)
|
|
70
|
+
if (!serverName)
|
|
71
|
+
continue
|
|
72
|
+
const base = name.slice(0, -'.crt'.length)
|
|
73
|
+
bySrvName.set(serverName, {
|
|
74
|
+
certPath: path.join(cfg.certsDir, name),
|
|
75
|
+
keyPath: path.join(cfg.certsDir, `${base}.key`),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Explicit `domains` entries take precedence over `certsDir` discoveries.
|
|
81
|
+
if (cfg.domains) {
|
|
82
|
+
for (const [serverName, pair] of Object.entries(cfg.domains))
|
|
83
|
+
bySrvName.set(serverName, pair)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries: SniTlsEntry[] = []
|
|
87
|
+
for (const [serverName, pair] of bySrvName) {
|
|
88
|
+
const entry = await readPair(serverName, pair.certPath, pair.keyPath, verbose)
|
|
89
|
+
if (entry)
|
|
90
|
+
entries.push(entry)
|
|
91
|
+
}
|
|
92
|
+
return entries
|
|
93
|
+
}
|