@stacksjs/rpx 0.11.18 → 0.11.20

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.
Files changed (46) hide show
  1. package/dist/bin/cli.js +225 -210
  2. package/dist/cert-inspect.d.ts +5 -3
  3. package/dist/chunk-0f32jmrb.js +1 -0
  4. package/dist/chunk-bs9e342s.js +1 -0
  5. package/dist/chunk-vxj1ckdm.js +176 -0
  6. package/dist/{chunk-rbgb5fyg.js → chunk-w888yhnp.js} +12 -12
  7. package/dist/daemon.d.ts +17 -8
  8. package/dist/dns.d.ts +2 -0
  9. package/dist/https.d.ts +10 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +7 -7
  12. package/dist/logger.d.ts +8 -8
  13. package/dist/proxy-handler.d.ts +1 -1
  14. package/dist/proxy-pool.d.ts +82 -0
  15. package/dist/start.d.ts +31 -8
  16. package/dist/types.d.ts +13 -0
  17. package/dist/utils.d.ts +15 -0
  18. package/package.json +11 -7
  19. package/dist/chunk-9njrhbd4.js +0 -1
  20. package/dist/chunk-a0b9f9fs.js +0 -1
  21. package/dist/chunk-s4tdtq37.js +0 -161
  22. package/src/cert-inspect.ts +0 -69
  23. package/src/colors.ts +0 -13
  24. package/src/config.ts +0 -45
  25. package/src/daemon-runner.ts +0 -180
  26. package/src/daemon.ts +0 -786
  27. package/src/dns-state.ts +0 -116
  28. package/src/dns.ts +0 -509
  29. package/src/host-match.ts +0 -52
  30. package/src/host-routes.ts +0 -147
  31. package/src/hosts.ts +0 -278
  32. package/src/https.ts +0 -862
  33. package/src/index.ts +0 -158
  34. package/src/logger.ts +0 -19
  35. package/src/macos-trust.ts +0 -175
  36. package/src/on-demand.ts +0 -264
  37. package/src/origin-guard.ts +0 -105
  38. package/src/port-manager.ts +0 -183
  39. package/src/process-manager.ts +0 -164
  40. package/src/proxy-handler.ts +0 -315
  41. package/src/registry.ts +0 -366
  42. package/src/sni.ts +0 -93
  43. package/src/start.ts +0 -1421
  44. package/src/static-files.ts +0 -201
  45. package/src/types.ts +0 -256
  46. package/src/utils.ts +0 -210
