@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,256 @@
1
+ import { LRUCache } from '../../cache/lru'
2
+ import type { MapOf, MixedPromise } from '../../types'
3
+ import { fs } from '../../utils/fs'
4
+ import { processBody } from '../../utils/http'
5
+
6
+ const RX_PARAM = /[[\]{}()*+?.\\^$|]/g
7
+ export const RX_DYNAMIC = /\[([\w$]+)\]/
8
+ export const RX_CATCHALL = /\[\.\.\.([\w$]+)\]/
9
+
10
+ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
11
+ const cleanPath = path.replace(/\\/g, '/').replace(/^\/+/, '')
12
+ if (!cleanPath) return null
13
+ if (!RX_DYNAMIC.test(cleanPath) && !RX_CATCHALL.test(cleanPath)) return null
14
+
15
+ const params: string[] = []
16
+ const segments = cleanPath.split('/')
17
+ const last = segments.length - 1
18
+ let catchAll = false
19
+
20
+ const mappedPaths: string[] = []
21
+ for (let i = 0; i < segments.length; i++) {
22
+ const segment = segments[i]
23
+
24
+ const catchAllMatch = segment.match(RX_CATCHALL)
25
+ if (catchAllMatch) {
26
+ // Only terminal: a segment after `[...x]` has no unambiguous meaning
27
+ // (which segments belong to the rest?), so the file stays inert — the
28
+ // same behavior it had before catch-alls existed.
29
+ if (i !== last) return null
30
+ params.push(catchAllMatch[1])
31
+ // `.+` rather than `.*`: the catch-all requires at least one segment,
32
+ // so `docs/[...slug]` does not shadow a `docs/index` sibling for
33
+ // `/docs` itself.
34
+ mappedPaths.push('(.+)')
35
+ catchAll = true
36
+ continue
37
+ }
38
+
39
+ const dynamicMatch = segment.match(RX_DYNAMIC)
40
+ if (dynamicMatch) {
41
+ params.push(dynamicMatch[1])
42
+ mappedPaths.push('([^/]+?)')
43
+ continue
44
+ }
45
+
46
+ mappedPaths.push(segment.replace(RX_PARAM, '\\$&'))
47
+ }
48
+
49
+ return {
50
+ pattern: new RegExp(`^/${mappedPaths.join('/')}(?:\\.([a-z]*))?$`),
51
+ params,
52
+ catchAll,
53
+ }
54
+ }
55
+
56
+ export namespace RouteData {
57
+ export interface Info {
58
+ readonly file: Bun.BunFile
59
+ readonly filePath: fs.AbsolutePath
60
+ readonly path: fs.RelativePath
61
+ readonly params: string[]
62
+ readonly valid: boolean
63
+ readonly isDynamic: boolean
64
+ readonly catchAll: boolean
65
+ readonly regex: RegExp | null
66
+ getParams(path: string): MapOf<string> | null
67
+ }
68
+
69
+ export type Meta = {
70
+ type: 'endpoint' | 'route' | 'proxy' | 'static' | 'websocket'
71
+ isRoot: boolean
72
+ fileName: string
73
+ }
74
+ }
75
+
76
+ export namespace Route {
77
+ export type Info = RouteData.Info
78
+ export type Meta = RouteData.Meta
79
+ }
80
+
81
+ // A class, not an object of statics, because it is also a **type** namespace:
82
+ // `RouteData.Info` and `RouteData.Meta` are referenced in type position at four
83
+ // sites above and below. A class declaration provides both a value and a
84
+ // namespace for its nested classes; `const RouteData = { Info: class {} }`
85
+ // provides only the value, and every one of those type references stops
86
+ // resolving.
87
+ // biome-ignore lint/complexity/noStaticOnlyClass: also a type namespace — see above
88
+ export class RouteData {
89
+ static Info = class Info {
90
+ readonly params: string[]
91
+ readonly filePath: fs.AbsolutePath
92
+ readonly path: fs.RelativePath
93
+ readonly regex: RegExp | null
94
+ readonly catchAll: boolean
95
+
96
+ constructor(filePath: fs.AbsolutePath, path: fs.RelativePath) {
97
+ this.filePath = fs.resolve(filePath) as fs.AbsolutePath
98
+ this.path = path
99
+
100
+ const route = getDynamicRoute(path)
101
+ this.regex = route?.pattern || null
102
+ this.params = route?.params || []
103
+ this.catchAll = route?.catchAll || false
104
+ }
105
+
106
+ get file() {
107
+ return Bun.file(this.filePath)
108
+ }
109
+
110
+ get valid() {
111
+ return fs.exists(this.filePath)
112
+ }
113
+
114
+ get isDynamic() {
115
+ return this.regex !== null
116
+ }
117
+
118
+ getParams(path: string): MapOf<string> | null {
119
+ if (!this.regex) return null
120
+ const cleanPath = path.startsWith('/') ? path : `/${path}`
121
+ const match = cleanPath.match(this.regex)
122
+ if (!match) return null
123
+
124
+ const boundParams: MapOf<string> = {}
125
+ for (let i = 0; i < this.params.length; i++) {
126
+ boundParams[this.params[i]] = match[i + 1]
127
+ }
128
+ return boundParams as MapOf<string>
129
+ }
130
+ }
131
+ }
132
+
133
+ export namespace Handler {
134
+ export type Response = MixedPromise<
135
+ globalThis.Response | Bun.BunFile | undefined | object | string | void
136
+ >
137
+
138
+ export namespace Dynamic {
139
+ export type Config = {
140
+ ext: string[]
141
+ dir?: fs.AbsolutePath
142
+ include?: string[]
143
+ }
144
+
145
+ export type Route = {
146
+ pattern: RegExp
147
+ params: string[]
148
+ /** True when the final segment is a `[...name]` multi-segment matcher. */
149
+ catchAll?: boolean
150
+ }
151
+ }
152
+
153
+ export namespace Route {
154
+ export type Info = RouteData.Info
155
+ export type Meta = RouteData.Meta
156
+ }
157
+
158
+ export namespace Error {
159
+ export type Data = {
160
+ errorCode: number
161
+ errorText: string
162
+ errorBody: string
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Keyed by class identity rather than stored as a `${name}_cache` dynamic
169
+ * property: the getter runs several times per request, and the template
170
+ * string + megamorphic property lookup showed up in profiles. A `Map` (not
171
+ * `WeakMap`) is fine — handler classes live for the process.
172
+ */
173
+ const handlerCaches = new Map<any, HandlerCache<string, Route.Info>>()
174
+
175
+ export class Handler {
176
+ /**
177
+ * Opt out of the route cache's bypass.
178
+ *
179
+ * `HandlerMap.resolve` remembers which handler served a path and goes
180
+ * straight to it next time, skipping every handler above it. That is safe
181
+ * when `canHandle` answers a *routing* question ("is there a .tsx file
182
+ * here?"), because the answer is a property of the path. It is unsafe when
183
+ * `canHandle` answers a question about *this* request:
184
+ * `MiddlewareHandler.canHandle` is `Boolean(response)`, i.e. "was this
185
+ * request denied", so an allowed request caches the page handler and every
186
+ * later request to that path is served without the gate ever running.
187
+ *
188
+ * A handler that sets this is consulted before any cache hit it outranks,
189
+ * and is never itself written into the cache.
190
+ */
191
+ static alwaysResolve = false
192
+
193
+ /**
194
+ * Whether this handler answers with the *bytes of a file*, and so must be
195
+ * judged by `config.blocked`.
196
+ *
197
+ * `false` for handlers that read the request path as a route *name*:
198
+ * `ApiHandler` resolves a module and executes it, `ProxyHandler` answers
199
+ * from an upstream, `MiddlewareHandler` from app code. None can leak a
200
+ * source file by being handed a path that looks like one, and applying the
201
+ * deny-list to them made `/api/manifest.json` a 403 no config could undo.
202
+ *
203
+ * Deny by default: anything that does not opt out — including a plugin's
204
+ * handler — keeps the check. This is the single source of truth for that
205
+ * question; `router.ts` gates its request-path check on it, and
206
+ * `DynamicHandler.resolveRoute` gates the resolved-file check on it.
207
+ */
208
+ static servesFiles = true
209
+
210
+ protected constructor() {}
211
+
212
+ static get cache(): HandlerCache<string, Route.Info> {
213
+ let cache = handlerCaches.get(this)
214
+ if (!cache) {
215
+ cache = new HandlerCache()
216
+ handlerCaches.set(this, cache)
217
+ }
218
+ return cache
219
+ }
220
+
221
+ static canHandle(path: string, req: Request): MixedPromise<boolean>
222
+ static canHandle(): boolean {
223
+ return false
224
+ }
225
+
226
+ static Route = RouteData
227
+ static initRoutes(): MixedPromise<void> {}
228
+
229
+ static async params(
230
+ req: Request,
231
+ overrides?: MapOf<any>,
232
+ ): Promise<MapOf<any>> {
233
+ const body = await processBody(req)
234
+ return Object.assign({}, body, overrides)
235
+ }
236
+
237
+ static handle(path: string, req: Request): Handler.Response
238
+ static handle() {
239
+ return undefined
240
+ }
241
+
242
+ static [Symbol.hasInstance](instance: any): boolean {
243
+ if (!instance) return false
244
+ return (
245
+ instance === this ||
246
+ (typeof instance === 'function' && instance.prototype instanceof this) ||
247
+ Object.prototype.isPrototypeOf.call(this.prototype, instance)
248
+ )
249
+ }
250
+ }
251
+
252
+ export class HandlerCache<K, V> extends LRUCache<K, V> {
253
+ constructor(cacheSize = import.meta.env.THREAD_WORKER ? 50 : 500) {
254
+ super(cacheSize)
255
+ }
256
+ }
@@ -0,0 +1,285 @@
1
+ import Bakery from '../../core'
2
+ import { hostKey } from '../../core/bakery'
3
+ import { matchBlockedCached } from '../../core/context'
4
+ import { errorMsg, handlerLog } from '../../logger/serve-log'
5
+ import type { MixedPromise } from '../../types'
6
+ import { fs, Try } from '../../utils'
7
+ import {
8
+ Handler,
9
+ HandlerCache,
10
+ type Route,
11
+ RX_CATCHALL,
12
+ RX_DYNAMIC,
13
+ } from './$base'
14
+ import { resolveMount } from './$mounts'
15
+ import { getRoute } from './$routing'
16
+
17
+ const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
18
+
19
+ /**
20
+ * `file`, carrying the `?v=<mtime>` suffix that gives DEV edit-and-refresh.
21
+ *
22
+ * The suffix makes Bun's module registry treat every edit as a new module,
23
+ * which is what lets an edited route (a `.ts` API handler, a `.tsx` page) take
24
+ * the cheap dev path — route-cache flush plus browser reload — instead of the
25
+ * full process restart it used to force. It also costs a stat and a string
26
+ * alloc per request and permanently retains each superseded module: acceptable
27
+ * in DEV, pure waste in PROD where route files cannot change. So PROD imports
28
+ * the bare specifier once and serves from the registry cache; the trade is that
29
+ * PROD needs a restart to pick up changed route files, which is already true
30
+ * operationally since PROD runs no watcher.
31
+ *
32
+ * `&& !TEST` is load-bearing: `init.ts` defaults `PROD` to true whenever
33
+ * `--dev` is absent, and `bun test` loads init via the CLI package's tests — so
34
+ * a bare `PROD` gate flipped mid-suite and broke the reload tests in files
35
+ * loaded after it, while passing in isolation.
36
+ *
37
+ * Only the route module itself is busted: components it imports (a shared
38
+ * `Layout.tsx`, helpers) stay in Bun's registry until a restart. Documented in
39
+ * `docs/getting-started/first-app.md`.
40
+ *
41
+ * One implementation on purpose. `ApiHandler` and `TSXHandler`/`TSXErrorHandler`
42
+ * both need it, and they carried a byte-identical copy each — including this
43
+ * reasoning — which is two places for the `!TEST` clause to be dropped from.
44
+ */
45
+ export function bustInDev(file: fs.AbsolutePath): fs.AbsolutePath {
46
+ if (import.meta.env.PROD && !import.meta.env.TEST) return file
47
+ return `${file}?v=${Bun.file(file).lastModified}` as fs.AbsolutePath
48
+ }
49
+
50
+ export class DynamicHandler extends Handler {
51
+ static get config(): Handler.Dynamic.Config {
52
+ return {
53
+ ext: [],
54
+ dir: Bakery.serveRoot,
55
+ include: ['**/*'],
56
+ }
57
+ }
58
+
59
+ static get dynamicCache(): HandlerCache<RegExp, Route.Info> {
60
+ // Same per-class Map pattern as `Handler.cache` — see the comment there.
61
+ let cache = dynamicCaches.get(this)
62
+ if (!cache) {
63
+ cache = new HandlerCache()
64
+ dynamicCaches.set(this, cache)
65
+ }
66
+ return cache
67
+ }
68
+
69
+ static initRoutes() {
70
+ this.cache.clear()
71
+ this.dynamicCache.clear()
72
+ }
73
+
74
+ static canHandle(path: string, req?: Request): MixedPromise<boolean>
75
+ static async canHandle(path: string) {
76
+ // A request path spelled like a route template ('/blog/[id]' or
77
+ // '/docs/[...slug]') addresses the template file, not a route.
78
+ if (RX_DYNAMIC.test(path) || RX_CATCHALL.test(path)) return false
79
+ if (this.cache.has(hostKey(path))) return true
80
+ // The dynamic half of the line above. A dynamic route is never written to
81
+ // `this.cache`, so without this every request to one ran `resolveRoute`
82
+ // twice — once here and once in `handle` — and each run is two globbing
83
+ // `getRoute` scans. `findDynamicRoute` is a regex test per cached route
84
+ // plus one stat for the match, and a hit here implies `resolveRoute` is
85
+ // about to be truthy too: it consults the same cache and returns that
86
+ // entry unless a static file outranks it, which is also truthy. The
87
+ // side effects skipped (cache population) are redone by `handle`.
88
+ if (this.findDynamicRoute(path)) return true
89
+ const info = await this.resolveRoute(path)
90
+ return Boolean(info)
91
+ }
92
+
93
+ static async executeModule(
94
+ file: fs.AbsolutePath,
95
+ req: Request,
96
+ body: any,
97
+ ): Promise<any> {
98
+ // A broken route module is a server fault, not a missing route: `null`
99
+ // means 404 to every caller, so after logging the file for context the
100
+ // failure is rethrown. `extractErrorData` turns the throw into a 500 whose
101
+ // `errorBody` carries the stack — shown in DEV, redacted by `publicBody`
102
+ // in PROD. Only the genuinely-missing cases below return null.
103
+ const [error, mod] = await Try.catch(import(file))
104
+ if (error) {
105
+ handlerLog.API_IMPORT_ERR({ file, error: errorMsg(error) })
106
+ throw error
107
+ }
108
+
109
+ if (mod?.default === undefined) return null
110
+ if (typeof mod.default !== 'function') return mod.default
111
+
112
+ // `ApiCallback` declares three parameters and this is its only call site,
113
+ // so the third was always `undefined`. Passed rather than removed from the
114
+ // type: `html()` in core/jsx.ts already hands `Bakery.server` to the render
115
+ // function it wraps, so dropping it would leave wrapped and unwrapped
116
+ // routes with different signatures for no gain.
117
+ return await mod.default(req, body, Bakery.server)
118
+ }
119
+
120
+ // Segment matching with catch-all and specificity precedence. The branches
121
+ // are the precedence rules; splitting them hides the ordering that is the
122
+ // whole point.
123
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: route-precedence dispatcher
124
+ static findDynamicRoute(path: string): Route.Info | null {
125
+ const root = Bakery.serveRoot
126
+ let deferred: Route.Info[] | null = null
127
+ for (const [_, info] of this.dynamicCache) {
128
+ // Cheapest filter first. `valid` is a stat and `isForbidden` walks every
129
+ // directory between the file and the root doing an existsSync at each
130
+ // level, and both ran for every cached route before anything asked
131
+ // whether the route even matched the path — so a miss cost one tree-walk
132
+ // per entry and a hit cost one per entry ahead of the match. The regex is
133
+ // pure and all four filters still `continue`, so a matching-but-rejected
134
+ // entry does not shadow a later one; `$dynamic.test.ts` pins that.
135
+ if (!info.getParams(path)) continue
136
+ // Every single-segment route outranks every catch-all, whatever order
137
+ // the cache filled in — so catch-alls are set aside, and their stat and
138
+ // tree-walk filters run only after the loop finds no specific match.
139
+ if (info.catchAll) {
140
+ ;(deferred ??= []).push(info)
141
+ continue
142
+ }
143
+ if (!info.valid) continue
144
+ // `filePath` is already `fs.resolve`d by the Info constructor; resolving
145
+ // it again here re-normalised an identical string.
146
+ if (!info.filePath!.startsWith(`${root}/`)) continue
147
+ if (fs.isForbidden(info.filePath!, root)) continue
148
+ return info
149
+ }
150
+ if (deferred) {
151
+ // A real file always beats a catch-all, whatever handler would serve
152
+ // it: when the requested path names an existing file, every catch-all
153
+ // declines so the file's own handler (possibly lower-priority — CSS
154
+ // falls all the way to StaticHandler) gets asked. One stat, paid only
155
+ // when a catch-all is about to answer. `getCatchAllRoute` applies the
156
+ // same rule on the discovery path; the two must agree — including the
157
+ // containment clamp: `root + path` is unresolved, so a `..` in the
158
+ // path would have `statSync` resolve it outside the root and turn this
159
+ // into an existence probe. See the comment there.
160
+ const target = fs.resolve(root, `.${path}`)
161
+ if (
162
+ (target === root || target.startsWith(`${root}/`)) &&
163
+ fs.isFileSync(target)
164
+ ) {
165
+ return null
166
+ }
167
+ // Longest route path first: `docs/guides/[...rest]` beats
168
+ // `docs/[...rest]` for the paths both match.
169
+ if (deferred.length > 1) {
170
+ deferred.sort((a, b) => b.path.length - a.path.length)
171
+ }
172
+ for (const info of deferred) {
173
+ if (!info.valid) continue
174
+ if (!info.filePath!.startsWith(`${root}/`)) continue
175
+ if (fs.isForbidden(info.filePath!, root)) continue
176
+ return info
177
+ }
178
+ }
179
+ return null
180
+ }
181
+
182
+ static getCachedRoute(path: string): Route.Info | null {
183
+ const key = hostKey(path)
184
+ return this.validateCachedRoute(key, this.cache.get(key)!)
185
+ }
186
+
187
+ static cacheStaticRoute(path: string, info: Route.Info): Route.Info {
188
+ const key = hostKey(path)
189
+ this.cache.set(key, info)
190
+
191
+ if (path.endsWith('/index')) {
192
+ this.cache.set(hostKey(path.slice(0, -6) || '/'), info)
193
+ }
194
+ return info
195
+ }
196
+
197
+ static validateCachedRoute(path: string, info: Route.Info | null) {
198
+ if (!info) return null
199
+ if (info.valid && !fs.isForbidden(info.filePath!, Bakery.serveRoot))
200
+ return info
201
+ this.cache.delete(path)
202
+ if (info.regex) this.dynamicCache.delete(info.regex)
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * Resolve `path` to a file this handler may serve.
208
+ *
209
+ * The deny-list is matched against the *request path* in `router.ts`, but
210
+ * `routeGlobs` deliberately answers a request with a file of a different
211
+ * extension — `/schema.css` and `/schema` both resolve to `schema.ts` via
212
+ * stem+ext substitution. So the router's check was being asked about a
213
+ * string that named no file: it said "not blocked", and `TSHandler`
214
+ * compiled and served the very file the default list exists to protect.
215
+ * Verified against a live server before the fix: `/schema.ts` 403,
216
+ * `/schema.js` 200 with the file's contents.
217
+ *
218
+ * Re-running the check against what actually resolved is the fix. It is
219
+ * gated on `servesFiles` so route-only handlers keep their exemption, and
220
+ * it wraps every branch — cached, static, dynamic, catch-all — rather than
221
+ * each return point, so a future branch cannot forget it.
222
+ */
223
+ static resolveRoute(path: string): Promise<Route.Info | null>
224
+ static async resolveRoute(path: string) {
225
+ const info = await this.resolveRouteFile(path)
226
+ if (!info) return null
227
+
228
+ if (
229
+ this.servesFiles &&
230
+ matchBlockedCached(Bakery.config.blocked, `/${info.path}`)
231
+ ) {
232
+ return null
233
+ }
234
+
235
+ return info
236
+ }
237
+
238
+ protected static resolveRouteFile(path: string): Promise<Route.Info | null>
239
+ protected static async resolveRouteFile(path: string) {
240
+ const cached = this.getCachedRoute(path)
241
+ if (cached) return cached
242
+
243
+ // One read of the `config` getter: every access builds a fresh object and
244
+ // reads `Bakery.serveRoot` (an AsyncLocalStorage getStore), and this
245
+ // function used to read it three times.
246
+ const config = this.config
247
+
248
+ // A plugin may claim this prefix and serve it from its own directory; the
249
+ // rest of the pipeline (compiler, caches, containment) is unchanged.
250
+ const mounted = resolveMount(path)
251
+ const dir = mounted ? mounted.mount.dir : config.dir || Bakery.serveRoot
252
+ const routePath = mounted ? mounted.rest : path
253
+
254
+ const staticInfo = await getRoute(routePath, config.ext, dir, dir, {
255
+ staticOnly: true,
256
+ })
257
+ if (staticInfo) {
258
+ return this.cacheStaticRoute(path, staticInfo)
259
+ }
260
+
261
+ // `findDynamicRoute` already required `valid` and `!isForbidden` against
262
+ // `Bakery.serveRoot` before returning, so wrapping it in
263
+ // `validateCachedRoute` — which asserts exactly those two, against exactly
264
+ // that root — re-ran a stat and a directory tree-walk on a value that had
265
+ // just passed both, and its eviction branch was unreachable from here.
266
+ // `getCachedRoute` still needs the wrapper; entries read from `this.cache`
267
+ // have not been checked.
268
+ const dyn = this.findDynamicRoute(path)
269
+ if (dyn) {
270
+ return dyn
271
+ }
272
+
273
+ const info = await getRoute(routePath, config.ext, dir, dir, {
274
+ dynamicOnly: true,
275
+ })
276
+ if (!info) return null
277
+
278
+ if (info.isDynamic && info.regex) {
279
+ this.dynamicCache.set(info.regex, info)
280
+ return info
281
+ }
282
+
283
+ return this.cacheStaticRoute(path, info)
284
+ }
285
+ }