@stacksjs/rpx 0.11.18 → 0.11.19

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/dns.ts CHANGED
@@ -279,6 +279,57 @@ export function isDnsServerRunning(): boolean {
279
279
  return dnsServer !== null
280
280
  }
281
281
 
282
+ /** True when something is answering A queries for `host` on the rpx dev DNS port. */
283
+ export async function isRpxDevelopmentDnsAnswering(host: string, timeoutMs = 500): Promise<boolean> {
284
+ const domain = host.trim().toLowerCase().replace(/\.$/, '')
285
+ if (!domain)
286
+ return false
287
+
288
+ return new Promise((resolve) => {
289
+ const socket = dgram.createSocket('udp4')
290
+ const timer = setTimeout(() => {
291
+ socket.close()
292
+ resolve(false)
293
+ }, timeoutMs)
294
+
295
+ socket.on('message', (msg) => {
296
+ clearTimeout(timer)
297
+ socket.close()
298
+ try {
299
+ const header = parseHeader(msg)
300
+ resolve(header.ancount > 0)
301
+ }
302
+ catch {
303
+ resolve(false)
304
+ }
305
+ })
306
+
307
+ socket.on('error', () => {
308
+ clearTimeout(timer)
309
+ socket.close()
310
+ resolve(false)
311
+ })
312
+
313
+ const query = buildQuery(domain, 1)
314
+ socket.send(query, DNS_PORT, '127.0.0.1', (err) => {
315
+ if (err) {
316
+ clearTimeout(timer)
317
+ socket.close()
318
+ resolve(false)
319
+ }
320
+ })
321
+ })
322
+ }
323
+
324
+ function buildQuery(name: string, type: number): Buffer {
325
+ const header = Buffer.alloc(12)
326
+ header.writeUInt16BE(1, 0)
327
+ header.writeUInt16BE(0x0100, 2)
328
+ header.writeUInt16BE(1, 4)
329
+ const question = Buffer.concat([encodeName(name), Buffer.from([0, type, 0, 1])])
330
+ return Buffer.concat([header, question])
331
+ }
332
+
282
333
  // ---------------------------------------------------------------------------
283
334
  // macOS resolver files
284
335
  // ---------------------------------------------------------------------------
