@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/registry.ts DELETED
@@ -1,366 +0,0 @@
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, StaticRouteConfig } 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
- /** Upstream `host:port`. Optional when `static` is set. */
28
- from?: string
29
- to: string
30
- /**
31
- * Optional path prefix this route owns under the host `to` (e.g. `'/api'`).
32
- * Enables several routes to share one host, each claiming a different path
33
- * (`/api` → app, `/docs` → static dir, `/` → public). Omitted (or `'/'`)
34
- * means the route is the host default and behaves exactly like host-only
35
- * routing did before path routing existed.
36
- */
37
- path?: string
38
- /**
39
- * Optional. PID of the long-running process that owns this entry. When set,
40
- * the daemon's PID-GC reaps the entry the moment that process dies. Omit
41
- * (or set to `undefined`) for manually-managed entries created via
42
- * `rpx register` — those persist until explicit `rpx unregister`.
43
- */
44
- pid?: number
45
- cwd?: string
46
- createdAt: string
47
- pathRewrites?: PathRewrite[]
48
- cleanUrls?: boolean
49
- changeOrigin?: boolean
50
- /** Serve a local directory for this route instead of proxying. */
51
- static?: string | StaticRouteConfig
52
- }
53
-
54
- const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
55
-
56
- /**
57
- * Default location for the registry directory. The daemon's PID file and log
58
- * sit alongside it under `~/.stacks/rpx/`.
59
- */
60
- export function getRegistryDir(): string {
61
- return path.join(homedir(), '.stacks', 'rpx', 'registry.d')
62
- }
63
-
64
- /**
65
- * Validate an entry id. Rejects anything that could escape the registry dir
66
- * (path traversal, slashes) or that would round-trip oddly through a filename.
67
- */
68
- export function isValidId(id: string): boolean {
69
- return typeof id === 'string' && id.length > 0 && id.length <= 128 && ID_PATTERN.test(id)
70
- }
71
-
72
- function entryPath(dir: string, id: string): string {
73
- if (!isValidId(id))
74
- throw new Error(`invalid registry id: ${JSON.stringify(id)}`)
75
- return path.join(dir, `${id}.json`)
76
- }
77
-
78
- /**
79
- * Check whether a PID is alive. `kill(pid, 0)` returns without sending a
80
- * signal but throws ESRCH if the process is gone — exactly the probe we need.
81
- * EPERM means the process exists but we don't own it; treat as alive.
82
- */
83
- export function isPidAlive(pid: number): boolean {
84
- if (!Number.isInteger(pid) || pid <= 0)
85
- return false
86
- try {
87
- process.kill(pid, 0)
88
- return true
89
- }
90
- catch (err) {
91
- return (err as NodeJS.ErrnoException).code === 'EPERM'
92
- }
93
- }
94
-
95
- function isValidEntry(value: unknown): value is RegistryEntry {
96
- if (!value || typeof value !== 'object')
97
- return false
98
- const e = value as Partial<RegistryEntry>
99
- // pid is optional. When present it must be a positive integer; when absent
100
- // (manual entries from `rpx register`) the daemon's PID-GC skips it.
101
- const pidOk = e.pid === undefined
102
- || (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
103
- // A route forwards to an upstream (`from`) OR serves files (`static`).
104
- const hasFrom = typeof e.from === 'string' && e.from.length > 0
105
- const hasStatic = typeof e.static === 'string'
106
- || (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
107
- // path is optional; when present it must be a string.
108
- const pathOk = e.path === undefined || typeof e.path === 'string'
109
- return (
110
- typeof e.id === 'string' && isValidId(e.id)
111
- && (hasFrom || hasStatic)
112
- && typeof e.to === 'string' && e.to.length > 0
113
- && pathOk
114
- && pidOk
115
- && typeof e.createdAt === 'string'
116
- )
117
- }
118
-
119
- async function ensureDir(dir: string): Promise<void> {
120
- await fsp.mkdir(dir, { recursive: true })
121
- }
122
-
123
- /**
124
- * Atomically write an entry to disk.
125
- *
126
- * Writes to a temp file in the same directory, then renames into place. POSIX
127
- * rename within the same filesystem is atomic, so a concurrent reader either
128
- * sees the old file or the new file — never a half-written one.
129
- */
130
- export async function writeEntry(entry: RegistryEntry, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
131
- if (!isValidEntry(entry))
132
- throw new Error(`invalid registry entry: ${JSON.stringify(entry)}`)
133
-
134
- await ensureDir(dir)
135
- const finalPath = entryPath(dir, entry.id)
136
- const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`
137
- const json = JSON.stringify(entry, null, 2)
138
-
139
- try {
140
- await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o644 })
141
- await fsp.rename(tmpPath, finalPath)
142
- debugLog('registry', `wrote entry ${entry.id} → ${finalPath}`, verbose)
143
- }
144
- catch (err) {
145
- // Best-effort cleanup of the temp file if the rename never landed.
146
- await fsp.unlink(tmpPath).catch(() => {})
147
- throw err
148
- }
149
- }
150
-
151
- /**
152
- * Remove an entry by id. No-op if the file is already gone.
153
- */
154
- export async function removeEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<void> {
155
- const target = entryPath(dir, id)
156
- try {
157
- await fsp.unlink(target)
158
- debugLog('registry', `removed entry ${id}`, verbose)
159
- }
160
- catch (err) {
161
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
162
- throw err
163
- }
164
- }
165
-
166
- /**
167
- * Read a single entry by id. Returns `null` if missing or malformed (malformed
168
- * files are deleted so they don't keep poisoning subsequent reads).
169
- */
170
- export async function readEntry(id: string, dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry | null> {
171
- const target = entryPath(dir, id)
172
- try {
173
- const raw = await fsp.readFile(target, 'utf8')
174
- const parsed = JSON.parse(raw)
175
- if (!isValidEntry(parsed)) {
176
- debugLog('registry', `entry ${id} failed validation, removing`, verbose)
177
- await fsp.unlink(target).catch(() => {})
178
- return null
179
- }
180
- return parsed
181
- }
182
- catch (err) {
183
- const code = (err as NodeJS.ErrnoException).code
184
- if (code === 'ENOENT')
185
- return null
186
- if (err instanceof SyntaxError) {
187
- debugLog('registry', `entry ${id} has invalid JSON, removing`, verbose)
188
- await fsp.unlink(target).catch(() => {})
189
- return null
190
- }
191
- throw err
192
- }
193
- }
194
-
195
- /**
196
- * Read all entries from the registry directory. Malformed files are pruned.
197
- * This does NOT GC stale PIDs — call `gcStaleEntries` for that explicitly.
198
- */
199
- export async function readAll(dir: string = getRegistryDir(), verbose?: boolean): Promise<RegistryEntry[]> {
200
- let names: string[]
201
- try {
202
- names = await fsp.readdir(dir)
203
- }
204
- catch (err) {
205
- if ((err as NodeJS.ErrnoException).code === 'ENOENT')
206
- return []
207
- throw err
208
- }
209
-
210
- const out: RegistryEntry[] = []
211
- for (const name of names) {
212
- if (!name.endsWith('.json'))
213
- continue
214
- const id = name.slice(0, -'.json'.length)
215
- if (!isValidId(id))
216
- continue
217
- const entry = await readEntry(id, dir, verbose)
218
- if (entry)
219
- out.push(entry)
220
- }
221
- return out
222
- }
223
-
224
- /**
225
- * Remove entries whose writer PID is no longer alive. Returns the count of
226
- * entries removed. Safe to call repeatedly; intended to run on daemon startup
227
- * and on a slow timer (e.g. every 5s) while the daemon is up.
228
- */
229
- export async function gcStaleEntries(dir: string = getRegistryDir(), verbose?: boolean): Promise<number> {
230
- const entries = await readAll(dir, verbose)
231
- let removed = 0
232
- for (const entry of entries) {
233
- // Manually-managed entries (no pid) opt out of PID-GC. The user is
234
- // responsible for `rpx unregister` when they're done.
235
- if (entry.pid === undefined)
236
- continue
237
- if (!isPidAlive(entry.pid)) {
238
- debugLog('registry', `GC: pid ${entry.pid} for ${entry.id} is dead, removing`, verbose)
239
- await removeEntry(entry.id, dir, verbose).catch(() => {})
240
- removed++
241
- }
242
- }
243
- return removed
244
- }
245
-
246
- export interface WatchHandle {
247
- close: () => void
248
- }
249
-
250
- export interface WatchOptions {
251
- debounceMs?: number
252
- pollMs?: number
253
- verbose?: boolean
254
- }
255
-
256
- /**
257
- * Watch the registry directory and invoke `onChange` with the full current
258
- * entry list whenever something changes. Events are debounced so a flurry of
259
- * rapid writes (e.g. several `./buddy dev` invocations starting in parallel)
260
- * triggers at most one rebuild.
261
- *
262
- * The watcher tolerates a missing directory at startup — it creates the dir
263
- * before opening the watch, so the first `writeEntry` doesn't race the daemon.
264
- */
265
- export function watchRegistry(
266
- onChange: (entries: RegistryEntry[]) => void | Promise<void>,
267
- opts: WatchOptions & { dir?: string } = {},
268
- ): WatchHandle {
269
- const dir = opts.dir ?? getRegistryDir()
270
- const debounceMs = opts.debounceMs ?? 100
271
- const pollMs = opts.pollMs ?? Math.max(debounceMs * 2, 250)
272
- const verbose = opts.verbose
273
-
274
- // Create the dir up front so fs.watch has something to attach to.
275
- fs.mkdirSync(dir, { recursive: true })
276
-
277
- let pending: ReturnType<typeof setTimeout> | null = null
278
- let closed = false
279
- let lastSignature: string | null = null
280
- let pollInFlight = false
281
-
282
- const signatureFor = (entries: RegistryEntry[]): string => {
283
- return JSON.stringify(
284
- entries
285
- .map(entry => ({
286
- id: entry.id,
287
- from: entry.from,
288
- to: entry.to,
289
- path: entry.path,
290
- pid: entry.pid,
291
- pathRewrites: entry.pathRewrites,
292
- cleanUrls: entry.cleanUrls,
293
- changeOrigin: entry.changeOrigin,
294
- static: entry.static,
295
- }))
296
- .sort((a, b) => a.id.localeCompare(b.id)),
297
- )
298
- }
299
-
300
- const fire = () => {
301
- pending = null
302
- if (closed)
303
- return
304
- readAll(dir, verbose)
305
- .then((entries) => {
306
- lastSignature = signatureFor(entries)
307
- return onChange(entries)
308
- })
309
- .catch((err) => {
310
- debugLog('registry', `watcher onChange failed: ${err}`, verbose)
311
- })
312
- }
313
-
314
- const schedule = () => {
315
- if (closed)
316
- return
317
- if (pending)
318
- clearTimeout(pending)
319
- pending = setTimeout(fire, debounceMs)
320
- }
321
-
322
- const poll = () => {
323
- if (closed || pollInFlight)
324
- return
325
-
326
- pollInFlight = true
327
- readAll(dir, verbose)
328
- .then((entries) => {
329
- const signature = signatureFor(entries)
330
- if (signature !== lastSignature)
331
- schedule()
332
- })
333
- .catch((err) => {
334
- debugLog('registry', `watcher poll failed: ${err}`, verbose)
335
- })
336
- .finally(() => {
337
- pollInFlight = false
338
- })
339
- }
340
-
341
- const pollInterval = setInterval(poll, pollMs)
342
-
343
- const watcher = fs.watch(dir, { persistent: true }, (_eventType, filename) => {
344
- // Ignore temp files from our own atomic-write protocol.
345
- if (filename && /\.tmp\.\d+\.\d+$/.test(filename))
346
- return
347
- schedule()
348
- })
349
-
350
- watcher.on('error', (err) => {
351
- debugLog('registry', `watcher error: ${err}`, verbose)
352
- })
353
-
354
- // Fire once on startup so the daemon picks up entries that already exist.
355
- schedule()
356
-
357
- return {
358
- close: () => {
359
- closed = true
360
- if (pending)
361
- clearTimeout(pending)
362
- clearInterval(pollInterval)
363
- watcher.close()
364
- },
365
- }
366
- }
package/src/sni.ts DELETED
@@ -1,93 +0,0 @@
1
- /**
2
- * Build a Bun.serve TLS array for per-domain SNI from real PEM files on disk.
3
- *
4
- * Production deployments (Let's Encrypt) have one cert+key per domain. Bun's
5
- * `Bun.serve({ tls: [{ serverName, cert, key }, ...] })` selects the right cert
6
- * by SNI server name at handshake time, so a single listener can front many
7
- * domains with their own real certs.
8
- */
9
- import type { DomainCert, ProductionTlsConfig } from './types'
10
- import * as fsp from 'node:fs/promises'
11
- import * as path from 'node:path'
12
- import { debugLog } from './utils'
13
-
14
- /** One entry of the Bun.serve `tls` array. */
15
- export interface SniTlsEntry {
16
- serverName: string
17
- cert: string
18
- key: string
19
- }
20
-
21
- /**
22
- * Map a PEM filename under a `certsDir` to its SNI server name. Returns `null`
23
- * for files that aren't `<name>.crt`. The wildcard convention
24
- * `_wildcard.<apex>.crt` maps to server name `*.<apex>`.
25
- */
26
- export function serverNameFromCertFilename(filename: string): string | null {
27
- if (!filename.endsWith('.crt'))
28
- return null
29
- const base = filename.slice(0, -'.crt'.length)
30
- if (base.length === 0)
31
- return null
32
- if (base.startsWith('_wildcard.'))
33
- return `*.${base.slice('_wildcard.'.length)}`
34
- return base
35
- }
36
-
37
- async function readPair(serverName: string, certPath: string, keyPath: string, verbose?: boolean): Promise<SniTlsEntry | null> {
38
- try {
39
- const [cert, key] = await Promise.all([
40
- fsp.readFile(certPath, 'utf8'),
41
- fsp.readFile(keyPath, 'utf8'),
42
- ])
43
- return { serverName, cert, key }
44
- }
45
- catch (err) {
46
- debugLog('sni', `skipping ${serverName}: ${(err as Error).message}`, verbose)
47
- return null
48
- }
49
- }
50
-
51
- /**
52
- * Build the SNI TLS array from a {@link ProductionTlsConfig}. Reads PEM files
53
- * from an explicit `domains` map and/or a `certsDir` convention. Files that
54
- * can't be read are skipped (logged in verbose mode). Returns `[]` when nothing
55
- * usable is found so the caller can fall back to the dev cert flow.
56
- */
57
- export async function buildSniTlsConfig(cfg: ProductionTlsConfig, verbose?: boolean): Promise<SniTlsEntry[]> {
58
- const bySrvName = new Map<string, DomainCert>()
59
-
60
- if (cfg.certsDir) {
61
- let names: string[] = []
62
- try {
63
- names = await fsp.readdir(cfg.certsDir)
64
- }
65
- catch (err) {
66
- debugLog('sni', `certsDir read failed (${cfg.certsDir}): ${(err as Error).message}`, verbose)
67
- }
68
- for (const name of names) {
69
- const serverName = serverNameFromCertFilename(name)
70
- if (!serverName)
71
- continue
72
- const base = name.slice(0, -'.crt'.length)
73
- bySrvName.set(serverName, {
74
- certPath: path.join(cfg.certsDir, name),
75
- keyPath: path.join(cfg.certsDir, `${base}.key`),
76
- })
77
- }
78
- }
79
-
80
- // Explicit `domains` entries take precedence over `certsDir` discoveries.
81
- if (cfg.domains) {
82
- for (const [serverName, pair] of Object.entries(cfg.domains))
83
- bySrvName.set(serverName, pair)
84
- }
85
-
86
- const entries: SniTlsEntry[] = []
87
- for (const [serverName, pair] of bySrvName) {
88
- const entry = await readPair(serverName, pair.certPath, pair.keyPath, verbose)
89
- if (entry)
90
- entries.push(entry)
91
- }
92
- return entries
93
- }