@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/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'
@@ -26,7 +26,7 @@ import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler
26
26
  import { isWildcardPattern } from './host-match'
27
27
  import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
28
28
  import { resolveStaticRoute } from './static-files'
29
- import { debugLog, getSudoPassword, safeStringify } from './utils'
29
+ import { debugLog, getSudoPassword, safeStringify, shouldReusePort } from './utils'
30
30
 
31
31
  const processManager = new ProcessManager()
32
32
  const version = '0.12.0'
@@ -237,7 +237,8 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
237
237
  try {
238
238
  await tryConnect()
239
239
  }
240
- catch (err: any) {
240
+ catch (err: unknown) {
241
+ const e = err as NodeJS.ErrnoException
241
242
  // Check if we've exceeded the maximum test duration
242
243
  if (Date.now() - startTime > maxTestDuration) {
243
244
  debugLog('connection', `Connection test timed out after ${maxTestDuration}ms, but continuing anyway`, verbose)
@@ -246,7 +247,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
246
247
  }
247
248
 
248
249
  // If we're dealing with a server that takes time to start up
249
- if (err.code === 'ECONNREFUSED' && retries > 0) {
250
+ if (e.code === 'ECONNREFUSED' && retries > 0) {
250
251
  debugLog('connection', `Connection refused, server might be starting up. Retrying in 2 seconds... (${retries} retries left)`, verbose)
251
252
  await new Promise(resolve => setTimeout(resolve, 2000))
252
253
  return testConnection(hostname, port, verbose, retries - 1)
@@ -294,7 +295,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
294
295
 
295
296
  // For production environments, we might want to be more strict
296
297
  // But for typical usage, let's be permissive and just warn
297
- 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}`
298
299
  debugLog('connection', `${errorMessage}. To bypass this check set RPX_BYPASS_CONNECTION_TEST=true`, verbose)
299
300
  log.warn(errorMessage)
300
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.`)
@@ -585,6 +586,7 @@ async function createProxyServer(
585
586
  const bunServer = Bun.serve({
586
587
  port: listenPort,
587
588
  hostname,
589
+ reusePort: shouldReusePort(),
588
590
  tls: {
589
591
  key: ssl.key,
590
592
  cert: ssl.cert,
@@ -597,9 +599,19 @@ async function createProxyServer(
597
599
  const url = new URL(req.url)
598
600
  debugLog('request', `Bun.serve received: ${req.method} ${url.pathname}`, verbose)
599
601
 
600
- // 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).
601
613
  const baseUrl = `http://${sourceUrl.host}`
602
- const targetUrl = new URL(url.pathname + url.search, baseUrl)
614
+ const targetUrl = `${baseUrl}${url.pathname}${url.search}`
603
615
 
604
616
  // Forward the request
605
617
  try {
@@ -612,30 +624,15 @@ async function createProxyServer(
612
624
  headers.set('x-forwarded-proto', 'https')
613
625
  headers.set('x-forwarded-host', to)
614
626
 
615
- 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, {
616
631
  method: req.method,
617
632
  headers,
618
633
  body: req.body,
619
634
  redirect: 'manual',
620
635
  })
621
-
622
- // Clone response with modified headers if needed
623
- const responseHeaders = new Headers(response.headers)
624
-
625
- // Handle clean URLs redirect
626
- if (cleanUrls && url.pathname.endsWith('.html')) {
627
- const cleanPath = url.pathname.replace(/\.html$/, '')
628
- return new Response(null, {
629
- status: 301,
630
- headers: { Location: cleanPath },
631
- })
632
- }
633
-
634
- return new Response(response.body, {
635
- status: response.status,
636
- statusText: response.statusText,
637
- headers: responseHeaders,
638
- })
639
636
  }
640
637
  catch (err) {
641
638
  debugLog('request', `Proxy error: ${err}`, verbose)
@@ -934,7 +931,7 @@ export function startProxy(options: ProxyOption): void {
934
931
  }
935
932
 
936
933
  // Helper function to safely get verbose flag from different config types
937
- function getVerbose(options: any): boolean {
934
+ function getVerbose(options: ProxyOptions): boolean {
938
935
  return options?.verbose || false
939
936
  }
940
937
 
@@ -944,7 +941,7 @@ function getVerbose(options: any): boolean {
944
941
  * `false`). Real-server deployments with real DNS should set
945
942
  * `hostsManagement: false` so rpx never touches `/etc/hosts`.
946
943
  */
947
- function isHostsManagementEnabled(options: any): boolean {
944
+ function isHostsManagementEnabled(options: ProxyOptions): boolean {
948
945
  if (options?.hostsManagement === false)
949
946
  return false
950
947
  const cleanup = options?.cleanup
@@ -971,7 +968,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
971
968
  cleanUrls: false,
972
969
  changeOrigin: false,
973
970
  regenerateUntrustedCerts: true,
974
- } as any
971
+ } as ResolvedProxyOptions
975
972
 
976
973
  if (options) {
977
974
  mergedOptions = {
@@ -1007,7 +1004,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1007
1004
  : [{
1008
1005
  id: mergedOptions.id,
1009
1006
  from: mergedOptions.from,
1010
- to: mergedOptions.to,
1007
+ to: mergedOptions.to ?? 'rpx.localhost',
1011
1008
  path: mergedOptions.path,
1012
1009
  cleanUrls: mergedOptions.cleanUrls,
1013
1010
  changeOrigin: mergedOptions.changeOrigin,
@@ -1030,7 +1027,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1030
1027
  await processManager.startProcess(proxyId, proxy.start, verbose)
1031
1028
 
1032
1029
  // Parse the URL to get hostname and port
1033
- 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}`)
1034
1031
  const hostname = fromUrl.hostname || 'localhost'
1035
1032
  const port = Number(fromUrl.port) || 80
1036
1033
 
@@ -1136,7 +1133,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1136
1133
 
1137
1134
  // Prepare proxy configurations
1138
1135
  const proxyOptions = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
1139
- ? mergedOptions.proxies.map((proxy: any) => ({
1136
+ ? mergedOptions.proxies.map(proxy => ({
1140
1137
  ...proxy,
1141
1138
  https: mergedOptions.https,
1142
1139
  cleanup: mergedOptions.cleanup,
@@ -1340,6 +1337,9 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
1340
1337
  const bunServer = Bun.serve({
1341
1338
  port: listenPort,
1342
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(),
1343
1343
  tls: {
1344
1344
  key: sslConfig.key,
1345
1345
  cert: sslConfig.cert,
package/src/types.ts CHANGED
@@ -233,6 +233,17 @@ export type BaseProxyOption = Partial<BaseProxyConfig>
233
233
  export type ProxyOption = Partial<SingleProxyConfig>
234
234
  export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig>
235
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
+
236
247
  export interface SSLConfig {
237
248
  key: string
238
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-s4tdtq37.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};
@@ -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};