@stacksjs/rpx 0.11.14 → 0.11.16

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/src/daemon.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * paths are reachable without touching `~/.stacks/rpx` or :443.
17
17
  */
18
18
  /* eslint-disable no-console */
19
- import type { ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
19
+ import type { OnDemandTlsConfig, ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
20
20
  import type { ProxyRoute, ProxyServer as ProxyServerLike } from './proxy-handler'
21
21
  import { spawn as nodeSpawn } from 'node:child_process'
22
22
  import * as fsp from 'node:fs/promises'
@@ -26,8 +26,10 @@ import * as process from 'node:process'
26
26
  import { log } from './logger'
27
27
  import { checkExistingCertificates, generateCertificate } from './https'
28
28
  import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
29
- import { matchHost } from './host-match'
29
+ import { buildHostRoutes, matchHostRoute, normalizePathPrefix } from './host-routes'
30
+ import type { HostRoutes } from './host-routes'
30
31
  import { buildSniTlsConfig } from './sni'
32
+ import { OnDemandCertManager } from './on-demand'
31
33
  import { resolveStaticRoute } from './static-files'
32
34
  import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
33
35
  import type { RegistryEntry } from './registry'
@@ -58,6 +60,12 @@ export interface DaemonOptions {
58
60
  * self-signed shared cert.
59
61
  */
60
62
  productionCerts?: ProductionTlsConfig
63
+ /**
64
+ * On-demand TLS: lazily issue real certs for approved unknown hosts via ACME
65
+ * http-01 (served from this daemon's `:80` listener). Opt-in via `enabled`.
66
+ * Seeded with the `productionCerts`/`certsDir` certs already on disk.
67
+ */
68
+ onDemandTls?: OnDemandTlsConfig
61
69
  /** PID-GC interval in ms. Defaults to 5000. */
62
70
  gcIntervalMs?: number
63
71
  }
@@ -70,6 +78,13 @@ export interface DaemonHandle {
70
78
  httpsPort: number
71
79
  httpPort: number
72
80
  pidPath: string
81
+ /**
82
+ * Pre-warm an on-demand cert for `host` (issue it now if approved & missing,
83
+ * rebuilding the `:443` listener). Resolves `true` if a cert is available
84
+ * afterwards. No-op resolving `false` when on-demand TLS isn't enabled. Lets a
85
+ * tunnel server warm a subdomain's cert at registration time.
86
+ */
87
+ ensureCert: (host: string) => Promise<boolean>
73
88
  }
74
89
 
75
90
  const DEFAULT_GC_INTERVAL_MS = 5000
@@ -158,10 +173,12 @@ export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Pro
158
173
  */
159
174
  function entryToRoute(entry: RegistryEntry): ProxyRoute {
160
175
  const cleanUrls = entry.cleanUrls ?? false
176
+ const basePath = normalizePathPrefix(entry.path)
161
177
  if (entry.static) {
162
178
  return {
163
179
  static: resolveStaticRoute(entry.static, cleanUrls),
164
180
  cleanUrls,
181
+ basePath,
165
182
  }
166
183
  }
167
184
  const from = entry.from ?? 'localhost:1'
@@ -171,6 +188,7 @@ function entryToRoute(entry: RegistryEntry): ProxyRoute {
171
188
  cleanUrls,
172
189
  changeOrigin: entry.changeOrigin ?? false,
173
190
  pathRewrites: entry.pathRewrites,
191
+ basePath,
174
192
  }
175
193
  }
176
194
 
@@ -290,6 +308,9 @@ async function elevateDaemonToRoot(
290
308
  try { process.kill(pid, 'SIGTERM') }
291
309
  catch { /* EPERM — root-owned shared daemon */ }
292
310
  },
311
+ // On-demand issuance runs inside the elevated child's own runDaemon
312
+ // handle; this caller-side stub can't reach it directly.
313
+ ensureCert: () => Promise.resolve(false),
293
314
  }
294
315
  }
295
316
  // sudo exits fast when auth fails; while the daemon runs it stays alive.