@@ -311,9 +362,9 @@ async function flushDnsCache(verbose?: boolean): Promise<void> {
311
362
  if (process.platform !== 'darwin')
312
363
  return
313
364
 
314
- const { execSudoSync, getSudoPassword } = await import('./utils')
365
+ const { execSudoSync, getSudoPassword, isProcessElevated } = await import('./utils')
315
366
 
316
- if (!getSudoPassword()) {
367
+ if (!isProcessElevated() && !getSudoPassword()) {
317
368
  debugLog('dns', 'Cannot flush DNS cache without SUDO_PASSWORD', verbose)
318
369
  return
319
370
  }
@@ -353,8 +404,8 @@ async function installResolvers(basenames: string[], verbose?: boolean): Promise
353
404
  if (process.platform !== 'darwin')
354
405
  return true
355
406
 
356
- const { getSudoPassword } = await import('./utils')
357
- if (!getSudoPassword()) {
407
+ const { getSudoPassword, isProcessElevated } = await import('./utils')
408
+ if (!isProcessElevated() && !getSudoPassword()) {
358
409
  debugLog('dns', 'SUDO_PASSWORD not set, cannot create resolver files', verbose)
359
410
  return false
360
411
  }
@@ -375,8 +426,8 @@ async function uninstallResolvers(basenames: string[], verbose?: boolean): Promi
375
426
  if (process.platform !== 'darwin')
376
427
  return
377
428
 
378
- const { getSudoPassword } = await import('./utils')
379
- if (!getSudoPassword())
429
+ const { getSudoPassword, isProcessElevated } = await import('./utils')
430
+ if (!isProcessElevated() && !getSudoPassword())
380
431
  return
381
432
 
382
433
  try {
@@ -415,7 +466,15 @@ export async function setupDevelopmentDns(opts: DevelopmentDnsOptions): Promise<
415
466
  return false
416
467
 
417
468
  const basenames = resolverBasenamesForDomains(domains)
418
- const started = await startDnsServer(domains, opts.verbose)
469
+ let started = await startDnsServer(domains, opts.verbose)
470
+ if (!started) {
471
+ const probeHost = domains[0]
472
+ if (probeHost && await isRpxDevelopmentDnsAnswering(probeHost))
473
+ started = true
474
+ else
475
+ debugLog('dns', 'Dev DNS server not available on 127.0.0.1:15353', opts.verbose)
476
+ }
477
+
419
478
  if (!started)
420
479
  return false
421
480
 
package/src/hosts.ts CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import * as process from 'node:process'
6
6
  import { promisify } from 'node:util'
7
- import { debugLog, getSudoPassword } from './utils'
7
+ import { debugLog, getSudoPassword, isProcessElevated } from './utils'
8
8
 
9
9
  const execAsync = promisify(exec)
10
10
 
@@ -29,6 +29,11 @@ async function execSudo(command: string): Promise<string> {
29
29
  if (process.platform === 'win32')
30
30
  throw new Error('Administrator privileges required on Windows')
31
31
 
32
+ if (isProcessElevated()) {
33
+ const { stdout } = await execAsync(command)
34
+ return stdout
35
+ }
36
+
32
37
  const sudoPassword = getSudoPassword()
33
38
  const escaped = command.replace(/'/g, `'\\''`)
34
39
 
package/src/https.ts CHANGED
@@ -6,7 +6,11 @@ import { homedir } from 'node:os'
6
6
  import * as path from 'node:path'
7
7
  import { join } from 'node:path'
8
8
  import * as process from 'node:process'
9
- import { addCertToSystemTrustStoreAndSaveCert, createRootCA, generateCertificate as generateCert } from '@stacksjs/tlsx'
9
+ import {
10
+ addCertToSystemTrustStoreAndSaveCert,
11
+ createRootCA,
12
+ generateCertificate as generateCert,
13
+ } from '@stacksjs/tlsx'
10
14
  import { log } from './logger'
11
15
  import { config } from './config'
12
16
  import {
@@ -44,6 +48,45 @@ export {
44
48
 
45
49
  let cachedSSLConfig: { key: string, cert: string, ca?: string } | null = null
46
50
 
51
+ /** Shared dev host cert path used by the rpx daemon and `./buddy dev`. */
52
+ export const SHARED_DEV_HOST_CERT_PATH: string = join(homedir(), '.stacks', 'ssl', 'rpx.localhost.crt')
53
+
54
+ /**
55
+ * Bun needs one `tls[]` entry per SNI name even when a single PEM covers every SAN.
56
+ * Without this, :443 serves the default cert (wrong CN → ERR_CERT_COMMON_NAME_INVALID).
57
+ */
58
+ export function devSslToSniEntries(
59
+ hosts: string[],
60
+ ssl: SSLConfig,
61
+ ): Array<{ serverName: string, cert: string, key: string }> {
62
+ return [...new Set(hosts.filter(Boolean))].map(serverName => ({
63
+ serverName,
64
+ cert: ssl.cert,
65
+ key: ssl.key,
66
+ }))
67
+ }
68
+
69
+ /** ProxyOptions for the shared multi-app dev certificate (all registry hosts as SANs). */
70
+ export function buildRegistryTlsProxyOptions(
71
+ registryHosts: string[],
72
+ primary: string,
73
+ verbose?: boolean,
74
+ ): ProxyOptions {
75
+ const sslDir = join(homedir(), '.stacks', 'ssl')
76
+ const hostnames = [...new Set([primary, ...registryHosts, 'rpx.localhost'])]
77
+ return {
78
+ https: {
79
+ certPath: SHARED_DEV_HOST_CERT_PATH,
80
+ keyPath: join(sslDir, 'rpx.localhost.key'),
81
+ caCertPath: join(sslDir, 'rpx.localhost.ca.crt'),
82
+ commonName: primary,
83
+ },
84
+ verbose,
85
+ regenerateUntrustedCerts: true,
86
+ proxies: hostnames.map(to => ({ from: 'localhost:1', to, cleanUrls: false })),
87
+ }
88
+ }
89
+
47
90
  // Canonical filenames for the shared Root CA. The CA is a singleton across all
48
91
  // rpx-managed domains so that browsers only need to trust it once. Host certs
49
92
  // for individual domains are issued from this CA on demand.
@@ -123,8 +166,8 @@ export function resolveSSLPaths(options: ProxyConfigs, defaultConfig: typeof con
123
166
  })
124
167
 
125
168
  // Filter out undefined values from arrays
126
- const altNameIPs = options.https.altNameIPs?.filter((ip: any): ip is string => ip !== undefined) || baseConfig.altNameIPs
127
- const altNameURIs = options.https.altNameURIs?.filter((uri: any): uri is string => uri !== undefined) || baseConfig.altNameURIs
169
+ const altNameIPs = options.https.altNameIPs?.filter((ip: string | undefined): ip is string => ip !== undefined) || baseConfig.altNameIPs
170
+ const altNameURIs = options.https.altNameURIs?.filter((uri: string | undefined): uri is string => uri !== undefined) || baseConfig.altNameURIs
128
171
 
129
172
  // Override with provided paths
130
173
  return {
package/src/index.ts CHANGED
@@ -11,9 +11,12 @@ export {
11
11
  } from './hosts'
12
12
 
13
13
  export {
14
+ SHARED_DEV_HOST_CERT_PATH,
15
+ buildRegistryTlsProxyOptions,
14
16
  checkExistingCertificates,
15
17
  cleanupCertificates,
16
18
  clearSslConfigCache,
19
+ devSslToSniEntries,
17
20
  forceTrustCertificate,
18
21
  generateCertificate,
19
22
  getRootCAPaths,
package/src/logger.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  export const log: {
2
- info: (...args: any[]) => void
3
- success: (...args: any[]) => void
4
- warn: (...args: any[]) => void
5
- error: (...args: any[]) => void
6
- debug: (...args: any[]) => void
7
- log: (...args: any[]) => void
8
- start: (...args: any[]) => void
9
- box: (...args: any[]) => void
2
+ info: (...args: unknown[]) => void
3
+ success: (...args: unknown[]) => void
4
+ warn: (...args: unknown[]) => void
5
+ error: (...args: unknown[]) => void
6
+ debug: (...args: unknown[]) => void
7
+ log: (...args: unknown[]) => void
8
+ start: (...args: unknown[]) => void
9
+ box: (...args: unknown[]) => void
10
10
  } = {
11
- info: (...args: any[]) => console.log('[info]', ...args),
12
- success: (...args: any[]) => console.log('[success]', ...args),
13
- warn: (...args: any[]) => console.warn('[warn]', ...args),
14
- error: (...args: any[]) => console.error('[error]', ...args),
15
- debug: (...args: any[]) => console.debug('[debug]', ...args),
16
- log: (...args: any[]) => console.log(...args),
17
- start: (...args: any[]) => console.log('[start]', ...args),
18
- box: (...args: any[]) => console.log('[box]', ...args),
11
+ info: (...args: unknown[]) => console.log('[info]', ...args),
12
+ success: (...args: unknown[]) => console.log('[success]', ...args),
13
+ warn: (...args: unknown[]) => console.warn('[warn]', ...args),
14
+ error: (...args: unknown[]) => console.error('[error]', ...args),
15
+ debug: (...args: unknown[]) => console.debug('[debug]', ...args),
16
+ log: (...args: unknown[]) => console.log(...args),
17
+ start: (...args: unknown[]) => console.log('[start]', ...args),
18
+ box: (...args: unknown[]) => console.log('[box]', ...args),
19
19
  }
@@ -21,8 +21,30 @@
21
21
  * })
22
22
  * Bun.serve({ fetch: req => guard(req) ?? handler(req, server) })
23
23
  */
24
+ import { timingSafeEqual } from 'node:crypto'
24
25
  import { matchesWildcard } from './host-match'
25
26
 
27
+ /**
28
+ * Compare a request-supplied secret against the expected value in constant time,
29
+ * so the shared secret can't be recovered byte-by-byte via response timing. The
30
+ * length check leaks only the secret's length (standard and acceptable); the
31
+ * byte comparison itself is timing-safe.
32
+ */
33
+ function secretMatches(provided: string | null, expected: string): boolean {
34
+ if (provided == null)
35
+ return false
36
+ const a = Buffer.from(provided)
37
+ const b = Buffer.from(expected)
38
+ if (a.length !== b.length)
39
+ return false
40
+ return timingSafeEqual(a, b)
41
+ }
42
+
43
+ /** Lowercase and strip a single trailing dot so `Host: example.com.` ≡ `example.com`. */
44
+ function normalizeHost(host: string): string {
45
+ return host.toLowerCase().replace(/\.$/, '')
46
+ }
47
+
26
48
  export interface OriginGuardOptions {
27
49
  /** Header the CDN injects on the origin hop (case-insensitive), e.g. `x-origin-verify`. */
28
50
  header: string
@@ -51,9 +73,9 @@ const DEFAULT_EXEMPT = ['/.well-known/acme-challenge/']
51
73
  function hostnameOf(req: Request): string {
52
74
  const hostHeader = req.headers.get('host')
53
75
  if (hostHeader)
54
- return hostHeader.split(':')[0].toLowerCase()
76
+ return normalizeHost(hostHeader.split(':')[0])
55
77
  try {
56
- return new URL(req.url).hostname.toLowerCase()
78
+ return normalizeHost(new URL(req.url).hostname)
57
79
  }
58
80
  catch {
59
81
  return ''
@@ -65,7 +87,7 @@ export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
65
87
  const exact = new Set<string>()
66
88
  const wildcards: string[] = []
67
89
  for (const h of options.hosts) {
68
- const host = h.toLowerCase()
90
+ const host = normalizeHost(h)
69
91
  if (host.startsWith('*.'))
70
92
  wildcards.push(host)
71
93
  else
@@ -76,7 +98,7 @@ export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
76
98
  ?? 'Forbidden: direct origin access is not allowed; requests must arrive via the CDN.\n'
77
99
 
78
100
  const protects = (hostname: string): boolean => {
79
- const h = hostname.toLowerCase()
101
+ const h = normalizeHost(hostname)
80
102
  return exact.has(h) || wildcards.some(w => matchesWildcard(h, w))
81
103
  }
82
104
 
@@ -95,7 +117,7 @@ export function createOriginGuard(options: OriginGuardOptions): OriginGuard {
95
117
  if (exemptPaths.some(p => pathname.startsWith(p)))
96
118
  return undefined
97
119
 
98
- if (req.headers.get(header) === options.value)
120
+ if (secretMatches(req.headers.get(header), options.value))
99
121
  return undefined
100
122
 
101
123
  return new Response(forbidden, { status: 403, headers: { 'content-type': 'text/plain' } })
@@ -16,6 +16,7 @@
16
16
  import type { ServerWebSocket } from 'bun'
17
17
  import type { ResolvedStaticRoute } from './static-files'
18
18
  import type { PathRewrite } from './types'
19
+ import { FALLBACK, POOL_BUSY, proxyViaPool, TIMEOUT } from './proxy-pool'
19
20
  import { serveStaticFile } from './static-files'
20
21
  import { debugLog, resolvePathRewrite } from './utils'
21
22
 
@@ -67,9 +68,10 @@ export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<
67
68
 
68
69
  /** Minimal shape of the Bun server needed for WebSocket upgrades. */
69
70
  export interface ProxyServer {
70
- // Loose `any` so it structurally accepts Bun's `Server<WebSocketData>` for
71
- // any data generic (the daemon/start callers parameterize differently).
72
- upgrade: (req: Request, options?: { data?: any, headers?: any }) => boolean
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
73
75
  }
74
76
 
75
77
  /** Data attached to an upgraded client socket so the ws handler can dial upstream. */
@@ -101,8 +103,10 @@ const HOP_BY_HOP = new Set([
101
103
 
102
104
  function extractHostname(req: Request): string {
103
105
  const hostHeader = req.headers.get('host') || ''
104
- // Strip port (`stacks.localhost:443` → `stacks.localhost`).
105
- return hostHeader.split(':')[0]
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)
106
110
  }
107
111
 
108
112
  /**
@@ -125,25 +129,42 @@ export function stripBasePath(pathname: string, basePath?: string): string {
125
129
 
126
130
  /**
127
131
  * Resolve the upstream target (`host` + `path`) for a request against a route,
128
- * applying any matching path rewrite.
132
+ * applying any matching path rewrite. Takes the already-extracted `pathname` so
133
+ * the hot path never re-parses the request URL.
129
134
  */
130
- function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
131
- const url = new URL(req.url)
135
+ function resolveTarget(pathname: string, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string } {
132
136
  let targetHost = route.sourceHost ?? ''
133
137
  // Proxy backends preserve their mount prefix by default (most apps own their
134
138
  // `/api` namespace), opting in to stripping via `stripBasePathPrefix`.
135
139
  // Explicit `pathRewrites` still apply on top of this.
136
140
  const stripBase = route.stripBasePathPrefix ?? false
137
- let targetPath = stripBase ? stripBasePath(url.pathname, route.basePath) : url.pathname
141
+ let targetPath = stripBase ? stripBasePath(pathname, route.basePath) : pathname
138
142
 
139
143
  const rewriteMatch = resolvePathRewrite(targetPath, route.pathRewrites)
140
144
  if (rewriteMatch) {
141
145
  targetHost = rewriteMatch.targetHost
142
146
  targetPath = rewriteMatch.targetPath
143
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
147
+ debugLog('request', `Path rewrite: ${pathname} → ${targetHost}${targetPath}`, verbose)
144
148
  }
145
149
 
146
- return { targetHost, targetPath, search: url.search }
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) }
147
168
  }
148
169
 
149
170
  /**
@@ -154,11 +175,11 @@ function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { ta
154
175
  * traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
155
176
  */
156
177
  export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
157
- return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
158
- const url = new URL(req.url)
178
+ const inner = async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
179
+ const { pathname, search } = splitPathQuery(req.url)
159
180
  const hostname = extractHostname(req)
160
181
 
161
- const route = getRoute(hostname, url.pathname)
182
+ const route = getRoute(hostname, pathname)
162
183
  if (!route) {
163
184
  debugLog('request', `No route found for host: ${hostname}`, verbose)
164
185
  return new Response(`No proxy configured for ${hostname}`, { status: 404 })
@@ -169,7 +190,7 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
169
190
  // own root for `/docs`.
170
191
  if (route.static) {
171
192
  const strip = route.stripBasePathPrefix ?? true
172
- const staticPath = strip ? stripBasePath(url.pathname, route.basePath) : url.pathname
193
+ const staticPath = strip ? stripBasePath(pathname, route.basePath) : pathname
173
194
  return serveStaticFile(staticPath, route.static)
174
195
  }
175
196
 
@@ -178,7 +199,7 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
178
199
  if (!server || !route.sourceHost)
179
200
  return new Response('WebSocket upgrade not supported here', { status: 400 })
180
201
 
181
- const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
202
+ const { targetHost, targetPath } = resolveTarget(pathname, route, verbose)
182
203
  const targetUrl = `ws://${targetHost}${targetPath}${search}`
183
204
 
184
205
  const forwardHeaders: Record<string, string> = {}
@@ -203,46 +224,93 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
203
224
  if (!route.sourceHost)
204
225
  return new Response(`No upstream configured for ${hostname}`, { status: 502 })
205
226
 
206
- const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
207
- const targetUrl = `http://${targetHost}${targetPath}${search}`
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
208
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'
209
248
  try {
210
- const headers = new Headers(req.headers)
211
- headers.set('host', targetHost)
212
- if (route.changeOrigin)
213
- headers.set('origin', `http://${route.sourceHost}`)
214
- headers.set('x-forwarded-for', '127.0.0.1')
215
- headers.set('x-forwarded-proto', 'https')
216
- headers.set('x-forwarded-host', hostname)
217
-
218
- const response = await fetch(targetUrl, {
249
+ return await proxyViaPool({
250
+ hostPort: targetHost,
219
251
  method: req.method,
220
- headers,
252
+ path: `${targetPath}${search}`,
253
+ reqHeaders: req.headers,
254
+ forwardedHost: hostname,
255
+ originOverride,
221
256
  body: req.body,
222
- redirect: 'manual',
223
- })
224
-
225
- // Strip `.html` and 301 to the clean URL when enabled.
226
- if (route.cleanUrls && url.pathname.endsWith('.html')) {
227
- const cleanPath = url.pathname.replace(/\.html$/, '')
228
- return new Response(null, {
229
- status: 301,
230
- headers: { Location: cleanPath },
231
- })
232
- }
233
-
234
- const responseHeaders = new Headers(response.headers)
235
- return new Response(response.body, {
236
- status: response.status,
237
- statusText: response.statusText,
238
- headers: responseHeaders,
239
257
  })
240
258
  }
241
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
+ }
242
295
  debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
243
296
  return new Response(`Proxy Error: ${err}`, { status: 502 })
244
297
  }
245
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
+ }
246
314
  }
247
315
 
248
316
  /**
@@ -259,8 +327,11 @@ export function createProxyWebSocketHandler(verbose?: boolean) {
259
327
  const { targetUrl, forwardHeaders } = ws.data
260
328
  let upstream: WebSocket
261
329
  try {
262
- // Bun's WebSocket accepts a `headers` option (control-channel auth etc.).
263
- upstream = new WebSocket(targetUrl, { headers: forwardHeaders } as any)
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 })
264
335
  }
265
336
  catch (err) {
266
337
  debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
@@ -274,12 +345,13 @@ export function createProxyWebSocketHandler(verbose?: boolean) {
274
345
  upstream.addEventListener('open', () => {
275
346
  st.upstreamOpen = true
276
347
  for (const frame of st.pending)
277
- upstream.send(frame as any)
348
+ upstream.send(frame)
278
349
  st.pending = []
279
350
  })
280
351
  upstream.addEventListener('message', (ev: MessageEvent) => {
281
352
  // Forward both binary (ArrayBuffer) and text frames to the client.
282
- ws.send(ev.data as any)
353
+ // `binaryType` is 'arraybuffer', so `ev.data` is `string | ArrayBuffer`.
354
+ ws.send(ev.data as string | ArrayBuffer)
283
355
  })
284
356
  upstream.addEventListener('close', (ev: CloseEvent) => {
285
357
  try { ws.close(ev.code || 1000, ev.reason || '') }
@@ -298,7 +370,7 @@ export function createProxyWebSocketHandler(verbose?: boolean) {
298
370
  return
299
371
  const frame = typeof message === 'string' ? message : new Uint8Array(message)
300
372
  if (st.upstreamOpen)
301
- st.upstream.send(frame as any)
373
+ st.upstream.send(frame)
302
374
  else
303
375
  st.pending.push(frame)
304
376
  },