@stacksjs/rpx 0.11.13 → 0.11.14

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,8 +16,8 @@
16
16
  * paths are reachable without touching `~/.stacks/rpx` or :443.
17
17
  */
18
18
  /* eslint-disable no-console */
19
- import type { ProxyOptions, SSLConfig, TlsOption } from './types'
20
- import type { ProxyRoute } from './proxy-handler'
19
+ import type { ProductionTlsConfig, ProxyOptions, SSLConfig, TlsOption } from './types'
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'
23
23
  import { homedir } from 'node:os'
@@ -25,7 +25,10 @@ import * as path from 'node:path'
25
25
  import * as process from 'node:process'
26
26
  import { log } from './logger'
27
27
  import { checkExistingCertificates, generateCertificate } from './https'
28
- import { createProxyFetchHandler } from './proxy-handler'
28
+ import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
29
+ import { matchHost } from './host-match'
30
+ import { buildSniTlsConfig } from './sni'
31
+ import { resolveStaticRoute } from './static-files'
29
32
  import { gcStaleEntries, getRegistryDir, isPidAlive, readAll, watchRegistry } from './registry'
30
33
  import type { RegistryEntry } from './registry'
31
34
  import {
@@ -49,6 +52,12 @@ export interface DaemonOptions {
49
52
  hostname?: string
50
53
  /** TLS bootstrap options forwarded to httpsConfig. */
51
54
  https?: TlsOption
55
+ /**
56
+ * Production per-domain SNI certs (real PEMs on disk). When usable certs are
57
+ * found, the listener serves them per SNI server name instead of the dev
58
+ * self-signed shared cert.
59
+ */
60
+ productionCerts?: ProductionTlsConfig
52
61
  /** PID-GC interval in ms. Defaults to 5000. */
53
62
  gcIntervalMs?: number
54
63
  }
@@ -148,10 +157,18 @@ export async function releaseDaemonLock(rpxDir: string = getDaemonRpxDir()): Pro
148
157
  * fetch handler. The entry's `from` is normalized to `host:port`.
149
158
  */
150
159
  function entryToRoute(entry: RegistryEntry): ProxyRoute {
151
- const fromUrl = new URL(entry.from.startsWith('http') ? entry.from : `http://${entry.from}`)
160
+ const cleanUrls = entry.cleanUrls ?? false
161
+ if (entry.static) {
162
+ return {
163
+ static: resolveStaticRoute(entry.static, cleanUrls),
164
+ cleanUrls,
165
+ }
166
+ }
167
+ const from = entry.from ?? 'localhost:1'
168
+ const fromUrl = new URL(from.startsWith('http') ? from : `http://${from}`)
152
169
  return {
153
170
  sourceHost: fromUrl.host,
154
- cleanUrls: entry.cleanUrls ?? false,
171
+ cleanUrls,
155
172
  changeOrigin: entry.changeOrigin ?? false,
156
173
  pathRewrites: entry.pathRewrites,
157
174
  }
@@ -315,8 +332,10 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
315
332
  const pidPath = await acquireDaemonLock(rpxDir)
316
333
 
317
334
  // 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`).
318
337
  let routingTable = new Map<string, ProxyRoute>()
319
- const getRoute = (host: string): ProxyRoute | undefined => routingTable.get(host)
338
+ const getRoute = (host: string): ProxyRoute | undefined => matchHost(routingTable, host)
320
339
 
321
340
  function rebuild(entries: RegistryEntry[]): void {
322
341
  const next = new Map<string, ProxyRoute>()
@@ -340,19 +359,42 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<DaemonHandle>
340
359
  debugLog('daemon', `DNS setup on start failed: ${err}`, verbose)
341
360
  })
342
361
 
343
- const sslConfig = await bootstrapTls(opts, registryDir)
362
+ // Production per-domain SNI: serve real PEM certs (e.g. Let's Encrypt) keyed
363
+ // by server name on the one listener. Falls back to the dev shared cert when
364
+ // no usable production certs are configured.
365
+ let sniTls: Array<{ serverName: string, cert: string, key: string }> = []
366
+ if (opts.productionCerts) {
367
+ sniTls = await buildSniTlsConfig(opts.productionCerts, verbose)
368
+ if (verbose && sniTls.length > 0)
369
+ log.info(`SNI: serving ${sniTls.length} real cert(s): ${sniTls.map(e => e.serverName).join(', ')}`)
370
+ }
344
371
 
345
- const httpsServer = Bun.serve({
346
- port: httpsPort,
347
- hostname,
348
- tls: {
372
+ const fetchHandler = createProxyFetchHandler(getRoute, verbose)
373
+ const wsHandler = createProxyWebSocketHandler(verbose)
374
+
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 = {
349
382
  key: sslConfig.key,
350
383
  cert: sslConfig.cert,
351
384
  ca: sslConfig.ca,
352
385
  requestCert: false,
353
386
  rejectUnauthorized: false,
387
+ }
388
+ }
389
+
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)
354
396
  },
355
- fetch: createProxyFetchHandler(getRoute, verbose),
397
+ websocket: wsHandler,
356
398
  error(err: Error) {
357
399
  debugLog('daemon', `https server error: ${err}`, verbose)
358
400
  return new Response(`Server Error: ${err.message}`, { status: 500 })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Host-based route matching with wildcard support.
3
+ *
4
+ * The routing table is keyed by host pattern. A pattern is either an exact
5
+ * hostname (`api.example.com`) or a wildcard (`*.example.com`). Lookup prefers
6
+ * an exact match, then the most-specific (deepest-suffix) wildcard.
7
+ *
8
+ * Kept dependency-free and pure so it's reusable from both the daemon and the
9
+ * in-process multi-proxy path, and trivially unit-testable.
10
+ */
11
+
12
+ export function isWildcardPattern(pattern: string): boolean {
13
+ return pattern.startsWith('*.')
14
+ }
15
+
16
+ /**
17
+ * True if `hostname` matches the wildcard `pattern` (`*.suffix`). A wildcard
18
+ * matches exactly one or more leading labels — `*.example.com` matches
19
+ * `a.example.com` and `a.b.example.com`, but NOT the bare apex `example.com`.
20
+ */
21
+ export function matchesWildcard(hostname: string, pattern: string): boolean {
22
+ if (!isWildcardPattern(pattern))
23
+ return false
24
+ const suffix = pattern.slice(1) // '*.example.com' → '.example.com'
25
+ return hostname.length > suffix.length && hostname.endsWith(suffix)
26
+ }
27
+
28
+ /**
29
+ * Find the route value for `hostname` in a host-keyed map. Exact match wins;
30
+ * otherwise the matching wildcard with the longest (most-specific) suffix wins.
31
+ * Returns `undefined` when nothing matches.
32
+ */
33
+ export function matchHost<T>(table: Map<string, T>, hostname: string): T | undefined {
34
+ const exact = table.get(hostname)
35
+ if (exact !== undefined)
36
+ return exact
37
+
38
+ let best: T | undefined
39
+ let bestLen = -1
40
+ for (const [pattern, value] of table) {
41
+ if (!isWildcardPattern(pattern))
42
+ continue
43
+ if (matchesWildcard(hostname, pattern)) {
44
+ const len = pattern.length - 1 // length of the matched suffix
45
+ if (len > bestLen) {
46
+ bestLen = len
47
+ best = value
48
+ }
49
+ }
50
+ }
51
+ return best
52
+ }
package/src/index.ts CHANGED
@@ -112,8 +112,22 @@ export type {
112
112
  StopDaemonResult,
113
113
  } from './daemon'
114
114
 
115
- export { createProxyFetchHandler } from './proxy-handler'
116
- export type { GetRoute, ProxyFetchHandler, ProxyRoute } from './proxy-handler'
115
+ export { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
116
+ export type { GetRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
117
+
118
+ export { isWildcardPattern, matchesWildcard, matchHost } from './host-match'
119
+
120
+ export {
121
+ contentTypeFor,
122
+ resolveStaticFile,
123
+ resolveStaticRoute,
124
+ safeRelativePath,
125
+ serveStaticFile,
126
+ } from './static-files'
127
+ export type { ResolvedStaticRoute, StaticResolution } from './static-files'
128
+
129
+ export { buildSniTlsConfig, serverNameFromCertFilename } from './sni'
130
+ export type { SniTlsEntry } from './sni'
117
131
 
118
132
  export { deriveIdFromTarget, runViaDaemon } from './daemon-runner'
119
133
  export type { DaemonRunnerOptions, DaemonRunnerProxy } from './daemon-runner'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The fetch handler used by the shared :443 server. Both the in-process
2
+ * The request handlers used by the shared :443 server. Both the in-process
3
3
  * multi-proxy mode in `start.ts` and the long-running daemon delegate to this
4
4
  * module so routing semantics stay in one place.
5
5
  *
@@ -7,37 +7,108 @@
7
7
  * The callback indirection lets each caller use whatever data structure makes
8
8
  * sense (a fixed Map at startup, or a hot-swappable registry view) without
9
9
  * coupling this module to either.
10
+ *
11
+ * Three transports are supported per route:
12
+ * - HTTP(S) proxying via `fetch()` to an upstream `host:port`.
13
+ * - WebSocket proxying via `server.upgrade()` + an upstream `WebSocket`.
14
+ * - Static file serving from a local directory (`route.static`).
10
15
  */
16
+ import type { ServerWebSocket } from 'bun'
17
+ import type { ResolvedStaticRoute } from './static-files'
11
18
  import type { PathRewrite } from './types'
12
- import { debugLog } from './utils'
13
- import { resolvePathRewrite } from './utils'
19
+ import { serveStaticFile } from './static-files'
20
+ import { debugLog, resolvePathRewrite } from './utils'
14
21
 
15
22
  export interface ProxyRoute {
16
- /** Upstream `host:port` to forward requests to (e.g. `localhost:5173`). */
17
- sourceHost: string
23
+ /**
24
+ * Upstream `host:port` to forward requests to (e.g. `localhost:5173`).
25
+ * Optional when `static` is set.
26
+ */
27
+ sourceHost?: string
18
28
  /** Strip `.html` suffix and 301 to clean URLs. */
19
29
  cleanUrls?: boolean
20
30
  /** Set the `origin` header to the target. */
21
31
  changeOrigin?: boolean
22
32
  /** Per-route path rewrites (vite/nginx-style prefix routing). */
23
33
  pathRewrites?: PathRewrite[]
34
+ /** When set, serve files from a local directory instead of proxying. */
35
+ static?: ResolvedStaticRoute
24
36
  }
25
37
 
26
38
  export type GetRoute = (hostname: string) => ProxyRoute | undefined
27
39
 
28
- export type ProxyFetchHandler = (req: Request) => Promise<Response>
40
+ export type ProxyFetchHandler = (req: Request, server?: ProxyServer) => Promise<Response | undefined>
41
+
42
+ /** Minimal shape of the Bun server needed for WebSocket upgrades. */
43
+ export interface ProxyServer {
44
+ // Loose `any` so it structurally accepts Bun's `Server<WebSocketData>` for
45
+ // any data generic (the daemon/start callers parameterize differently).
46
+ upgrade: (req: Request, options?: { data?: any, headers?: any }) => boolean
47
+ }
48
+
49
+ /** Data attached to an upgraded client socket so the ws handler can dial upstream. */
50
+ interface WsData {
51
+ targetUrl: string
52
+ forwardHeaders: Record<string, string>
53
+ }
54
+
55
+ /** Per-socket state: the upstream client + a buffer for early client frames. */
56
+ interface WsState {
57
+ upstream: WebSocket
58
+ upstreamOpen: boolean
59
+ pending: Array<string | ArrayBufferLike | Uint8Array>
60
+ }
61
+
62
+ const HOP_BY_HOP = new Set([
63
+ 'connection',
64
+ 'keep-alive',
65
+ 'proxy-authenticate',
66
+ 'proxy-authorization',
67
+ 'te',
68
+ 'trailer',
69
+ 'transfer-encoding',
70
+ 'upgrade',
71
+ 'sec-websocket-key',
72
+ 'sec-websocket-version',
73
+ 'sec-websocket-extensions',
74
+ ])
75
+
76
+ function extractHostname(req: Request): string {
77
+ const hostHeader = req.headers.get('host') || ''
78
+ // Strip port (`stacks.localhost:443` → `stacks.localhost`).
79
+ return hostHeader.split(':')[0]
80
+ }
81
+
82
+ /**
83
+ * Resolve the upstream target (`host` + `path`) for a request against a route,
84
+ * applying any matching path rewrite.
85
+ */
86
+ function resolveTarget(req: Request, route: ProxyRoute, verbose?: boolean): { targetHost: string, targetPath: string, search: string } {
87
+ const url = new URL(req.url)
88
+ let targetHost = route.sourceHost ?? ''
89
+ let targetPath = url.pathname
90
+
91
+ const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
92
+ if (rewriteMatch) {
93
+ targetHost = rewriteMatch.targetHost
94
+ targetPath = rewriteMatch.targetPath
95
+ debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
96
+ }
97
+
98
+ return { targetHost, targetPath, search: url.search }
99
+ }
29
100
 
30
101
  /**
31
102
  * Build a Bun.serve-compatible `fetch` handler that routes requests based on
32
103
  * the `Host` header. Returns 404 when no route matches and 502 on upstream
33
- * failures.
104
+ * failures. When a request is a WebSocket upgrade and `server` is supplied, it
105
+ * is upgraded (returns `undefined` so Bun completes the handshake) and the
106
+ * traffic is handled by the `websocket` handler from {@link createProxyWebSocketHandler}.
34
107
  */
35
108
  export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean): ProxyFetchHandler {
36
- return async (req: Request): Promise<Response> => {
109
+ return async (req: Request, server?: ProxyServer): Promise<Response | undefined> => {
37
110
  const url = new URL(req.url)
38
- const hostHeader = req.headers.get('host') || ''
39
- // Strip port (`stacks.localhost:443` → `stacks.localhost`).
40
- const hostname = hostHeader.split(':')[0]
111
+ const hostname = extractHostname(req)
41
112
 
42
113
  const route = getRoute(hostname)
43
114
  if (!route) {
@@ -45,19 +116,42 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
45
116
  return new Response(`No proxy configured for ${hostname}`, { status: 404 })
46
117
  }
47
118
 
48
- let targetHost = route.sourceHost
49
- let targetPath = url.pathname
119
+ // Static file serving short-circuits everything else.
120
+ if (route.static)
121
+ return serveStaticFile(url.pathname, route.static)
122
+
123
+ // WebSocket upgrade: hand the socket to Bun and dial the upstream on open.
124
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
125
+ if (!server || !route.sourceHost)
126
+ return new Response('WebSocket upgrade not supported here', { status: 400 })
127
+
128
+ const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
129
+ const targetUrl = `ws://${targetHost}${targetPath}${search}`
50
130
 
51
- // Per-route path rewrites: prefix preserved by default, matching Vite /
52
- // nginx / http-proxy-middleware semantics. See `resolvePathRewrite`.
53
- const rewriteMatch = resolvePathRewrite(url.pathname, route.pathRewrites)
54
- if (rewriteMatch) {
55
- targetHost = rewriteMatch.targetHost
56
- targetPath = rewriteMatch.targetPath
57
- debugLog('request', `Path rewrite: ${url.pathname} → ${targetHost}${targetPath}`, verbose)
131
+ const forwardHeaders: Record<string, string> = {}
132
+ for (const [k, v] of req.headers) {
133
+ if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'host')
134
+ forwardHeaders[k] = v
135
+ }
136
+ forwardHeaders.host = targetHost
137
+ forwardHeaders['x-forwarded-for'] = '127.0.0.1'
138
+ forwardHeaders['x-forwarded-proto'] = 'https'
139
+ forwardHeaders['x-forwarded-host'] = hostname
140
+
141
+ const data: WsData = { targetUrl, forwardHeaders }
142
+ const ok = server.upgrade(req, { data })
143
+ if (ok) {
144
+ debugLog('ws', `upgraded ${hostname}${targetPath} → ${targetUrl}`, verbose)
145
+ return undefined
146
+ }
147
+ return new Response('WebSocket upgrade failed', { status: 400 })
58
148
  }
59
149
 
60
- const targetUrl = `http://${targetHost}${targetPath}${url.search}`
150
+ if (!route.sourceHost)
151
+ return new Response(`No upstream configured for ${hostname}`, { status: 502 })
152
+
153
+ const { targetHost, targetPath, search } = resolveTarget(req, route, verbose)
154
+ const targetUrl = `http://${targetHost}${targetPath}${search}`
61
155
 
62
156
  try {
63
157
  const headers = new Headers(req.headers)
@@ -97,3 +191,72 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean):
97
191
  }
98
192
  }
99
193
  }
194
+
195
+ /**
196
+ * Build the `websocket` handler block for Bun.serve. It opens an upstream
197
+ * `WebSocket` per client socket, buffers client→upstream frames until the
198
+ * upstream connection is open, and pipes messages, closes and errors in both
199
+ * directions with a clean teardown.
200
+ */
201
+ export function createProxyWebSocketHandler(verbose?: boolean) {
202
+ const state = new WeakMap<ServerWebSocket<WsData>, WsState>()
203
+
204
+ return {
205
+ open(ws: ServerWebSocket<WsData>): void {
206
+ const { targetUrl, forwardHeaders } = ws.data
207
+ let upstream: WebSocket
208
+ try {
209
+ // Bun's WebSocket accepts a `headers` option (control-channel auth etc.).
210
+ upstream = new WebSocket(targetUrl, { headers: forwardHeaders } as any)
211
+ }
212
+ catch (err) {
213
+ debugLog('ws', `failed to open upstream ${targetUrl}: ${err}`, verbose)
214
+ ws.close(1011, 'upstream connect failed')
215
+ return
216
+ }
217
+ upstream.binaryType = 'arraybuffer'
218
+ const st: WsState = { upstream, upstreamOpen: false, pending: [] }
219
+ state.set(ws, st)
220
+
221
+ upstream.addEventListener('open', () => {
222
+ st.upstreamOpen = true
223
+ for (const frame of st.pending)
224
+ upstream.send(frame as any)
225
+ st.pending = []
226
+ })
227
+ upstream.addEventListener('message', (ev: MessageEvent) => {
228
+ // Forward both binary (ArrayBuffer) and text frames to the client.
229
+ ws.send(ev.data as any)
230
+ })
231
+ upstream.addEventListener('close', (ev: CloseEvent) => {
232
+ try { ws.close(ev.code || 1000, ev.reason || '') }
233
+ catch { /* already closing */ }
234
+ })
235
+ upstream.addEventListener('error', () => {
236
+ debugLog('ws', `upstream error for ${targetUrl}`, verbose)
237
+ try { ws.close(1011, 'upstream error') }
238
+ catch { /* already closing */ }
239
+ })
240
+ },
241
+
242
+ message(ws: ServerWebSocket<WsData>, message: string | Buffer): void {
243
+ const st = state.get(ws)
244
+ if (!st)
245
+ return
246
+ const frame = typeof message === 'string' ? message : new Uint8Array(message)
247
+ if (st.upstreamOpen)
248
+ st.upstream.send(frame as any)
249
+ else
250
+ st.pending.push(frame)
251
+ },
252
+
253
+ close(ws: ServerWebSocket<WsData>, code: number, reason: string): void {
254
+ const st = state.get(ws)
255
+ if (!st)
256
+ return
257
+ state.delete(ws)
258
+ try { st.upstream.close(code || 1000, reason || '') }
259
+ catch { /* already closed */ }
260
+ },
261
+ }
262
+ }
package/src/registry.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * - `id` is validated against a strict charset to keep it from escaping
15
15
  * the registry directory.
16
16
  */
17
- import type { PathRewrite } from './types'
17
+ import type { PathRewrite, StaticRouteConfig } from './types'
18
18
  import * as fs from 'node:fs'
19
19
  import * as fsp from 'node:fs/promises'
20
20
  import { homedir } from 'node:os'
@@ -24,7 +24,8 @@ import { debugLog } from './utils'
24
24
 
25
25
  export interface RegistryEntry {
26
26
  id: string
27
- from: string
27
+ /** Upstream `host:port`. Optional when `static` is set. */
28
+ from?: string
28
29
  to: string
29
30
  /**
30
31
  * Optional. PID of the long-running process that owns this entry. When set,
@@ -38,6 +39,8 @@ export interface RegistryEntry {
38
39
  pathRewrites?: PathRewrite[]
39
40
  cleanUrls?: boolean
40
41
  changeOrigin?: boolean
42
+ /** Serve a local directory for this route instead of proxying. */
43
+ static?: string | StaticRouteConfig
41
44
  }
42
45
 
43
46
  const ID_PATTERN = /^[a-zA-Z0-9._-]+$/
@@ -89,9 +92,13 @@ function isValidEntry(value: unknown): value is RegistryEntry {
89
92
  // (manual entries from `rpx register`) the daemon's PID-GC skips it.
90
93
  const pidOk = e.pid === undefined
91
94
  || (typeof e.pid === 'number' && Number.isInteger(e.pid) && e.pid > 0)
95
+ // A route forwards to an upstream (`from`) OR serves files (`static`).
96
+ const hasFrom = typeof e.from === 'string' && e.from.length > 0
97
+ const hasStatic = typeof e.static === 'string'
98
+ || (!!e.static && typeof e.static === 'object' && typeof (e.static as StaticRouteConfig).dir === 'string')
92
99
  return (
93
100
  typeof e.id === 'string' && isValidId(e.id)
94
- && typeof e.from === 'string' && e.from.length > 0
101
+ && (hasFrom || hasStatic)
95
102
  && typeof e.to === 'string' && e.to.length > 0
96
103
  && pidOk
97
104
  && typeof e.createdAt === 'string'
@@ -272,6 +279,7 @@ export function watchRegistry(
272
279
  pathRewrites: entry.pathRewrites,
273
280
  cleanUrls: entry.cleanUrls,
274
281
  changeOrigin: entry.changeOrigin,
282
+ static: entry.static,
275
283
  }))
276
284
  .sort((a, b) => a.id.localeCompare(b.id)),
277
285
  )
package/src/sni.ts ADDED
@@ -0,0 +1,93 @@
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
+ }