@stacksjs/rpx 0.11.19 → 0.11.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/acme-challenge.d.ts +13 -0
  2. package/dist/auth.d.ts +21 -0
  3. package/dist/bin/cli.js +252 -200
  4. package/dist/cert-inspect.d.ts +5 -3
  5. package/dist/{chunk-83dqq28c.js → chunk-1108y1dk.js} +1 -1
  6. package/dist/{chunk-0f32jmrb.js → chunk-c9brkawa.js} +1 -1
  7. package/dist/{chunk-w888yhnp.js → chunk-cqkz06bg.js} +1 -1
  8. package/dist/chunk-qs36t2rf.js +224 -0
  9. package/dist/daemon.d.ts +4 -1
  10. package/dist/https.d.ts +1 -0
  11. package/dist/index.d.ts +33 -1
  12. package/dist/index.js +7 -7
  13. package/dist/proxy-handler.d.ts +18 -1
  14. package/dist/proxy-pool.d.ts +5 -0
  15. package/dist/redirect.d.ts +40 -0
  16. package/dist/registry.d.ts +2 -1
  17. package/dist/site-resolver.d.ts +106 -0
  18. package/dist/site-splash.d.ts +19 -0
  19. package/dist/site-supervisor.d.ts +56 -0
  20. package/dist/start.d.ts +31 -8
  21. package/dist/types.d.ts +64 -0
  22. package/package.json +6 -7
  23. package/dist/chunk-rs8gqpax.js +0 -174
  24. package/src/cert-inspect.ts +0 -69
  25. package/src/colors.ts +0 -13
  26. package/src/config.ts +0 -45
  27. package/src/daemon-runner.ts +0 -180
  28. package/src/daemon.ts +0 -1209
  29. package/src/dns-state.ts +0 -116
  30. package/src/dns.ts +0 -568
  31. package/src/host-match.ts +0 -52
  32. package/src/host-routes.ts +0 -147
  33. package/src/hosts.ts +0 -283
  34. package/src/https.ts +0 -905
  35. package/src/index.ts +0 -161
  36. package/src/logger.ts +0 -19
  37. package/src/macos-trust.ts +0 -175
  38. package/src/on-demand.ts +0 -264
  39. package/src/origin-guard.ts +0 -127
  40. package/src/port-manager.ts +0 -183
  41. package/src/process-manager.ts +0 -164
  42. package/src/proxy-handler.ts +0 -387
  43. package/src/proxy-pool.ts +0 -1003
  44. package/src/registry.ts +0 -366
  45. package/src/sni.ts +0 -93
  46. package/src/start.ts +0 -1421
  47. package/src/static-files.ts +0 -201
  48. package/src/types.ts +0 -267
  49. package/src/utils.ts +0 -243
