@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.
@@ -0,0 +1,15 @@
1
+ import type { PathRewrite } from './types';
2
+ /**
3
+ * Build a Bun.serve-compatible `fetch` handler that routes requests based on
4
+ * the `Host` header. Returns 404 when no route matches and 502 on upstream
5
+ * failures.
6
+ */
7
+ export declare function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler;
8
+ export declare interface ProxyRoute {
9
+ sourceHost: string
10
+ cleanUrls?: boolean
11
+ changeOrigin?: boolean
12
+ pathRewrites?: PathRewrite[]
13
+ }
14
+ export type GetRoute = (hostname: string) => ProxyRoute | undefined;
15
+ export type ProxyFetchHandler = (req: Request) => Promise<Response>;
@@ -0,0 +1,74 @@
1
+ import type { PathRewrite } from './types';
2
+ /**
3
+ * Default location for the registry directory. The daemon's PID file and log
4
+ * sit alongside it under `~/.stacks/rpx/`.
5
+ */
6
+ export declare function getRegistryDir(): string;
7
+ /**
8
+ * Validate an entry id. Rejects anything that could escape the registry dir
9
+ * (path traversal, slashes) or that would round-trip oddly through a filename.
10
+ */
11
+ export declare function isValidId(id: string): boolean;
12
+ /**
13
+ * Check whether a PID is alive. `kill(pid, 0)` returns without sending a
14
+ * signal but throws ESRCH if the process is gone — exactly the probe we need.
15
+ * EPERM means the process exists but we don't own it; treat as alive.
16
+ */
17
+ export declare function isPidAlive(pid: number): boolean;
18
+ /**
19
+ * Atomically write an entry to disk.
20
+ *
21
+ * Writes to a temp file in the same directory, then renames into place. POSIX
22
+ * rename within the same filesystem is atomic, so a concurrent reader either
23
+ * sees the old file or the new file — never a half-written one.
24
+ */
25
+ export declare function writeEntry(entry: RegistryEntry, dir?: string, verbose?: boolean): Promise<void>;
26
+ /**
27
+ * Remove an entry by id. No-op if the file is already gone.
28
+ */
29
+ export declare function removeEntry(id: string, dir?: string, verbose?: boolean): Promise<void>;
30
+ /**
31
+ * Read a single entry by id. Returns `null` if missing or malformed (malformed
32
+ * files are deleted so they don't keep poisoning subsequent reads).
33
+ */
34
+ export declare function readEntry(id: string, dir?: string, verbose?: boolean): Promise<RegistryEntry | null>;
35
+ /**
36
+ * Read all entries from the registry directory. Malformed files are pruned.
37
+ * This does NOT GC stale PIDs — call `gcStaleEntries` for that explicitly.
38
+ */
39
+ export declare function readAll(dir?: string, verbose?: boolean): Promise<RegistryEntry[]>;
40
+ /**
41
+ * Remove entries whose writer PID is no longer alive. Returns the count of
42
+ * entries removed. Safe to call repeatedly; intended to run on daemon startup
43
+ * and on a slow timer (e.g. every 5s) while the daemon is up.
44
+ */
45
+ export declare function gcStaleEntries(dir?: string, verbose?: boolean): Promise<number>;
46
+ /**
47
+ * Watch the registry directory and invoke `onChange` with the full current
48
+ * entry list whenever something changes. Events are debounced so a flurry of
49
+ * rapid writes (e.g. several `./buddy dev` invocations starting in parallel)
50
+ * triggers at most one rebuild.
51
+ *
52
+ * The watcher tolerates a missing directory at startup — it creates the dir
53
+ * before opening the watch, so the first `writeEntry` doesn't race the daemon.
54
+ */
55
+ export declare function watchRegistry(onChange: (entries: RegistryEntry[]) => void | Promise<void>, opts?: WatchOptions & { dir?: string }): WatchHandle;
56
+ export declare interface RegistryEntry {
57
+ id: string
58
+ from: string
59
+ to: string
60
+ pid?: number
61
+ cwd?: string
62
+ createdAt: string
63
+ pathRewrites?: PathRewrite[]
64
+ cleanUrls?: boolean
65
+ changeOrigin?: boolean
66
+ }
67
+ export declare interface WatchHandle {
68
+ close: () => void
69
+ }
70
+ export declare interface WatchOptions {
71
+ debounceMs?: number
72
+ pollMs?: number
73
+ verbose?: boolean
74
+ }
package/dist/types.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare interface BaseProxyConfig {
15
15
  to: string
16
16
  start?: StartOptions
17
17
  pathRewrites?: PathRewrite[]
18
+ id?: string
18
19
  }
