@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/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: any) {
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 (err.code === 'ECONNREFUSED' && retries > 0) {
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: ${err.message}`
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
- // Build target URL from sourceUrl object
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 = new URL(url.pathname + url.search, baseUrl)
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
- const response = await fetch(targetUrl.toString(), {
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: any): boolean {
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: any): boolean {
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 any
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.startsWith('http') ? proxy.from : `http://${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((proxy: any) => ({
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 sharedFetchHandler = createProxyFetchHandler(
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',
@@ -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};
@@ -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};