@bakery-framework/core 2.3.0 → 2.4.1
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/package.json +1 -1
- package/src/core/config.ts +168 -44
- package/src/core/host-mounts.ts +179 -0
- package/src/global.d.ts +35 -1
- package/src/router.ts +126 -5
- package/src/utils/http/dom.ts +7 -2
package/package.json
CHANGED
package/src/core/config.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
import { fs } from '../utils/fs'
|
|
12
12
|
import { checkCacheVersion } from './cache-version'
|
|
13
13
|
import { hostStore } from './context'
|
|
14
|
+
import { normalizeMountPath } from './host-mounts'
|
|
15
|
+
|
|
16
|
+
// Re-exported for the CLI's boot checks, which reach config through this
|
|
17
|
+
// subpath; the module itself imports nothing, so it can sit under the router.
|
|
18
|
+
export { checkHostMounts } from './host-mounts'
|
|
14
19
|
|
|
15
20
|
export const NOOP = () => {}
|
|
16
21
|
|
|
@@ -62,7 +67,9 @@ const defaultConfig: Required<AppConfig> = {
|
|
|
62
67
|
// and says so rather than ending nothing.
|
|
63
68
|
sessions: {},
|
|
64
69
|
root: 'src',
|
|
70
|
+
domains: [],
|
|
65
71
|
hosts: {},
|
|
72
|
+
hostMounts: [],
|
|
66
73
|
websocket: {
|
|
67
74
|
message: NOOP,
|
|
68
75
|
open: NOOP,
|
|
@@ -103,6 +110,7 @@ export function clearHostConfigCache(): void {
|
|
|
103
110
|
cachedConfig = null
|
|
104
111
|
hostConfigCache.clear()
|
|
105
112
|
hostLookup = null
|
|
113
|
+
staticDomains = null
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
export async function initConfig(): Promise<Readonly<ProcessedAppConfig>> {
|
|
@@ -214,6 +222,9 @@ function mergeHostConfig(
|
|
|
214
222
|
const merged: any = { ...base }
|
|
215
223
|
|
|
216
224
|
if (entry.root) merged.root = fs.resolve(entry.root)
|
|
225
|
+
if (entry.mount !== undefined) {
|
|
226
|
+
merged.mount = normalizeMountPath(entry.mount, 'mount')
|
|
227
|
+
}
|
|
217
228
|
if (entry.importMap)
|
|
218
229
|
merged.importMap = { ...base.importMap, ...entry.importMap }
|
|
219
230
|
if (entry.middleware) merged.middleware = entry.middleware
|
|
@@ -235,7 +246,7 @@ function mergeHostConfig(
|
|
|
235
246
|
}
|
|
236
247
|
|
|
237
248
|
/**
|
|
238
|
-
* The configured host
|
|
249
|
+
* The configured host entries by lowercased key.
|
|
239
250
|
*
|
|
240
251
|
* A Map built from `Object.entries` rather than a lookup on `hosts` itself:
|
|
241
252
|
* own enumerable keys only, so `Host: constructor` cannot reach a prototype
|
|
@@ -244,10 +255,7 @@ function mergeHostConfig(
|
|
|
244
255
|
*
|
|
245
256
|
* If two keys differ only in case, the first one declared wins.
|
|
246
257
|
*/
|
|
247
|
-
function
|
|
248
|
-
hosts: Record<string, HostEntry>,
|
|
249
|
-
hostname: string,
|
|
250
|
-
): HostEntry | undefined {
|
|
258
|
+
function hostEntries(hosts: Record<string, HostEntry>): Map<string, HostEntry> {
|
|
251
259
|
if (!hostLookup || hostLookup.source !== hosts) {
|
|
252
260
|
const entries = new Map<string, HostEntry>()
|
|
253
261
|
for (const [key, entry] of Object.entries(hosts)) {
|
|
@@ -256,64 +264,180 @@ function lookupHostEntry(
|
|
|
256
264
|
}
|
|
257
265
|
hostLookup = { source: hosts, entries }
|
|
258
266
|
}
|
|
267
|
+
return hostLookup.entries
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** One label of a domain. */
|
|
271
|
+
const DOMAIN_LABEL = /^[a-z0-9_-]+$/
|
|
272
|
+
|
|
273
|
+
/** An IPv4 address, or what `getHostname` leaves of a bracketed IPv6 one. */
|
|
274
|
+
function isIpLiteral(name: string): boolean {
|
|
275
|
+
return name.startsWith('[') || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(name)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* `value` as a domain, lowercased and without a trailing dot, or null when it
|
|
280
|
+
* is not one. An IP address is not: it has no subdomains to name.
|
|
281
|
+
*/
|
|
282
|
+
function domainName(value: unknown): string | null {
|
|
283
|
+
if (typeof value !== 'string') return null
|
|
284
|
+
const domain = value.trim().toLowerCase().replace(/\.$/, '')
|
|
285
|
+
if (!domain || isIpLiteral(domain)) return null
|
|
286
|
+
return domain.split('.').every(label => DOMAIN_LABEL.test(label))
|
|
287
|
+
? domain
|
|
288
|
+
: null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A static `domains` value, normalized once per value. */
|
|
292
|
+
let staticDomains: { source: unknown; list: readonly string[] } | null = null
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The domains `'@'`, `'*'` and bare-label keys are read against: `localhost`
|
|
296
|
+
* and whatever `domains` says, longest first, so a nested one (`bpc.edu.ph`
|
|
297
|
+
* beside `edu.ph`) is tried before the one it sits in. A function is asked
|
|
298
|
+
* each time. An answer that is not a domain is left out, which `checkHosts`
|
|
299
|
+
* refuses at boot for a static one and cannot for a function's.
|
|
300
|
+
*/
|
|
301
|
+
function appDomains(config: Readonly<ProcessedAppConfig>): readonly string[] {
|
|
302
|
+
const { domains } = config
|
|
303
|
+
const fixed = typeof domains !== 'function'
|
|
304
|
+
if (fixed && staticDomains?.source === domains) return staticDomains.list
|
|
305
|
+
|
|
306
|
+
const value = fixed ? domains : domains()
|
|
307
|
+
const given = Array.isArray(value) ? value : value ? [value] : []
|
|
308
|
+
const named = given.map(domainName).filter(d => d !== null)
|
|
309
|
+
const list = [...new Set(['localhost', ...named])].sort(
|
|
310
|
+
(a, b) => b.length - a.length,
|
|
311
|
+
)
|
|
312
|
+
if (fixed) staticDomains = { source: domains, list }
|
|
313
|
+
return list
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** What a hostname matched under `hosts`. */
|
|
317
|
+
type HostMatch = {
|
|
318
|
+
/** The key that matched, lowercased: what per-entry tables are keyed by. */
|
|
319
|
+
key: string
|
|
320
|
+
/**
|
|
321
|
+
* The tenant: what `hostKey()` prefixes (sessions, file caches) and what a
|
|
322
|
+
* resolved config is cached by. The hostname itself for an exact key, a
|
|
323
|
+
* bare label or `'@'`, which a finite set of keys and domains bounds. For
|
|
324
|
+
* `'*'` it is `*.` and the domain, so made-up subdomains share one tenant
|
|
325
|
+
* instead of each minting a cache bucket and a session namespace.
|
|
326
|
+
*/
|
|
327
|
+
tenant: string
|
|
328
|
+
entry: HostEntry
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The `hosts` entry `hostname` is served by, or null for the base config.
|
|
333
|
+
*
|
|
334
|
+
* An exact key first, so every name configured before the other forms
|
|
335
|
+
* existed still matches as it did. Then, under the longest domain the
|
|
336
|
+
* hostname sits in: the domain itself is `'@'`, one label in front of it is
|
|
337
|
+
* that label's key (`admin` for `admin.bpc.edu.ph`), and anything else in
|
|
338
|
+
* front of it is `'*'`. A bare label names one subdomain, not its children:
|
|
339
|
+
* `admin.x.bpc.edu.ph` is `'*'`'s. A hostname under none of the domains, an
|
|
340
|
+
* IP address included, matches only an exact key.
|
|
341
|
+
*/
|
|
342
|
+
function matchHost(hostname: string): HostMatch | null {
|
|
343
|
+
if (!hostname || !cachedConfig) return null
|
|
344
|
+
const config = getConfig()
|
|
345
|
+
const entries = hostEntries(config.hosts ?? {})
|
|
346
|
+
if (!entries.size) return null
|
|
347
|
+
const name = hostname.toLowerCase().replace(/\.$/, '')
|
|
348
|
+
|
|
349
|
+
const exact = entries.get(name)
|
|
350
|
+
if (exact) return { key: name, tenant: name, entry: exact }
|
|
351
|
+
if (isIpLiteral(name)) return null
|
|
352
|
+
|
|
353
|
+
for (const domain of appDomains(config)) {
|
|
354
|
+
if (name === domain) {
|
|
355
|
+
const apex = entries.get('@')
|
|
356
|
+
return apex ? { key: '@', tenant: domain, entry: apex } : null
|
|
357
|
+
}
|
|
358
|
+
if (!name.endsWith(`.${domain}`)) continue
|
|
359
|
+
|
|
360
|
+
const sub = name.slice(0, -domain.length - 1)
|
|
361
|
+
if (!sub.includes('.') && sub !== '@' && sub !== '*') {
|
|
362
|
+
const named = entries.get(sub)
|
|
363
|
+
if (named) return { key: sub, tenant: name, entry: named }
|
|
364
|
+
}
|
|
365
|
+
const rest = entries.get('*')
|
|
366
|
+
return rest ? { key: '*', tenant: `*.${domain}`, entry: rest } : null
|
|
367
|
+
}
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
259
370
|
|
|
260
|
-
|
|
371
|
+
/** The `hosts` key `hostname` matched, lowercased, or `''` for none. */
|
|
372
|
+
export function hostEntryKey(hostname: string): string {
|
|
373
|
+
return matchHost(hostname)?.key ?? ''
|
|
261
374
|
}
|
|
262
375
|
|
|
263
376
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
377
|
+
* Check `domains` and every `hosts` entry's `mount` before anything binds, so
|
|
378
|
+
* a value that cannot mean anything stops the boot and names itself. A
|
|
379
|
+
* `domains` function is asked on each request instead, so its answers cannot
|
|
380
|
+
* be checked here.
|
|
381
|
+
*/
|
|
382
|
+
export function checkHosts(config: Readonly<ProcessedAppConfig>): void {
|
|
383
|
+
const { domains } = config
|
|
384
|
+
if (typeof domains !== 'function') {
|
|
385
|
+
const listed = Array.isArray(domains)
|
|
386
|
+
const values = listed ? domains : domains ? [domains] : []
|
|
387
|
+
values.forEach((value, i) => {
|
|
388
|
+
if (domainName(value) === null) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`domains${listed ? `[${i}]` : ''} ${JSON.stringify(value)} is not a domain: labels of letters, digits, '-' and '_' joined by dots, and not an IP address`,
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
for (const [key, entry] of Object.entries(config.hosts ?? {})) {
|
|
396
|
+
if (entry?.mount !== undefined) {
|
|
397
|
+
normalizeMountPath(entry.mount, `hosts['${key}'].mount`)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The tenant `hostname` belongs to (see `HostMatch.tenant`), and `''` when it
|
|
404
|
+
* matches no `hosts` entry, including when no `hosts` are configured at all.
|
|
266
405
|
*
|
|
267
|
-
* Same reasoning as `resolveHostConfig` below, which
|
|
268
|
-
*
|
|
269
|
-
* derived from it that is *kept* (a config entry, a cache key, a file on disk
|
|
270
|
-
*
|
|
406
|
+
* Same reasoning as `resolveHostConfig` below, which refuses to cache an
|
|
407
|
+
* unknown hostname: the value arrives on the `Host` header, so anything
|
|
408
|
+
* derived from it that is *kept* (a config entry, a cache key, a file on disk)
|
|
409
|
+
* has to be bounded by the configured set, or an unauthenticated client can
|
|
271
410
|
* grow it without limit. Collapsing unknown hosts to `''` puts them all in one
|
|
272
411
|
* bucket, which is correct because they all get the base config and therefore
|
|
273
|
-
* the same content
|
|
412
|
+
* the same content; `'*'` collapses its subdomains to one tenant per domain
|
|
413
|
+
* for the same reason.
|
|
274
414
|
*
|
|
275
|
-
* Folded with `toLowerCase
|
|
276
|
-
*
|
|
415
|
+
* Folded with `toLowerCase`: hostnames are case-insensitive (RFC 4343), so
|
|
416
|
+
* `EXAMPLE.com` and `example.com` must be one key, not two. `toLowerCase`,
|
|
417
|
+
* not `toLocaleLowerCase`, which maps `I` to a dotless `ı` in a Turkish
|
|
418
|
+
* locale.
|
|
277
419
|
*/
|
|
278
420
|
export function resolveHostname(hostname: string): string {
|
|
279
421
|
// No config yet (a unit test that never called `initConfig`) means no `hosts`
|
|
280
422
|
// to match against, which is the same answer as an unconfigured host.
|
|
281
|
-
|
|
282
|
-
const hosts = getConfig().hosts
|
|
283
|
-
if (!hosts) return ''
|
|
284
|
-
const key = hostname.toLowerCase()
|
|
285
|
-
return lookupHostEntry(hosts, key) ? key : ''
|
|
423
|
+
return matchHost(hostname)?.tenant ?? ''
|
|
286
424
|
}
|
|
287
425
|
|
|
288
426
|
export function resolveHostConfig(
|
|
289
427
|
hostname: string,
|
|
290
428
|
): Readonly<ProcessedAppConfig> {
|
|
291
429
|
const base = getConfig()
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const key = hostname.toLowerCase()
|
|
301
|
-
|
|
302
|
-
const cached = hostConfigCache.get(key)
|
|
430
|
+
const match = matchHost(hostname)
|
|
431
|
+
// An unknown hostname is deliberately not cached: it comes straight from
|
|
432
|
+
// the Host header, so caching misses let any client grow this map one
|
|
433
|
+
// made-up name at a time. A match is cached by its tenant, which the
|
|
434
|
+
// configured keys and domains bound.
|
|
435
|
+
if (!match) return base
|
|
436
|
+
|
|
437
|
+
const cached = hostConfigCache.get(match.tenant)
|
|
303
438
|
if (cached) return cached
|
|
304
439
|
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
// Deliberately NOT cached. The hostname comes straight from the Host
|
|
308
|
-
// header, so caching unknown values let any client grow this map without
|
|
309
|
-
// bound: one request per made-up hostname until the process runs out of
|
|
310
|
-
// memory. Known hosts are a fixed, small set, so only those are cached.
|
|
311
|
-
// Normalizing above keeps that bound honest: without it, `EXAMPLE.com` and
|
|
312
|
-
// `example.com` would occupy two entries for one configured host.
|
|
313
|
-
return base
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const merged = mergeHostConfig(base as ProcessedAppConfig, entry)
|
|
317
|
-
hostConfigCache.set(key, merged)
|
|
440
|
+
const merged = mergeHostConfig(base as ProcessedAppConfig, match.entry)
|
|
441
|
+
hostConfigCache.set(match.tenant, merged)
|
|
318
442
|
return merged
|
|
319
443
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host mounts: a host that serves one path subtree of the app at its root.
|
|
3
|
+
*
|
|
4
|
+
* `hostMounts: [{ host: 'kit.*', path: '/kit' }]` makes `kit.example.com/forms`
|
|
5
|
+
* the page `/kit/forms` is on every other host. The subtree stays part of the
|
|
6
|
+
* app: its pages load the app's modules, stylesheets and API from the root,
|
|
7
|
+
* the way they do on any host, so only page requests move into it. Which
|
|
8
|
+
* requests those are is `routeOf` in `router.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Patterns rather than `hosts` keys, because a mount's hostname is often not
|
|
11
|
+
* known at boot: an app whose domain is a setting can still name "the `kit`
|
|
12
|
+
* label of whatever domain this is". A mount is also not a tenant. It changes
|
|
13
|
+
* which page a path names and nothing else, so sessions, caches and the
|
|
14
|
+
* `hosts` entries do not see it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type HostMount = {
|
|
18
|
+
/**
|
|
19
|
+
* The hostnames the mount serves: a name (`kit.example.com`), a pattern in
|
|
20
|
+
* which `*` stands for one or more whole labels (`kit.*`,
|
|
21
|
+
* `*.docs.example.com`), or a function of the hostname, which it receives
|
|
22
|
+
* lowercased and without the port.
|
|
23
|
+
*/
|
|
24
|
+
host: string | ((hostname: string) => boolean)
|
|
25
|
+
/** The subtree served at the host's root, such as `/kit`. */
|
|
26
|
+
path: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CompiledHostMount = {
|
|
30
|
+
/** The subtree: a leading slash and no trailing one. */
|
|
31
|
+
readonly path: string
|
|
32
|
+
/** Whether a lowercased hostname without its port is served by the mount. */
|
|
33
|
+
matches(hostname: string): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One label of a hostname pattern, `*` aside. */
|
|
37
|
+
const LABEL = /^[a-z0-9_-]+$/
|
|
38
|
+
|
|
39
|
+
/** `*` as a pattern label: one or more whole labels. */
|
|
40
|
+
const ANY_LABELS = '[^.]+(?:\\.[^.]+)*'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A hostname pattern as a regular expression. Throws naming `where` when
|
|
44
|
+
* `host` is not a name, or puts `*` inside a label: `k*t.example.com` would
|
|
45
|
+
* have to choose between matching part of a label and matching labels, and
|
|
46
|
+
* either reading surprises someone.
|
|
47
|
+
*/
|
|
48
|
+
export function hostPattern(host: string, where = 'host'): RegExp {
|
|
49
|
+
const labels = host.toLowerCase().split('.')
|
|
50
|
+
if (labels.some(label => label === '')) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${where} '${host}' is not a hostname: a name has no empty label`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const parts = labels.map(label => {
|
|
56
|
+
if (label === '*') return ANY_LABELS
|
|
57
|
+
if (!LABEL.test(label)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${where} '${host}' has the label '${label}': a label is letters, digits, '-' and '_', and '*' stands for whole labels only`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
return label
|
|
63
|
+
})
|
|
64
|
+
return new RegExp(`^${parts.join('\\.')}$`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What every host serves from the app's root, a mounted one included: the
|
|
69
|
+
* framework's and the plugins' routes, the API, and the site-wide metadata
|
|
70
|
+
* RFC 8615 reserves. The router keeps requests under these out of a mount,
|
|
71
|
+
* and no mount may name a path under one.
|
|
72
|
+
*/
|
|
73
|
+
export const ROOT_ONLY = ['/_', '/api/', '/.well-known/'] as const
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A mount's path checked and normalized: a leading slash, no trailing one.
|
|
77
|
+
* Throws naming `where` for a path that cannot mean anything. Shared by
|
|
78
|
+
* `hostMounts` and a `hosts` entry's `mount`.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeMountPath(value: unknown, where: string): string {
|
|
81
|
+
if (typeof value !== 'string' || !value.startsWith('/')) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${where} must be a path starting with '/': got ${JSON.stringify(value)}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
const path = value.replace(/\/+$/, '')
|
|
87
|
+
if (path === '') {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${where} '${value}' is the whole app, which every host already serves`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
const segments = path.slice(1).split('/')
|
|
93
|
+
if (segments.some(s => s === '' || s === '.' || s === '..')) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${where} '${value}' has an empty, '.' or '..' segment: a mount names one directory`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
if (/[?#*\\]/.test(path)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${where} '${value}' is a path, so it holds no '?', '#', '*' or '\\'`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
const reserved = ROOT_ONLY.find(prefix => `${path}/`.startsWith(prefix))
|
|
104
|
+
if (reserved) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${where} '${value}' is under ${reserved}, which every host serves as it is`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return path
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function compile(mount: HostMount, where: string): CompiledHostMount {
|
|
113
|
+
if (!mount || typeof mount !== 'object') {
|
|
114
|
+
throw new Error(`${where} must be { host, path }`)
|
|
115
|
+
}
|
|
116
|
+
const path = normalizeMountPath(mount.path, `${where}.path`)
|
|
117
|
+
const { host } = mount
|
|
118
|
+
if (typeof host === 'function') return { path, matches: host }
|
|
119
|
+
if (typeof host !== 'string') {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${where}.host must be a hostname, a pattern such as 'kit.*', or a function of the hostname`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
const pattern = hostPattern(host, `${where}.host`)
|
|
125
|
+
return { path, matches: hostname => pattern.test(hostname) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compiled once per array: the processed config is frozen for the life of
|
|
130
|
+
* the process, and a test that swaps it in brings a new array, so identity is
|
|
131
|
+
* the whole validity check.
|
|
132
|
+
*/
|
|
133
|
+
const compiled = new WeakMap<
|
|
134
|
+
readonly HostMount[],
|
|
135
|
+
readonly CompiledHostMount[]
|
|
136
|
+
>()
|
|
137
|
+
|
|
138
|
+
/** `hostMounts` compiled, throwing at the first entry that cannot mean anything. */
|
|
139
|
+
export function compileHostMounts(
|
|
140
|
+
mounts: readonly HostMount[],
|
|
141
|
+
): readonly CompiledHostMount[] {
|
|
142
|
+
const cached = compiled.get(mounts)
|
|
143
|
+
if (cached) return cached
|
|
144
|
+
if (!Array.isArray(mounts)) {
|
|
145
|
+
throw new Error('hostMounts must be an array of { host, path }')
|
|
146
|
+
}
|
|
147
|
+
const result = mounts.map((mount, i) => compile(mount, `hostMounts[${i}]`))
|
|
148
|
+
compiled.set(mounts, result)
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compile the app's `hostMounts` before anything binds, so an entry that
|
|
154
|
+
* cannot mean anything stops the boot and names itself, rather than throwing
|
|
155
|
+
* on the first request that reaches it.
|
|
156
|
+
*/
|
|
157
|
+
export function checkHostMounts(config: {
|
|
158
|
+
hostMounts: readonly HostMount[]
|
|
159
|
+
}): void {
|
|
160
|
+
compileHostMounts(config.hostMounts)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The first mount serving `hostname`, or null. */
|
|
164
|
+
export function findHostMount(
|
|
165
|
+
mounts: readonly CompiledHostMount[],
|
|
166
|
+
hostname: string,
|
|
167
|
+
): CompiledHostMount | null {
|
|
168
|
+
if (!mounts.length) return null
|
|
169
|
+
const host = hostname.toLowerCase()
|
|
170
|
+
for (const mount of mounts) {
|
|
171
|
+
if (mount.matches(host)) return mount
|
|
172
|
+
}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `path` on a mounted host, as the app names it: `/` is the mount itself. */
|
|
177
|
+
export function mountedPath(path: string, mount: string): string {
|
|
178
|
+
return path === '/' ? mount : `${mount}${path}`
|
|
179
|
+
}
|
package/src/global.d.ts
CHANGED
|
@@ -55,6 +55,13 @@ declare global {
|
|
|
55
55
|
|
|
56
56
|
type HostEntry = {
|
|
57
57
|
root?: string
|
|
58
|
+
/**
|
|
59
|
+
* A subtree of the app this host serves at its root: with
|
|
60
|
+
* `mount: '/kit'`, `/forms` on this host is the page `/kit/forms`. Only
|
|
61
|
+
* page requests move into it; what those pages load comes from the
|
|
62
|
+
* app's root. See `routeOf` in `router.ts`.
|
|
63
|
+
*/
|
|
64
|
+
mount?: string
|
|
58
65
|
importMap?: Record<string, string>
|
|
59
66
|
middleware?: ((
|
|
60
67
|
req: Request,
|
|
@@ -167,7 +174,28 @@ declare global {
|
|
|
167
174
|
*/
|
|
168
175
|
sessions?: import('./session').SessionOptions
|
|
169
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The app's domains, which three kinds of `hosts` key are read against:
|
|
179
|
+
* `'@'` is a domain itself, a bare label such as `'admin'` is
|
|
180
|
+
* `admin.<domain>`, and `'*'` is any other subdomain. `localhost` always
|
|
181
|
+
* counts, so the same keys work on `admin.localhost`. A function is asked
|
|
182
|
+
* on each request, for an app whose domain is a setting that can change
|
|
183
|
+
* while it runs, and has to answer without awaiting.
|
|
184
|
+
*/
|
|
185
|
+
domains?: string | string[] | (() => string | string[])
|
|
186
|
+
|
|
170
187
|
hosts?: Record<string, HostEntry>
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Hosts that serve one path subtree of the app at their root:
|
|
191
|
+
* `{ host: 'kit.*', path: '/kit' }` makes `kit.example.com/forms` the
|
|
192
|
+
* page `/kit/forms`. Only page requests move into the subtree (a
|
|
193
|
+
* navigation, or a method other than GET and HEAD); the modules,
|
|
194
|
+
* stylesheets, files and API those pages load come from the app's root,
|
|
195
|
+
* as on any host. The first entry whose `host` matches wins. See
|
|
196
|
+
* `core/host-mounts.ts` and `routeOf` in `router.ts`.
|
|
197
|
+
*/
|
|
198
|
+
hostMounts?: import('./core/host-mounts').HostMount[]
|
|
171
199
|
}
|
|
172
200
|
|
|
173
201
|
type ServerPlugin = _plugins.ServerPlugin
|
|
@@ -411,7 +439,13 @@ declare global {
|
|
|
411
439
|
{
|
|
412
440
|
blocked: Bun.Glob
|
|
413
441
|
}
|
|
414
|
-
>
|
|
442
|
+
> & {
|
|
443
|
+
/**
|
|
444
|
+
* The subtree this host serves at its root, from its `hosts` entry's
|
|
445
|
+
* `mount`, normalized. Absent on the base config and on a host without one.
|
|
446
|
+
*/
|
|
447
|
+
mount?: string
|
|
448
|
+
}
|
|
415
449
|
|
|
416
450
|
type ServerWebSocket<T = MapOf<any>> = Override<
|
|
417
451
|
Bun.ServerWebSocket,
|
package/src/router.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { Bakery, hostStore } from './core/bakery'
|
|
1
|
+
import { Bakery, getHostname, hostStore } from './core/bakery'
|
|
2
2
|
import { matchBlockedCached } from './core/context'
|
|
3
|
+
import {
|
|
4
|
+
compileHostMounts,
|
|
5
|
+
findHostMount,
|
|
6
|
+
mountedPath,
|
|
7
|
+
ROOT_ONLY,
|
|
8
|
+
} from './core/host-mounts'
|
|
3
9
|
import { PluginHooks } from './core/plugins'
|
|
4
10
|
import { DefaultErrorHandler } from './handlers/assets/static'
|
|
5
11
|
import type { Handler } from './handlers/core/$base'
|
|
6
12
|
import { ErrorHandler } from './handlers/core/$error'
|
|
13
|
+
import { resolveMount } from './handlers/core/$mounts'
|
|
7
14
|
import { WebSocketHandler } from './handlers/core/$websocket'
|
|
8
15
|
import { errorMsg, getElapsed, log, serveLog } from './logger'
|
|
9
16
|
import { Session } from './session'
|
|
@@ -82,14 +89,108 @@ function servesFiles(handler: any): boolean {
|
|
|
82
89
|
return handler?.servesFiles !== false
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
/**
|
|
93
|
+
* How routing reads one request: the path it resolves, the host mount it
|
|
94
|
+
* moved into, and whether this URL's answer depends on the kind of request,
|
|
95
|
+
* which is what `processResponse` sends a `Vary` for.
|
|
96
|
+
*/
|
|
97
|
+
type Routing = { path: string; mount: string | null; varies: boolean }
|
|
98
|
+
|
|
99
|
+
export type RequestRoute = Readonly<Pick<Routing, 'path' | 'mount'>>
|
|
100
|
+
|
|
101
|
+
/** Weak, as `parsedUrl`'s memo is: an entry lives as long as its request. */
|
|
102
|
+
const routings = new WeakMap<Request, Routing>()
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether `path` could be a page of a mounted subtree: nothing in `ROOT_ONLY`,
|
|
106
|
+
* no file named, and no plugin's route mount or proxy entry claiming it.
|
|
107
|
+
*
|
|
108
|
+
* "No file named" is a last segment with no extension. A page links its
|
|
109
|
+
* stylesheets, images and downloads by their root paths, and a navigation to
|
|
110
|
+
* one of them (a PDF opened in a tab) has to reach the file.
|
|
111
|
+
*/
|
|
112
|
+
function pagePathOnMount(
|
|
113
|
+
path: string,
|
|
114
|
+
config: Readonly<ProcessedAppConfig>,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (ROOT_ONLY.some(prefix => path.startsWith(prefix))) return false
|
|
117
|
+
if (path.slice(path.lastIndexOf('/') + 1).includes('.')) return false
|
|
118
|
+
if (resolveMount(path)) return false
|
|
119
|
+
for (const prefix in config.proxy) {
|
|
120
|
+
if (path.startsWith(prefix)) return false
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether `req` asks for a page rather than for something a page loads.
|
|
127
|
+
*
|
|
128
|
+
* A navigation (a tab or a frame loading the URL) is one, and so is any
|
|
129
|
+
* method but GET and HEAD: a form post, or a server action, which a Vue page
|
|
130
|
+
* posts to its own URL. A module import, a stylesheet, an image and a `fetch`
|
|
131
|
+
* are not, and that is the line that has to hold: an import of a TypeScript
|
|
132
|
+
* module is written `/kit/useDemo`, with no extension to tell it from a page,
|
|
133
|
+
* and moved into the subtree it would reach a catch-all page instead.
|
|
134
|
+
*
|
|
135
|
+
* A browser says which it is in `Sec-Fetch-Mode`. A client that sends no
|
|
136
|
+
* Fetch Metadata is navigating when it asks for HTML.
|
|
137
|
+
*/
|
|
138
|
+
function isPageRequest(req: Request): boolean {
|
|
139
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return true
|
|
140
|
+
if (req.headers.has('upgrade')) return false
|
|
141
|
+
const mode = req.headers.get('sec-fetch-mode')
|
|
142
|
+
if (mode) return mode === 'navigate'
|
|
143
|
+
return (req.headers.get('accept') ?? '').includes('text/html')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function routing(req: Request): Routing {
|
|
147
|
+
const cached = routings.get(req)
|
|
148
|
+
if (cached) return cached
|
|
149
|
+
|
|
150
|
+
// Shared with worker.ts, which has already asked for the same parse; a
|
|
151
|
+
// direct caller (test, embedder) gets it parsed here instead.
|
|
152
|
+
const path = parsedUrl(req).pathname
|
|
153
|
+
let result: Routing = { path, mount: null, varies: false }
|
|
154
|
+
|
|
155
|
+
// The host's `hosts` entry decides first (its `mount`, already resolved into
|
|
156
|
+
// the ambient config), then the first `hostMounts` pattern that matches. A
|
|
157
|
+
// partial config (a test's, an embedder's) may carry neither.
|
|
158
|
+
const config = Bakery.config
|
|
159
|
+
let mount = config.mount ?? null
|
|
160
|
+
if (!mount && config.hostMounts?.length) {
|
|
161
|
+
const patterns = compileHostMounts(config.hostMounts)
|
|
162
|
+
mount = findHostMount(patterns, getHostname(req, config))?.path ?? null
|
|
163
|
+
}
|
|
164
|
+
if (mount && pagePathOnMount(path, config)) {
|
|
165
|
+
result = isPageRequest(req)
|
|
166
|
+
? { path: mountedPath(path, mount), mount, varies: true }
|
|
167
|
+
: { path, mount: null, varies: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
routings.set(req, result)
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The path routing resolves for `req`, and the host mount it moved into.
|
|
176
|
+
*
|
|
177
|
+
* On a host with no `mount` (from its `hosts` entry, or a `hostMounts`
|
|
178
|
+
* pattern), that is the request's own path and no mount. On a mounted host, a
|
|
179
|
+
* page request (`isPageRequest`) for
|
|
180
|
+
* a path that can be a page (`pagePathOnMount`) resolves under the mount:
|
|
181
|
+
* `/forms` is `/kit/forms`, and `/` is `/kit`. Every other request keeps its
|
|
182
|
+
* path, so the modules, stylesheets and API a subtree's pages load still come
|
|
183
|
+
* from the app's root. Memoized per request.
|
|
184
|
+
*/
|
|
185
|
+
export function routeOf(req: Request): RequestRoute {
|
|
186
|
+
return routing(req)
|
|
187
|
+
}
|
|
188
|
+
|
|
85
189
|
export function handleRequest(
|
|
86
190
|
req: Request,
|
|
87
191
|
): Handler.Response | MixedPromise<symbol>
|
|
88
192
|
export async function handleRequest(req: Request) {
|
|
89
|
-
|
|
90
|
-
// direct caller (test, embedder) gets it parsed here instead.
|
|
91
|
-
const url = parsedUrl(req)
|
|
92
|
-
const path = url.pathname
|
|
193
|
+
const path = routing(req).path
|
|
93
194
|
|
|
94
195
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
95
196
|
// `hostStore.getStore()?.config ?? getConfig()` on every access, and the
|
|
@@ -312,6 +413,21 @@ export async function handleRequestError(
|
|
|
312
413
|
return DefaultErrorHandler.handle(path, req, error)
|
|
313
414
|
}
|
|
314
415
|
|
|
416
|
+
/** Add `tokens` to `res`'s `Vary`, keeping what it names and naming each once. */
|
|
417
|
+
function addVary(res: Response, tokens: readonly string[]): void {
|
|
418
|
+
const existing = res.headers.get('Vary')
|
|
419
|
+
const named = new Set(
|
|
420
|
+
(existing ?? '')
|
|
421
|
+
.split(',')
|
|
422
|
+
.map(token => token.trim().toLowerCase())
|
|
423
|
+
.filter(Boolean),
|
|
424
|
+
)
|
|
425
|
+
if (named.has('*')) return
|
|
426
|
+
const missing = tokens.filter(token => !named.has(token.toLowerCase()))
|
|
427
|
+
if (!missing.length) return
|
|
428
|
+
res.headers.set('Vary', [existing, ...missing].filter(Boolean).join(', '))
|
|
429
|
+
}
|
|
430
|
+
|
|
315
431
|
export async function processResponse(
|
|
316
432
|
data: Handler.Response | MixedPromise<symbol>,
|
|
317
433
|
req: Request,
|
|
@@ -373,6 +489,11 @@ export async function processResponse(
|
|
|
373
489
|
const cors = Bakery.config.cors
|
|
374
490
|
if (cors) applyCors(cors, req, resp)
|
|
375
491
|
|
|
492
|
+
// On a mounted host one URL can answer a navigation from the subtree and a
|
|
493
|
+
// module import from the root (see `routeOf`), so a cache keyed on the URL
|
|
494
|
+
// alone could hand either one to the other.
|
|
495
|
+
if (routing(req).varies) addVary(resp, ['Sec-Fetch-Mode', 'Accept'])
|
|
496
|
+
|
|
376
497
|
const final = ETag.sendResponse(req, resp)
|
|
377
498
|
if (!(final instanceof Response)) {
|
|
378
499
|
log({
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises'
|
|
2
2
|
import { LRUCache } from '../../cache/lru'
|
|
3
3
|
import { Bakery, hostStore } from '../../core/bakery'
|
|
4
|
+
import { hostEntryKey } from '../../core/config'
|
|
4
5
|
import type { MapOf } from '../../types'
|
|
5
6
|
import { is } from '../common/misc'
|
|
6
7
|
import { Try } from '../common/try'
|
|
@@ -64,7 +65,10 @@ export function initHostImportMaps() {
|
|
|
64
65
|
imports[key] = value
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
// By the key as matching folds it, and read back through `hostEntryKey`:
|
|
69
|
+
// a request's hostname is not the key under `'@'`, `'*'` or a bare
|
|
70
|
+
// label, and was not even for a name, whose case went unfolded.
|
|
71
|
+
hostDepMaps.set(hostname.toLowerCase(), JSON.stringify({ imports }))
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -154,7 +158,8 @@ export async function initImportMap() {
|
|
|
154
158
|
export namespace DOMTools {
|
|
155
159
|
export function importMap() {
|
|
156
160
|
const hostname = hostStore.getStore()?.hostname
|
|
157
|
-
const
|
|
161
|
+
const key = hostname && hostDepMaps.size ? hostEntryKey(hostname) : ''
|
|
162
|
+
const map = (key && hostDepMaps.get(key)) || depMap
|
|
158
163
|
return `<script type="importmap">${map}</script>`
|
|
159
164
|
}
|
|
160
165
|
|