@stacksjs/rpx 0.11.19 → 0.11.21

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 (49) hide show
  1. package/dist/acme-challenge.d.ts +13 -0
  2. package/dist/auth.d.ts +21 -0
  3. package/dist/bin/cli.js +252 -200
  4. package/dist/cert-inspect.d.ts +5 -3
  5. package/dist/{chunk-83dqq28c.js → chunk-1108y1dk.js} +1 -1
  6. package/dist/{chunk-0f32jmrb.js → chunk-c9brkawa.js} +1 -1
  7. package/dist/{chunk-w888yhnp.js → chunk-cqkz06bg.js} +1 -1
  8. package/dist/chunk-qs36t2rf.js +224 -0
  9. package/dist/daemon.d.ts +4 -1
  10. package/dist/https.d.ts +1 -0
  11. package/dist/index.d.ts +33 -1
  12. package/dist/index.js +7 -7
  13. package/dist/proxy-handler.d.ts +18 -1
  14. package/dist/proxy-pool.d.ts +5 -0
  15. package/dist/redirect.d.ts +40 -0
  16. package/dist/registry.d.ts +2 -1
  17. package/dist/site-resolver.d.ts +106 -0
  18. package/dist/site-splash.d.ts +19 -0
  19. package/dist/site-supervisor.d.ts +56 -0
  20. package/dist/start.d.ts +31 -8
  21. package/dist/types.d.ts +64 -0
  22. package/package.json +6 -7
  23. package/dist/chunk-rs8gqpax.js +0 -174
  24. package/src/cert-inspect.ts +0 -69
  25. package/src/colors.ts +0 -13
  26. package/src/config.ts +0 -45
  27. package/src/daemon-runner.ts +0 -180
  28. package/src/daemon.ts +0 -1209
  29. package/src/dns-state.ts +0 -116
  30. package/src/dns.ts +0 -568
  31. package/src/host-match.ts +0 -52
  32. package/src/host-routes.ts +0 -147
  33. package/src/hosts.ts +0 -283
  34. package/src/https.ts +0 -905
  35. package/src/index.ts +0 -161
  36. package/src/logger.ts +0 -19
  37. package/src/macos-trust.ts +0 -175
  38. package/src/on-demand.ts +0 -264
  39. package/src/origin-guard.ts +0 -127
  40. package/src/port-manager.ts +0 -183
  41. package/src/process-manager.ts +0 -164
  42. package/src/proxy-handler.ts +0 -387
  43. package/src/proxy-pool.ts +0 -1003
  44. package/src/registry.ts +0 -366
  45. package/src/sni.ts +0 -93
  46. package/src/start.ts +0 -1421
  47. package/src/static-files.ts +0 -201
  48. package/src/types.ts +0 -267
  49. package/src/utils.ts +0 -243
