@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/dist/bin/cli.js +162 -149
- package/dist/chunk-0f32jmrb.js +1 -0
- package/dist/chunk-83dqq28c.js +1 -0
- package/dist/chunk-rs8gqpax.js +174 -0
- package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
- package/dist/daemon.d.ts +17 -8
- package/dist/dns.d.ts +2 -0
- package/dist/https.d.ts +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -7
- package/dist/logger.d.ts +8 -8
- package/dist/proxy-handler.d.ts +1 -1
- package/dist/proxy-pool.d.ts +77 -0
- package/dist/types.d.ts +10 -0
- package/dist/utils.d.ts +15 -0
- package/package.json +6 -1
- package/src/daemon.ts +471 -48
- package/src/dns.ts +66 -7
- package/src/hosts.ts +6 -1
- package/src/https.ts +46 -3
- package/src/index.ts +3 -0
- package/src/logger.ts +16 -16
- package/src/origin-guard.ts +27 -5
- package/src/proxy-handler.ts +122 -50
- package/src/proxy-pool.ts +1003 -0
- package/src/start.ts +32 -32
- package/src/types.ts +11 -0
- package/src/utils.ts +33 -0
- package/dist/chunk-9njrhbd4.js +0 -1
- package/dist/chunk-a0b9f9fs.js +0 -1
- package/dist/chunk-s4tdtq37.js +0 -161
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
|
-
|
|
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 {
|
|
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:
|
|
127
|
-
const altNameURIs = options.https.altNameURIs?.filter((uri:
|
|
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:
|
|
3
|
-
success: (...args:
|
|
4
|
-
warn: (...args:
|
|
5
|
-
error: (...args:
|
|
6
|
-
debug: (...args:
|
|
7
|
-
log: (...args:
|
|
8
|
-
start: (...args:
|
|
9
|
-
box: (...args:
|
|
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:
|
|
12
|
-
success: (...args:
|
|
13
|
-
warn: (...args:
|
|
14
|
-
error: (...args:
|
|
15
|
-
debug: (...args:
|
|
16
|
-
log: (...args:
|
|
17
|
-
start: (...args:
|
|
18
|
-
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
|
}
|
package/src/origin-guard.ts
CHANGED
|
@@ -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]
|
|
76
|
+
return normalizeHost(hostHeader.split(':')[0])
|
|
55
77
|
try {
|
|
56
|
-
return new URL(req.url).hostname
|
|
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
|
|
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
|
|
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)
|
|
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' } })
|
package/src/proxy-handler.ts
CHANGED
|
@@ -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
|
-
//
|
|
71
|
-
// any data generic (the daemon/start callers
|
|
72
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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: ${
|
|
147
|
+
debugLog('request', `Path rewrite: ${pathname} → ${targetHost}${targetPath}`, verbose)
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
return { targetHost, targetPath
|
|
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
|
-
|
|
158
|
-
const
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
373
|
+
st.upstream.send(frame)
|
|
302
374
|
else
|
|
303
375
|
st.pending.push(frame)
|
|
304
376
|
},
|