@stacksjs/rpx 0.11.5 → 0.11.8
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/README.md +82 -0
- package/dist/bin/cli.js +244 -8
- package/dist/chunk-6z1nzq0x.js +1 -0
- package/dist/chunk-jpf41gb9.js +49 -0
- package/dist/chunk-qcdcnadb.js +1 -0
- package/dist/daemon-runner.d.ts +32 -0
- package/dist/daemon.d.ts +99 -0
- package/dist/https.d.ts +23 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +156 -0
- package/dist/proxy-handler.d.ts +15 -0
- package/dist/registry.d.ts +74 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +11 -9
- package/src/daemon-runner.ts +161 -0
- package/src/daemon.ts +518 -0
- package/src/https.ts +151 -80
- package/src/index.ts +43 -0
- package/src/process-manager.ts +2 -2
- package/src/proxy-handler.ts +99 -0
- package/src/registry.ts +346 -0
- package/src/start.ts +66 -80
- package/src/types.ts +11 -0
- package/src/utils.ts +48 -0
- package/dist/chunk-8yenn1z8.js +0 -45
- package/dist/chunk-cvt0dqrv.js +0 -49
- package/dist/chunk-cy653fq8.js +0 -1
- package/dist/chunk-grcvjvzg.js +0 -124
- package/dist/chunk-hj5q1vd6.js +0 -1
- package/dist/chunk-sqn04kae.js +0 -2
- package/dist/chunk-wcerh8e8.js +0 -1
- package/dist/dns.d.ts +0 -21
- package/dist/src/index.js +0 -1
package/src/registry.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of currently-active rpx proxies.
|
|
3
|
+
*
|
|
4
|
+
* Each running upstream (e.g. a `./buddy dev` invocation) writes a small JSON
|
|
5
|
+
* file into `~/.stacks/rpx/registry.d/<id>.json` describing where to forward
|
|
6
|
+
* traffic. The rpx daemon watches this directory and rebuilds its routing
|
|
7
|
+
* table whenever entries appear, change, or disappear.
|
|
8
|
+
*
|
|
9
|
+
* Design choices worth knowing about:
|
|
10
|
+
* - One file per entry → no shared-file locking, no merge conflicts.
|
|
11
|
+
* - Atomic write via temp file + rename → readers never see partial JSON.
|
|
12
|
+
* - Each entry carries the writer's PID so the daemon can GC files left
|
|
13
|
+
* behind by a writer that was killed -9.
|
|
14
|
+
* - `id` is validated against a strict charset to keep it from escaping
|
|
15
|
+
* the registry directory.
|
|
16
|
+
*/
|
|
17
|
+
import type { PathRewrite } from './types'
|
|
18
|
+
import * as fs from 'node:fs'
|
|
19
|
+
import * as fsp from 'node:fs/promises'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import * as process from 'node:process'
|
|
23
|
+
import { debugLog } from './utils'
|
|
24
|
+
|
|
25
|
+
export interface RegistryEntry {
|
|
26
|
+
id: string
|
|
27
|
+
from: string
|
|
28
|
+
to: string
|
|
29
|
+
/**
|
|
30
|
+
* Optional. PID of the long-running process that owns this entry. When set,
|
|
31
|
+
* the daemon's PID-GC reaps the entry the moment that process dies. Omit
|
|
32
|
+
* (or set to `undefined`) for manually-managed entries created via
|
|
33
|
+
* `rpx register` — those persist until explicit `rpx unregister`.
|
|
34
|
+
*/
|
|
35
|
+
pid?: number
|
|
36
|
+
cwd?: string
|
|
37
|
+
createdAt: string
|
|
38
|
+
pathRewrites?: PathRewrite[]
|
|
39
|
+
cleanUrls?: boolean
|
|
40
|
+
changeOrigin?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default location for the registry directory. The daemon's PID file and log
|
|
47
|
+
* sit alongside it under `~/.stacks/rpx/`.
|
|
48
|
+
*/
|
|
49
|
+
export function getRegistryDir(): string {
|
|
50
|
+
return path.join(homedir(), '.stacks', 'rpx', 'registry.d')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate an entry id. Rejects anything that could escape the registry dir
|
|
55
|
+
* (path traversal, slashes) or that would round-trip oddly through a filename.
|
|
56
|
+
*/
|
|
57
|
+
export function isValidId(id: string): boolean {
|
|
58
|
+
return typeof id === 'string' && id.length > 0 && id.length <= 128 && ID_PATTERN.test(id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entryPath(dir: string, id: string): string {
|
|
62
|
+
if (!isValidId(id))
|
|
63
|
+
throw new Error(`invalid registry id: ${JSON.stringify(id)}`)
|
|
64
|
+
return path.join(dir, `${id}.json`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a PID is alive. `kill(pid, 0)` returns without sending a
|
|
69
|
+
* signal but throws ESRCH if the process is gone — exactly the probe we need.
|
|
70
|
+
* EPERM means the process exists but we don't own it; treat as alive.
|
|
71
|
+
*/
|
|
72
|
+
export function isPidAlive(pid: number): boolean {
|
|
73
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
74
|
+
return false
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isValidEntry(value: unknown): value is RegistryEntry {
|
|
85
|
+
if (!value || typeof value !== 'object')
|
|
86
|
+
return false
|
|
87
|
+
const e = value as Partial<RegistryEntry>
|
|
88
|
+
// pid is optional. When present it must be a positive integer; when absent
|
|
89
|
+
// (manual entries from `rpx register`) the daemon's PID-GC skips it.
|
|
90
|
+
const pidOk = e.pid === undefined
|
|
91
|
+
|| (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
|
|
92
|
+
return (
|
|
93
|
+
typeof e.id === 'string' && isValidId(e.id)
|
|
94
|
+
&& typeof e.from === 'string' && e.from.length > 0
|
|
95
|
+
&& typeof e.to === 'string' && e.to.length > 0
|
|
96
|
+
&& pidOk
|
|
97
|
+
&& typeof e.createdAt === 'string'
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
102
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Atomically write an entry to disk.
|
|
107
|
+
*
|
|
108
|
+
* Writes to a temp file in the same directory, then renames into place. POSIX
|
|
109
|
+
* rename within the same filesystem is atomic, so a concurrent reader either
|
|
110
|
+
* sees the old file or the new file — never a half-written one.
|
|
111
|
+
*/
|
|
112
|
+
export async function writeEntry(entry: RegistryEntry, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
|
|
113
|
+
if (!isValidEntry(entry))
|
|
114
|
+
throw new Error(`invalid registry entry: ${JSON.stringify(entry)}`)
|
|
115
|
+
|
|
116
|
+
await ensureDir(dir)
|
|
117
|
+
const finalPath = entryPath(dir, entry.id)
|
|
118
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`
|
|
119
|
+
const json = JSON.stringify(entry, null, 2)
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o644 })
|
|
123
|
+
await fsp.rename(tmpPath, finalPath)
|
|
124
|
+
debugLog('registry', `wrote entry ${entry.id} → ${finalPath}`, verbose)
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Best-effort cleanup of the temp file if the rename never landed.
|
|
128
|
+
await fsp.unlink(tmpPath).catch(() => {})
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove an entry by id. No-op if the file is already gone.
|
|
135
|
+
*/
|
|
136
|
+
export async function removeEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
|
|
137
|
+
const target = entryPath(dir, id)
|
|
138
|
+
try {
|
|
139
|
+
await fsp.unlink(target)
|
|
140
|
+
debugLog('registry', `removed entry ${id}`, verbose)
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read a single entry by id. Returns `null` if missing or malformed (malformed
|
|
150
|
+
* files are deleted so they don't keep poisoning subsequent reads).
|
|
151
|
+
*/
|
|
152
|
+
export async function readEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry | null> {
|
|
153
|
+
const target = entryPath(dir, id)
|
|
154
|
+
try {
|
|
155
|
+
const raw = await fsp.readFile(target, 'utf8')
|
|
156
|
+
const parsed = JSON.parse(raw)
|
|
157
|
+
if (!isValidEntry(parsed)) {
|
|
158
|
+
debugLog('registry', `entry ${id} failed validation, removing`, verbose)
|
|
159
|
+
await fsp.unlink(target).catch(() => {})
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
return parsed
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
166
|
+
if (code === 'ENOENT')
|
|
167
|
+
return null
|
|
168
|
+
if (err instanceof SyntaxError) {
|
|
169
|
+
debugLog('registry', `entry ${id} has invalid JSON, removing`, verbose)
|
|
170
|
+
await fsp.unlink(target).catch(() => {})
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
throw err
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read all entries from the registry directory. Malformed files are pruned.
|
|
179
|
+
* This does NOT GC stale PIDs — call `gcStaleEntries` for that explicitly.
|
|
180
|
+
*/
|
|
181
|
+
export async function readAll(dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry[]> {
|
|
182
|
+
let names: string[]
|
|
183
|
+
try {
|
|
184
|
+
names = await fsp.readdir(dir)
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
|
188
|
+
return []
|
|
189
|
+
throw err
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const out: RegistryEntry[] = []
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
if (!name.endsWith('.json'))
|
|
195
|
+
continue
|
|
196
|
+
const id = name.slice(0, -'.json'.length)
|
|
197
|
+
if (!isValidId(id))
|
|
198
|
+
continue
|
|
199
|
+
const entry = await readEntry(id, dir, verbose)
|
|
200
|
+
if (entry)
|
|
201
|
+
out.push(entry)
|
|
202
|
+
}
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Remove entries whose writer PID is no longer alive. Returns the count of
|
|
208
|
+
* entries removed. Safe to call repeatedly; intended to run on daemon startup
|
|
209
|
+
* and on a slow timer (e.g. every 5s) while the daemon is up.
|
|
210
|
+
*/
|
|
211
|
+
export async function gcStaleEntries(dir: string = getRegistryDir(), verbose?: boolean): Promise<number> {
|
|
212
|
+
const entries = await readAll(dir, verbose)
|
|
213
|
+
let removed = 0
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
// Manually-managed entries (no pid) opt out of PID-GC. The user is
|
|
216
|
+
// responsible for `rpx unregister` when they're done.
|
|
217
|
+
if (entry.pid === undefined)
|
|
218
|
+
continue
|
|
219
|
+
if (!isPidAlive(entry.pid)) {
|
|
220
|
+
debugLog('registry', `GC: pid ${entry.pid} for ${entry.id} is dead, removing`, verbose)
|
|
221
|
+
await removeEntry(entry.id, dir, verbose).catch(() => {})
|
|
222
|
+
removed++
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return removed
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface WatchHandle {
|
|
229
|
+
close: () => void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface WatchOptions {
|
|
233
|
+
debounceMs?: number
|
|
234
|
+
pollMs?: number
|
|
235
|
+
verbose?: boolean
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Watch the registry directory and invoke `onChange` with the full current
|
|
240
|
+
* entry list whenever something changes. Events are debounced so a flurry of
|
|
241
|
+
* rapid writes (e.g. several `./buddy dev` invocations starting in parallel)
|
|
242
|
+
* triggers at most one rebuild.
|
|
243
|
+
*
|
|
244
|
+
* The watcher tolerates a missing directory at startup — it creates the dir
|
|
245
|
+
* before opening the watch, so the first `writeEntry` doesn't race the daemon.
|
|
246
|
+
*/
|
|
247
|
+
export function watchRegistry(
|
|
248
|
+
onChange: (entries: RegistryEntry[]) => void | Promise<void>,
|
|
249
|
+
opts: WatchOptions & { dir?: string } = {},
|
|
250
|
+
): WatchHandle {
|
|
251
|
+
const dir = opts.dir ?? getRegistryDir()
|
|
252
|
+
const debounceMs = opts.debounceMs ?? 100
|
|
253
|
+
const pollMs = opts.pollMs ?? Math.max(debounceMs * 2, 250)
|
|
254
|
+
const verbose = opts.verbose
|
|
255
|
+
|
|
256
|
+
// Create the dir up front so fs.watch has something to attach to.
|
|
257
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
258
|
+
|
|
259
|
+
let pending: ReturnType<typeof setTimeout> | null = null
|
|
260
|
+
let closed = false
|
|
261
|
+
let lastSignature: string | null = null
|
|
262
|
+
let pollInFlight = false
|
|
263
|
+
|
|
264
|
+
const signatureFor = (entries: RegistryEntry[]): string => {
|
|
265
|
+
return JSON.stringify(
|
|
266
|
+
entries
|
|
267
|
+
.map(entry => ({
|
|
268
|
+
id: entry.id,
|
|
269
|
+
from: entry.from,
|
|
270
|
+
to: entry.to,
|
|
271
|
+
pid: entry.pid,
|
|
272
|
+
pathRewrites: entry.pathRewrites,
|
|
273
|
+
cleanUrls: entry.cleanUrls,
|
|
274
|
+
changeOrigin: entry.changeOrigin,
|
|
275
|
+
}))
|
|
276
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const fire = () => {
|
|
281
|
+
pending = null
|
|
282
|
+
if (closed)
|
|
283
|
+
return
|
|
284
|
+
readAll(dir, verbose)
|
|
285
|
+
.then((entries) => {
|
|
286
|
+
lastSignature = signatureFor(entries)
|
|
287
|
+
return onChange(entries)
|
|
288
|
+
})
|
|
289
|
+
.catch((err) => {
|
|
290
|
+
debugLog('registry', `watcher onChange failed: ${err}`, verbose)
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const schedule = () => {
|
|
295
|
+
if (closed)
|
|
296
|
+
return
|
|
297
|
+
if (pending)
|
|
298
|
+
clearTimeout(pending)
|
|
299
|
+
pending = setTimeout(fire, debounceMs)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const poll = () => {
|
|
303
|
+
if (closed || pollInFlight)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
pollInFlight = true
|
|
307
|
+
readAll(dir, verbose)
|
|
308
|
+
.then((entries) => {
|
|
309
|
+
const signature = signatureFor(entries)
|
|
310
|
+
if (signature !== lastSignature)
|
|
311
|
+
schedule()
|
|
312
|
+
})
|
|
313
|
+
.catch((err) => {
|
|
314
|
+
debugLog('registry', `watcher poll failed: ${err}`, verbose)
|
|
315
|
+
})
|
|
316
|
+
.finally(() => {
|
|
317
|
+
pollInFlight = false
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const pollInterval = setInterval(poll, pollMs)
|
|
322
|
+
|
|
323
|
+
const watcher = fs.watch(dir, { persistent: true }, (_eventType, filename) => {
|
|
324
|
+
// Ignore temp files from our own atomic-write protocol.
|
|
325
|
+
if (filename && /\.tmp\.\d+\.\d+$/.test(filename))
|
|
326
|
+
return
|
|
327
|
+
schedule()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
watcher.on('error', (err) => {
|
|
331
|
+
debugLog('registry', `watcher error: ${err}`, verbose)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Fire once on startup so the daemon picks up entries that already exist.
|
|
335
|
+
schedule()
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
close: () => {
|
|
339
|
+
closed = true
|
|
340
|
+
if (pending)
|
|
341
|
+
clearTimeout(pending)
|
|
342
|
+
clearInterval(pollInterval)
|
|
343
|
+
watcher.close()
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
}
|
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,
|
|
4
|
+
import type { BaseProxyConfig, CleanupOptions, ProxyConfig, ProxyOption, ProxyOptions, ProxySetupOptions, 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'
|
|
@@ -14,15 +14,18 @@ import * as process from 'node:process'
|
|
|
14
14
|
import * as tls from 'node:tls'
|
|
15
15
|
import { log } from './logger'
|
|
16
16
|
import { colors } from './colors'
|
|
17
|
-
import { version } from '../package.json'
|
|
18
17
|
import { config } from './config'
|
|
18
|
+
import { runViaDaemon } from './daemon-runner'
|
|
19
19
|
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 {
|
|
23
|
+
import { createProxyFetchHandler } from './proxy-handler'
|
|
24
|
+
import type { ProxyRoute } from './proxy-handler'
|
|
25
|
+
import { debugLog, getSudoPassword, safeStringify } from './utils'
|
|
24
26
|
|
|
25
27
|
const processManager = new ProcessManager()
|
|
28
|
+
const version = '0.12.0'
|
|
26
29
|
// Create a global port manager for coordinating port usage
|
|
27
30
|
const globalPortManager = new DefaultPortManager('0.0.0.0')
|
|
28
31
|
|
|
@@ -295,7 +298,7 @@ async function testConnection(hostname: string, port: number, verbose?: boolean,
|
|
|
295
298
|
}
|
|
296
299
|
|
|
297
300
|
export async function startServer(options: SingleProxyConfig): Promise<void> {
|
|
298
|
-
debugLog('server', `Starting server with options: ${
|
|
301
|
+
debugLog('server', `Starting server with options: ${safeStringify(options)}`, options.verbose)
|
|
299
302
|
|
|
300
303
|
// Parse URLs early to get the hostnames
|
|
301
304
|
const fromUrl = new URL((options.from?.startsWith('http') ? options.from : `http://${options.from}`) || 'localhost:5173')
|
|
@@ -487,7 +490,7 @@ async function createProxyServer(
|
|
|
487
490
|
headers: normalizedHeaders,
|
|
488
491
|
}
|
|
489
492
|
|
|
490
|
-
debugLog('request', `Proxy request options: ${
|
|
493
|
+
debugLog('request', `Proxy request options: ${safeStringify(proxyOptions)}`, verbose)
|
|
491
494
|
|
|
492
495
|
const proxyReq = http.request(proxyOptions, (proxyRes) => {
|
|
493
496
|
debugLog('response', `Proxy response received with status ${proxyRes.statusCode}`, verbose)
|
|
@@ -697,7 +700,7 @@ async function createProxyServer(
|
|
|
697
700
|
}
|
|
698
701
|
|
|
699
702
|
export async function setupProxy(options: ProxySetupOptions): Promise<void> {
|
|
700
|
-
debugLog('setup', `Setting up reverse proxy: ${
|
|
703
|
+
debugLog('setup', `Setting up reverse proxy: ${safeStringify(options)}`, options.verbose)
|
|
701
704
|
|
|
702
705
|
const { from, to, fromPort, sourceUrl, ssl, verbose, cleanup: cleanupOptions, vitePluginUsage, changeOrigin, cleanUrls } = options
|
|
703
706
|
const httpPort = 80
|
|
@@ -827,7 +830,32 @@ export function startProxy(options: ProxyOption): void {
|
|
|
827
830
|
...options,
|
|
828
831
|
}
|
|
829
832
|
|
|
830
|
-
debugLog('proxy', `Starting proxy with options: ${
|
|
833
|
+
debugLog('proxy', `Starting proxy with options: ${safeStringify(mergedOptions)}`, mergedOptions?.verbose)
|
|
834
|
+
|
|
835
|
+
// viaDaemon: register with the long-running daemon instead of binding our
|
|
836
|
+
// own :443. The daemon owns TLS termination and host-header routing for
|
|
837
|
+
// every concurrent `rpx start` on this machine.
|
|
838
|
+
if (mergedOptions.viaDaemon) {
|
|
839
|
+
if (!mergedOptions.from || !mergedOptions.to) {
|
|
840
|
+
log.error('viaDaemon mode requires both `from` and `to`')
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
runViaDaemon({
|
|
844
|
+
proxies: [{
|
|
845
|
+
id: mergedOptions.id,
|
|
846
|
+
from: mergedOptions.from,
|
|
847
|
+
to: mergedOptions.to,
|
|
848
|
+
cleanUrls: mergedOptions.cleanUrls,
|
|
849
|
+
changeOrigin: mergedOptions.changeOrigin,
|
|
850
|
+
pathRewrites: mergedOptions.pathRewrites,
|
|
851
|
+
}],
|
|
852
|
+
verbose: mergedOptions.verbose,
|
|
853
|
+
}).catch((err) => {
|
|
854
|
+
log.error(`Failed to register with rpx daemon: ${err.message}`)
|
|
855
|
+
process.exit(1)
|
|
856
|
+
})
|
|
857
|
+
return
|
|
858
|
+
}
|
|
831
859
|
|
|
832
860
|
// Start DNS server for custom domains on macOS (any domain that's not localhost/127.0.0.1)
|
|
833
861
|
const targetDomain = mergedOptions.to || ''
|
|
@@ -884,7 +912,7 @@ export function startProxy(options: ProxyOption): void {
|
|
|
884
912
|
regenerateUntrustedCerts: mergedOptions.regenerateUntrustedCerts,
|
|
885
913
|
}
|
|
886
914
|
|
|
887
|
-
debugLog('proxy', `Server options: ${
|
|
915
|
+
debugLog('proxy', `Server options: ${safeStringify(serverOptions)}`, mergedOptions.verbose)
|
|
888
916
|
|
|
889
917
|
startServer(serverOptions).catch((err) => {
|
|
890
918
|
debugLog('proxy', `Failed to start proxy: ${err}`, mergedOptions.verbose)
|
|
@@ -918,7 +946,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
918
946
|
verbose: false,
|
|
919
947
|
cleanUrls: false,
|
|
920
948
|
changeOrigin: false,
|
|
921
|
-
regenerateUntrustedCerts:
|
|
949
|
+
regenerateUntrustedCerts: true,
|
|
922
950
|
} as any
|
|
923
951
|
|
|
924
952
|
if (options) {
|
|
@@ -929,9 +957,35 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
929
957
|
}
|
|
930
958
|
|
|
931
959
|
const verbose = getVerbose(mergedOptions)
|
|
932
|
-
debugLog('config', `Starting with config: ${
|
|
960
|
+
debugLog('config', `Starting with config: ${safeStringify(mergedOptions, 2)}`, verbose)
|
|
933
961
|
debugLog('config', `Is multi-proxy? ${'proxies' in mergedOptions}`, verbose)
|
|
934
962
|
|
|
963
|
+
// viaDaemon mode short-circuits before any port binding / cert work — the
|
|
964
|
+
// daemon owns all of that. We only need to register entries and block.
|
|
965
|
+
if (mergedOptions.viaDaemon) {
|
|
966
|
+
const isMulti = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
|
|
967
|
+
const proxies = isMulti
|
|
968
|
+
? (mergedOptions.proxies as Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean }>)
|
|
969
|
+
.map(p => ({
|
|
970
|
+
id: p.id,
|
|
971
|
+
from: p.from,
|
|
972
|
+
to: p.to,
|
|
973
|
+
cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls,
|
|
974
|
+
changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin,
|
|
975
|
+
pathRewrites: p.pathRewrites,
|
|
976
|
+
}))
|
|
977
|
+
: [{
|
|
978
|
+
id: mergedOptions.id,
|
|
979
|
+
from: mergedOptions.from,
|
|
980
|
+
to: mergedOptions.to,
|
|
981
|
+
cleanUrls: mergedOptions.cleanUrls,
|
|
982
|
+
changeOrigin: mergedOptions.changeOrigin,
|
|
983
|
+
pathRewrites: mergedOptions.pathRewrites,
|
|
984
|
+
}]
|
|
985
|
+
await runViaDaemon({ proxies, verbose })
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
935
989
|
// Start dev servers first if configured
|
|
936
990
|
if ('proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)) {
|
|
937
991
|
debugLog('servers', `Found ${mergedOptions.proxies.length} proxies in config`, verbose)
|
|
@@ -1161,16 +1215,13 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1161
1215
|
if (sslConfig && proxyOptions.length > 1) {
|
|
1162
1216
|
debugLog('proxies', `Creating shared HTTPS server for ${proxyOptions.length} domains`, verbose)
|
|
1163
1217
|
|
|
1164
|
-
|
|
1165
|
-
const routingTable = new Map<string, { fromPort: number, sourceHost: string, cleanUrls: boolean, changeOrigin: boolean, pathRewrites?: PathRewrite[] }>()
|
|
1218
|
+
const routingTable = new Map<string, ProxyRoute>()
|
|
1166
1219
|
|
|
1167
1220
|
for (const option of proxyOptions) {
|
|
1168
1221
|
const domain = option.to || 'rpx.localhost'
|
|
1169
1222
|
const fromUrl = new URL(option.from?.startsWith('http') ? option.from : `http://${option.from}`)
|
|
1170
|
-
const fromPort = Number.parseInt(fromUrl.port) || 80
|
|
1171
1223
|
|
|
1172
1224
|
routingTable.set(domain, {
|
|
1173
|
-
fromPort,
|
|
1174
1225
|
sourceHost: fromUrl.host,
|
|
1175
1226
|
cleanUrls: option.cleanUrls || false,
|
|
1176
1227
|
changeOrigin: option.changeOrigin || false,
|
|
@@ -1221,72 +1272,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
|
|
|
1221
1272
|
requestCert: false,
|
|
1222
1273
|
rejectUnauthorized: false,
|
|
1223
1274
|
},
|
|
1224
|
-
|
|
1225
|
-
const url = new URL(req.url)
|
|
1226
|
-
const hostHeader = req.headers.get('host') || ''
|
|
1227
|
-
// Strip port from Host header (e.g., "stacks.localhost:443" → "stacks.localhost")
|
|
1228
|
-
const hostname = hostHeader.split(':')[0]
|
|
1229
|
-
|
|
1230
|
-
const route = routingTable.get(hostname)
|
|
1231
|
-
if (!route) {
|
|
1232
|
-
debugLog('request', `No route found for host: ${hostname}`, verbose)
|
|
1233
|
-
return new Response(`No proxy configured for ${hostname}`, { status: 404 })
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
let targetHost = route.sourceHost
|
|
1237
|
-
let targetPath = url.pathname
|
|
1238
|
-
|
|
1239
|
-
// Check path rewrites — route specific path prefixes to different backends.
|
|
1240
|
-
// By default the prefix is preserved (matches Vite/nginx/http-proxy-middleware
|
|
1241
|
-
// semantics); set `stripPrefix: true` per-rewrite to strip.
|
|
1242
|
-
const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
|
|
1243
|
-
if (rewriteMatch) {
|
|
1244
|
-
targetHost = rewriteMatch.targetHost
|
|
1245
|
-
targetPath = rewriteMatch.targetPath
|
|
1246
|
-
debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const targetUrl = `http://${targetHost}${targetPath}${url.search}`
|
|
1250
|
-
|
|
1251
|
-
try {
|
|
1252
|
-
const headers = new Headers(req.headers)
|
|
1253
|
-
headers.set('host', targetHost)
|
|
1254
|
-
if (route.changeOrigin) {
|
|
1255
|
-
headers.set('origin', `http://${route.sourceHost}`)
|
|
1256
|
-
}
|
|
1257
|
-
headers.set('x-forwarded-for', '127.0.0.1')
|
|
1258
|
-
headers.set('x-forwarded-proto', 'https')
|
|
1259
|
-
headers.set('x-forwarded-host', hostname)
|
|
1260
|
-
|
|
1261
|
-
const response = await fetch(targetUrl, {
|
|
1262
|
-
method: req.method,
|
|
1263
|
-
headers,
|
|
1264
|
-
body: req.body,
|
|
1265
|
-
redirect: 'manual',
|
|
1266
|
-
})
|
|
1267
|
-
|
|
1268
|
-
const responseHeaders = new Headers(response.headers)
|
|
1269
|
-
|
|
1270
|
-
// Handle clean URLs redirect
|
|
1271
|
-
if (route.cleanUrls && url.pathname.endsWith('.html')) {
|
|
1272
|
-
const cleanPath = url.pathname.replace(/\.html$/, '')
|
|
1273
|
-
return new Response(null, {
|
|
1274
|
-
status: 301,
|
|
1275
|
-
headers: { Location: cleanPath },
|
|
1276
|
-
})
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
return new Response(response.body, {
|
|
1280
|
-
status: response.status,
|
|
1281
|
-
statusText: response.statusText,
|
|
1282
|
-
headers: responseHeaders,
|
|
1283
|
-
})
|
|
1284
|
-
}
|
|
1285
|
-
catch (err) {
|
|
1286
|
-
debugLog('request', `Proxy error for ${hostname}: ${err}`, verbose)
|
|
1287
|
-
return new Response(`Proxy Error: ${err}`, { status: 502 })
|
|
1288
|
-
}
|
|
1289
|
-
},
|
|
1275
|
+
fetch: createProxyFetchHandler(host => routingTable.get(host), verbose),
|
|
1290
1276
|
error(err: Error) {
|
|
1291
1277
|
debugLog('server', `Shared proxy server error: ${err}`, verbose)
|
|
1292
1278
|
return new Response(`Server Error: ${err.message}`, { status: 500 })
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface BaseProxyConfig {
|
|
|
29
29
|
to: string // stacks.localhost
|
|
30
30
|
start?: StartOptions
|
|
31
31
|
pathRewrites?: PathRewrite[]
|
|
32
|
+
/**
|
|
33
|
+
* Stable id used when registering this proxy with the rpx daemon. Derived
|
|
34
|
+
* from `to` if omitted. Must match `/^[a-zA-Z0-9._-]+$/` and be ≤128 chars.
|
|
35
|
+
*/
|
|
36
|
+
id?: string
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export type BaseProxyOptions = Partial<BaseProxyConfig>
|
|
@@ -53,6 +58,12 @@ export interface SharedProxyConfig {
|
|
|
53
58
|
cleanUrls: boolean
|
|
54
59
|
changeOrigin?: boolean // default: false - changes the origin of the host header to the target URL
|
|
55
60
|
regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system.
|
|
61
|
+
/**
|
|
62
|
+
* Route this proxy through the long-running rpx daemon instead of binding
|
|
63
|
+
* its own :443. Lets multiple `rpx start` invocations coexist on shared
|
|
64
|
+
* `:443` (Valet-style). Default: `false` for backward compatibility.
|
|
65
|
+
*/
|
|
66
|
+
viaDaemon?: boolean
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
export type SharedProxyOptions = Partial<SharedProxyConfig>
|
package/src/utils.ts
CHANGED
|
@@ -36,6 +36,54 @@ export function debugLog(category: string, message: string, verbose?: boolean):
|
|
|
36
36
|
logger.debug(`[rpx:${category}] ${message}`)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const REDACTED = '[redacted]'
|
|
40
|
+
const SENSITIVE_KEYS = new Set([
|
|
41
|
+
'certificate',
|
|
42
|
+
'privatekey',
|
|
43
|
+
'key',
|
|
44
|
+
'cert',
|
|
45
|
+
'ca',
|
|
46
|
+
'rootca',
|
|
47
|
+
'password',
|
|
48
|
+
'sudo_password',
|
|
49
|
+
])
|
|
50
|
+
const PEM_BLOCK_RE = /-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/
|
|
51
|
+
|
|
52
|
+
function isSensitiveKey(key: string): boolean {
|
|
53
|
+
const normalized = key.toLowerCase()
|
|
54
|
+
return SENSITIVE_KEYS.has(normalized)
|
|
55
|
+
|| normalized.endsWith('password')
|
|
56
|
+
|| normalized.includes('secret')
|
|
57
|
+
|| normalized.includes('token')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function redactSensitive(value: unknown): unknown {
|
|
61
|
+
if (Array.isArray(value))
|
|
62
|
+
return value.map(item => redactSensitive(item))
|
|
63
|
+
|
|
64
|
+
if (typeof value === 'string')
|
|
65
|
+
return PEM_BLOCK_RE.test(value) ? REDACTED : value
|
|
66
|
+
|
|
67
|
+
if (!value || typeof value !== 'object')
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
const output: Record<string, unknown> = {}
|
|
71
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
72
|
+
if (isSensitiveKey(key)) {
|
|
73
|
+
output[key] = REDACTED
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
output[key] = redactSensitive(nested)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return output
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function safeStringify(value: unknown, space?: number): string {
|
|
84
|
+
return JSON.stringify(redactSensitive(value), null, space)
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
/**
|
|
40
88
|
* Extracts hostnames from proxy configuration
|
|
41
89
|
*/
|