@bakery-framework/core 2.4.0 → 2.4.2

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.4.2",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -1,12 +1,7 @@
1
1
  import { Strings } from '../cache/string'
2
2
  import { Bakery } from '../core/bakery'
3
3
  import { PluginHooks } from '../core/plugins'
4
- import {
5
- compLog,
6
- errorMsg,
7
- errorWithPosition,
8
- handlerLog,
9
- } from '../logger/serve-log'
4
+ import { compLog, errorWithPosition, handlerLog } from '../logger/serve-log'
10
5
  import type { MapOf } from '../types'
11
6
  import { is, Try, toHash } from '../utils/common'
12
7
  import { FileSystem as fs } from '../utils/fs'
@@ -128,10 +123,14 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
128
123
  * Compile `source`, naming `path` so a failure can say where it happened.
129
124
  *
130
125
  * The two branches are not symmetric on purpose. Without a path there is no
131
- * file to name and no caller that can render a 500 (the Vue plugin compiles
132
- * fragments of an SFC it has already parsed), so a failure logs and hands the
133
- * source back. With a path the failure is a served request, and the answer is
134
- * `null`: `TSHandler` turns that into the 500 it has always had a branch for.
126
+ * file to resolve imports against and no caller that can render a 500 (the
127
+ * Vue plugin compiles fragments of an SFC it has already parsed), so a failure
128
+ * logs and hands the source back. `origin.file` names the file such a
129
+ * fragment came from, for that log line only: without it the line read
130
+ * "Failed to compile inline source: Transform failed", and a component that
131
+ * would not parse in the browser had to be found by hand. With a path the
132
+ * failure is a served request, and the answer is `null`: `TSHandler` turns
133
+ * that into the 500 it has always had a branch for.
135
134
  *
136
135
  * It used to be neither. The transpiler was wrapped only on the pathless
137
136
  * branch, so a syntax error in a `.ts` asset threw straight past `compile()`,
@@ -140,22 +139,31 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
140
139
  * `Unhandled Server Error: <message>` with no file and no line while the client
141
140
  * got `An unexpected error occurred.` A total blackout for a one-character typo.
142
141
  */
143
- export function compileText(source: string): Promise<string>
142
+ export function compileText(
143
+ source: string,
144
+ origin?: { file: string },
145
+ ): Promise<string>
144
146
  export function compileText(
145
147
  source: string,
146
148
  path: fs.AbsolutePath,
147
149
  ): Promise<string | null>
148
- export async function compileText(source: string, path?: fs.AbsolutePath) {
149
- if (!path) {
150
- try {
151
- const content = await (await getTranspiler()).transform(source)
152
- return content
153
- } catch (err) {
154
- compLog.COMPILE_SOURCE_FAIL({ error: errorMsg(err) })
155
- return source
156
- }
150
+ export async function compileText(
151
+ source: string,
152
+ where?: fs.AbsolutePath | { file: string },
153
+ ) {
154
+ if (typeof where !== 'string') {
155
+ const [failed, content] = await Try.catch(
156
+ (await getTranspiler()).transform(source),
157
+ )
158
+ if (!failed) return content!
159
+
160
+ const error = errorWithPosition(failed)
161
+ if (where) compLog.COMPILE_FAIL({ file: where.file, error })
162
+ else compLog.COMPILE_SOURCE_FAIL({ error })
163
+ return source
157
164
  }
158
165
 
166
+ const path = where
159
167
  source = preprocessImports(source, path)
160
168
 
161
169
  const [failed, transformed] = await Try.catch(
@@ -163,8 +171,9 @@ export async function compileText(source: string, path?: fs.AbsolutePath) {
163
171
  )
164
172
 
165
173
  if (failed) {
166
- // `errorWithPosition`, not `errorMsg`: the thrown `BuildMessage` has no
167
- // stack, so `errorMsg` degrades to the bare message and the line number ( // the only thing that makes this actionable) is dropped. The file comes
174
+ // `errorWithPosition`, not `errorMsg`: what `transform()` rejects with
175
+ // has no stack, so `errorMsg` degrades to a bare message and drops the
176
+ // line number, the only thing that makes this actionable. The file comes
168
177
  // from `{file}`; the diagnostic's own `position.file` is `input.ts`,
169
178
  // because `transform()` was handed a string.
170
179
  compLog.COMPILE_FAIL({ file: path, error: errorWithPosition(failed) })
@@ -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/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,
@@ -257,12 +257,20 @@ export function errorDetail(err: any): string {
257
257
  * `position.file` is `input.ts` whenever `transform()` was handed a source
258
258
  * string rather than a path (which is every compile this codebase does), so
259
259
  * printing it beside the real path would contradict it.
260
+ *
261
+ * `transform()` rejects with an `AggregateError` whose own message is only
262
+ * "Transform failed", with no position, and whose `errors` are the
263
+ * diagnostics, so the first of those is the one read. The rest usually follow
264
+ * from it: a `;` inside an object literal is reported as `Expected "}" but
265
+ * found ";"`, then as `Unexpected }` at the brace after it.
260
266
  */
261
267
  export function errorWithPosition(err: any): string {
262
- const position = (err as Diagnostic)?.position
268
+ const first: Diagnostic =
269
+ Array.isArray(err?.errors) && err.errors.length ? err.errors[0] : err
270
+ const position = first?.position
263
271
  if (!position || position.line == null) return errorMsg(err)
264
272
 
265
- const message = (err as Diagnostic).message || errorMsg(err)
273
+ const message = first.message || errorMsg(first)
266
274
  return `${message} (line ${position.line}, column ${position.column ?? 0})`
267
275
  }
268
276
 
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,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
- 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