@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,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
|
+
}
|