@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/dist/bin/cli.js +188 -184
- package/dist/{chunk-5ygwd93k.js → chunk-1sg87m7v.js} +1 -1
- package/dist/{chunk-a0ddh9cv.js → chunk-a0b9f9fs.js} +1 -1
- package/dist/chunk-n3etse5z.js +161 -0
- package/dist/{chunk-3pgh05pc.js → chunk-rbgb5fyg.js} +1 -1
- package/dist/daemon-runner.d.ts +3 -1
- package/dist/daemon.d.ts +6 -1
- package/dist/host-routes.d.ts +43 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +6 -6
- package/dist/on-demand.d.ts +40 -0
- package/dist/proxy-handler.d.ts +14 -1
- package/dist/registry.d.ts +2 -0
- package/dist/types.d.ts +32 -0
- package/package.json +2 -2
- package/src/daemon-runner.ts +14 -3
- package/src/daemon.ts +132 -34
- package/src/host-routes.ts +147 -0
- package/src/index.ts +13 -1
- package/src/on-demand.ts +264 -0
- package/src/proxy-handler.ts +60 -7
- package/src/registry.ts +12 -0
- package/src/start.ts +40 -14
- package/src/types.ts +66 -0
- package/dist/chunk-tx5hnj92.js +0 -157
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 {
|
|
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
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
debugLog('daemon', `routing table now covers ${
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|