@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.
- package/LICENSE +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- 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
|
+
}
|