@bakery-framework/core 2.4.0 → 2.5.0-alpha.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/cache/shared-db.ts +3 -4
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +0 -2
- package/src/compiler/dev-service.ts +1 -1
- package/src/compiler/prompt-tracker.ts +1 -1
- package/src/core/cache-version.ts +0 -5
- package/src/core/config.ts +163 -44
- package/src/core/host-mounts.ts +7 -2
- package/src/core/jsx.ts +1 -2
- package/src/global.d.ts +25 -3
- package/src/handlers/core/$base.ts +0 -1
- package/src/handlers/core/$dynamic.ts +0 -1
- package/src/handlers/core/$registry.ts +0 -1
- package/src/handlers/core/$routing.ts +0 -1
- package/src/logger/logger.ts +2 -2
- package/src/router.ts +12 -8
- package/src/utils/common/index.ts +2 -2
- package/src/utils/fs.ts +4 -1
- package/src/utils/http/dom.ts +8 -3
- package/src/utils/http/etag.ts +0 -1
- package/src/utils/http/html.ts +1 -1
- package/src/utils/http/index.ts +1 -1
- package/src/utils/http/validate.ts +2 -4
- package/src/utils/common/match.ts +0 -6
- package/src/utils/common/try.ts +0 -6
- package/src/utils/http/escape.ts +0 -8
- package/src/utils/isomorphic/index.ts +0 -15
package/package.json
CHANGED
package/src/cache/shared-db.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Bakery } from '../core/bakery'
|
|
|
4
4
|
import { checkCacheVersion } from '../core/cache-version'
|
|
5
5
|
import { serveLog } from '../logger/serve-log'
|
|
6
6
|
import { fs } from '../utils'
|
|
7
|
-
import { Try } from '../utils/
|
|
7
|
+
import { Try } from '../utils/isomorphic/try'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The tiered cache's spill-to-disk store: sessions and LRU overflow.
|
|
@@ -134,9 +134,8 @@ if (!stored) {
|
|
|
134
134
|
export function applyJournalMode(db: Database, file: string): string {
|
|
135
135
|
const asked = Try.return(
|
|
136
136
|
() =>
|
|
137
|
-
db
|
|
138
|
-
|
|
139
|
-
.get()?.journal_mode ?? '',
|
|
137
|
+
db.query<{ journal_mode: string }, []>('PRAGMA journal_mode = WAL;').get()
|
|
138
|
+
?.journal_mode ?? '',
|
|
140
139
|
(error: Error) => `threw: ${error.message}`,
|
|
141
140
|
)
|
|
142
141
|
if (asked.toLowerCase() === 'wal') return 'wal'
|
package/src/client/livereload.ts
CHANGED
|
@@ -362,7 +362,7 @@ function connect() {
|
|
|
362
362
|
ws.onmessage = e => {
|
|
363
363
|
const data = typeof e.data === 'string' ? e.data : String(e.data)
|
|
364
364
|
|
|
365
|
-
// The server sends two frame shapes:
|
|
365
|
+
// The server sends two frame shapes: plain strings (a
|
|
366
366
|
// watcher-relative filename, or the literal 'force_reload') and JSON
|
|
367
367
|
// objects, currently `{type: 'error', title, body}` from
|
|
368
368
|
// compiler/dev-service.ts's notifyError. A relative path can never begin
|
|
@@ -374,7 +374,7 @@ function connect() {
|
|
|
374
374
|
frame = JSON.parse(data)
|
|
375
375
|
} catch {
|
|
376
376
|
// A '{'-prefixed frame that is not JSON is not a filename either;
|
|
377
|
-
// frame stays null and the string falls through as a
|
|
377
|
+
// frame stays null and the string falls through as a plain frame.
|
|
378
378
|
}
|
|
379
379
|
if (frame) {
|
|
380
380
|
if (frame.type === 'error') {
|
package/src/client/utils.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function notifySockets(server: any, filename: string) {
|
|
|
29
29
|
* `{type: 'error', title, body}`. client/livereload.ts renders it as an
|
|
30
30
|
* overlay and removes it on the next successful reload frame.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
32
|
+
* Reload frames are plain filename strings: the client distinguishes
|
|
33
33
|
* the two by a leading `{`, so this must always be JSON and filenames must
|
|
34
34
|
* never be. Paths that cannot reach a live socket at all (worker dead, boot
|
|
35
35
|
* failure before `Bun.serve`) are covered client-side by the disconnect
|
|
@@ -64,11 +64,6 @@ export function isCacheStale(
|
|
|
64
64
|
)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/** Test seam: forget that the check ran, so a fixture can exercise it again. */
|
|
68
|
-
export function __resetCacheVersionCheck(): void {
|
|
69
|
-
done = null
|
|
70
|
-
}
|
|
71
|
-
|
|
72
67
|
async function run(): Promise<void> {
|
|
73
68
|
// Workers inherit a directory the master already validated. Re-running here
|
|
74
69
|
// would race the master's own wipe.
|
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/core/jsx.ts
CHANGED
|
@@ -142,8 +142,7 @@ export function html<P = {}>(render: RenderFn<P>) {
|
|
|
142
142
|
const prefix =
|
|
143
143
|
startsAt < 0 ? '' : rawDom.slice(startsAt, startsAt + 9).toLowerCase()
|
|
144
144
|
|
|
145
|
-
if (prefix.startsWith('<html'))
|
|
146
|
-
return `<!DOCTYPE html>\n${rawDom}`
|
|
145
|
+
if (prefix.startsWith('<html')) return `<!DOCTYPE html>\n${rawDom}`
|
|
147
146
|
if (prefix.startsWith('<!doctype')) return rawDom
|
|
148
147
|
|
|
149
148
|
let title = 'Document'
|
package/src/global.d.ts
CHANGED
|
@@ -50,11 +50,17 @@ declare global {
|
|
|
50
50
|
* string) is ignored and the next middleware runs. See `$middleware.ts`.
|
|
51
51
|
*/
|
|
52
52
|
type MiddlewareResponse =
|
|
53
|
-
|
|
|
54
|
-
| import('./utils/common/json').JsonResponseData
|
|
53
|
+
Response | import('./utils/common/json').JsonResponseData
|
|
55
54
|
|
|
56
55
|
type HostEntry = {
|
|
57
56
|
root?: string
|
|
57
|
+
/**
|
|
58
|
+
* A subtree of the app this host serves at its root: with
|
|
59
|
+
* `mount: '/kit'`, `/forms` on this host is the page `/kit/forms`. Only
|
|
60
|
+
* page requests move into it; what those pages load comes from the
|
|
61
|
+
* app's root. See `routeOf` in `router.ts`.
|
|
62
|
+
*/
|
|
63
|
+
mount?: string
|
|
58
64
|
importMap?: Record<string, string>
|
|
59
65
|
middleware?: ((
|
|
60
66
|
req: Request,
|
|
@@ -167,6 +173,16 @@ declare global {
|
|
|
167
173
|
*/
|
|
168
174
|
sessions?: import('./session').SessionOptions
|
|
169
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The app's domains, which three kinds of `hosts` key are read against:
|
|
178
|
+
* `'@'` is a domain itself, a bare label such as `'admin'` is
|
|
179
|
+
* `admin.<domain>`, and `'*'` is any other subdomain. `localhost` always
|
|
180
|
+
* counts, so the same keys work on `admin.localhost`. A function is asked
|
|
181
|
+
* on each request, for an app whose domain is a setting that can change
|
|
182
|
+
* while it runs, and has to answer without awaiting.
|
|
183
|
+
*/
|
|
184
|
+
domains?: string | string[] | (() => string | string[])
|
|
185
|
+
|
|
170
186
|
hosts?: Record<string, HostEntry>
|
|
171
187
|
|
|
172
188
|
/**
|
|
@@ -422,7 +438,13 @@ declare global {
|
|
|
422
438
|
{
|
|
423
439
|
blocked: Bun.Glob
|
|
424
440
|
}
|
|
425
|
-
>
|
|
441
|
+
> & {
|
|
442
|
+
/**
|
|
443
|
+
* The subtree this host serves at its root, from its `hosts` entry's
|
|
444
|
+
* `mount`, normalized. Absent on the base config and on a host without one.
|
|
445
|
+
*/
|
|
446
|
+
mount?: string
|
|
447
|
+
}
|
|
426
448
|
|
|
427
449
|
type ServerWebSocket<T = MapOf<any>> = Override<
|
|
428
450
|
Bun.ServerWebSocket,
|
|
@@ -115,7 +115,6 @@ export namespace Route {
|
|
|
115
115
|
// namespace for its nested classes; `const RouteData = { Info: class {} }`
|
|
116
116
|
// provides only the value, and every one of those type references stops
|
|
117
117
|
// resolving.
|
|
118
|
-
// biome-ignore lint/complexity/noStaticOnlyClass: also a type namespace. See above
|
|
119
118
|
export class RouteData {
|
|
120
119
|
static Info = class Info {
|
|
121
120
|
readonly params: string[]
|
|
@@ -130,7 +130,6 @@ export class DynamicHandler extends Handler {
|
|
|
130
130
|
// Segment matching with catch-all and specificity precedence. The branches
|
|
131
131
|
// are the precedence rules; splitting them hides the ordering that is the
|
|
132
132
|
// whole point.
|
|
133
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: route-precedence dispatcher
|
|
134
133
|
static findDynamicRoute(path: string): Route.Info | null {
|
|
135
134
|
const root = Bakery.serveRoot
|
|
136
135
|
let deferred: Route.Info[] | null = null
|
|
@@ -125,7 +125,6 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
125
125
|
*/
|
|
126
126
|
// The `probed` bookkeeping exists because middleware has side effects and
|
|
127
127
|
// must not run twice. That constraint is what the branching encodes.
|
|
128
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
|
|
129
128
|
async resolve(path: string, req?: Request, ...rest: any[]) {
|
|
130
129
|
// The **resolved** host, not the header. `__hostname` is whatever arrived
|
|
131
130
|
// in `Host`, and `routeCache` is one bounded LRU shared by every tenant, so
|
package/src/logger/logger.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { PromptTracker } from '../compiler/prompt-tracker'
|
|
2
2
|
import type { MapOf } from '../types'
|
|
3
3
|
import { Case } from '../utils/common/case'
|
|
4
|
-
import { match } from '../utils/
|
|
5
|
-
import { Try } from '../utils/
|
|
4
|
+
import { match } from '../utils/isomorphic/match'
|
|
5
|
+
import { Try } from '../utils/isomorphic/try'
|
|
6
6
|
|
|
7
7
|
const logLevels = ['info', 'warn', 'error', 'fatal', 'debug', 'trace'] as const
|
|
8
8
|
const byLength = 15
|
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/fs.ts
CHANGED
|
@@ -585,7 +585,10 @@ export namespace FileSystem {
|
|
|
585
585
|
* of the source before it. That is how an SFC in a dev server kept its old
|
|
586
586
|
* styles beside a new template and script after three quick edits.
|
|
587
587
|
*/
|
|
588
|
-
function versionedName(
|
|
588
|
+
function versionedName(
|
|
589
|
+
cacheName: string,
|
|
590
|
+
sourceMtime: number | null,
|
|
591
|
+
): string {
|
|
589
592
|
if (!sourceMtime) return cacheName
|
|
590
593
|
const ext = parse(cacheName).ext
|
|
591
594
|
return `${cacheName.slice(0, cacheName.length - ext.length)}@${sourceMtime}${ext}`
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
-
import { Try } from '../
|
|
7
|
+
import { Try } from '../isomorphic/try'
|
|
7
8
|
import { fs } from '../fs'
|
|
8
9
|
import { escapeScriptJson } from '../isomorphic/escape'
|
|
9
10
|
|
|
@@ -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
|
|
package/src/utils/http/etag.ts
CHANGED
|
@@ -135,7 +135,6 @@ export namespace ETag {
|
|
|
135
135
|
negotiationMemo.clear()
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation, one branch per encoding outcome
|
|
139
138
|
function negotiateFile(file: Bun.BunFile, req?: Request) {
|
|
140
139
|
const fileName = file.name || 'file'
|
|
141
140
|
|
package/src/utils/http/html.ts
CHANGED
package/src/utils/http/index.ts
CHANGED
|
@@ -28,8 +28,7 @@ export interface StandardSchemaLike<T> {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
type StandardResult<T> =
|
|
31
|
-
|
|
32
|
-
| { issues: readonly StandardIssue[] }
|
|
31
|
+
{ value: T; issues?: undefined } | { issues: readonly StandardIssue[] }
|
|
33
32
|
|
|
34
33
|
interface StandardIssue {
|
|
35
34
|
message: string
|
|
@@ -48,8 +47,7 @@ export interface ValidationIssue {
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
export type ValidationResult<T> =
|
|
51
|
-
|
|
52
|
-
| { ok: false; issues: ValidationIssue[] }
|
|
50
|
+
{ ok: true; value: T } | { ok: false; issues: ValidationIssue[] }
|
|
53
51
|
|
|
54
52
|
function isStandardSchema<T>(v: Validator<T>): v is StandardSchemaLike<T> {
|
|
55
53
|
return typeof v === 'object' && v !== null && '~standard' in v
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Moved to `utils/isomorphic/match` so the browser bundle and the server share
|
|
3
|
-
* one implementation: the prototype-chain fix (`Object.hasOwn` rather than
|
|
4
|
-
* `in`) previously had to be applied to both copies separately.
|
|
5
|
-
*/
|
|
6
|
-
export * from '../isomorphic/match'
|
package/src/utils/common/try.ts
DELETED
package/src/utils/http/escape.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-facing path for the escaping primitives.
|
|
3
|
-
*
|
|
4
|
-
* The implementation lives in `utils/isomorphic/escape` because the browser
|
|
5
|
-
* bundle needs it too; server code imports it from here (or from `@server/utils`)
|
|
6
|
-
* so callers don't have to know which side of that boundary it sits on.
|
|
7
|
-
*/
|
|
8
|
-
export { escapeHtml, escapeScriptJson } from '../isomorphic/escape'
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure utilities shared by the server and the browser bundle.
|
|
3
|
-
*
|
|
4
|
-
* Rule for this directory: no `Bun.*`, no node builtins, no DOM globals. These
|
|
5
|
-
* modules are compiled into the client bundle as-is, so anything that reaches
|
|
6
|
-
* for a runtime API belongs one layer up in `utils/` or in `client/`.
|
|
7
|
-
*/
|
|
8
|
-
export * from './case'
|
|
9
|
-
export * from './escape'
|
|
10
|
-
export * from './is'
|
|
11
|
-
export * from './match'
|
|
12
|
-
export * from './math'
|
|
13
|
-
export * from './misc'
|
|
14
|
-
export * from './stringify'
|
|
15
|
-
export * from './try'
|