19
20
  export declare interface CleanupConfig {
20
21
  domains: string[]
@@ -33,6 +34,7 @@ export declare interface SharedProxyConfig {
33
34
  cleanUrls: boolean
34
35
  changeOrigin?: boolean
35
36
  regenerateUntrustedCerts?: boolean
37
+ viaDaemon?: boolean
36
38
  }
37
39
  export declare interface SingleProxyConfig extends BaseProxyConfig, SharedProxyConfig {}
38
40
  export declare interface MultiProxyConfig extends SharedProxyConfig {
package/dist/utils.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare function getSudoPassword(): string | undefined;
8
8
  */
9
9
  export declare function execSudoSync(command: string): string;
10
10
  export declare function debugLog(category: string, message: string, verbose?: boolean): void;
11
+ export declare function redactSensitive(value: unknown): unknown;
12
+ export declare function safeStringify(value: unknown, space?: number): string;
11
13
  /**
12
14
  * Extracts hostnames from proxy configuration
13
15
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/rpx",
3
3
  "type": "module",
4
- "version": "0.11.5",
4
+ "version": "0.11.8",
5
5
  "description": "A modern and smart reverse proxy.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "src"
44
44
  ],
45
45
  "scripts": {
46
- "build": "bun build.ts && bun run compile",
46
+ "build": "bun build.ts && bun build ./bin/cli.ts --compile --minify --outfile bin/rpx",
47
47
  "compile": "bun build ./bin/cli.ts --compile --minify --outfile bin/rpx",
48
48
  "compile:all": "bun run compile:linux-x64 && bun run compile:linux-arm64 && bun run compile:windows-x64 && bun run compile:darwin-x64 && bun run compile:darwin-arm64",
49
49
  "compile:linux-x64": "bun build ./bin/cli.ts --compile --minify --target=bun-linux-x64 --outfile bin/rpx-linux-x64",
@@ -51,11 +51,12 @@
51
51
  "compile:windows-x64": "bun build ./bin/cli.ts --compile --minify --target=bun-windows-x64 --outfile bin/rpx-windows-x64.exe",
52
52
  "compile:darwin-x64": "bun build ./bin/cli.ts --compile --minify --target=bun-darwin-x64 --outfile bin/rpx-darwin-x64",
53
53
  "compile:darwin-arm64": "bun build ./bin/cli.ts --compile --minify --target=bun-darwin-arm64 --outfile bin/rpx-darwin-arm64",
54
- "lint": "bunx --bun eslint .",
55
- "lint:fix": "bunx --bun eslint . --fix",
54
+ "lint": "bunx --bun pickier .",
55
+ "lint:fix": "bunx --bun pickier . --fix",
56
56
  "fresh": "bunx rimraf node_modules/ bun.lock && bun i",
57
57
  "changelog": "changelogen --output CHANGELOG.md",
58
- "prepublishOnly": "bun --bun run build && bun run compile:all && bun run zip",
58
+ "prepublishOnly": "bun build.ts",
59
+ "release:binaries": "bun run compile:all && bun run zip",
59
60
  "test": "bun test",
60
61
  "typecheck": "bunx tsc --noEmit",
61
62
  "zip": "bun run zip:all",
@@ -66,10 +67,11 @@
66
67
  "zip:darwin-x64": "zip -j bin/rpx-darwin-x64.zip bin/rpx-darwin-x64",
67
68
  "zip:darwin-arm64": "zip -j bin/rpx-darwin-arm64.zip bin/rpx-darwin-arm64"
68
69
  },
70
+ "dependencies": {
71
+ "@stacksjs/clapp": "^0.2.10",
72
+ "@stacksjs/tlsx": "^0.13.6"
73
+ },
69
74
  "devDependencies": {
70
- "@stacksjs/clapp": "^0.2.0",
71
- "@stacksjs/tlsx": "^0.13.0",
72
- "bun-plugin-dtsx": "^0.21.17",
73
75
  "bunfig": "^0.15.6",
74
76
  "typescript": "^5.9.3"
75
77
  },
@@ -77,6 +79,6 @@
77
79
  "pre-commit": "bunx lint-staged"
78
80
  },
79
81
  "lint-staged": {
80
- "*.{js,ts}": "bunx eslint . --fix"
82
+ "*.{js,ts}": "bunx --bun pickier . --fix"
81
83
  }
82
84
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bridges the `startProxy` / `startProxies` entrypoints to the long-running
3
+ * rpx daemon. When `viaDaemon: true` is set on a proxy config (or
4
+ * `--via-daemon` is passed to the CLI), we don't bind our own `:443` —
5
+ * instead we:
6
+ *
7
+ * 1. Write a registry entry per proxy under `~/.stacks/rpx/registry.d`.
8
+ * 2. Ensure the daemon is running (lazy-spawn if needed).
9
+ * 3. Block until SIGINT/SIGTERM, then unregister our entries.
10
+ *
11
+ * The daemon's PID-GC reaps anything we miss if this process dies `kill -9`.
12
+ */
13
+ import type { PathRewrite } from './types'
14
+ import * as fs from 'node:fs'
15
+ import * as path from 'node:path'
16
+ import * as process from 'node:process'
17
+ import { ensureDaemonRunning } from './daemon'
18
+ import { log } from './logger'
19
+ import { getRegistryDir, isValidId, removeEntry, writeEntry } from './registry'
20
+ import { debugLog } from './utils'
21
+
22
+ export interface DaemonRunnerProxy {
23
+ id?: string
24
+ from: string
25
+ to: string
26
+ cleanUrls?: boolean
27
+ changeOrigin?: boolean
28
+ pathRewrites?: PathRewrite[]
29
+ }
30
+
31
+ export interface DaemonRunnerOptions {
32
+ proxies: DaemonRunnerProxy[]
33
+ verbose?: boolean
34
+ /** Override the registry dir (tests). Defaults to `~/.stacks/rpx/registry.d`. */
35
+ registryDir?: string
36
+ /** Override the rpx state dir (tests). Defaults to `~/.stacks/rpx`. */
37
+ rpxDir?: string
38
+ /**
39
+ * Skip the blocking await + signal handlers. Tests use this to register
40
+ * entries, verify, and tear down without keeping the test runner alive.
41
+ */
42
+ detached?: boolean
43
+ /** Override the daemon spawn command (tests). */
44
+ spawnCommand?: string[]
45
+ /** Passed through to `ensureDaemonRunning` (default 5000ms). */
46
+ startupTimeoutMs?: number
47
+ /** Extra env for the daemon child (e.g. `SUDO_PASSWORD`). */
48
+ spawnEnv?: Record<string, string>
49
+ /**
50
+ * When true, registry entries omit `pid` so they persist until
51
+ * `rpx unregister` — avoids PID-GC dropping routes when the registering
52
+ * process is short-lived (e.g. a CLI wrapper).
53
+ */
54
+ persistent?: boolean
55
+ }
56
+
57
+ /**
58
+ * Sanitize an arbitrary `to` host into a valid registry id. Drops anything
59
+ * that isn't `[a-zA-Z0-9._-]`, collapses runs to a single dash, and trims
60
+ * leading/trailing dashes. Falls back to `'rpx'` if nothing's left.
61
+ */
62
+ export function deriveIdFromTarget(to: string): string {
63
+ const cleaned = to.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 128)
64
+ return cleaned.length > 0 ? cleaned : 'rpx'
65
+ }
66
+
67
+ /**
68
+ * Register every proxy with the daemon and (unless `detached`) block until a
69
+ * shutdown signal arrives. Throws if any id collides or the daemon fails to
70
+ * spawn.
71
+ */
72
+ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
73
+ if (opts.proxies.length === 0)
74
+ throw new Error('runViaDaemon: no proxies provided')
75
+
76
+ const verbose = opts.verbose ?? false
77
+ const registryDir = opts.registryDir
78
+ const ids = new Set<string>()
79
+
80
+ // Resolve and validate all ids up front so we don't half-register and bail.
81
+ const resolved = opts.proxies.map((p) => {
82
+ const id = p.id ?? deriveIdFromTarget(p.to)
83
+ if (!isValidId(id))
84
+ throw new Error(`invalid registry id "${id}" derived from to="${p.to}"`)
85
+ if (ids.has(id))
86
+ throw new Error(`duplicate registry id "${id}" — set an explicit \`id\` on one of the proxies`)
87
+ ids.add(id)
88
+ return { ...p, id }
89
+ })
90
+
91
+ const createdAt = new Date().toISOString()
92
+ for (const p of resolved) {
93
+ await writeEntry({
94
+ id: p.id,
95
+ from: p.from,
96
+ to: p.to,
97
+ pid: opts.persistent ? undefined : process.pid,
98
+ cwd: process.cwd(),
99
+ createdAt,
100
+ cleanUrls: p.cleanUrls,
101
+ changeOrigin: p.changeOrigin,
102
+ pathRewrites: p.pathRewrites,
103
+ }, registryDir, verbose)
104
+ }
105
+
106
+ const result = await ensureDaemonRunning({
107
+ rpxDir: opts.rpxDir,
108
+ verbose,
109
+ spawnCommand: opts.spawnCommand,
110
+ startupTimeoutMs: opts.startupTimeoutMs,
111
+ spawnEnv: opts.spawnEnv,
112
+ })
113
+
114
+ for (const p of resolved)
115
+ log.success(`https://${p.to} → ${p.from}`)
116
+ log.info(`(via rpx daemon pid=${result.pid}; \`rpx daemon:status\` to inspect)`)
117
+
118
+ if (opts.detached)
119
+ return
120
+
121
+ // Cleanup registry entries on shutdown so the daemon's routing table reflects
122
+ // reality immediately (its PID-GC would catch us eventually, but this is
123
+ // faster and avoids a stale-route window).
124
+ let cleaned = false
125
+ const dirForCleanup = registryDir ?? getRegistryDir()
126
+ const idsForCleanup = resolved.map(p => p.id)
127
+
128
+ const cleanup = async (): Promise<void> => {
129
+ if (cleaned)
130
+ return
131
+ cleaned = true
132
+ for (const id of idsForCleanup) {
133
+ await removeEntry(id, registryDir, verbose).catch((err) => {
134
+ debugLog('runner', `removeEntry(${id}) failed: ${err}`, verbose)
135
+ })
136
+ }
137
+ }
138
+
139
+ const onSignal = (sig: NodeJS.Signals): void => {
140
+ debugLog('runner', `received ${sig}, unregistering ${idsForCleanup.length} entries`, verbose)
141
+ cleanup().finally(() => process.exit(0))
142
+ }
143
+ process.once('SIGINT', onSignal)
144
+ process.once('SIGTERM', onSignal)
145
+
146
+ // Last-resort sync cleanup if the process exits without our signal handlers
147
+ // running (e.g. a thrown uncaught exception or `process.exit()` from elsewhere).
148
+ process.once('exit', () => {
149
+ if (cleaned)
150
+ return
151
+ for (const id of idsForCleanup) {
152
+ try {
153
+ fs.unlinkSync(path.join(dirForCleanup, `${id}.json`))
154
+ }
155
+ catch {}
156
+ }
157
+ })
158
+
159
+ // Park forever; signal handlers do the actual exiting.
160
+ await new Promise<void>(() => {})
161
+ }