@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0-alpha.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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/common/try'
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
- .query<{ journal_mode: string }, []>('PRAGMA journal_mode = WAL;')
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'
@@ -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: legacy plain strings (a
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 legacy frame.
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') {
@@ -1,5 +1,3 @@
1
- /** biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: '*/
2
-
3
1
  /**
4
2
  * Browser runtime: binds the shared utilities onto `globalThis` and adds the
5
3
  * DOM-dependent pieces ($fmt, speculation rules, live navigation) on top.
@@ -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
- * Legacy reload frames stay plain filename strings: the client distinguishes
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
@@ -1,6 +1,6 @@
1
1
  import { unlinkSync } from 'node:fs'
2
2
  import { cacheDir } from '../core/context'
3
- import { Try } from '../utils/common/try'
3
+ import { Try } from '../utils/isomorphic/try'
4
4
 
5
5
  /**
6
6
  * **`cacheDir` comes from `core/context`, not from `Bakery`. Do not change it
@@ -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.
@@ -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 entry for an already-lowercased hostname, or undefined.
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 lookupHostEntry(
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
- return hostLookup.entries.get(hostname)
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
- * The canonical form of `hostname` if the app declares it under `hosts`, and
270
- * `''` otherwise, including when no `hosts` are configured at all.
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
- * Same reasoning as `resolveHostConfig` below, which already refuses to cache
273
- * an unknown hostname: the value arrives on the `Host` header, so anything
274
- * derived from it that is *kept* (a config entry, a cache key, a file on disk
275
- *) has to be bounded by the configured set, or an unauthenticated client can
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` for the reason spelled out below: hostnames are
281
- * 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.
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
- if (!hostname || !cachedConfig) return ''
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 hosts = base.hosts
298
- if (!hosts || !Object.keys(hosts).length) return base
299
-
300
- // Hostnames are case-insensitive (RFC 4343) and nothing upstream normalizes
301
- // the Host header, so a request to `EXAMPLE.com` used to miss an
302
- // `example.com` entry and silently fall back to the base config. Both sides
303
- // fold to lower case: `toLowerCase`, not `toLocaleLowerCase`, which would
304
- // map `I` to a dotless `ı` in a Turkish locale.
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 entry = lookupHostEntry(hosts, key)
311
- if (!entry) {
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
  }
@@ -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
- function mountPath(value: unknown, where: string): string {
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 = mountPath(mount.path, `${where}.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
- | Response
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
@@ -174,7 +174,6 @@ async function getCatchAllRoute(
174
174
  return info
175
175
  }
176
176
 
177
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request-to-route dispatcher
178
177
  export async function getRoute(
179
178
  pathOrArr: string | string[],
180
179
  exts: string[] = [],
@@ -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/common/match'
5
- import { Try } from '../utils/common/try'
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
- // A partial config (a test's, an embedder's) may carry no `hostMounts`.
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
- const mounts = config.hostMounts?.length
158
- ? compileHostMounts(config.hostMounts)
159
- : null
160
- const mount = mounts && findHostMount(mounts, getHostname(req, 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
+ }
161
164
  if (mount && pagePathOnMount(path, config)) {
162
165
  result = isPageRequest(req)
163
- ? { path: mountedPath(path, mount.path), mount: mount.path, varies: true }
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 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
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
@@ -1,5 +1,5 @@
1
1
  export * from './case'
2
2
  export * from './json'
3
- export * from './match'
3
+ export * from '../isomorphic/match'
4
4
  export * from './misc'
5
- export * from './try'
5
+ export * from '../isomorphic/try'
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(cacheName: string, sourceMtime: number | null): string {
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}`
@@ -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 '../common/try'
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
- 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
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  import { Bakery, hostStore } from '../../core/bakery'
2
2
  import type { MapOf } from '../../types'
3
- import { Try } from '../common/try'
3
+ import { Try } from '../isomorphic/try'
4
4
  import { DOMTools, headBodyCache } from './dom'
5
5
  import { ETag } from './etag'
6
6
 
@@ -3,7 +3,7 @@ export * from './body'
3
3
  export * from './credential'
4
4
  export * from './csrf'
5
5
  export * from './dom'
6
- export * from './escape'
6
+ export * from '../isomorphic/escape'
7
7
  export * from './etag'
8
8
  export * from './html'
9
9
  export * from './ip'
@@ -28,8 +28,7 @@ export interface StandardSchemaLike<T> {
28
28
  }
29
29
 
30
30
  type StandardResult<T> =
31
- | { value: T; issues?: undefined }
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
- | { ok: true; value: T }
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'
@@ -1,6 +0,0 @@
1
- /**
2
- * Moved to `utils/isomorphic/try` so the browser bundle and the server share one
3
- * implementation. Re-exported here because `@server/utils` and a number of
4
- * modules import `Try` from this path.
5
- */
6
- export * from '../isomorphic/try'
@@ -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'