@@ -312,6 +333,9 @@ async function elevateDaemonToRoot(
312
333
  * listeners are bound and the initial routing table is populated. Use
313
334
  * `handle.done` for the lifetime promise.
314
335
  */
336
+ // `opts` IS used throughout; pickier's no-unused-vars mis-fires on this fn after
337
+ // the on-demand serve refactor (its --fix would wrongly rename to `_opts`).
338
+ // eslint-disable-next-line pickier/no-unused-vars
315
339
  export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle> {
316
340
  const verbose = opts.verbose ?? false
317
341
  const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
@@ -332,17 +356,20 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
332
356
  const pidPath = await acquireDaemonLock(rpxDir)
333
357
 
334
358
  // Module-scoped state so the watcher and fetch handler share one routing view.
335
- // Routing table keyed by host pattern. Lookup prefers an exact match, then
336
- // the most-specific `*.suffix` wildcard (see `matchHost`).
337
- let routingTable = new Map<string, ProxyRoute>()
338
- const getRoute = (host: string): ProxyRoute | undefined => matchHost(routingTable, host)
359
+ // Routing table keyed by host pattern; each host owns an ordered list of
360
+ // path-scoped routes. Lookup prefers an exact host match, then the
361
+ // most-specific `*.suffix` wildcard (see `matchHostList`); within a host the
362
+ // longest matching path prefix wins (see `matchHostRoute`).
363
+ let routingTable: HostRoutes<ProxyRoute> = new Map()
364
+ const getRoute = (host: string, pathname: string): ProxyRoute | undefined =>
365
+ matchHostRoute(routingTable, host, pathname)
339
366
 
340
367
  function rebuild(entries: RegistryEntry[]): void {
341
- const next = new Map<string, ProxyRoute>()
342
- for (const e of entries)
343
- next.set(e.to, entryToRoute(e))
344
- routingTable = next
345
- debugLog('daemon', `routing table now covers ${next.size} host(s): ${Array.from(next.keys()).join(', ') || '<empty>'}`, verbose)
368
+ routingTable = buildHostRoutes(
369
+ entries.map(e => ({ host: e.to, path: e.path, route: entryToRoute(e) })),
370
+ )
371
+ const hosts = Array.from(routingTable.keys())
372
+ debugLog('daemon', `routing table now covers ${hosts.length} host(s): ${hosts.join(', ') || '<empty>'}`, verbose)
346
373
  }
347
374
 
348
375
  // Initial GC + load before binding so the very first request finds a route.
@@ -372,34 +399,88 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
372
399
  const fetchHandler = createProxyFetchHandler(getRoute, verbose)
373
400
  const wsHandler = createProxyWebSocketHandler(verbose)
374
401
 
375
- let tlsConfig: unknown
376
- if (sniTls.length > 0) {
377
- tlsConfig = sniTls.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
378
- }
379
- else {
380
- const sslConfig = await bootstrapTls(opts, registryDir)
381
- tlsConfig = {
382
- key: sslConfig.key,
383
- cert: sslConfig.cert,
384
- ca: sslConfig.ca,
402
+ // Bootstrap the dev shared cert once when there's no real SNI set, so a single
403
+ // SNI listener with on-demand can still answer hosts that aren't covered yet.
404
+ let devSslConfig: SSLConfig | null = null
405
+ if (sniTls.length === 0)
406
+ devSslConfig = await bootstrapTls(opts, registryDir)
407
+
408
+ // On-demand TLS manager (opt-in). Holds the live SNI set; lazily issues real
409
+ // certs for approved unknown hosts via ACME http-01 served from our :80
410
+ // listener (Bun can't issue at handshake time — see on-demand.ts header).
411
+ const onDemandCfg = opts.onDemandTls
412
+ const onDemand: OnDemandCertManager | null = onDemandCfg?.enabled
413
+ ? new OnDemandCertManager({
414
+ config: onDemandCfg,
415
+ certsDir: onDemandCfg.certsDir ?? opts.productionCerts?.certsDir ?? path.join(rpxDir, 'on-demand-certs'),
416
+ initial: sniTls,
417
+ verbose,
418
+ // A new cert was issued/adopted — rebuild :443 with the augmented set.
419
+ onCertAdded: (entries) => { void rebuildTls(entries) },
420
+ })
421
+ : null
422
+
423
+ /** Build the TLS option for Bun.serve from the current SNI set (or dev cert). */
424
+ function tlsFor(entries: Array<{ serverName: string, cert: string, key: string }>): unknown {
425
+ if (entries.length > 0)
426
+ return entries.map(e => ({ serverName: e.serverName, cert: e.cert, key: e.key }))
427
+ // No real certs: fall back to the dev self-signed shared cert.
428
+ return {
429
+ key: devSslConfig!.key,
430
+ cert: devSslConfig!.cert,
431
+ ca: devSslConfig!.ca,
385
432
  requestCert: false,
386
433
  rejectUnauthorized: false,
387
434
  }
388
435
  }
389
436
 
390
- const httpsServer = Bun.serve({
391
- port: httpsPort,
392
- hostname,
393
- tls: tlsConfig as any,
394
- fetch(req: Request, server: unknown) {
395
- return fetchHandler(req, server as ProxyServerLike)
396
- },
397
- websocket: wsHandler,
398
- error(err: Error) {
399
- debugLog('daemon', `https server error: ${err}`, verbose)
400
- return new Response(`Server Error: ${err.message}`, { status: 500 })
401
- },
402
- })
437
+ /** (Re)create the :443 listener. Factored so on-demand can rebuild it. */
438
+ function serveHttps(entries: Array<{ serverName: string, cert: string, key: string }>): ReturnType<typeof Bun.serve> {
439
+ return Bun.serve({
440
+ port: httpsPort,
441
+ hostname,
442
+ tls: tlsFor(entries) as any,
443
+ fetch(req: Request, server: unknown) {
444
+ return fetchHandler(req, server as ProxyServerLike)
445
+ },
446
+ websocket: wsHandler,
447
+ error(err: Error) {
448
+ debugLog('daemon', `https server error: ${err}`, verbose)
449
+ return new Response(`Server Error: ${err.message}`, { status: 500 })
450
+ },
451
+ })
452
+ }
453
+
454
+ let httpsServer = serveHttps(onDemand ? onDemand.sniEntries() : sniTls)
455
+
456
+ /**
457
+ * Bun has no working SNICallback and `server.reload({ tls })` does not update
458
+ * certs at runtime (verified Bun 1.3.14/1.4.0). So to serve a freshly-issued
459
+ * cert we tear the old listener down and re-bind with the augmented SNI set.
460
+ * The rebind is sub-second; if the OS hasn't freed the port yet we retry on a
461
+ * short async backoff. In-flight requests on the old listener drain
462
+ * (`stop(false)`). Only ever invoked from the (async) issuance callback.
463
+ */
464
+ async function rebuildTls(entries: Array<{ serverName: string, cert: string, key: string }>): Promise<void> {
465
+ if (stopped)
466
+ return
467
+ debugLog('daemon', `rebuilding :443 with ${entries.length} SNI cert(s)`, verbose)
468
+ httpsServer.stop(false)
469
+ let lastErr: unknown
470
+ for (let attempt = 0; attempt < 20 && !stopped; attempt++) {
471
+ try {
472
+ httpsServer = serveHttps(entries)
473
+ return
474
+ }
475
+ catch (err) {
476
+ // EADDRINUSE while the old socket releases — back off briefly, retry.
477
+ lastErr = err
478
+ await new Promise(resolve => setTimeout(resolve, 25))
479
+ }
480
+ }
481
+ // Could not rebind: the old listener is already down. Surface the failure.
482
+ log.error(`rpx: failed to rebuild :443 after issuing cert: ${(lastErr as Error)?.message}`)
483
+ }
403
484
 
404
485
  let httpServer: ReturnType<typeof Bun.serve> | null = null
405
486
  if (httpPort > 0) {
@@ -409,6 +490,22 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
409
490
  fetch(req: Request) {
410
491
  const u = new URL(req.url)
411
492
  const host = (req.headers.get('host') ?? u.hostname).split(':')[0]
493
+
494
+ // Serve ACME http-01 challenges for in-flight on-demand issuances.
495
+ if (onDemand && u.pathname.startsWith('/.well-known/acme-challenge/')) {
496
+ const keyAuth = onDemand.challengeStore.handlePath(u.pathname)
497
+ if (keyAuth !== undefined)
498
+ return new Response(keyAuth, { status: 200, headers: { 'content-type': 'text/plain' } })
499
+ return new Response('challenge not found', { status: 404 })
500
+ }
501
+
502
+ // First plaintext hit for an approved-but-uncovered host: kick off
503
+ // issuance so the cert exists for the subsequent HTTPS request. We don't
504
+ // block the redirect on it (the browser retries over HTTPS anyway).
505
+ if (onDemand && !onDemand.hasCert(host)) {
506
+ onDemand.ensureCert(host).catch(() => {})
507
+ }
508
+
412
509
  return new Response(null, {
413
510
  status: 301,
414
511
  headers: { Location: `https://${host}${u.pathname}${u.search}` },
@@ -483,6 +580,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
483
580
  httpsPort: typeof httpsServer.port === 'number' ? httpsServer.port : httpsPort,
484
581
  httpPort: httpServer && typeof httpServer.port === 'number' ? httpServer.port : httpPort,
485
582
  pidPath,
583
+ ensureCert: (host: string) => (onDemand ? onDemand.ensureCert(host) : Promise.resolve(false)),
486
584
  }
487
585
  }
488
586
 
@@ -0,0 +1,147 @@
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/index.ts CHANGED
@@ -112,11 +112,20 @@ export type {
112
112
  StopDaemonResult,
113
113
  } from './daemon'
114
114
 
115
- export { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
115
+ export { createProxyFetchHandler, createProxyWebSocketHandler, stripBasePath } from './proxy-handler'
116
116
  export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
117
117
 
118
118
  export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
119
119
 
120
+ export {
121
+ buildHostRoutes,
122
+ matchHostList,
123
+ matchHostRoute,
124
+ normalizePathPrefix,
125
+ pathPrefixMatches,
126
+ } from './host-routes'
127
+ export type { HostRoutes, PathRoute } from './host-routes'
128
+
120
129
  export {
121
130
  contentTypeFor,
122
131
  resolveStaticFile,
@@ -129,6 +138,9 @@ export type { ResolvedStaticRoute, StaticResolution } from './static-files'
129
138
  export { buildSniTlsConfig, serverNameFromCertFilename } from './sni'
130
139
  export type { SniTlsEntry } from './sni'
131
140
 
141
+ export { isLikelyHostname, matchesAllowedSuffix, OnDemandCertManager } from './on-demand'
142
+ export type { CertIssuer, OnDemandCertManagerOptions } from './on-demand'
143
+
132
144
  export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
133
145
  export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
134
146