@bakery-framework/core 2.4.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 +163 -44
- package/src/core/host-mounts.ts +7 -2
- package/src/global.d.ts +24 -1
- package/src/router.ts +12 -8
- package/src/utils/http/dom.ts +7 -2
package/package.json
CHANGED
package/src/core/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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'
|
|
14
15
|
|
|
15
16
|
// Re-exported for the CLI's boot checks, which reach config through this
|
|
16
17
|
// subpath; the module itself imports nothing, so it can sit under the router.
|
|
@@ -66,6 +67,7 @@ const defaultConfig: Required<AppConfig> = {
|
|
|
66
67
|
// and says so rather than ending nothing.
|
|
67
68
|
sessions: {},
|
|
68
69
|
root: 'src',
|
|
70
|
+
domains: [],
|
|
69
71
|
hosts: {},
|
|
70
72
|
hostMounts: [],
|
|
71
73
|
websocket: {
|
|
@@ -108,6 +110,7 @@ export function clearHostConfigCache(): void {
|
|
|
108
110
|
cachedConfig = null
|
|
109
111
|
hostConfigCache.clear()
|
|
110
112
|
hostLookup = null
|
|
113
|
+
staticDomains = null
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export async function initConfig(): Promise<Readonly<ProcessedAppConfig>> {
|
|
@@ -219,6 +222,9 @@ function mergeHostConfig(
|
|
|
219
222
|
const merged: any = { ...base }
|
|
220
223
|
|
|
221
224
|
if (entry.root) merged.root = fs.resolve(entry.root)
|
|
225
|
+
if (entry.mount !== undefined) {
|
|
226
|
+
merged.mount = normalizeMountPath(entry.mount, 'mount')
|
|
227
|
+
}
|
|
222
228
|
if (entry.importMap)
|
|
223
229
|
merged.importMap = { ...base.importMap, ...entry.importMap }
|
|
224
230
|
if (entry.middleware) merged.middleware = entry.middleware
|
|
@@ -240,7 +246,7 @@ function mergeHostConfig(
|
|
|
240
246
|
}
|
|
241
247
|
|
|
242
248
|
/**
|
|
243
|
-
* The configured host
|
|
249
|
+
* The configured host entries by lowercased key.
|
|
244
250
|
*
|
|
245
251
|
* A Map built from `Object.entries` rather than a lookup on `hosts` itself:
|
|
246
252
|
* own enumerable keys only, so `Host: constructor` cannot reach a prototype
|
|
@@ -249,10 +255,7 @@ function mergeHostConfig(
|
|
|
249
255
|
*
|
|
250
256
|
* If two keys differ only in case, the first one declared wins.
|
|
251
257
|
*/
|
|
252
|
-
function
|
|
253
|
-
hosts: Record<string, HostEntry>,
|
|
254
|
-
hostname: string,
|
|
255
|
-
): HostEntry | undefined {
|
|
258
|
+
function hostEntries(hosts: Record<string, HostEntry>): Map<string, HostEntry> {
|
|
256
259
|
if (!hostLookup || hostLookup.source !== hosts) {
|
|
257
260
|
const entries = new Map<string, HostEntry>()
|
|
258
261
|
for (const [key, entry] of Object.entries(hosts)) {
|
|
@@ -261,64 +264,180 @@ function lookupHostEntry(
|
|
|
261
264
|
}
|
|
262
265
|
hostLookup = { source: hosts, entries }
|
|
263
266
|
}
|
|
267
|
+
return hostLookup.entries
|
|
268
|
+
}
|
|
264
269
|
|
|
265
|
-
|
|
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)
|
|
266
276
|
}
|
|
267
277
|
|
|
268
278
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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.
|
|
271
333
|
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The `hosts` key `hostname` matched, lowercased, or `''` for none. */
|
|
372
|
+
export function hostEntryKey(hostname: string): string {
|
|
373
|
+
return matchHost(hostname)?.key ?? ''
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
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.
|
|
405
|
+
*
|
|
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
|
|
276
410
|
* grow it without limit. Collapsing unknown hosts to `''` puts them all in one
|
|
277
411
|
* bucket, which is correct because they all get the base config and therefore
|
|
278
|
-
* the same content
|
|
412
|
+
* the same content; `'*'` collapses its subdomains to one tenant per domain
|
|
413
|
+
* for the same reason.
|
|
279
414
|
*
|
|
280
|
-
* Folded with `toLowerCase
|
|
281
|
-
*
|
|
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.
|
|
282
419
|
*/
|
|
283
420
|
export function resolveHostname(hostname: string): string {
|
|
284
421
|
// No config yet (a unit test that never called `initConfig`) means no `hosts`
|
|
285
422
|
// to match against, which is the same answer as an unconfigured host.
|
|
286
|
-
|
|
287
|
-
const hosts = getConfig().hosts
|
|
288
|
-
if (!hosts) return ''
|
|
289
|
-
const key = hostname.toLowerCase()
|
|
290
|
-
return lookupHostEntry(hosts, key) ? key : ''
|
|
423
|
+
return matchHost(hostname)?.tenant ?? ''
|
|
291
424
|
}
|
|
292
425
|
|
|
293
426
|
export function resolveHostConfig(
|
|
294
427
|
hostname: string,
|
|
295
428
|
): Readonly<ProcessedAppConfig> {
|
|
296
429
|
const base = getConfig()
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const key = hostname.toLowerCase()
|
|
306
|
-
|
|
307
|
-
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)
|
|
308
438
|
if (cached) return cached
|
|
309
439
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
// Deliberately NOT cached. The hostname comes straight from the Host
|
|
313
|
-
// header, so caching unknown values let any client grow this map without
|
|
314
|
-
// bound: one request per made-up hostname until the process runs out of
|
|
315
|
-
// memory. Known hosts are a fixed, small set, so only those are cached.
|
|
316
|
-
// Normalizing above keeps that bound honest: without it, `EXAMPLE.com` and
|
|
317
|
-
// `example.com` would occupy two entries for one configured host.
|
|
318
|
-
return base
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const merged = mergeHostConfig(base as ProcessedAppConfig, entry)
|
|
322
|
-
hostConfigCache.set(key, merged)
|
|
440
|
+
const merged = mergeHostConfig(base as ProcessedAppConfig, match.entry)
|
|
441
|
+
hostConfigCache.set(match.tenant, merged)
|
|
323
442
|
return merged
|
|
324
443
|
}
|
package/src/core/host-mounts.ts
CHANGED
|
@@ -72,7 +72,12 @@ export function hostPattern(host: string, where = 'host'): RegExp {
|
|
|
72
72
|
*/
|
|
73
73
|
export const ROOT_ONLY = ['/_', '/api/', '/.well-known/'] as const
|
|
74
74
|
|
|
75
|
-
|
|
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 {
|
|
76
81
|
if (typeof value !== 'string' || !value.startsWith('/')) {
|
|
77
82
|
throw new Error(
|
|
78
83
|
`${where} must be a path starting with '/': got ${JSON.stringify(value)}`,
|
|
@@ -108,7 +113,7 @@ function compile(mount: HostMount, where: string): CompiledHostMount {
|
|
|
108
113
|
if (!mount || typeof mount !== 'object') {
|
|
109
114
|
throw new Error(`${where} must be { host, path }`)
|
|
110
115
|
}
|
|
111
|
-
const path =
|
|
116
|
+
const path = normalizeMountPath(mount.path, `${where}.path`)
|
|
112
117
|
const { host } = mount
|
|
113
118
|
if (typeof host === 'function') return { path, matches: host }
|
|
114
119
|
if (typeof host !== 'string') {
|
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,6 +174,16 @@ 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>
|
|
171
188
|
|
|
172
189
|
/**
|
|
@@ -422,7 +439,13 @@ declare global {
|
|
|
422
439
|
{
|
|
423
440
|
blocked: Bun.Glob
|
|
424
441
|
}
|
|
425
|
-
>
|
|
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
|
+
}
|
|
426
449
|
|
|
427
450
|
type ServerWebSocket<T = MapOf<any>> = Override<
|
|
428
451
|
Bun.ServerWebSocket,
|
package/src/router.ts
CHANGED
|
@@ -152,15 +152,18 @@ function routing(req: Request): Routing {
|
|
|
152
152
|
const path = parsedUrl(req).pathname
|
|
153
153
|
let result: Routing = { path, mount: null, varies: false }
|
|
154
154
|
|
|
155
|
-
//
|
|
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.
|
|
156
158
|
const config = Bakery.config
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
}
|
|
161
164
|
if (mount && pagePathOnMount(path, config)) {
|
|
162
165
|
result = isPageRequest(req)
|
|
163
|
-
? { path: mountedPath(path, mount
|
|
166
|
+
? { path: mountedPath(path, mount), mount, varies: true }
|
|
164
167
|
: { path, mount: null, varies: true }
|
|
165
168
|
}
|
|
166
169
|
|
|
@@ -171,8 +174,9 @@ function routing(req: Request): Routing {
|
|
|
171
174
|
/**
|
|
172
175
|
* The path routing resolves for `req`, and the host mount it moved into.
|
|
173
176
|
*
|
|
174
|
-
* On a host
|
|
175
|
-
* path and no mount. On a mounted host, a
|
|
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
|
|
176
180
|
* a path that can be a page (`pagePathOnMount`) resolves under the mount:
|
|
177
181
|
* `/forms` is `/kit/forms`, and `/` is `/kit`. Every other request keeps its
|
|
178
182
|
* path, so the modules, stylesheets and API a subtree's pages load still come
|
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
|
|