@bakery-framework/core 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
@@ -0,0 +1,71 @@
1
+ import { Bakery } from '../core/bakery'
2
+ import { errorMsg, serveLog } from '../logger'
3
+ import type { MapOf } from '../types'
4
+ import { fs } from '../utils/fs'
5
+ import { parseJSONC } from '../utils/jsonc'
6
+
7
+ // 🚀 Hoisted Regexes
8
+ const RE_ROOT_RELATIVE = /^(\.\/)?(\.server|api|node_modules)\//
9
+ const RE_HTTP = /^https?:\/\//
10
+ const RE_LEADING_SLASHES = /^(\.\/|\/)/
11
+ const RE_RELATIVE = /^\.\.?\//
12
+ const RE_TRAILING_WILDCARD = /\/?\*?$/
13
+
14
+ // The application's tsconfig, resolved against its cwd. This used to write into
15
+ // a tsconfig *inside the framework* — app-specific paths mutating a shipped
16
+ // package file, which is also why running the test suite dirtied the tree.
17
+ const APP_CONFIG_PATH = fs.resolve(process.cwd(), 'tsconfig.json')
18
+ const APP_DIR = process.cwd()
19
+
20
+ function buildPaths(): MapOf<string[]> {
21
+ const newPaths: MapOf<string[]> = {}
22
+
23
+ for (const [key, val] of Object.entries(Bakery.config.importMap)) {
24
+ if (RE_HTTP.test(val)) continue
25
+ // Values the server maps to a served URL ('/_client/utils.js') are not
26
+ // filesystem paths; turning them into tsconfig paths yields a directory
27
+ // that does not exist. Their type mapping belongs in tsconfig.base.json.
28
+ if (val.startsWith('/')) continue
29
+
30
+ const isDir = key.endsWith('/')
31
+ const tsKey = isDir ? `${key.slice(0, -1)}/*` : key
32
+
33
+ const absolutePath = RE_ROOT_RELATIVE.test(val)
34
+ ? fs.resolve(Bakery.root, val)
35
+ : fs.resolve(Bakery.serveRoot, val.replace(RE_LEADING_SLASHES, ''))
36
+
37
+ const relativePath = fs.relative(APP_DIR, absolutePath)
38
+
39
+ let tsVal = (RE_RELATIVE.test(relativePath) ? '' : './') + relativePath
40
+ if (isDir) tsVal = tsVal.replace(RE_TRAILING_WILDCARD, '/*')
41
+
42
+ newPaths[tsKey] = [tsVal]
43
+ }
44
+
45
+ return newPaths
46
+ }
47
+
48
+ export async function syncTSConfigPaths(): Promise<void> {
49
+ try {
50
+ const newPaths = buildPaths()
51
+ let appConfig: any = { compilerOptions: { paths: {} } }
52
+
53
+ if (fs.exists(APP_CONFIG_PATH)) {
54
+ appConfig = parseJSONC(await Bun.file(APP_CONFIG_PATH).text())
55
+ }
56
+
57
+ appConfig.compilerOptions ??= {}
58
+ delete appConfig.compilerOptions.baseUrl
59
+
60
+ const currentPaths = appConfig.compilerOptions.paths ?? {}
61
+
62
+ if (Bun.deepEquals(currentPaths, newPaths)) return
63
+
64
+ appConfig.compilerOptions.paths = newPaths
65
+ await Bun.write(APP_CONFIG_PATH, JSON.stringify(appConfig, null, 2))
66
+
67
+ serveLog.TSCONFIG_SYNCED()
68
+ } catch (err: any) {
69
+ serveLog.UNHANDLED_ERR({ error: `TSConfig sync error: ${errorMsg(err)}` })
70
+ }
71
+ }
@@ -0,0 +1,96 @@
1
+ import { HandlerMap } from '../handlers/core/$registry'
2
+ import { fs } from '../utils/fs'
3
+ import { SharedMemoryPool } from '../utils/shared-pool'
4
+ import { getConfig, resolveHostname } from './config'
5
+ import { getAppVersion, hostStore } from './context'
6
+
7
+ export type { HostContext } from './context'
8
+ export { hostStore } from './context'
9
+
10
+ /**
11
+ * Namespace a cache key by the current host.
12
+ *
13
+ * The hostname is resolved against `config.hosts` first, and an unconfigured
14
+ * one collapses to the unprefixed key. That is not cosmetic: this key becomes a
15
+ * **filename** in five handlers, and `getOrCreateCachedFile` writes three files
16
+ * per entry with no bound and no eviction. Prefixing the raw `Host` header let
17
+ * any client mint an unlimited number of cache entries — 25 requests for one
18
+ * path under 25 made-up hostnames took the cache directory from 9 files to 84.
19
+ *
20
+ * `resolveHostConfig` already carries this reasoning for the config cache; the
21
+ * file caches simply never got it. Collapsing is safe because an unconfigured
22
+ * host is served the base config, so its content is identical to the default
23
+ * bucket's.
24
+ */
25
+ export function hostKey(path: string): string {
26
+ const host = resolveHostname(hostStore.getStore()?.hostname || '')
27
+ return host ? `${host}:${path}` : path
28
+ }
29
+
30
+ export function getHostname(
31
+ req: Request,
32
+ config?: Readonly<ProcessedAppConfig>,
33
+ ): string {
34
+ if (req.__hostname) return req.__hostname
35
+
36
+ let hostname = ''
37
+ const cfg = config ?? Bakery.config
38
+
39
+ if (cfg.trustProxy) {
40
+ const forwardedHost = req.headers.get('x-forwarded-host')
41
+ if (forwardedHost) {
42
+ hostname = forwardedHost.split(',')[0].trim().split(':')[0]
43
+ }
44
+ }
45
+
46
+ if (!hostname) {
47
+ const hostHeader = req.headers.get('host')
48
+ if (hostHeader) hostname = hostHeader.split(':')[0]
49
+ else hostname = new URL(req.url).hostname
50
+ }
51
+
52
+ return hostname
53
+ }
54
+
55
+ export const Bakery: globalThis.Bakery = {
56
+ get config(): Readonly<ProcessedAppConfig> {
57
+ return hostStore.getStore()?.config ?? getConfig()
58
+ },
59
+ get serveRoot() {
60
+ return this.config.root
61
+ },
62
+ get apiRoot() {
63
+ return fs.resolve(this.serveRoot, 'api')
64
+ },
65
+ get publicRoot() {
66
+ return fs.resolve(Bakery.root, 'public')
67
+ },
68
+ root: fs.cwd,
69
+ get version() {
70
+ return getAppVersion()
71
+ },
72
+ sharedPool: new SharedMemoryPool(1024 * 1024),
73
+ // The disposable directory is the hidden one, and the precious one is not.
74
+ // This is the reverse of the old `.bakery/cache` + `.data` pairing, and the
75
+ // reversal is the whole point: `.cache` is wiped by the framework itself on
76
+ // every version bump and dev<->prod switch, so a `rm -rf .*` or a "clean out
77
+ // the dotfiles" sweep does exactly what the framework already does. The
78
+ // database is not disposable, so it does not live behind a leading dot where
79
+ // such a sweep can reach it.
80
+ cacheDir: `${fs.cwd}/.cache`,
81
+ // Holds the database and its backups. Visible, and deliberately not under
82
+ // `.cache`: clearing a cache must never be able to destroy data.
83
+ dataDir: `${fs.cwd}/bakery`,
84
+ startNs: Bun.nanoseconds(),
85
+ handlers: {
86
+ fetch: new HandlerMap(),
87
+ error: new HandlerMap(),
88
+ websocket: new HandlerMap(),
89
+ },
90
+ shutdownHooks: [] as any[],
91
+ onShutdown(hook: () => Promise<void> | void) {
92
+ this.shutdownHooks.push(hook)
93
+ },
94
+ }
95
+
96
+ export default Bakery
@@ -0,0 +1,119 @@
1
+ import { readdir, rm } from 'node:fs/promises'
2
+ import { serveLog } from '../logger/serve-log'
3
+ import { Try } from '../utils/common'
4
+ import { fs } from '../utils/fs'
5
+ import { Bakery } from './bakery'
6
+ import { getAppVersion, getFrameworkVersion } from './context'
7
+
8
+ /**
9
+ * Discard `.cache/` when the thing that produced it has changed.
10
+ *
11
+ * Its own module, and that is the fix rather than an organisational
12
+ * preference. This used to live in `config.ts` and run from `initConfig()`,
13
+ * which is far too late: `cache/shared-db.ts` opens `.cache/shared-cache.db`
14
+ * at **import** time, so by the time the check ran the process was already
15
+ * holding a handle to a file inside the directory it was about to delete. On
16
+ * Windows that delete fails with `EBUSY`, node's recursive walk stops at the
17
+ * locked entry, and everything it had not reached yet survived — including
18
+ * `html/`, the compiled-page cache, which is exactly what a version bump has
19
+ * to discard.
20
+ *
21
+ * Now `shared-db.ts` awaits this before opening the database, and `initConfig`
22
+ * still calls it for the case where nothing has touched the cache yet. It is
23
+ * memoised, so whichever gets there first does the work and the other is free.
24
+ */
25
+ let done: Promise<void> | null = null
26
+
27
+ export function checkCacheVersion(): Promise<void> {
28
+ done ??= run()
29
+ return done
30
+ }
31
+
32
+ /** Test seam: forget that the check ran, so a fixture can exercise it again. */
33
+ export function __resetCacheVersionCheck(): void {
34
+ done = null
35
+ }
36
+
37
+ async function run(): Promise<void> {
38
+ // Workers inherit a directory the master already validated. Re-running here
39
+ // would race the master's own wipe.
40
+ if (import.meta.env.WORKER) return
41
+
42
+ // Read from `Bakery.cacheDir`, never re-derived from `fs.cwd`. This value is
43
+ // handed straight to a recursive delete below, and it used to be a
44
+ // hand-written copy of the constant in `core/bakery.ts` — two sources of
45
+ // truth for a `rm -rf`. Renaming the cache directory in one place and not the
46
+ // other would have pointed this delete at whatever the stale literal named.
47
+ const cacheDir = Bakery.cacheDir
48
+ const markerPath = `${cacheDir}/server.json`
49
+
50
+ const current = {
51
+ mode: import.meta.env.DEV ? 'development' : 'production',
52
+ // The app's version and the framework's are both here, and they are
53
+ // different files on purpose: keying on the app alone meant
54
+ // `bun update @bakery-framework/core` left a cache compiled by the previous
55
+ // framework version, with nothing to invalidate it.
56
+ version: getAppVersion(),
57
+ framework: getFrameworkVersion(),
58
+ }
59
+
60
+ const [err, prev] = await Try.catch(() => Bun.file(markerPath).json())
61
+ const stale =
62
+ err ||
63
+ !prev ||
64
+ prev.mode !== current.mode ||
65
+ prev.version !== current.version ||
66
+ prev.framework !== current.framework
67
+
68
+ if (!stale) return
69
+
70
+ const survivors = await wipe(cacheDir)
71
+ if (!fs.exists(cacheDir)) await fs.mkdir(cacheDir)
72
+
73
+ // The marker is written **only** when the directory is actually empty.
74
+ //
75
+ // It used to be written unconditionally, right after a delete whose every
76
+ // error was swallowed — so a failed wipe produced a file asserting the cache
77
+ // was current, and nothing ever tried again. A stale compiled page then
78
+ // outlived the upgrade that was supposed to remove it, silently and
79
+ // permanently. Leaving the old marker in place costs one retry per boot and
80
+ // is the only honest option: the cache is not current, so nothing should say
81
+ // it is.
82
+ if (survivors.length) {
83
+ serveLog.CACHE_WIPE_INCOMPLETE({
84
+ dir: cacheDir,
85
+ files: survivors.join(', '),
86
+ })
87
+ return
88
+ }
89
+
90
+ await Bun.write(markerPath, `${JSON.stringify(current, null, 2)}\n`)
91
+ }
92
+
93
+ /**
94
+ * Empty `dir`, entry by entry. Returns whatever is still there.
95
+ *
96
+ * Per-entry rather than one recursive call on the directory itself, because a
97
+ * single locked file otherwise shields every entry the walk had not yet
98
+ * reached — the failure is ordering-dependent, so it presents as "sometimes the
99
+ * cache clears".
100
+ */
101
+ export const __wipeCacheDir = wipe
102
+
103
+ async function wipe(dir: string): Promise<string[]> {
104
+ if (!fs.exists(dir)) return []
105
+ const [readErr, entries] = await Try.catch(() => readdir(dir))
106
+ if (readErr || !entries) return ['<unreadable>']
107
+
108
+ for (const entry of entries) {
109
+ // Errors are deliberately not swallowed *silently* here — each failure is
110
+ // collected and reported by the caller.
111
+ await Try.catch(() =>
112
+ rm(`${dir}/${entry}`, { recursive: true, force: true }),
113
+ )
114
+ }
115
+
116
+ const [rereadErr, left] = await Try.catch(() => readdir(dir))
117
+ if (rereadErr) return ['<unreadable>']
118
+ return left ?? []
119
+ }
@@ -0,0 +1,296 @@
1
+ import { log } from '../logger'
2
+ import { errorMsg, serveLog } from '../logger/serve-log'
3
+ import { Try } from '../utils/common'
4
+ import {
5
+ DEFAULT_BLOCKED_GLOBS,
6
+ DEFAULT_DB_BACKUPS,
7
+ DEFAULT_HOST,
8
+ DEFAULT_PORT,
9
+ DEFAULT_RATE_LIMIT,
10
+ } from '../utils/constants'
11
+ import { fs } from '../utils/fs'
12
+ import { checkCacheVersion } from './cache-version'
13
+ import { hostStore } from './context'
14
+
15
+ export const NOOP = () => {}
16
+
17
+ const defaultConfig: Required<AppConfig> = {
18
+ port: DEFAULT_PORT,
19
+ host: DEFAULT_HOST,
20
+ maxBodySize: 20 * 1024 * 1024,
21
+ middleware: [],
22
+ backups: DEFAULT_DB_BACKUPS,
23
+ blocked: [],
24
+ head: '',
25
+ body: '',
26
+ plugins: [],
27
+ onStart: NOOP,
28
+ onError(e) {
29
+ const host = hostStore.getStore()?.hostname || 'global'
30
+ log({ level: 'warn', by: host, msg: e.errorBody })
31
+ },
32
+ onShutdown: NOOP,
33
+ // `maxCacheSize: 500` used to sit here. Nothing in the framework ever read
34
+ // it — not the route LRUs, not the tiered cache — so it was a knob that
35
+ // typechecked, appeared in completion, and did nothing. Removed from
36
+ // `AppConfig` at the same time; `defaultConfig` is `Required<AppConfig>`, so
37
+ // the two can only move together.
38
+ importMap: {
39
+ // Served by VirtualAssetHandler; the framework's own files are no longer
40
+ // reachable at an app-relative '.server/...' path.
41
+ '@client/utils': '/_client/utils.js',
42
+ },
43
+ onRequest: NOOP,
44
+ proxy: {},
45
+ rateLimit: DEFAULT_RATE_LIMIT,
46
+ trustProxy: false,
47
+ // Empty means auto-detect, matching `head`/`body` above. There is no default
48
+ // *path* to give here: the default behaviour is the probe itself.
49
+ schema: '',
50
+ root: 'src',
51
+ hosts: {},
52
+ websocket: {
53
+ message: NOOP,
54
+ open: NOOP,
55
+ close: NOOP,
56
+ drain: NOOP,
57
+ },
58
+ }
59
+
60
+ let cachedConfig: ProcessedAppConfig | null = null
61
+ const hostConfigCache = new Map<string, Readonly<ProcessedAppConfig>>()
62
+
63
+ let configLoadError: string | null = null
64
+
65
+ /**
66
+ * The import failure recorded by the most recent `initConfig()` run, when a
67
+ * `server.config.ts` was *present but failed to load*. DEV-only by
68
+ * construction — in PROD that same condition throws out of `initConfig()`
69
+ * instead of being recorded. Read by `runStartupBanner()` so the warning is
70
+ * restated where the developer is actually looking, not just in scrollback.
71
+ */
72
+ export function getConfigLoadError(): string | null {
73
+ return configLoadError
74
+ }
75
+
76
+ /**
77
+ * `hosts` folded to lower case, memoised against the object it came from.
78
+ *
79
+ * Keyed on identity rather than rebuilt per request because `hosts` is frozen
80
+ * for the process lifetime — except under `__setTestConfig`, where a new object
81
+ * arrives and the identity check rebuilds rather than serving a stale map.
82
+ */
83
+ let hostLookup: {
84
+ source: Record<string, HostEntry>
85
+ entries: Map<string, HostEntry>
86
+ } | null = null
87
+
88
+ export function clearHostConfigCache(): void {
89
+ cachedConfig = null
90
+ hostConfigCache.clear()
91
+ hostLookup = null
92
+ }
93
+
94
+ export async function initConfig(): Promise<Readonly<ProcessedAppConfig>> {
95
+ if (cachedConfig) return cachedConfig
96
+
97
+ await checkCacheVersion()
98
+ hostConfigCache.clear()
99
+
100
+ // Resolved against the application's cwd, not relative to this file. A
101
+ // static '../../server.config.ts' reached out of the framework into the app,
102
+ // which breaks as soon as the framework is a package rather than a directory
103
+ // inside the app. Absence is tolerated: the defaults below are a usable
104
+ // config, and zero-config boot is a supported feature.
105
+ const configPath = fs.resolve(process.cwd(), 'server.config.ts')
106
+ let serverConfig: any = {}
107
+ configLoadError = null
108
+
109
+ if (await Bun.file(configPath).exists()) {
110
+ const [error, loaded] = await Try.catch(import(configPath))
111
+ if (error) {
112
+ // Present-but-broken is not absence. This used to log one line and boot
113
+ // on `defaultConfig` — port 3000, no plugins, no hosts — so the
114
+ // developer debugged the vanished dashboard instead of the config that
115
+ // never parsed. In PROD that boot must not happen: the throw lands in
116
+ // the entry's existing catch (prod.ts / threads.ts / worker.ts), which
117
+ // logs and exits 1. In DEV a crash would just loop the watcher, so boot
118
+ // — loudly, and leave the error for the startup banner to restate.
119
+ const message = errorMsg(error)
120
+ if (import.meta.env.PROD) {
121
+ throw new Error(
122
+ `server.config.ts exists but failed to import — refusing to start on the default config.\n file: ${configPath}\n${message}`,
123
+ )
124
+ }
125
+ configLoadError = message
126
+ serveLog.CONFIG_BROKEN({ file: configPath, error: message })
127
+ } else {
128
+ serverConfig = loaded?.default ?? {}
129
+ }
130
+ }
131
+ const overriden = { ...defaultConfig, ...serverConfig } as Required<AppConfig>
132
+
133
+ overriden.importMap = Object.assign(
134
+ { '@client/utils': '/_client/utils.js' },
135
+ serverConfig.importMap || {},
136
+ )
137
+
138
+ const blockedGlob = [
139
+ ...DEFAULT_BLOCKED_GLOBS,
140
+ ...overriden.blocked.map(pattern =>
141
+ pattern.startsWith('**/') ? pattern : `**/${pattern}`,
142
+ ),
143
+ ].join(',')
144
+
145
+ cachedConfig = Object.assign(overriden, {
146
+ blocked: new Bun.Glob(`{${blockedGlob}}`),
147
+ root: fs.resolve(overriden.root),
148
+ })
149
+
150
+ return Object.freeze(cachedConfig)
151
+ }
152
+
153
+ let testOverrides: Partial<ProcessedAppConfig> | null = null
154
+
155
+ /**
156
+ * Test seam, symmetric with `__setTestDb` in `@bakery-framework/orm`.
157
+ *
158
+ * The resolved config is frozen, so a test that needs to exercise a
159
+ * config-dependent branch cannot simply assign to it. The alternative was
160
+ * `mock.module('./core/config', …)`, which is process-global and never
161
+ * restored — one file doing that left `Bakery.config` undefined for every test
162
+ * file loaded after it, and the suite stayed green only because Bun happened
163
+ * to order that file last.
164
+ *
165
+ * Always pair with `__resetTestConfig()` in `afterAll`.
166
+ */
167
+ export function __setTestConfig(overrides: Partial<ProcessedAppConfig>): void {
168
+ testOverrides = overrides
169
+ }
170
+
171
+ export function __resetTestConfig(): void {
172
+ testOverrides = null
173
+ }
174
+
175
+ export function getConfig(): Readonly<ProcessedAppConfig> {
176
+ if (!cachedConfig) {
177
+ throw new Error('Config has not been initialized. Call initConfig() first.')
178
+ }
179
+
180
+ if (testOverrides) {
181
+ return Object.freeze({ ...cachedConfig, ...testOverrides })
182
+ }
183
+
184
+ return cachedConfig
185
+ }
186
+
187
+ function mergeHostConfig(
188
+ base: ProcessedAppConfig,
189
+ entry: HostEntry,
190
+ ): Readonly<ProcessedAppConfig> {
191
+ const merged: any = { ...base }
192
+
193
+ if (entry.root) merged.root = fs.resolve(entry.root)
194
+ if (entry.importMap)
195
+ merged.importMap = { ...base.importMap, ...entry.importMap }
196
+ if (entry.middleware) merged.middleware = entry.middleware
197
+ if (entry.onRequest) merged.onRequest = entry.onRequest
198
+ if (entry.onError) merged.onError = entry.onError
199
+ if (entry.head !== undefined) merged.head = entry.head
200
+ if (entry.body !== undefined) merged.body = entry.body
201
+ if (entry.proxy) merged.proxy = entry.proxy
202
+ if (entry.rateLimit !== undefined) merged.rateLimit = entry.rateLimit
203
+ if (entry.blocked) {
204
+ const blockedGlob = [
205
+ ...DEFAULT_BLOCKED_GLOBS,
206
+ ...entry.blocked.map(p => (p.startsWith('**/') ? p : `**/${p}`)),
207
+ ].join(',')
208
+ merged.blocked = new Bun.Glob(`{${blockedGlob}}`)
209
+ }
210
+
211
+ return Object.freeze(merged)
212
+ }
213
+
214
+ /**
215
+ * The configured host entry for an already-lowercased hostname, or undefined.
216
+ *
217
+ * A Map built from `Object.entries` rather than a lookup on `hosts` itself:
218
+ * own enumerable keys only, so `Host: constructor` cannot reach a prototype
219
+ * member and get merged as if it were a host entry. That is what the previous
220
+ * `Object.hasOwn` guard bought, and it is preserved here.
221
+ *
222
+ * If two keys differ only in case, the first one declared wins.
223
+ */
224
+ function lookupHostEntry(
225
+ hosts: Record<string, HostEntry>,
226
+ hostname: string,
227
+ ): HostEntry | undefined {
228
+ if (!hostLookup || hostLookup.source !== hosts) {
229
+ const entries = new Map<string, HostEntry>()
230
+ for (const [key, entry] of Object.entries(hosts)) {
231
+ const normalized = key.toLowerCase()
232
+ if (!entries.has(normalized)) entries.set(normalized, entry)
233
+ }
234
+ hostLookup = { source: hosts, entries }
235
+ }
236
+
237
+ return hostLookup.entries.get(hostname)
238
+ }
239
+
240
+ /**
241
+ * The canonical form of `hostname` if the app declares it under `hosts`, and
242
+ * `''` otherwise — including when no `hosts` are configured at all.
243
+ *
244
+ * Same reasoning as `resolveHostConfig` below, which already refuses to cache
245
+ * an unknown hostname: the value arrives on the `Host` header, so anything
246
+ * derived from it that is *kept* — a config entry, a cache key, a file on disk
247
+ * — has to be bounded by the configured set, or an unauthenticated client can
248
+ * grow it without limit. Collapsing unknown hosts to `''` puts them all in one
249
+ * bucket, which is correct because they all get the base config and therefore
250
+ * the same content.
251
+ *
252
+ * Folded with `toLowerCase` for the reason spelled out below: hostnames are
253
+ * case-insensitive, so `EXAMPLE.com` and `example.com` must be one key, not two.
254
+ */
255
+ export function resolveHostname(hostname: string): string {
256
+ // No config yet (a unit test that never called `initConfig`) means no `hosts`
257
+ // to match against, which is the same answer as an unconfigured host.
258
+ if (!hostname || !cachedConfig) return ''
259
+ const hosts = getConfig().hosts
260
+ if (!hosts) return ''
261
+ const key = hostname.toLowerCase()
262
+ return lookupHostEntry(hosts, key) ? key : ''
263
+ }
264
+
265
+ export function resolveHostConfig(
266
+ hostname: string,
267
+ ): Readonly<ProcessedAppConfig> {
268
+ const base = getConfig()
269
+ const hosts = base.hosts
270
+ if (!hosts || !Object.keys(hosts).length) return base
271
+
272
+ // Hostnames are case-insensitive (RFC 4343) and nothing upstream normalises
273
+ // the Host header, so a request to `EXAMPLE.com` used to miss an
274
+ // `example.com` entry and silently fall back to the base config. Both sides
275
+ // fold to lower case — `toLowerCase`, not `toLocaleLowerCase`, which would
276
+ // map `I` to a dotless `ı` in a Turkish locale.
277
+ const key = hostname.toLowerCase()
278
+
279
+ const cached = hostConfigCache.get(key)
280
+ if (cached) return cached
281
+
282
+ const entry = lookupHostEntry(hosts, key)
283
+ if (!entry) {
284
+ // Deliberately NOT cached. The hostname comes straight from the Host
285
+ // header, so caching unknown values let any client grow this map without
286
+ // bound — one request per made-up hostname until the process runs out of
287
+ // memory. Known hosts are a fixed, small set, so only those are cached.
288
+ // Normalising above keeps that bound honest: without it, `EXAMPLE.com` and
289
+ // `example.com` would occupy two entries for one configured host.
290
+ return base
291
+ }
292
+
293
+ const merged = mergeHostConfig(base as ProcessedAppConfig, entry)
294
+ hostConfigCache.set(key, merged)
295
+ return merged
296
+ }