@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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 entry for an already-lowercased hostname, or undefined.
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 lookupHostEntry(
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
- return hostLookup.entries.get(hostname)
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
- * The canonical form of `hostname` if the app declares it under `hosts`, and
265
- * `''` otherwise, including when no `hosts` are configured at all.
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 already refuses to cache
268
- * an unknown hostname: the value arrives on the `Host` header, so anything
269
- * derived from it that is *kept* (a config entry, a cache key, a file on disk
270
- *) has to be bounded by the configured set, or an unauthenticated client can
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` for the reason spelled out below: hostnames are
276
- * case-insensitive, so `EXAMPLE.com` and `example.com` must be one key, not two.
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
- if (!hostname || !cachedConfig) return ''
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 hosts = base.hosts
293
- if (!hosts || !Object.keys(hosts).length) return base
294
-
295
- // Hostnames are case-insensitive (RFC 4343) and nothing upstream normalizes
296
- // the Host header, so a request to `EXAMPLE.com` used to miss an
297
- // `example.com` entry and silently fall back to the base config. Both sides
298
- // fold to lower case: `toLowerCase`, not `toLocaleLowerCase`, which would
299
- // map `I` to a dotless `ı` in a Turkish locale.
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 entry = lookupHostEntry(hosts, key)
306
- if (!entry) {
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
- // Shared with worker.ts, which has already asked for the same parse; a
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({
@@ -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
- hostDepMaps.set(hostname, JSON.stringify({ imports }))
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 map = (hostname && hostDepMaps.get(hostname)) || depMap
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