package/src/host-match.ts DELETED
@@ -1,52 +0,0 @@
1
- /**
2
- * Host-based route matching with wildcard support.
3
- *
4
- * The routing table is keyed by host pattern. A pattern is either an exact
5
- * hostname (`api.example.com`) or a wildcard (`*.example.com`). Lookup prefers
6
- * an exact match, then the most-specific (deepest-suffix) wildcard.
7
- *
8
- * Kept dependency-free and pure so it's reusable from both the daemon and the
9
- * in-process multi-proxy path, and trivially unit-testable.
10
- */
11
-
12
- export function isWildcardPattern(pattern: string): boolean {
13
- return pattern.startsWith('*.')
14
- }
15
-
16
- /**
17
- * True if `hostname` matches the wildcard `pattern` (`*.suffix`). A wildcard
18
- * matches exactly one or more leading labels — `*.example.com` matches
19
- * `a.example.com` and `a.b.example.com`, but NOT the bare apex `example.com`.
20
- */
21
- export function matchesWildcard(hostname: string, pattern: string): boolean {
22
- if (!isWildcardPattern(pattern))
23
- return false
24
- const suffix = pattern.slice(1) // '*.example.com' → '.example.com'
25
- return hostname.length > suffix.length && hostname.endsWith(suffix)
26
- }
27
-
28
- /**
29
- * Find the route value for `hostname` in a host-keyed map. Exact match wins;
30
- * otherwise the matching wildcard with the longest (most-specific) suffix wins.
31
- * Returns `undefined` when nothing matches.
32
- */
33
- export function matchHost<T>(table: Map<string, T>, hostname: string): T | undefined {
34
- const exact = table.get(hostname)
35
- if (exact !== undefined)
36
- return exact
37
-
38
- let best: T | undefined
39
- let bestLen = -1
40
- for (const [pattern, value] of table) {
41
- if (!isWildcardPattern(pattern))
42
- continue
43
- if (matchesWildcard(hostname, pattern)) {
44
- const len = pattern.length - 1 // length of the matched suffix
45
- if (len > bestLen) {
46
- bestLen = len
47
- best = value
48
- }
49
- }
50
- }
51
- return best
52
- }
@@ -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,283 +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, isProcessElevated } 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
- if (isProcessElevated()) {
33
- const { stdout } = await execAsync(command)
34
- return stdout
35
- }
36
-
37
- const sudoPassword = getSudoPassword()
38
- const escaped = command.replace(/'/g, `'\\''`)
39
-
40
- try {
41
- if (sudoPassword) {
42
- const { stdout } = await execAsync(`echo '${sudoPassword}' | sudo -S sh -c '${escaped}' 2>/dev/null`)
43
- sudoPrivilegesAcquired = true
44
- return stdout
45
- }
46
-
47
- if (sudoPrivilegesAcquired) {
48
- try {
49
- const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
50
- return stdout
51
- }
52
- // eslint-disable-next-line unused-imports/no-unused-vars
53
- catch (error) {
54
- debugLog('hosts', 'Cached sudo privileges expired, requesting again', true)
55
- }
56
- }
57
-
58
- try {
59
- const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
60
- sudoPrivilegesAcquired = true
61
- return stdout
62
- }
63
- catch {
64
- throw new Error('sudo required but no cached credentials (set SUDO_PASSWORD in .env or run sudo -v)')
65
- }
66
- }
67
- catch (error) {
68
- throw new Error(`Failed to execute sudo command: ${(error as Error).message}`)
69
- }
70
- }
71
-
72
- export async function addHosts(hosts: string[], verbose?: boolean): Promise<void> {
73
- const needsHostsFile = hosts.filter(h => !isLoopbackDevelopmentHost(h))
74
- const skipped = hosts.filter(h => isLoopbackDevelopmentHost(h))
75
- if (skipped.length > 0) {
76
- debugLog('hosts', `Skipping /etc/hosts for loopback dev names: ${skipped.join(', ')}`, verbose)
77
- }
78
- if (needsHostsFile.length === 0)
79
- return
80
-
81
- debugLog('hosts', `Adding hosts: ${needsHostsFile.join(', ')}`, verbose)
82
- debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, verbose)
83
-
84
- try {
85
- // Read existing hosts file content
86
- let existingContent: string
87
- try {
88
- existingContent = await fs.promises.readFile(hostsFilePath, 'utf-8')
89
- }
90
- catch {
91
- // /etc/hosts typically requires elevated permissions — fall back to sudo
92
- debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
93
-
94
- try {
95
- existingContent = await execSudo(`cat "${hostsFilePath}"`)
96
- }
97
- catch (sudoErr) {
98
- console.log(' Could not read hosts file — skipping hosts setup')
99
- debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
100
- throw new Error(`Cannot read hosts file: ${sudoErr}`)
101
- }
102
- }
103
-
104
- // Prepare new entries, only including those that don't exist
105
- const newEntries = needsHostsFile.filter((host) => {
106
- const ipv4Entry = `127.0.0.1 ${host}`
107
- const ipv6Entry = `::1 ${host}`
108
- return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry)
109
- })
110
-
111
- if (newEntries.length === 0) {
112
- debugLog('hosts', 'All hosts already exist in hosts file', verbose)
113
- return
114
- }
115
-
116
- // Create content for new entries
117
- const hostEntries = newEntries.map(host =>
118
- `\n# Added by rpx\n127.0.0.1 ${host}\n::1 ${host}`,
119
- ).join('\n')
120
-
121
- const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
122
-
123
- try {
124
- // Write to temporary file
125
- await fs.promises.writeFile(tmpFile, existingContent + hostEntries, 'utf8')
126
-
127
- // Use tee with sudo to write the content to hosts file
128
- await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
129
- console.log(` Hosts updated: ${newEntries.join(', ')}`)
130
- }
131
- // eslint-disable-next-line unused-imports/no-unused-vars
132
- catch (error) {
133
- // Don't throw — just tell the user what to add manually
134
- console.log(' Could not update hosts file automatically')
135
- console.log(' Add these entries to /etc/hosts:')
136
- newEntries.forEach((host) => {
137
- console.log(` 127.0.0.1 ${host}`)
138
- console.log(` ::1 ${host}`)
139
- })
140
- console.log(` Or run: sudo nano ${hostsFilePath}`)
141
- }
142
- finally {
143
- try {
144
- await fs.promises.unlink(tmpFile)
145
- }
146
- catch {
147
- // Ignore cleanup errors
148
- }
149
- }
150
- }
151
- catch (err) {
152
- const error = err as Error
153
- debugLog('hosts', `Failed to manage hosts file: ${error.message}`, verbose)
154
- // Don't throw - hosts file management is best-effort
155
- }
156
- }
157
-
158
- export async function removeHosts(hosts: string[], verbose?: boolean): Promise<void> {
159
- debugLog('hosts', `Removing hosts: ${hosts.join(', ')}`, verbose)
160
-
161
- try {
162
- // Read existing hosts file content
163
- let content: string
164
- try {
165
- content = await fs.promises.readFile(hostsFilePath, 'utf-8')
166
- }
167
- catch {
168
- debugLog('hosts', 'Reading hosts file requires elevated permissions, using sudo', verbose)
169
-
170
- try {
171
- content = await execSudo(`cat "${hostsFilePath}"`)
172
- }
173
- catch (sudoErr) {
174
- debugLog('hosts', `sudo read also failed: ${sudoErr}`, verbose)
175
- throw new Error(`Cannot read hosts file: ${sudoErr}`)
176
- }
177
- }
178
-
179
- const lines = content.split('\n')
180
- let modified = false
181
-
182
- // Filter out our added entries and their comments
183
- const filteredLines = lines.filter((line) => {
184
- // Check if this line contains one of our hosts
185
- const isHostLine = hosts.some(host =>
186
- line.includes(` ${host}`)
187
- && (line.includes('127.0.0.1') || line.includes('::1')),
188
- )
189
-
190
- if (isHostLine) {
191
- modified = true
192
- return false
193
- }
194
-
195
- // If it's our comment line, remove it
196
- if (line.trim() === '# Added by rpx') {
197
- modified = true
198
- return false
199
- }
200
-
201
- return true
202
- })
203
-
204
- // If nothing was removed, we're done
205
- if (!modified) {
206
- debugLog('hosts', 'No matching hosts found to remove', verbose)
207
- return
208
- }
209
-
210
- // Remove empty lines at the end of the file
211
- while (filteredLines[filteredLines.length - 1]?.trim() === '')
212
- filteredLines.pop()
213
-
214
- // Ensure file ends with a single newline
215
- const newContent = `${filteredLines.join('\n')}\n`
216
-
217
- const tmpFile = path.join(os.tmpdir(), `rpx-hosts-${Date.now()}.tmp`)
218
-
219
- try {
220
- // Write to temporary file
221
- await fs.promises.writeFile(tmpFile, newContent, 'utf8')
222
-
223
- // Use tee with sudo to write the content to hosts file
224
- await execSudo(`cat "${tmpFile}" | tee "${hostsFilePath}" > /dev/null`)
225
- debugLog('hosts', 'Hosts removed successfully', verbose)
226
- }
227
- // eslint-disable-next-line unused-imports/no-unused-vars
228
- catch (error) {
229
- debugLog('hosts', 'Could not clean up hosts file automatically', verbose)
230
- }
231
- finally {
232
- try {
233
- // Clean up the temp file
234
- await fs.promises.unlink(tmpFile)
235
- }
236
- catch (unlinkErr) {
237
- // Ignore cleanup errors
238
- debugLog('hosts', `Failed to remove temporary file: ${unlinkErr}`, verbose)
239
- }
240
- }
241
- }
242
- catch (err) {
243
- debugLog('hosts', `Failed to clean up hosts file: ${(err as Error).message}`, verbose)
244
- // Don't throw - hosts file cleanup is best-effort
245
- }
246
- }
247
-
248
- export async function checkHosts(hosts: string[], verbose?: boolean): Promise<boolean[]> {
249
- debugLog('hosts', `Checking hosts: ${hosts}`, verbose)
250
-
251
- let content: string
252
- try {
253
- content = await fs.promises.readFile(hostsFilePath, 'utf-8')
254
- }
255
- catch (readErr) {
256
- debugLog('hosts', `Error reading hosts file: ${readErr}`, verbose)
257
-
258
- // Try with sudo using SUDO_PASSWORD if available
259
- try {
260
- const sudoPassword = getSudoPassword()
261
- let cmd: string
262
- if (sudoPassword) {
263
- cmd = `echo '${sudoPassword}' | sudo -S cat "${hostsFilePath}" 2>/dev/null`
264
- }
265
- else {
266
- cmd = `sudo -n cat "${hostsFilePath}" 2>/dev/null || cat "${hostsFilePath}" 2>/dev/null || echo ""`
267
- }
268
- const { stdout } = await execAsync(cmd)
269
- content = stdout
270
- }
271
- catch (sudoErr) {
272
- // Can't read hosts file - assume entries don't exist
273
- debugLog('hosts', `Cannot read hosts file, assuming entries don't exist: ${sudoErr}`, verbose)
274
- return hosts.map(() => false)
275
- }
276
- }
277
-
278
- return hosts.map((host) => {
279
- const ipv4Entry = `127.0.0.1 ${host}`
280
- const ipv6Entry = `::1 ${host}`
281
- return content.includes(ipv4Entry) || content.includes(ipv6Entry)
282
- })
283
- }