@@ -1,147 +0,0 @@
1
- /**
2
- * Path-aware routing within a single host.
3
- *
4
- * `host-match.ts` answers "which host pattern owns this hostname?" (exact wins,
5
- * then most-specific wildcard). That is sufficient when every host maps to a
6
- * single backend. But a single domain often needs to serve several things at
7
- * once — e.g. `stacksjs.com/api/*` proxied to an app on `:3000`,
8
- * `stacksjs.com/docs*` served from `/var/www/docs`, and `stacksjs.com/` served
9
- * from `/var/www/public`. That requires a second routing dimension: the
10
- * request **path**.
11
- *
12
- * This module layers path routing on top of host routing without disturbing
13
- * host-only routing:
14
- * - Each host pattern maps to a list of `(path, route)` entries.
15
- * - Lookup first resolves the host (reusing `matchHost` semantics), then picks
16
- * the entry whose `path` is the longest prefix of the request pathname.
17
- * - A host with a single entry whose `path` is `'/'` (or empty) behaves
18
- * exactly like the old host-only table — full backward compatibility.
19
- *
20
- * Kept dependency-free and pure so it's reusable from both the daemon and the
21
- * in-process multi-proxy path, and trivially unit-testable.
22
- */
23
- import { isWildcardPattern, matchesWildcard } from './host-match'
24
-
25
- /** One path-scoped route under a host. */
26
- export interface PathRoute<T> {
27
- /**
28
- * Path prefix this route owns, e.g. `'/api'`. `'/'` (or `''`) is the host
29
- * default that catches everything not claimed by a more specific prefix.
30
- */
31
- path: string
32
- /** The route value (e.g. a {@link import('./proxy-handler').ProxyRoute}). */
33
- route: T
34
- }
35
-
36
- /**
37
- * A host-keyed routing table where each host owns an ordered set of
38
- * path-scoped routes. Build it with {@link buildHostRoutes}.
39
- */
40
- export type HostRoutes<T> = Map<string, Array<PathRoute<T>>>
41
-
42
- /**
43
- * Normalize a path prefix to a leading-slash, no-trailing-slash form so prefix
44
- * comparisons are predictable. `''`/`undefined`/`'/'` all normalize to `'/'`
45
- * (the host default). `'/api/'` → `'/api'`, `'docs'` → `'/docs'`.
46
- */
47
- export function normalizePathPrefix(path: string | undefined): string {
48
- if (!path || path === '/')
49
- return '/'
50
- let p = path.trim()
51
- if (!p.startsWith('/'))
52
- p = `/${p}`
53
- // Strip trailing slashes (but keep the root '/').
54
- p = p.replace(/\/+$/, '')
55
- return p === '' ? '/' : p
56
- }
57
-
58
- /**
59
- * True if `pathname` is matched by the prefix `prefix`. The root prefix `'/'`
60
- * matches everything. A non-root prefix matches when the pathname equals it
61
- * (`/api`), or continues with a `/` (`/api/x`) — so `/api` does NOT match
62
- * `/apifoo`, only a real path-segment boundary.
63
- */
64
- export function pathPrefixMatches(pathname: string, prefix: string): boolean {
65
- if (prefix === '/')
66
- return true
67
- if (pathname === prefix)
68
- return true
69
- return pathname.startsWith(`${prefix}/`)
70
- }
71
-
72
- /**
73
- * Build a {@link HostRoutes} table from a flat list of entries. Entries are
74
- * grouped by host; within each host the path-routes are sorted longest-prefix
75
- * first so {@link matchHostRoute} can take the first match. If two entries
76
- * collide on the same (host, path) the later one wins (matching `Map.set`).
77
- */
78
- export function buildHostRoutes<T>(
79
- entries: Array<{ host: string, path?: string, route: T }>,
80
- ): HostRoutes<T> {
81
- const byHost = new Map<string, Map<string, T>>()
82
- for (const e of entries) {
83
- const prefix = normalizePathPrefix(e.path)
84
- let paths = byHost.get(e.host)
85
- if (!paths) {
86
- paths = new Map<string, T>()
87
- byHost.set(e.host, paths)
88
- }
89
- paths.set(prefix, e.route)
90
- }
91
-
92
- const table: HostRoutes<T> = new Map()
93
- for (const [host, paths] of byHost) {
94
- const list: Array<PathRoute<T>> = []
95
- for (const [path, route] of paths)
96
- list.push({ path, route })
97
- // Longest prefix first; '/' (length 1) naturally sorts last as the default.
98
- list.sort((a, b) => b.path.length - a.path.length)
99
- table.set(host, list)
100
- }
101
- return table
102
- }
103
-
104
- /**
105
- * Find the path-route list for `hostname` in a {@link HostRoutes} table. Exact
106
- * host match wins; otherwise the most-specific (deepest-suffix) wildcard wins —
107
- * mirroring {@link import('./host-match').matchHost}.
108
- */
109
- export function matchHostList<T>(table: HostRoutes<T>, hostname: string): Array<PathRoute<T>> | undefined {
110
- const exact = table.get(hostname)
111
- if (exact !== undefined)
112
- return exact
113
-
114
- let best: Array<PathRoute<T>> | undefined
115
- let bestLen = -1
116
- for (const [pattern, value] of table) {
117
- if (!isWildcardPattern(pattern))
118
- continue
119
- if (matchesWildcard(hostname, pattern)) {
120
- const len = pattern.length - 1
121
- if (len > bestLen) {
122
- bestLen = len
123
- best = value
124
- }
125
- }
126
- }
127
- return best
128
- }
129
-
130
- /**
131
- * Resolve a (hostname, pathname) pair to a single route value. First the host
132
- * is resolved ({@link matchHostList}); then the longest matching path prefix
133
- * within that host wins. Returns `undefined` when no host matches, or a host
134
- * matches but no path prefix (including the `'/'` default) covers the request.
135
- */
136
- export function matchHostRoute<T>(table: HostRoutes<T>, hostname: string, pathname: string): T | undefined {
137
- const list = matchHostList(table, hostname)
138
- if (!list)
139
- return undefined
140
- // `list` is pre-sorted longest-prefix-first, so the first match is the most
141
- // specific one ('/' is last and matches everything as the host default).
142
- for (const entry of list) {
143
- if (pathPrefixMatches(pathname, entry.path))
144
- return entry.route
145
- }
146
- return undefined
147
- }
package/src/hosts.ts DELETED
@@ -1,278 +0,0 @@
1
- import { exec } from 'node:child_process'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import * as process from 'node:process'
6
- import { promisify } from 'node:util'
7
- import { debugLog, getSudoPassword } from './utils'
8
-
9
- const execAsync = promisify(exec)
10
-
11
- /** `.localhost` names resolve to loopback per RFC 6761 — no /etc/hosts entry needed. */
12
- export function isLoopbackDevelopmentHost(host: string): boolean {
13
- const normalized = host.trim().toLowerCase()
14
- return normalized === 'localhost'
15
- || normalized.endsWith('.localhost')
16
- || normalized.endsWith('.localhost.')
17
- }
18
-
19
- export const hostsFilePath: string = process.platform === 'win32'
20
- ? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
21
- : '/etc/hosts'
22
-
23
- // Flag to track if we've already received sudo privileges in this session
24
- let sudoPrivilegesAcquired = false
25
-
26
- // Single function to execute sudo commands, with caching for permissions.
27
- // Wraps in sh -c so pipes/redirects all run under sudo.
28
- async function execSudo(command: string): Promise<string> {
29
- if (process.platform === 'win32')
30
- throw new Error('Administrator privileges required on Windows')
31
-
32
- const sudoPassword = getSudoPassword()
33
- const escaped = command.replace(/'/g, `'\\''`)
34
-
35
- try {
36
- if (sudoPassword) {
37
- const { stdout } = await execAsync(`echo '${sudoPassword}' | sudo -S sh -c '${escaped}' 2>/dev/null`)
38
- sudoPrivilegesAcquired = true
39
- return stdout
40
- }
41
-
42
- if (sudoPrivilegesAcquired) {
43
- try {
44
- const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
45
- return stdout
46
- }
47
- // eslint-disable-next-line unused-imports/no-unused-vars
48
- catch (error) {
49
- debugLog('hosts', 'Cached sudo privileges expired, requesting again', true)
50
- }
51
- }
52
-
53
- try {
54
- const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
55
- sudoPrivilegesAcquired = true
56
- return stdout
57
- }
58
- catch {
59
- throw new Error('sudo required but no cached credentials (set SUDO_PASSWORD in .env or run sudo -v)')
60
- }
61
- }
62
- catch (error) {
63
- throw new Error(`Failed to execute sudo command: ${(error as Error).message}`)
64
- }
65
- }
66
-
67
- export async function addHosts(hosts: string[], verbose?: boolean): Promise<void> {
68
- const needsHostsFile = hosts.filter(h => !isLoopbackDevelopmentHost(h))
69
- const skipped = hosts.filter(h => isLoopbackDevelopmentHost(h))
70
- if (skipped.length > 0) {
71
- debugLog('hosts', `Skipping /etc/hosts for loopback dev names: ${skipped.join(', ')}`, verbose)
72
- }
73
- if (needsHostsFile.length === 0)
74
- return
75
-
76
- debugLog('hosts', `Adding hosts: ${needsHostsFile.join(', ')}`, verbose)
77
- debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, verbose)
78
-
79
- try {
80
- // Read existing hosts file content
81
- let existingContent: string
82
- try {
83
- existingContent = await fs.promises.readFile(hostsFilePath, 'utf-8')
84
- }
85
- catch {
86
- // /etc/hosts typically requires elevated permissions — fall back to sudo
87
- debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
88
-
89
- try {
90
- existingContent = await execSudo(`cat "${hostsFilePath}"`)
91
- }
92
- catch (sudoErr) {
93
- console.log(' Could not read hosts file — skipping hosts setup')
94
- debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
95
- throw new Error(`Cannot read hosts file: ${sudoErr}`)
96
- }
97
- }
98
-
99
- // Prepare new entries, only including those that don't exist
100
- const newEntries = needsHostsFile.filter((host) => {
101
- const ipv4Entry = `127.0.0.1 ${host}`
102
- const ipv6Entry = `::1 ${host}`
103
- return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry)
104
- })
105
-
106
- if (newEntries.length === 0) {
107
- debugLog('hosts', 'All hosts already exist in hosts file', verbose)
108
- return
109
- }
110
-
111
- // Create content for new entries
112
- const hostEntries = newEntries.map(host =>
113
- `\n# Added by rpx\n127.0.0.1 ${host}\n::1 ${host}`,
114
- ).join('\n')
115
-
116
- const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
117
-
118
- try {
119
- // Write to temporary file
120
- await fs.promises.writeFile(tmpFile, existingContent + hostEntries, 'utf8')
121
-
122
- // Use tee with sudo to write the content to hosts file
123
- await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
124
- console.log(` Hosts updated: ${newEntries.join(', ')}`)
125
- }
126
- // eslint-disable-next-line unused-imports/no-unused-vars
127
- catch (error) {
128
- // Don't throw — just tell the user what to add manually
129
- console.log(' Could not update hosts file automatically')
130
- console.log(' Add these entries to /etc/hosts:')
131
- newEntries.forEach((host) => {
132
- console.log(` 127.0.0.1 ${host}`)
133
- console.log(` ::1 ${host}`)
134
- })
135
- console.log(` Or run: sudo nano ${hostsFilePath}`)
136
- }
137
- finally {
138
- try {
139
- await fs.promises.unlink(tmpFile)
140
- }
141
- catch {
142
- // Ignore cleanup errors
143
- }
144
- }
145
- }
146
- catch (err) {
147
- const error = err as Error
148
- debugLog('hosts', `Failed to manage hosts file: ${error.message}`, verbose)
149
- // Don't throw - hosts file management is best-effort
150
- }
151
- }
152
-
153
- export async function removeHosts(hosts: string[], verbose?: boolean): Promise<void> {
154
- debugLog('hosts', `Removing hosts: ${hosts.join(', ')}`, verbose)
155
-
156
- try {
157
- // Read existing hosts file content
158
- let content: string
159
- try {
160
- content = await fs.promises.readFile(hostsFilePath, 'utf-8')
161
- }
162
- catch {
163
- debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
164
-
165
- try {
166
- content = await execSudo(`cat "${hostsFilePath}"`)
167
- }
168
- catch (sudoErr) {
169
- debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
170
- throw new Error(`Cannot read hosts file: ${sudoErr}`)
171
- }
172
- }
173
-
174
- const lines = content.split('\n')
175
- let modified = false
176
-
177
- // Filter out our added entries and their comments
178
- const filteredLines = lines.filter((line) => {
179
- // Check if this line contains one of our hosts
180
- const isHostLine = hosts.some(host =>
181
- line.includes(` ${host}`)
182
- && (line.includes('127.0.0.1') || line.includes('::1')),
183
- )
184
-
185
- if (isHostLine) {
186
- modified = true
187
- return false
188
- }
189
-
190
- // If it's our comment line, remove it
191
- if (line.trim() === '# Added by rpx') {
192
- modified = true
193
- return false
194
- }
195
-
196
- return true
197
- })
198
-
199
- // If nothing was removed, we're done
200
- if (!modified) {
201
- debugLog('hosts', 'No matching hosts found to remove', verbose)
202
- return
203
- }
204
-
205
- // Remove empty lines at the end of the file
206
- while (filteredLines[filteredLines.length - 1]?.trim() === '')
207
- filteredLines.pop()
208
-
209
- // Ensure file ends with a single newline
210
- const newContent = `${filteredLines.join('\n')}\n`
211
-
212
- const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
213
-
214
- try {
215
- // Write to temporary file
216
- await fs.promises.writeFile(tmpFile, newContent, 'utf8')
217
-
218
- // Use tee with sudo to write the content to hosts file
219
- await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
220
- debugLog('hosts', 'Hosts removed successfully', verbose)
221
- }
222
- // eslint-disable-next-line unused-imports/no-unused-vars
223
- catch (error) {
224
- debugLog('hosts', 'Could not clean up hosts file automatically', verbose)
225
- }
226
- finally {
227
- try {
228
- // Clean up the temp file
229
- await fs.promises.unlink(tmpFile)
230
- }
231
- catch (unlinkErr) {
232
- // Ignore cleanup errors
233
- debugLog('hosts', `Failed to remove temporary file: ${unlinkErr}`, verbose)
234
- }
235
- }
236
- }
237
- catch (err) {
238
- debugLog('hosts', `Failed to clean up hosts file: ${(err as Error).message}`, verbose)
239
- // Don't throw - hosts file cleanup is best-effort
240
- }
241
- }
242
-
243
- export async function checkHosts(hosts: string[], verbose?: boolean): Promise<boolean[]> {
244
- debugLog('hosts', `Checking hosts: ${hosts}`, verbose)
245
-
246
- let content: string
247
- try {
248
- content = await fs.promises.readFile(hostsFilePath, 'utf-8')
249
- }
250
- catch (readErr) {
251
- debugLog('hosts', `Error reading hosts file: ${readErr}`, verbose)
252
-
253
- // Try with sudo using SUDO_PASSWORD if available
254
- try {
255
- const sudoPassword = getSudoPassword()
256
- let cmd: string
257
- if (sudoPassword) {
258
- cmd = `echo '${sudoPassword}' | sudo -S cat "${hostsFilePath}" 2>/dev/null`
259
- }
260
- else {
261
- cmd = `sudo -n cat "${hostsFilePath}" 2>/dev/null || cat "${hostsFilePath}" 2>/dev/null || echo ""`
262
- }
263
- const { stdout } = await execAsync(cmd)
264
- content = stdout
265
- }
266
- catch (sudoErr) {
267
- // Can't read hosts file - assume entries don't exist
268
- debugLog('hosts', `Cannot read hosts file, assuming entries don't exist: ${sudoErr}`, verbose)
269
- return hosts.map(() => false)
270
- }
271
- }
272
-
273
- return hosts.map((host) => {
274
- const ipv4Entry = `127.0.0.1 ${host}`
275
- const ipv6Entry = `::1 ${host}`
276
- return content.includes(ipv4Entry) || content.includes(ipv6Entry)
277
- })
278
- }