@stacksjs/rpx 0.11.3 → 0.11.5

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/types.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { TlsConfig, TlsOption } from '@stacksjs/tlsx'
2
+
3
+ export interface StartOptions {
4
+ command: string
5
+ cwd?: string
6
+ env?: Record<string, string>
7
+ }
8
+
9
+ export interface PathRewrite {
10
+ /** Path prefix to match, e.g. '/api' */
11
+ from: string
12
+ /** Target backend to route to, e.g. 'localhost:3008' */
13
+ to: string
14
+ /**
15
+ * Strip the matched prefix before forwarding. Default: `false` (preserve path).
16
+ *
17
+ * Matches the behavior of Vite's `server.proxy`, nginx `proxy_pass http://host:port`
18
+ * (no trailing slash), and http-proxy-middleware's default. Most upstreams that own
19
+ * a `/api` namespace expect the prefix to remain on the request URL.
20
+ *
21
+ * Set to `true` only when the upstream registers routes WITHOUT the prefix
22
+ * (e.g., upstream serves `/cart/add` and you want `/api/cart/add` to reach it).
23
+ */
24
+ stripPrefix?: boolean
25
+ }
26
+
27
+ export interface BaseProxyConfig {
28
+ from: string // localhost:5173
29
+ to: string // stacks.localhost
30
+ start?: StartOptions
31
+ pathRewrites?: PathRewrite[]
32
+ }
33
+
34
+ export type BaseProxyOptions = Partial<BaseProxyConfig>
35
+
36
+ export interface CleanupConfig {
37
+ domains: string[] // default: [], if only specific domain/s should be cleaned up
38
+ hosts: boolean // default: true, if hosts file should be cleaned up
39
+ certs: boolean // default: false, if certificates should be cleaned up
40
+ verbose: boolean // default: false
41
+ vitePluginUsage?: boolean // default: false, if cleanup was initiated by the Vite plugin
42
+ }
43
+
44
+ export type CleanupOptions = Partial<CleanupConfig>
45
+
46
+ export interface SharedProxyConfig {
47
+ https: boolean | TlsOption
48
+ cleanup: boolean | CleanupOptions
49
+ vitePluginUsage: boolean
50
+ verbose: boolean
51
+ _cachedSSLConfig?: SSLConfig | null
52
+ start?: StartOptions
53
+ cleanUrls: boolean
54
+ changeOrigin?: boolean // default: false - changes the origin of the host header to the target URL
55
+ regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system.
56
+ }
57
+
58
+ export type SharedProxyOptions = Partial<SharedProxyConfig>
59
+
60
+ export interface SingleProxyConfig extends BaseProxyConfig, SharedProxyConfig {}
61
+
62
+ export interface MultiProxyConfig extends SharedProxyConfig {
63
+ proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[] }>
64
+ }
65
+
66
+ export type ProxyConfig = SingleProxyConfig
67
+ export type ProxyConfigs = SingleProxyConfig | MultiProxyConfig
68
+
69
+ export type BaseProxyOption = Partial<BaseProxyConfig>
70
+ export type ProxyOption = Partial<SingleProxyConfig>
71
+ export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig>
72
+
73
+ export interface SSLConfig {
74
+ key: string
75
+ cert: string
76
+ ca?: string | string[]
77
+ }
78
+
79
+ export interface ProxySetupOptions extends Omit<ProxyOption, 'from'> {
80
+ fromPort: number
81
+ sourceUrl: Pick<URL, 'hostname' | 'host'>
82
+ ssl: SSLConfig | null
83
+ from: string
84
+ to: string
85
+ portManager?: PortManager
86
+ }
87
+
88
+ export interface PortManager {
89
+ usedPorts: Set<number>
90
+ getNextAvailablePort: (startPort: number) => Promise<number>
91
+ }
92
+
93
+ export type { TlsConfig, TlsOption }
package/src/utils.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { MultiProxyConfig, PathRewrite, ProxyConfigs, ProxyOption, ProxyOptions, SingleProxyConfig } from './types'
2
+ import { execSync } from 'node:child_process'
3
+ import * as fs from 'node:fs/promises'
4
+ import { Logger } from '@stacksjs/clarity'
5
+
6
+ const logger = new Logger('rpx', {
7
+ showTags: false,
8
+ })
9
+
10
+ /**
11
+ * Get sudo password from environment variable if set
12
+ */
13
+ export function getSudoPassword(): string | undefined {
14
+ return process.env.SUDO_PASSWORD
15
+ }
16
+
17
+ /**
18
+ * Execute a command with sudo, using SUDO_PASSWORD if available
19
+ */
20
+ export function execSudoSync(command: string): string {
21
+ const sudoPassword = getSudoPassword()
22
+ const escaped = command.replace(/'/g, `'\\''`)
23
+
24
+ if (sudoPassword) {
25
+ return execSync(`echo '${sudoPassword}' | sudo -S sh -c '${escaped}' 2>/dev/null`, {
26
+ encoding: 'utf-8',
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ })
29
+ }
30
+
31
+ return execSync(`sudo sh -c '${escaped}'`, { encoding: 'utf-8' })
32
+ }
33
+
34
+ export function debugLog(category: string, message: string, verbose?: boolean): void {
35
+ if (verbose)
36
+ logger.debug(`[rpx:${category}] ${message}`)
37
+ }
38
+
39
+ /**
40
+ * Extracts hostnames from proxy configuration
41
+ */
42
+ export function extractHostname(options: ProxyOption | ProxyOptions): string[] {
43
+ if (isMultiProxyOptions(options)) {
44
+ return options.proxies.map((proxy) => {
45
+ const domain = proxy.to || 'stacks.localhost'
46
+ return domain.startsWith('http') ? new URL(domain).hostname : domain
47
+ })
48
+ }
49
+
50
+ if (isSingleProxyOptions(options)) {
51
+ const domain = options.to || 'stacks.localhost'
52
+ return [domain.startsWith('http') ? new URL(domain).hostname : domain]
53
+ }
54
+
55
+ return ['stacks.localhost']
56
+ }
57
+
58
+ interface RootCA {
59
+ certificate: string
60
+ privateKey: string
61
+ }
62
+
63
+ export function isValidRootCA(value: unknown): value is RootCA {
64
+ return (
65
+ typeof value === 'object'
66
+ && value !== null
67
+ && 'certificate' in value
68
+ && 'privateKey' in value
69
+ && typeof (value as RootCA).certificate === 'string'
70
+ && typeof (value as RootCA).privateKey === 'string'
71
+ )
72
+ }
73
+
74
+ export function getPrimaryDomain(options?: ProxyOption | ProxyOptions): string {
75
+ if (!options)
76
+ return 'stacks.localhost'
77
+
78
+ if (isMultiProxyOptions(options) && options.proxies.length > 0)
79
+ return options.proxies[0].to || 'stacks.localhost'
80
+
81
+ if (isSingleProxyOptions(options))
82
+ return options.to || 'stacks.localhost'
83
+
84
+ return 'stacks.localhost'
85
+ }
86
+
87
+ /**
88
+ * Type guard for multi-proxy configuration
89
+ */
90
+ export function isMultiProxyConfig(options: ProxyConfigs | ProxyOptions): options is MultiProxyConfig {
91
+ return !!(options && 'proxies' in options && Array.isArray((options as MultiProxyConfig).proxies))
92
+ }
93
+
94
+ /**
95
+ * Type guard to check if options are for multi-proxy configuration
96
+ */
97
+ export function isMultiProxyOptions(options: ProxyOption | ProxyOptions): options is MultiProxyConfig {
98
+ return 'proxies' in options && Array.isArray((options as MultiProxyConfig).proxies)
99
+ }
100
+
101
+ /**
102
+ * Type guard to check if options are for single-proxy configuration
103
+ */
104
+ export function isSingleProxyOptions(options: ProxyOption | ProxyOptions): options is SingleProxyConfig {
105
+ return 'to' in options && typeof (options as SingleProxyConfig).to === 'string'
106
+ }
107
+
108
+ export function isSingleProxyConfig(options: ProxyConfigs | ProxyOptions): options is SingleProxyConfig {
109
+ return !!(options && 'to' in options && !('proxies' in options))
110
+ }
111
+
112
+ /**
113
+ * Resolve a path against a list of `pathRewrites`.
114
+ *
115
+ * Returns `null` if no rewrite matches; otherwise returns `{ targetHost, targetPath }`
116
+ * with the prefix preserved by default (or stripped when `stripPrefix === true`).
117
+ *
118
+ * Matching rule: rewrite matches if `pathname` is exactly `from` OR starts with
119
+ * `from + '/'`. So `/api` matches `/api`, `/api/`, `/api/cart` — but not `/apidocs`.
120
+ */
121
+ export function resolvePathRewrite(
122
+ pathname: string,
123
+ rewrites: PathRewrite[] | undefined,
124
+ ): { targetHost: string, targetPath: string } | null {
125
+ if (!rewrites || rewrites.length === 0)
126
+ return null
127
+
128
+ for (const rewrite of rewrites) {
129
+ if (pathname === rewrite.from || pathname.startsWith(`${rewrite.from}/`)) {
130
+ const targetHost = rewrite.to.startsWith('http') ? new URL(rewrite.to).host : rewrite.to
131
+ const targetPath = rewrite.stripPrefix === true
132
+ ? (pathname.slice(rewrite.from.length) || '/')
133
+ : pathname
134
+ return { targetHost, targetPath }
135
+ }
136
+ }
137
+
138
+ return null
139
+ }
140
+
141
+ /**
142
+ * Safely delete a file if it exists
143
+ */
144
+ export async function safeDeleteFile(filePath: string, verbose?: boolean): Promise<void> {
145
+ try {
146
+ // Try to delete the file directly without checking existence first
147
+ await fs.unlink(filePath)
148
+ debugLog('certificates', `Successfully deleted: ${filePath}`, verbose)
149
+ }
150
+ catch (err) {
151
+ // Ignore errors where file doesn't exist
152
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
153
+ debugLog('certificates', `Warning: Could not delete ${filePath}: ${err}`, verbose)
154
+ }
155
+ }
156
+ }