@@ -1,387 +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 { FALLBACK, POOL_BUSY, proxyViaPool, TIMEOUT } from './proxy-pool'
20
- import { serveStaticFile } from './static-files'
21
- import { debugLog, resolvePathRewrite } from './utils'
22
-
23
- export interface ProxyRoute {
24
- /**
25
- * Upstream `host:port` to forward requests to (e.g. `localhost:5173`).
26
- * Optional when `static` is set.
27
- */
28
- sourceHost?: string
29
- /** Strip `.html` suffix and 301 to clean URLs. */
30
- cleanUrls?: boolean
31
- /** Set the `origin` header to the target. */
32
- changeOrigin?: boolean
33
- /** Per-route path rewrites (vite/nginx-style prefix routing). */
34
- pathRewrites?: PathRewrite[]
35
- /** When set, serve files from a local directory instead of proxying. */
36
- static?: ResolvedStaticRoute
37
- /**
38
- * Path prefix this route is mounted under (e.g. `/docs`). Used together with
39
- * {@link stripBasePathPrefix} to map request paths to the target. `/` (the
40
- * host default) is a no-op.
41
- */
42
- basePath?: string
43
- /**
44
- * Whether to strip {@link basePath} from the request pathname before
45
- * resolving the target.
46
- *
47
- * - Static routes default to `true`: a directory mounted at `/docs` serves
48
- * its own `index.html` for `/docs` and `<root>/guide` for `/docs/guide`.
49
- * - Proxy routes default to `false`: most apps own their namespace (an app
50
- * mounted at `/api` expects to still see `/api/...`), matching rpx's
51
- * `PathRewrite.stripPrefix` default and nginx `proxy_pass` (no trailing
52
- * slash) behavior.
53
- *
54
- * When unset, the per-transport default above applies.
55
- */
56
- stripBasePathPrefix?: boolean
57
- }
58
-
59
- /**
60
- * Resolve a route for an incoming request. `pathname` enables path-based
61
- * routing within a host (e.g. `/api/*` → app, `/docs*` → static dir). Callers
62
- * that only route by host can ignore the second argument — it's optional so
63
- * existing host-only `getRoute` callbacks remain valid.
64
- */
65
- export type GetRoute = (hostname: string, pathname: string) => ProxyRoute | undefined
66
-
67
- export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
68
-
69
- /** Minimal shape of the Bun server needed for WebSocket upgrades. */
70
- export interface ProxyServer {
71
- // `unknown` data + standard HeadersInit so it structurally accepts Bun's
72
- // `Server<WebSocketData>` for any data generic (the daemon/start callers
73
- // parameterize differently) without resorting to `any`.
74
- upgrade: (req: Request, options?: { data?: unknown, headers?: Bun.HeadersInit }) => boolean
75
- }
76
-
77
- /** Data attached to an upgraded client socket so the ws handler can dial upstream. */
78
- interface WsData {
79
- targetUrl: string
80
- forwardHeaders: Record<string, string>
81
- }
82
-
83
- /** Per-socket state: the upstream client + a buffer for early client frames. */
84
- interface WsState {
85
- upstream: WebSocket
86
- upstreamOpen: boolean
87
- pending: Array<string | ArrayBufferLike | Uint8Array>
88
- }
89
-
90
- const HOP_BY_HOP = new Set([
91
- 'connection',
92
- 'keep-alive',
93
- 'proxy-authenticate',
94
- 'proxy-authorization',
95
- 'te',
96
- 'trailer',
97
- 'transfer-encoding',
98
- 'upgrade',
99
- 'sec-websocket-key',
100
- 'sec-websocket-version',
101
- 'sec-websocket-extensions',
102
- ])
103
-
104
- function extractHostname(req: Request): string {
105
- const hostHeader = req.headers.get('host') || ''
106
- // Strip port (`stacks.localhost:443` → `stacks.localhost`) without allocating
107
- // an array (`split`) on every request.
108
- const colon = hostHeader.indexOf(':')
109
- return colon === -1 ? hostHeader : hostHeader.slice(0, colon)
110
- }
111
-
112
- /**
113
- * Strip the route's mount prefix (`basePath`) from a request pathname so a
114
- * target mounted under `/docs` sees `/` for `/docs` and `/guide` for
115
- * `/docs/guide`. A `/` (or empty) base strips nothing. The result always keeps
116
- * a leading `/`.
117
- */
118
- export function stripBasePath(pathname: string, basePath?: string): string {
119
- if (!basePath || basePath === '/')
120
- return pathname
121
- if (pathname === basePath)
122
- return '/'
123
- if (pathname.startsWith(`${basePath}/`)) {
124
- const rest = pathname.slice(basePath.length)
125
- return rest === '' ? '/' : rest
126
- }
127
- return pathname
128
- }
129
-
130
- /**
131
- * Resolve the upstream target (`host` + `path`) for a request against a route,
132
- * applying any matching path rewrite. Takes the already-extracted `pathname` so
133
- * the hot path never re-parses the request URL.
134
- */
135
- function resolveTarget(pathname: string, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string } {
136
- let targetHost = route.sourceHost ?? ''
137
- // Proxy backends preserve their mount prefix by default (most apps own their
138
- // `/api` namespace), opting in to stripping via `stripBasePathPrefix`.
139
- // Explicit `pathRewrites` still apply on top of this.
140
- const stripBase = route.stripBasePathPrefix ?? false
141
- let targetPath = stripBase ? stripBasePath(pathname, route.basePath) : pathname
142
-
143
- const rewriteMatch = resolvePathRewrite(targetPath, route.pathRewrites)
144
- if (rewriteMatch) {
145
- targetHost = rewriteMatch.targetHost
146
- targetPath = rewriteMatch.targetPath
147
- debugLog('request', `Path rewrite: ${pathname} → ${targetHost}${targetPath}`, verbose)
148
- }
149
-
150
- return { targetHost, targetPath }
151
- }
152
-
153
- /**
154
- * Extract the origin-form path and query from a request URL without the cost of
155
- * constructing a `URL`. `req.url` is always absolute-form here
156
- * (`http://host/path?q`), so we slice from the first `/` after the authority.
157
- * The raw (un-decoded) path is forwarded verbatim, which is what a proxy wants.
158
- */
159
- function splitPathQuery(rawUrl: string): { pathname: string, search: string } {
160
- const schemeEnd = rawUrl.indexOf('://')
161
- const pathStart = schemeEnd === -1 ? rawUrl.indexOf('/') : rawUrl.indexOf('/', schemeEnd + 3)
162
- if (pathStart === -1)
163
- return { pathname: '/', search: '' }
164
- const q = rawUrl.indexOf('?', pathStart)
165
- if (q === -1)
166
- return { pathname: rawUrl.slice(pathStart), search: '' }
167
- return { pathname: rawUrl.slice(pathStart, q), search: rawUrl.slice(q) }
168
- }
169
-
170
- /**
171
- * Build a Bun.serve-compatible `fetch` handler that routes requests based on
172
- * the `Host` header. Returns 404 when no route matches and 502 on upstream
173
- * failures. When a request is a WebSocket upgrade and `server` is supplied, it
174
- * is upgraded (returns `undefined` so Bun completes the handshake) and the
175
- * traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
176
- */
177
- export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
178
- const inner = async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
179
- const { pathname, search } = splitPathQuery(req.url)
180
- const hostname = extractHostname(req)
181
-
182
- const route = getRoute(hostname, pathname)
183
- if (!route) {
184
- debugLog('request', `No route found for host: ${hostname}`, verbose)
185
- return new Response(`No proxy configured for ${hostname}`, { status: 404 })
186
- }
187
-
188
- // Static file serving short-circuits everything else. Strip the route's
189
- // mount prefix (default for static) so a dir mounted at `/docs` serves its
190
- // own root for `/docs`.
191
- if (route.static) {
192
- const strip = route.stripBasePathPrefix ?? true
193
- const staticPath = strip ? stripBasePath(pathname, route.basePath) : pathname
194
- return serveStaticFile(staticPath, route.static)
195
- }
196
-
197
- // WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
198
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
199
- if (!server || !route.sourceHost)
200
- return new Response('WebSocket upgrade not supported here', { status: 400 })
201
-
202
- const { targetHost, targetPath } = resolveTarget(pathname, route, verbose)
203
- const targetUrl = `ws://${targetHost}${targetPath}${search}`
204
-
205
- const forwardHeaders: Record<string, string> = {}
206
- for (const [k, v] of req.headers) {
207
- if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'host')
208
- forwardHeaders[k] = v
209
- }
210
- forwardHeaders.host = targetHost
211
- forwardHeaders['x-forwarded-for'] = '127.0.0.1'
212
- forwardHeaders['x-forwarded-proto'] = 'https'
213
- forwardHeaders['x-forwarded-host'] = hostname
214
-
215
- const data: WsData = { targetUrl, forwardHeaders }
216
- const ok = server.upgrade(req, { data })
217
- if (ok) {
218
- debugLog('ws', `upgraded ${hostname}${targetPath} → ${targetUrl}`, verbose)
219
- return undefined
220
- }
221
- return new Response('WebSocket upgrade failed', { status: 400 })
222
- }
223
-
224
- if (!route.sourceHost)
225
- return new Response(`No upstream configured for ${hostname}`, { status: 502 })
226
-
227
- // Strip `.html` and 301 to the clean URL when enabled — before any upstream
228
- // work, since the redirect doesn't depend on the origin response.
229
- if (route.cleanUrls && pathname.endsWith('.html')) {
230
- const cleanPath = pathname.replace(/\.html$/, '')
231
- return new Response(null, {
232
- status: 301,
233
- headers: { Location: cleanPath },
234
- })
235
- }
236
-
237
- const { targetHost, targetPath } = resolveTarget(pathname, route, verbose)
238
- const originOverride = route.changeOrigin ? `http://${route.sourceHost}` : undefined
239
-
240
- // Forward through the pooled raw-socket transport: a reused keepalive pool
241
- // per upstream (like nginx's `keepalive`) that stays flat under load, where
242
- // fetch()'s connection churn exhausts ephemeral ports and collapses (~15x).
243
- // Forwarded headers (host, x-forwarded-*, origin) are serialized inline with
244
- // the passthrough headers — no intermediate Headers copy on the hot path. The
245
- // pool declines what it doesn't handle (streaming uploads, Expect, upgrades)
246
- // via FALLBACK, and bodyless requests can retry through fetch() as a backstop.
247
- const hasBody = req.body != null && req.method !== 'GET' && req.method !== 'HEAD'
248
- try {
249
- return await proxyViaPool({
250
- hostPort: targetHost,
251
- method: req.method,
252
- path: `${targetPath}${search}`,
253
- reqHeaders: req.headers,
254
- forwardedHost: hostname,
255
- originOverride,
256
- body: req.body,
257
- })
258
- }
259
- catch (err) {
260
- // Upstream stalled past the configured timeout → 504 (no fetch retry — it
261
- // would just stall again).
262
- if (err === TIMEOUT) {
263
- debugLog('request', `Upstream timeout for ${hostname}`, verbose)
264
- return new Response('Gateway Timeout', { status: 504 })
265
- }
266
- // Pool saturated: every connection to this upstream is busy and the wait
267
- // for a free slot timed out. Fail fast and loud (503) instead of parking
268
- // the request forever — a parked request with no response is what made the
269
- // listener appear "wedged" in production.
270
- if (err === POOL_BUSY) {
271
- debugLog('request', `Upstream pool saturated for ${hostname}`, verbose)
272
- return new Response('Service Unavailable', { status: 503, headers: { 'retry-after': '1' } })
273
- }
274
- if (err === FALLBACK || !hasBody) {
275
- try {
276
- const headers = new Headers(req.headers)
277
- headers.set('host', targetHost)
278
- headers.set('x-forwarded-for', '127.0.0.1')
279
- headers.set('x-forwarded-proto', 'https')
280
- headers.set('x-forwarded-host', hostname)
281
- if (originOverride !== undefined)
282
- headers.set('origin', originOverride)
283
- return await fetch(`http://${targetHost}${targetPath}${search}`, {
284
- method: req.method,
285
- headers,
286
- body: req.body,
287
- redirect: 'manual',
288
- })
289
- }
290
- catch (fetchErr) {
291
- debugLog('request', `Proxy error for ${hostname}: ${fetchErr}`, verbose)
292
- return new Response(`Proxy Error: ${fetchErr}`, { status: 502 })
293
- }
294
- }
295
- debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
296
- return new Response(`Proxy Error: ${err}`, { status: 502 })
297
- }
298
- }
299
-
300
- // A reverse proxy must never drop a connection on an unexpected throw: an
301
- // async fetch handler that *rejects* makes Bun close the socket with no
302
- // response (the client sees an empty reply) and log a stack trace per hit.
303
- // Wrap the whole pipeline so a routing bug, a malformed request, or a static
304
- // helper that throws always degrades to a 502 instead of a dropped connection.
305
- return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
306
- try {
307
- return await inner(req, server)
308
- }
309
- catch (err) {
310
- debugLog('request', `Unhandled proxy handler error: ${err}`, verbose)
311
- return new Response('Bad Gateway', { status: 502 })
312
- }
313
- }
314
- }
315
-
316
- /**
317
- * Build the `websocket` handler block for Bun.serve. It opens an upstream
318
- * `WebSocket` per client socket, buffers client→upstream frames until the
319
- * upstream connection is open, and pipes messages, closes and errors in both
320
- * directions with a clean teardown.
321
- */
322
- export function createProxyWebSocketHandler(verbose?: boolean) {
323
- const state = new WeakMap<ServerWebSocket<WsData>, WsState>()
324
-
325
- return {
326
- open(ws: ServerWebSocket<WsData>): void {
327
- const { targetUrl, forwardHeaders } = ws.data
328
- let upstream: WebSocket
329
- try {
330
- // Bun's WebSocket accepts a `headers` option (control-channel auth etc.)
331
- // that the DOM lib's constructor type doesn't model — describe that
332
- // extended constructor precisely instead of falling back to `any`.
333
- type BunWebSocketCtor = new (_url: string | URL, _options?: { headers?: Record<string, string> }) => WebSocket
334
- upstream = new (WebSocket as unknown as BunWebSocketCtor)(targetUrl, { headers: forwardHeaders })
335
- }
336
- catch (err) {
337
- debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
338
- ws.close(1011, 'upstream connect failed')
339
- return
340
- }
341
- upstream.binaryType = 'arraybuffer'
342
- const st: WsState = { upstream, upstreamOpen: false, pending: [] }
343
- state.set(ws, st)
344
-
345
- upstream.addEventListener('open', () => {
346
- st.upstreamOpen = true
347
- for (const frame of st.pending)
348
- upstream.send(frame)
349
- st.pending = []
350
- })
351
- upstream.addEventListener('message', (ev: MessageEvent) => {
352
- // Forward both binary (ArrayBuffer) and text frames to the client.
353
- // `binaryType` is 'arraybuffer', so `ev.data` is `string | ArrayBuffer`.
354
- ws.send(ev.data as string | ArrayBuffer)
355
- })
356
- upstream.addEventListener('close', (ev: CloseEvent) => {
357
- try { ws.close(ev.code || 1000, ev.reason || '') }
358
- catch { /* already closing */ }
359
- })
360
- upstream.addEventListener('error', () => {
361
- debugLog('ws', `upstream error for ${targetUrl}`, verbose)
362
- try { ws.close(1011, 'upstream error') }
363
- catch { /* already closing */ }
364
- })
365
- },
366
-
367
- message(ws: ServerWebSocket<WsData>, message: string | Buffer): void {
368
- const st = state.get(ws)
369
- if (!st)
370
- return
371
- const frame = typeof message === 'string' ? message : new Uint8Array(message)
372
- if (st.upstreamOpen)
373
- st.upstream.send(frame)
374
- else
375
- st.pending.push(frame)
376
- },
377
-
378
- close(ws: ServerWebSocket<WsData>, code: number, reason: string): void {
379
- const st = state.get(ws)
380
- if (!st)
381
- return
382
- state.delete(ws)
383
- try { st.upstream.close(code || 1000, reason || '') }
384
- catch { /* already closed */ }
385
- },
386
- }
387
- }