@stacksjs/rpx 0.11.17 → 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 -148
- 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 +12 -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 +40 -33
- package/src/types.ts +19 -0
- package/src/utils.ts +33 -0
- package/dist/chunk-a0b9f9fs.js +0 -1
- package/dist/chunk-fndafyac.js +0 -1
- package/dist/chunk-zx2ghrc1.js +0 -161
package/src/start.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
import type { IncomingHttpHeaders, SecureServerOptions } from 'node:http2'
|
|
3
3
|
import type { ServerOptions } from 'node:https'
|
|
4
|
-
import type { BaseProxyConfig, CleanupOptions, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
|
|
4
|
+
import type { BaseProxyConfig, CleanupOptions, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, ResolvedProxyOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
|
|
5
5
|
import { exec, execSync } from 'node:child_process'
|
|
6
6
|
import * as fs from 'node:fs'
|
|
7
7
|
import * as http from 'node:http'
|
|
@@ -20,12 +20,13 @@ import { addHosts, checkHosts, removeHosts } from './hosts'
|
|
|
20
20
|
import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https'
|
|
21
21
|
import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager'
|
|
22
22
|
import { ProcessManager } from './process-manager'
|
|
23
|
+
import { createOriginGuard } from './origin-guard'
|
|
23
24
|
import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
|
|
24
25
|
import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
|
|
25
26
|
import { isWildcardPattern } from './host-match'
|
|
26
27
|
import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
|
|
27
28
|
import { resolveStaticRoute } from './static-files'
|
|
28
|
-
import { debugLog, getSudoPassword, safeStringify } from './utils'
|
|
29
|
+
import { debugLog, getSudoPassword, safeStringify, shouldReusePort } from './utils'
|
|
29
30
|
|
|
30
31
|
const processManager = new ProcessManager()
|
|
31
32
|
const version = '0.12.0'
|
|
@@ -236,7 +237,8 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
|
|
|
236
237
|
try {
|
|
237
238
|
await tryConnect()
|
|
238
239
|
}
|
|
239
|
-
catch (err:
|
|
240
|
+
catch (err: unknown) {
|
|
241
|
+
const e = err as NodeJS.ErrnoException
|
|
240
242
|
// Check if we've exceeded the maximum test duration
|
|
241
243
|
if (Date.now() - startTime > maxTestDuration) {
|
|
242
244
|
debugLog('connection', `Connection test timed out after ${maxTestDuration}ms, but continuing anyway`, verbose)
|
|
@@ -245,7 +247,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
|
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
// If we're dealing with a server that takes time to start up
|
|
248
|
-
if (
|
|
250
|
+
if (e.code === 'ECONNREFUSED' && retries > 0) {
|
|
249
251
|
debugLog('connection', `Connection refused, server might be starting up. Retrying in 2 seconds... (${retries} retries left)`, verbose)
|
|
250
252
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
251
253
|
return testConnection(hostname, port, verbose, retries - 1)
|
|
@@ -293,7 +295,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
|
|
|
293
295
|
|
|
294
296
|
// For production environments, we might want to be more strict
|
|
295
297
|
// But for typical usage, let's be permissive and just warn
|
|
296
|
-
const errorMessage = `Failed to connect to ${hostname}:${port} after ${5 - retries} attempts: ${
|
|
298
|
+
const errorMessage = `Failed to connect to ${hostname}:${port} after ${5 - retries} attempts: ${e.message}`
|
|
297
299
|
debugLog('connection', `${errorMessage}. To bypass this check set RPX_BYPASS_CONNECTION_TEST=true`, verbose)
|
|
298
300
|
log.warn(errorMessage)
|
|
299
301
|
log.warn(`RPX will try to continue anyway. If you're sure this is correct, you can set RPX_BYPASS_CONNECTION_TEST=true to skip this check.`)
|
|
@@ -584,6 +586,7 @@ async function createProxyServer(
|
|
|
584
586
|
const bunServer = Bun.serve({
|
|
585
587
|
port: listenPort,
|
|
586
588
|
hostname,
|
|
589
|
+
reusePort: shouldReusePort(),
|
|
587
590
|
tls: {
|
|
588
591
|
key: ssl.key,
|
|
589
592
|
cert: ssl.cert,
|
|
@@ -596,9 +599,19 @@ async function createProxyServer(
|
|
|
596
599
|
const url = new URL(req.url)
|
|
597
600
|
debugLog('request', `Bun.serve received: ${req.method} ${url.pathname}`, verbose)
|
|
598
601
|
|
|
599
|
-
//
|
|
602
|
+
// Handle clean URLs redirect before any upstream work — the
|
|
603
|
+
// redirect doesn't depend on the origin response.
|
|
604
|
+
if (cleanUrls && url.pathname.endsWith('.html')) {
|
|
605
|
+
const cleanPath = url.pathname.replace(/\.html$/, '')
|
|
606
|
+
return new Response(null, {
|
|
607
|
+
status: 301,
|
|
608
|
+
headers: { Location: cleanPath },
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Build target URL by string concat (avoids an extra URL parse).
|
|
600
613
|
const baseUrl = `http://${sourceUrl.host}`
|
|
601
|
-
const targetUrl =
|
|
614
|
+
const targetUrl = `${baseUrl}${url.pathname}${url.search}`
|
|
602
615
|
|
|
603
616
|
// Forward the request
|
|
604
617
|
try {
|
|
@@ -611,30 +624,15 @@ async function createProxyServer(
|
|
|
611
624
|
headers.set('x-forwarded-proto', 'https')
|
|
612
625
|
headers.set('x-forwarded-host', to)
|
|
613
626
|
|
|
614
|
-
|
|
627
|
+
// Return the upstream response directly — Bun streams the body and
|
|
628
|
+
// forwards status/headers verbatim, so re-wrapping it in a fresh
|
|
629
|
+
// Response would just copy every header and allocate twice.
|
|
630
|
+
return await fetch(targetUrl, {
|
|
615
631
|
method: req.method,
|
|
616
632
|
headers,
|
|
617
633
|
body: req.body,
|
|
618
634
|
redirect: 'manual',
|
|
619
635
|
})
|
|
620
|
-
|
|
621
|
-
// Clone response with modified headers if needed
|
|
622
|
-
const responseHeaders = new Headers(response.headers)
|
|
623
|
-
|
|
624
|
-
// Handle clean URLs redirect
|
|
625
|
-
if (cleanUrls && url.pathname.endsWith('.html')) {
|
|
626
|
-
const cleanPath = url.pathname.replace(/\.html$/, '')
|
|
627
|
-
return new Response(null, {
|
|
628
|
-
status: 301,
|
|
629
|
-
headers: { Location: cleanPath },
|
|
630
|
-
})
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
return new Response(response.body, {
|
|
634
|
-
status: response.status,
|
|
635
|
-
statusText: response.statusText,
|
|
636
|
-
headers: responseHeaders,
|
|
637
|
-
})
|
|
638
636
|
}
|
|
639
637
|
catch (err) {
|
|
640
638
|
debugLog('request', `Proxy error: ${err}`, verbose)
|
|
@@ -933,7 +931,7 @@ export function startProxy(options: ProxyOption): void {
|
|
|
933
931
|
}
|
|
934
932
|
|
|
935
933
|
// Helper function to safely get verbose flag from different config types
|
|
936
|
-
function getVerbose(options:
|
|
934
|
+
function getVerbose(options: ProxyOptions): boolean {
|
|
937
935
|
return options?.verbose || false
|
|
938
936
|
}
|
|
939
937
|
|
|
@@ -943,7 +941,7 @@ function getVerbose(options: any): boolean {
|
|
|
943
941
|
* `false`). Real-server deployments with real DNS should set
|
|
944
942
|
* `hostsManagement: false` so rpx never touches `/etc/hosts`.
|
|
945
943
|
*/
|
|
946
|
-
function isHostsManagementEnabled(options:
|
|
944
|
+
function isHostsManagementEnabled(options: ProxyOptions): boolean {
|
|
947
945
|
if (options?.hostsManagement === false)
|
|
948
946
|
return false
|
|
949
947
|
const cleanup = options?.cleanup
|
|
@@ -970,7 +968,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
970
968
|
cleanUrls: false,
|
|
971
969
|
changeOrigin: false,
|
|
972
970
|
regenerateUntrustedCerts: true,
|
|
973
|
-
} as
|
|
971
|
+
} as ResolvedProxyOptions
|
|
974
972
|
|
|
975
973
|
if (options) {
|
|
976
974
|
mergedOptions = {
|
|
@@ -1006,7 +1004,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1006
1004
|
: [{
|
|
1007
1005
|
id: mergedOptions.id,
|
|
1008
1006
|
from: mergedOptions.from,
|
|
1009
|
-
to: mergedOptions.to,
|
|
1007
|
+
to: mergedOptions.to ?? 'rpx.localhost',
|
|
1010
1008
|
path: mergedOptions.path,
|
|
1011
1009
|
cleanUrls: mergedOptions.cleanUrls,
|
|
1012
1010
|
changeOrigin: mergedOptions.changeOrigin,
|
|
@@ -1029,7 +1027,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1029
1027
|
await processManager.startProcess(proxyId, proxy.start, verbose)
|
|
1030
1028
|
|
|
1031
1029
|
// Parse the URL to get hostname and port
|
|
1032
|
-
const fromUrl = new URL(proxy.from
|
|
1030
|
+
const fromUrl = new URL(proxy.from?.startsWith('http') ? proxy.from : `http://${proxy.from}`)
|
|
1033
1031
|
const hostname = fromUrl.hostname || 'localhost'
|
|
1034
1032
|
const port = Number(fromUrl.port) || 80
|
|
1035
1033
|
|
|
@@ -1135,7 +1133,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1135
1133
|
|
|
1136
1134
|
// Prepare proxy configurations
|
|
1137
1135
|
const proxyOptions = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
|
|
1138
|
-
? mergedOptions.proxies.map(
|
|
1136
|
+
? mergedOptions.proxies.map(proxy => ({
|
|
1139
1137
|
...proxy,
|
|
1140
1138
|
https: mergedOptions.https,
|
|
1141
1139
|
cleanup: mergedOptions.cleanup,
|
|
@@ -1323,16 +1321,25 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1323
1321
|
}
|
|
1324
1322
|
|
|
1325
1323
|
const routingTable = buildHostRoutes(routeEntries)
|
|
1326
|
-
const
|
|
1324
|
+
const baseFetchHandler = createProxyFetchHandler(
|
|
1327
1325
|
(host, pathname) => matchHostRoute(routingTable, host, pathname),
|
|
1328
1326
|
verbose,
|
|
1329
1327
|
)
|
|
1328
|
+
// Origin lockdown: when a CDN fronts this gateway, reject direct hits to the
|
|
1329
|
+
// fronted hosts that lack the CDN's shared-secret header (the CDN injects it).
|
|
1330
|
+
const originGuard = mergedOptions.originGuard ? createOriginGuard(mergedOptions.originGuard) : null
|
|
1331
|
+
const sharedFetchHandler = originGuard
|
|
1332
|
+
? (req: Request, server: ProxyServerLike) => originGuard(req) ?? baseFetchHandler(req, server)
|
|
1333
|
+
: baseFetchHandler
|
|
1330
1334
|
const sharedWsHandler = createProxyWebSocketHandler(verbose)
|
|
1331
1335
|
|
|
1332
1336
|
try {
|
|
1333
1337
|
const bunServer = Bun.serve({
|
|
1334
1338
|
port: listenPort,
|
|
1335
1339
|
hostname: '0.0.0.0',
|
|
1340
|
+
// Opt-in (RPX_REUSE_PORT): lets multiple rpx instances share :443 for
|
|
1341
|
+
// multi-core scaling on Linux. Off by default — see shouldReusePort().
|
|
1342
|
+
reusePort: shouldReusePort(),
|
|
1336
1343
|
tls: {
|
|
1337
1344
|
key: sslConfig.key,
|
|
1338
1345
|
cert: sslConfig.cert,
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TlsConfig, TlsOption } from '@stacksjs/tlsx'
|
|
2
|
+
import type { OriginGuardOptions } from './origin-guard'
|
|
2
3
|
|
|
3
4
|
export interface StartOptions {
|
|
4
5
|
command: string
|
|
@@ -208,6 +209,13 @@ export interface SharedProxyConfig {
|
|
|
208
209
|
* the first time it's needed. Opt-in — see {@link OnDemandTlsConfig}.
|
|
209
210
|
*/
|
|
210
211
|
onDemandTls?: OnDemandTlsConfig
|
|
212
|
+
/**
|
|
213
|
+
* Origin lockdown for "CDN in front of rpx" setups. When set, the shared
|
|
214
|
+
* HTTPS handler rejects requests to the listed hosts that lack the CDN's
|
|
215
|
+
* shared-secret header — so the publicly-resolvable origin can't be used to
|
|
216
|
+
* bypass the CDN. See {@link createOriginGuard}.
|
|
217
|
+
*/
|
|
218
|
+
originGuard?: OriginGuardOptions
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
export type SharedProxyOptions = Partial<SharedProxyConfig>
|
|
@@ -225,6 +233,17 @@ export type BaseProxyOption = Partial<BaseProxyConfig>
|
|
|
225
233
|
export type ProxyOption = Partial<SingleProxyConfig>
|
|
226
234
|
export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig>
|
|
227
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Internal shape used by `startProxies` after merging the built-in defaults with
|
|
238
|
+
* the caller's single- or multi-proxy options. Every field is optional, and the
|
|
239
|
+
* `proxies` array elements tolerate the per-proxy `cleanUrls`/`changeOrigin`
|
|
240
|
+
* overrides the runtime reads — so the merged object can be accessed across both
|
|
241
|
+
* single and multi shapes without falling back to `any`.
|
|
242
|
+
*/
|
|
243
|
+
export type ResolvedProxyOptions = Partial<SingleProxyConfig> & {
|
|
244
|
+
proxies?: Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean, pathRewrites?: PathRewrite[] }>
|
|
245
|
+
}
|
|
246
|
+
|
|
228
247
|
export interface SSLConfig {
|
|
229
248
|
key: string
|
|
230
249
|
cert: string
|
package/src/utils.ts
CHANGED
|
@@ -14,10 +14,26 @@ export function getSudoPassword(): string | undefined {
|
|
|
14
14
|
return process.env.SUDO_PASSWORD
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** True when the process can write system paths without `sudo` (e.g. rpx daemon started via sudo). */
|
|
18
|
+
export function isProcessElevated(): boolean {
|
|
19
|
+
if (process.platform === 'win32')
|
|
20
|
+
return false
|
|
21
|
+
try {
|
|
22
|
+
return typeof process.getuid === 'function' && process.getuid() === 0
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
/**
|
|
18
30
|
* Execute a command with sudo, using SUDO_PASSWORD if available
|
|
19
31
|
*/
|
|
20
32
|
export function execSudoSync(command: string): string {
|
|
33
|
+
if (isProcessElevated()) {
|
|
34
|
+
return execSync(command, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
const sudoPassword = getSudoPassword()
|
|
22
38
|
const escaped = command.replace(/'/g, `'\\''`)
|
|
23
39
|
|
|
@@ -42,6 +58,23 @@ export function debugLog(category: string, message: string, verbose?: boolean):
|
|
|
42
58
|
logger.debug(`[rpx:${category}] ${message}`)
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Whether the proxy's listeners should set `reusePort`. Off by default: rpx is a
|
|
63
|
+
* single-instance local-dev proxy, and one instance gains nothing from
|
|
64
|
+
* `SO_REUSEPORT` while losing the helpful "port 443 already in use" guard.
|
|
65
|
+
*
|
|
66
|
+
* Opt in with `RPX_REUSE_PORT=1` when running **multiple** rpx instances behind a
|
|
67
|
+
* process manager (systemd/pm2/k8s) on **Linux**, where the kernel load-balances
|
|
68
|
+
* accepted connections across them for multi-core scaling. It is a no-op on
|
|
69
|
+
* macOS/BSD, whose `SO_REUSEPORT` does not load-balance across processes — so
|
|
70
|
+
* rpx never spawns a worker cluster itself; scaling is left to the OS + your
|
|
71
|
+
* supervisor, which is where it belongs for a dev proxy.
|
|
72
|
+
*/
|
|
73
|
+
export function shouldReusePort(): boolean {
|
|
74
|
+
const v = process.env.RPX_REUSE_PORT
|
|
75
|
+
return v === '1' || v === 'true'
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
const REDACTED = '[redacted]'
|
|
46
79
|
const SENSITIVE_KEYS = new Set([
|
|
47
80
|
'certificate',
|
package/dist/chunk-a0b9f9fs.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{Ka as a,La as b,Ma as c,Na as d,Oa as e,Pa as f,Qa as g,Ra as h,Sa as i,Ta as j,Ua as k,Va as l,Wa as m,Xa as n}from"./chunk-rbgb5fyg.js";export{e as safeStringify,n as safeDeleteFile,m as resolvePathRewrite,d as redactSensitive,g as isValidRootCA,k as isSingleProxyOptions,l as isSingleProxyConfig,j as isMultiProxyOptions,i as isMultiProxyConfig,a as getSudoPassword,h as getPrimaryDomain,f as extractHostname,b as execSudoSync,c as debugLog};
|
package/dist/chunk-fndafyac.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{ka as a,la as b,ma as c,na as d,oa as e,pa as f,qa as g,ra as h,sa as i,ta as j,ua as k,va as l,wa as m,xa as n}from"./chunk-zx2ghrc1.js";import"./chunk-rbgb5fyg.js";export{l as tearDownDevelopmentDns,k as syncDevelopmentDnsFromRegistry,d as stopDnsServer,c as startDnsServer,h as setupResolver,j as setupDevelopmentDns,f as resolverFilePath,m as removeResolver,i as removeLegacyTldResolvers,n as reconcileStaleDevelopmentDns,e as isDnsServerRunning,g as contentLooksLikeRpxResolver,b as RPX_RESOLVER_MARKER,a as DNS_PORT};
|