@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 +1 -1
- package/src/core/config.ts +5 -0
- package/src/core/host-mounts.ts +174 -0
- package/src/global.d.ts +11 -0
- package/src/router.ts +122 -5
package/package.json
CHANGED
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|