@bakery-framework/core 2.3.0 → 2.4.0

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.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -12,6 +12,10 @@ import { fs } from '../utils/fs'
12
12
  import { checkCacheVersion } from './cache-version'
13
13
  import { hostStore } from './context'
14
14
 
15
+ // Re-exported for the CLI's boot checks, which reach config through this
16
+ // subpath; the module itself imports nothing, so it can sit under the router.
17
+ export { checkHostMounts } from './host-mounts'
18
+
15
19
  export const NOOP = () => {}
16
20
 
17
21
  const defaultConfig: Required<AppConfig> = {
@@ -63,6 +67,7 @@ const defaultConfig: Required<AppConfig> = {
63
67
  sessions: {},
64
68
  root: 'src',
65
69
  hosts: {},
70
+ hostMounts: [],
66
71
  websocket: {
67
72
  message: NOOP,
68
73
  open: NOOP,
@@ -0,0 +1,174 @@
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
+ function mountPath(value: unknown, where: string): string {
76
+ if (typeof value !== 'string' || !value.startsWith('/')) {
77
+ throw new Error(
78
+ `${where} must be a path starting with '/': got ${JSON.stringify(value)}`,
79
+ )
80
+ }
81
+ const path = value.replace(/\/+$/, '')
82
+ if (path === '') {
83
+ throw new Error(
84
+ `${where} '${value}' is the whole app, which every host already serves`,
85
+ )
86
+ }
87
+ const segments = path.slice(1).split('/')
88
+ if (segments.some(s => s === '' || s === '.' || s === '..')) {
89
+ throw new Error(
90
+ `${where} '${value}' has an empty, '.' or '..' segment: a mount names one directory`,
91
+ )
92
+ }
93
+ if (/[?#*\\]/.test(path)) {
94
+ throw new Error(
95
+ `${where} '${value}' is a path, so it holds no '?', '#', '*' or '\\'`,
96
+ )
97
+ }
98
+ const reserved = ROOT_ONLY.find(prefix => `${path}/`.startsWith(prefix))
99
+ if (reserved) {
100
+ throw new Error(
101
+ `${where} '${value}' is under ${reserved}, which every host serves as it is`,
102
+ )
103
+ }
104
+ return path
105
+ }
106
+
107
+ function compile(mount: HostMount, where: string): CompiledHostMount {
108
+ if (!mount || typeof mount !== 'object') {
109
+ throw new Error(`${where} must be { host, path }`)
110
+ }
111
+ const path = mountPath(mount.path, `${where}.path`)
112
+ const { host } = mount
113
+ if (typeof host === 'function') return { path, matches: host }
114
+ if (typeof host !== 'string') {
115
+ throw new Error(
116
+ `${where}.host must be a hostname, a pattern such as 'kit.*', or a function of the hostname`,
117
+ )
118
+ }
119
+ const pattern = hostPattern(host, `${where}.host`)
120
+ return { path, matches: hostname => pattern.test(hostname) }
121
+ }
122
+
123
+ /**
124
+ * Compiled once per array: the processed config is frozen for the life of
125
+ * the process, and a test that swaps it in brings a new array, so identity is
126
+ * the whole validity check.
127
+ */
128
+ const compiled = new WeakMap<
129
+ readonly HostMount[],
130
+ readonly CompiledHostMount[]
131
+ >()
132
+
133
+ /** `hostMounts` compiled, throwing at the first entry that cannot mean anything. */
134
+ export function compileHostMounts(
135
+ mounts: readonly HostMount[],
136
+ ): readonly CompiledHostMount[] {
137
+ const cached = compiled.get(mounts)
138
+ if (cached) return cached
139
+ if (!Array.isArray(mounts)) {
140
+ throw new Error('hostMounts must be an array of { host, path }')
141
+ }
142
+ const result = mounts.map((mount, i) => compile(mount, `hostMounts[${i}]`))
143
+ compiled.set(mounts, result)
144
+ return result
145
+ }
146
+
147
+ /**
148
+ * Compile the app's `hostMounts` before anything binds, so an entry that
149
+ * cannot mean anything stops the boot and names itself, rather than throwing
150
+ * on the first request that reaches it.
151
+ */
152
+ export function checkHostMounts(config: {
153
+ hostMounts: readonly HostMount[]
154
+ }): void {
155
+ compileHostMounts(config.hostMounts)
156
+ }
157
+
158
+ /** The first mount serving `hostname`, or null. */
159
+ export function findHostMount(
160
+ mounts: readonly CompiledHostMount[],
161
+ hostname: string,
162
+ ): CompiledHostMount | null {
163
+ if (!mounts.length) return null
164
+ const host = hostname.toLowerCase()
165
+ for (const mount of mounts) {
166
+ if (mount.matches(host)) return mount
167
+ }
168
+ return null
169
+ }
170
+
171
+ /** `path` on a mounted host, as the app names it: `/` is the mount itself. */
172
+ export function mountedPath(path: string, mount: string): string {
173
+ return path === '/' ? mount : `${mount}${path}`
174
+ }
package/src/global.d.ts CHANGED
@@ -168,6 +168,17 @@ declare global {
168
168
  sessions?: import('./session').SessionOptions
169
169
 
170
170
  hosts?: Record<string, HostEntry>
171
+
172
+ /**
173
+ * Hosts that serve one path subtree of the app at their root:
174
+ * `{ host: 'kit.*', path: '/kit' }` makes `kit.example.com/forms` the
175
+ * page `/kit/forms`. Only page requests move into the subtree (a
176
+ * navigation, or a method other than GET and HEAD); the modules,
177
+ * stylesheets, files and API those pages load come from the app's root,
178
+ * as on any host. The first entry whose `host` matches wins. See
179
+ * `core/host-mounts.ts` and `routeOf` in `router.ts`.
180
+ */
181
+ hostMounts?: import('./core/host-mounts').HostMount[]
171
182
  }
172
183
 
173
184
  type ServerPlugin = _plugins.ServerPlugin
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,104 @@ 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
+ // A partial config (a test's, an embedder's) may carry no `hostMounts`.
156
+ const config = Bakery.config
157
+ const mounts = config.hostMounts?.length
158
+ ? compileHostMounts(config.hostMounts)
159
+ : null
160
+ const mount = mounts && findHostMount(mounts, getHostname(req, config))
161
+ if (mount && pagePathOnMount(path, config)) {
162
+ result = isPageRequest(req)
163
+ ? { path: mountedPath(path, mount.path), mount: mount.path, varies: true }
164
+ : { path, mount: null, varies: true }
165
+ }
166
+
167
+ routings.set(req, result)
168
+ return result
169
+ }
170
+
171
+ /**
172
+ * The path routing resolves for `req`, and the host mount it moved into.
173
+ *
174
+ * On a host that no entry of `hostMounts` serves, that is the request's own
175
+ * path and no mount. On a mounted host, a page request (`isPageRequest`) for
176
+ * a path that can be a page (`pagePathOnMount`) resolves under the mount:
177
+ * `/forms` is `/kit/forms`, and `/` is `/kit`. Every other request keeps its
178
+ * path, so the modules, stylesheets and API a subtree's pages load still come
179
+ * from the app's root. Memoized per request.
180
+ */
181
+ export function routeOf(req: Request): RequestRoute {
182
+ return routing(req)
183
+ }
184
+
85
185
  export function handleRequest(
86
186
  req: Request,
87
187
  ): Handler.Response | MixedPromise<symbol>
88
188
  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
189
+ const path = routing(req).path
93
190
 
94
191
  // One read of the config getter, not three: `Bakery.serveRoot` walks
95
192
  // `hostStore.getStore()?.config ?? getConfig()` on every access, and the
@@ -312,6 +409,21 @@ export async function handleRequestError(
312
409
  return DefaultErrorHandler.handle(path, req, error)
313
410
  }
314
411
 
412
+ /** Add `tokens` to `res`'s `Vary`, keeping what it names and naming each once. */
413
+ function addVary(res: Response, tokens: readonly string[]): void {
414
+ const existing = res.headers.get('Vary')
415
+ const named = new Set(
416
+ (existing ?? '')
417
+ .split(',')
418
+ .map(token => token.trim().toLowerCase())
419
+ .filter(Boolean),
420
+ )
421
+ if (named.has('*')) return
422
+ const missing = tokens.filter(token => !named.has(token.toLowerCase()))
423
+ if (!missing.length) return
424
+ res.headers.set('Vary', [existing, ...missing].filter(Boolean).join(', '))
425
+ }
426
+
315
427
  export async function processResponse(
316
428
  data: Handler.Response | MixedPromise<symbol>,
317
429
  req: Request,
@@ -373,6 +485,11 @@ export async function processResponse(
373
485
  const cors = Bakery.config.cors
374
486
  if (cors) applyCors(cors, req, resp)
375
487
 
488
+ // On a mounted host one URL can answer a navigation from the subtree and a
489
+ // module import from the root (see `routeOf`), so a cache keyed on the URL
490
+ // alone could hand either one to the other.
491
+ if (routing(req).varies) addVary(resp, ['Sec-Fetch-Mode', 'Accept'])
492
+
376
493
  const final = ETag.sendResponse(req, resp)
377
494
  if (!(final instanceof Response)) {
378
495
  log({