@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,301 @@
|
|
|
1
|
+
import { errorDetail } from '../../logger/serve-log'
|
|
2
|
+
import type { MapOf, MixedPromise } from '../../types'
|
|
3
|
+
import { is } from '../../utils/common'
|
|
4
|
+
import { fs } from '../../utils/fs'
|
|
5
|
+
import { response } from '../../utils/http'
|
|
6
|
+
import { Handler } from './$base'
|
|
7
|
+
import { DynamicHandler } from './$dynamic'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ERROR: Handler.Error.Data = {
|
|
10
|
+
errorCode: 500,
|
|
11
|
+
errorText: 'Internal Server Error',
|
|
12
|
+
errorBody: 'An unexpected error occurred.',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A fresh copy of the process-wide default error data.
|
|
17
|
+
*
|
|
18
|
+
* The copy is the point, not the convenience. `extractErrorData` assigns the
|
|
19
|
+
* getter's result to a local and then *mutates* it, which is safe only while
|
|
20
|
+
* every `this` it runs under hands back a new object — a plain
|
|
21
|
+
* `= DEFAULT_ERROR` would let any caller write the process-wide default's
|
|
22
|
+
* fields. Both `ErrorHandler` and `DynamicErrorHandler` need the getter and
|
|
23
|
+
* they sit in different class hierarchies, so neither can inherit it from the
|
|
24
|
+
* other; they call this instead of each keeping a copy of the copy.
|
|
25
|
+
*/
|
|
26
|
+
function defaultErrorData(): Handler.Error.Data {
|
|
27
|
+
return Object.assign({}, DEFAULT_ERROR)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The three lines every page handler opens with: the error-data default, the
|
|
32
|
+
* route lookup, and the 404 for a path that resolves to nothing.
|
|
33
|
+
*
|
|
34
|
+
* `HTMLHandler`, `TSXHandler` and the Vue plugin's handler render three
|
|
35
|
+
* different file formats and their bodies genuinely differ — but all three
|
|
36
|
+
* reach them this way. `errors` is `undefined` for an ordinary page, and
|
|
37
|
+
* `DEFAULT_ERROR` exists only on the error subclasses, so the fallback stays
|
|
38
|
+
* `undefined` for the ordinary handlers. `DynamicErrorHandler.resolveRoute`
|
|
39
|
+
* uses the second argument to prefer `error-<code>` over `error`;
|
|
40
|
+
* `DynamicHandler.resolveRoute` ignores it.
|
|
41
|
+
*
|
|
42
|
+
* Hands back the 404 `Response` itself rather than a `null` the caller has to
|
|
43
|
+
* remember to turn into one.
|
|
44
|
+
*/
|
|
45
|
+
export async function beginPageRoute(
|
|
46
|
+
handler: typeof DynamicHandler | typeof DynamicErrorHandler,
|
|
47
|
+
path: string,
|
|
48
|
+
errors?: Handler.Error.Data,
|
|
49
|
+
): Promise<
|
|
50
|
+
| { errorData: Handler.Error.Data | undefined; info: Handler.Route.Info }
|
|
51
|
+
| Response
|
|
52
|
+
> {
|
|
53
|
+
const errorData = errors || (handler as any).DEFAULT_ERROR
|
|
54
|
+
const info = await handler.resolveRoute(path, errorData)
|
|
55
|
+
if (!info) return response.error('Not Found')
|
|
56
|
+
return { errorData, info }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stamp the DEV-only `__file` marker onto a page's params.
|
|
61
|
+
*
|
|
62
|
+
* One spelling of one rule: the marker goes on the params object **before**
|
|
63
|
+
* whatever error data gets merged in, and it names the route-relative path.
|
|
64
|
+
* `HTMLHandler` used to set it after the merge and `TSXHandler` before, which
|
|
65
|
+
* comes out the same today only because `publicErrorData` never emits a
|
|
66
|
+
* `__file` key — two orderings for one rule is what makes it look like the
|
|
67
|
+
* order might matter. It does not; this is the order, and `params` is the
|
|
68
|
+
* object because TSX also feeds that same object to `injectIfHtml`.
|
|
69
|
+
*/
|
|
70
|
+
export function markDevFile(params: MapOf<any>, routePath: string): void {
|
|
71
|
+
if (import.meta.env.DEV) params.__file = routePath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class HandlerError extends Error {
|
|
75
|
+
data: Handler.Error.Data
|
|
76
|
+
request?: Request = undefined
|
|
77
|
+
|
|
78
|
+
static getDefaultData() {
|
|
79
|
+
return DEFAULT_ERROR
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
message?: string,
|
|
84
|
+
req?: Request,
|
|
85
|
+
data?: Partial<Handler.Error.Data>,
|
|
86
|
+
) {
|
|
87
|
+
const finalData = {
|
|
88
|
+
...HandlerError.getDefaultData(),
|
|
89
|
+
...data,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
super(message || finalData.errorText || 'Handler Error')
|
|
93
|
+
this.data = finalData as Handler.Error.Data
|
|
94
|
+
this.request = req
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class ErrorHandler extends Handler {
|
|
99
|
+
/** A fresh copy every read — see `defaultErrorData`. */
|
|
100
|
+
static get DEFAULT_ERROR() {
|
|
101
|
+
return defaultErrorData()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static isError(error: any): boolean {
|
|
105
|
+
if (error instanceof Response) return error.status >= 400
|
|
106
|
+
if (error instanceof HandlerError) return true
|
|
107
|
+
if (is.object(error)) {
|
|
108
|
+
if ('errorCode' in error && is.number(error.errorCode)) {
|
|
109
|
+
return error.errorCode >= 400
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static canHandle(
|
|
117
|
+
path: string,
|
|
118
|
+
req: Request,
|
|
119
|
+
errors?: Handler.Error.Data,
|
|
120
|
+
): MixedPromise<boolean>
|
|
121
|
+
static canHandle() {
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static handle(
|
|
126
|
+
path: string,
|
|
127
|
+
req: Request,
|
|
128
|
+
errors?: Handler.Error.Data,
|
|
129
|
+
): Handler.Response
|
|
130
|
+
static handle() {}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The part of `error` that may cross the wire.
|
|
134
|
+
*
|
|
135
|
+
* `extractErrorData` puts `error.stack` into `errorBody` deliberately — that
|
|
136
|
+
* is what reaches the server log, and losing it would be a regression. But
|
|
137
|
+
* the same field was being handed straight to the client: a `SQLiteError`
|
|
138
|
+
* from a failed query answered a 500 with the failing statement, the table
|
|
139
|
+
* and column names, and absolute paths into the source tree.
|
|
140
|
+
*
|
|
141
|
+
* So the redaction belongs at the boundary, not at extraction. 5xx is the
|
|
142
|
+
* only band `extractErrorData` ever fills from a thrown `Error`, and
|
|
143
|
+
* therefore the only one whose body can be a stack; a 4xx body is authored
|
|
144
|
+
* by whoever constructed the `HandlerError` or the `Response` and is theirs
|
|
145
|
+
* to disclose.
|
|
146
|
+
*/
|
|
147
|
+
static publicBody(error: Handler.Error.Data): string {
|
|
148
|
+
if (import.meta.env.DEV) return error.errorBody
|
|
149
|
+
return error.errorCode >= 500 ? DEFAULT_ERROR.errorBody : error.errorBody
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether `error` is one of Bun's compile-time diagnostics.
|
|
154
|
+
*
|
|
155
|
+
* `BuildMessage` (a syntax error) and `ResolveMessage` (an import that
|
|
156
|
+
* resolves to nothing) are what the transpiler and the module resolver throw,
|
|
157
|
+
* and neither is `instanceof Error`. They used to reach the `is.object`
|
|
158
|
+
* branch below, which reads only `errorCode`/`errorText`/`errorBody` — none
|
|
159
|
+
* of which a diagnostic has — and so returned the untouched default. A typo
|
|
160
|
+
* in a route file, the single most common server-side failure there is,
|
|
161
|
+
* answered `An unexpected error occurred.` in development as well as in
|
|
162
|
+
* production.
|
|
163
|
+
*
|
|
164
|
+
* Recognised by shape rather than by constructor: the classes are not
|
|
165
|
+
* exported, and both report zero own enumerable keys, so a string `message`
|
|
166
|
+
* on a non-`Error` object is the only tell. The error-data keys are checked
|
|
167
|
+
* first by the caller, so a record that carries those still takes its own
|
|
168
|
+
* branch.
|
|
169
|
+
*/
|
|
170
|
+
static isDiagnostic(error: any): boolean {
|
|
171
|
+
if (!is.object(error)) return false
|
|
172
|
+
if (error instanceof Error || error instanceof Response) return false
|
|
173
|
+
return is.string(error.message) && error.message.length > 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
static extractErrorData(error: any): Handler.Error.Data {
|
|
177
|
+
if (error instanceof HandlerError) return error.data
|
|
178
|
+
|
|
179
|
+
if (error instanceof Error) {
|
|
180
|
+
return {
|
|
181
|
+
...this.DEFAULT_ERROR,
|
|
182
|
+
errorText: error.message,
|
|
183
|
+
// `errorDetail` is `error.stack` whenever there is one — so the common
|
|
184
|
+
// case is untouched — and falls back to the aggregated sub-diagnostics
|
|
185
|
+
// for the stackless `AggregateError` that `import()`ing a broken `.tsx`
|
|
186
|
+
// throws, where `String(error)` was a summary count and nothing else.
|
|
187
|
+
errorBody: errorDetail(error) || String(error),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (error instanceof Response) {
|
|
192
|
+
return {
|
|
193
|
+
...this.DEFAULT_ERROR,
|
|
194
|
+
errorCode: error.status,
|
|
195
|
+
errorText: error.statusText,
|
|
196
|
+
errorBody: `${error.status}: "${error.statusText}"`,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (is.object(error)) {
|
|
201
|
+
const errorObj = error as Partial<Handler.Error.Data>
|
|
202
|
+
const authored =
|
|
203
|
+
errorObj.errorCode !== undefined ||
|
|
204
|
+
errorObj.errorText !== undefined ||
|
|
205
|
+
errorObj.errorBody !== undefined
|
|
206
|
+
|
|
207
|
+
// Authored error data wins, `message` or no `message` — the branch order
|
|
208
|
+
// and its semantics are unchanged for everything that ever reached it.
|
|
209
|
+
if (!authored && this.isDiagnostic(error)) {
|
|
210
|
+
return {
|
|
211
|
+
...this.DEFAULT_ERROR,
|
|
212
|
+
errorText: error.message,
|
|
213
|
+
errorBody: errorDetail(error),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const errorData = this.DEFAULT_ERROR
|
|
218
|
+
|
|
219
|
+
errorData.errorCode = errorObj.errorCode ?? errorData.errorCode
|
|
220
|
+
errorData.errorText = errorObj.errorText ?? errorData.errorText
|
|
221
|
+
errorData.errorBody = errorObj.errorBody ?? errorData.errorBody
|
|
222
|
+
return errorData
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (is.string(error)) {
|
|
226
|
+
return { ...this.DEFAULT_ERROR, errorText: error }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.DEFAULT_ERROR
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class DynamicErrorHandler extends DynamicHandler {
|
|
234
|
+
/**
|
|
235
|
+
* The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back — see
|
|
236
|
+
* `defaultErrorData`. Declared again rather than inherited because this
|
|
237
|
+
* class descends from `DynamicHandler`, not from `ErrorHandler`.
|
|
238
|
+
*/
|
|
239
|
+
static get DEFAULT_ERROR() {
|
|
240
|
+
return defaultErrorData()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
static canHandle(
|
|
244
|
+
path: string,
|
|
245
|
+
req: Request,
|
|
246
|
+
errors?: Handler.Error.Data,
|
|
247
|
+
): MixedPromise<boolean>
|
|
248
|
+
static async canHandle(path: string, _: any, errors?: Handler.Error.Data) {
|
|
249
|
+
return Boolean(await this.resolveRoute(path, errors))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static handle(
|
|
253
|
+
path: string,
|
|
254
|
+
req: Request,
|
|
255
|
+
errors?: Handler.Error.Data,
|
|
256
|
+
): Handler.Response
|
|
257
|
+
|
|
258
|
+
static handle(path: string, req: Request, errors?: Handler.Error.Data) {
|
|
259
|
+
return (super.handle as any)(path, req, errors)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
static async resolveRoute(path: string, errors?: Handler.Error.Data) {
|
|
263
|
+
errors ||= this.DEFAULT_ERROR
|
|
264
|
+
|
|
265
|
+
const parsed = fs.parse(path)
|
|
266
|
+
const pathArray = parsed.dir.split('/').filter(Boolean)
|
|
267
|
+
|
|
268
|
+
for (let i = pathArray.length; i >= 0; i--) {
|
|
269
|
+
const prefix = i ? `/${pathArray.slice(0, i).join('/')}` : ''
|
|
270
|
+
|
|
271
|
+
const defsPage = `${prefix}/error`
|
|
272
|
+
const codePage = `${prefix}/error-${errors.errorCode}`
|
|
273
|
+
const routeInfo =
|
|
274
|
+
(await super.resolveRoute(codePage)) ||
|
|
275
|
+
(await super.resolveRoute(defsPage))
|
|
276
|
+
if (routeInfo) return routeInfo
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Error data with `errorBody` reduced to what a client may see.
|
|
285
|
+
*
|
|
286
|
+
* `ErrorHandler.publicBody` is the rule; this is it applied to a whole record,
|
|
287
|
+
* for the handlers that hand error data to a *template* rather than rendering
|
|
288
|
+
* a string themselves. `HTMLErrorHandler` and `TSXErrorHandler` merge the
|
|
289
|
+
* record into their page params, and those params reach the document twice —
|
|
290
|
+
* through `{{...}}` substitution and through the `window.__PAGE_PARAMS__`
|
|
291
|
+
* script `DOMTools.params` injects into every page. The second path is why
|
|
292
|
+
* redacting in the template was never enough: an `error.html` that never
|
|
293
|
+
* mentions `errorBody` still published the stack, absolute source paths and
|
|
294
|
+
* all, to any anonymous request in production.
|
|
295
|
+
*
|
|
296
|
+
* `errorText` is deliberately untouched — `DefaultErrorHandler` shows it in
|
|
297
|
+
* its heading in every mode, and one rule in one place beats two that drift.
|
|
298
|
+
*/
|
|
299
|
+
export function publicErrorData(error: Handler.Error.Data): Handler.Error.Data {
|
|
300
|
+
return { ...error, errorBody: ErrorHandler.publicBody(error) }
|
|
301
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
import { errorMsg, handlerLog } from '../../logger/serve-log'
|
|
3
|
+
import { injectIfHtml, response } from '../../utils/http'
|
|
4
|
+
import { Handler } from './$base'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-request slot for the response produced during `canHandle`, so `handle`
|
|
8
|
+
* can return it without re-running the chain. This was previously a static
|
|
9
|
+
* field, which meant two concurrent requests could swap responses — including
|
|
10
|
+
* each other's `Set-Cookie` headers — at any `await` boundary.
|
|
11
|
+
*/
|
|
12
|
+
const pending = new WeakMap<Request, Response>()
|
|
13
|
+
|
|
14
|
+
export class MiddlewareHandler extends Handler {
|
|
15
|
+
/** Answers from app code, not from disk. See `Handler.servesFiles`. */
|
|
16
|
+
static servesFiles = false
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `canHandle` here means "was this request denied", which is a fact about
|
|
20
|
+
* the request and not about the path. Without this the route cache would
|
|
21
|
+
* serve the page handler that the first *allowed* request resolved to,
|
|
22
|
+
* and every later request to that path would skip middleware entirely.
|
|
23
|
+
*/
|
|
24
|
+
static override alwaysResolve = true
|
|
25
|
+
|
|
26
|
+
static async canHandle(path: string, req: Request) {
|
|
27
|
+
const result = await this.handle(path, req)
|
|
28
|
+
if (result) pending.set(req, result)
|
|
29
|
+
return Boolean(result)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async handle(_path: string, req: Request) {
|
|
33
|
+
const cached = pending.get(req)
|
|
34
|
+
if (cached) {
|
|
35
|
+
pending.delete(req)
|
|
36
|
+
return cached
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One config read for both `onRequest` and the middleware chain — same
|
|
40
|
+
// request, same host store, so the snapshot cannot go stale mid-call.
|
|
41
|
+
const config = Bakery.config
|
|
42
|
+
const intercepted = await config.onRequest(req!)
|
|
43
|
+
if (intercepted) {
|
|
44
|
+
// `|| undefined` used to sit here, and `injectIfHtml` returns null for
|
|
45
|
+
// anything that is not HTML — so a plain-text 403 from `onRequest`
|
|
46
|
+
// vanished and the request carried on. Inject when it is HTML, keep the
|
|
47
|
+
// original otherwise, exactly as the middleware chain below does.
|
|
48
|
+
return (await injectIfHtml(intercepted)) || intercepted
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let data: any
|
|
52
|
+
|
|
53
|
+
for (const middleware of config.middleware) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await middleware(req, Bakery.server!)
|
|
56
|
+
if (result instanceof Response) {
|
|
57
|
+
data = result
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// Fail closed: a middleware that throws is often an auth check, and
|
|
62
|
+
// treating it as "no response" would let the request through.
|
|
63
|
+
handlerLog.MIDDLEWARE_ERR({ error: errorMsg(error) })
|
|
64
|
+
return response.error('Internal Server Error', 500)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const injectedRes = await injectIfHtml(data)
|
|
69
|
+
return injectedRes || data
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fs } from '../../utils'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route mounts: map a URL prefix onto a directory outside the app's serve root.
|
|
5
|
+
*
|
|
6
|
+
* Handlers resolve files under `Bakery.serveRoot`, so a plugin wanting to serve
|
|
7
|
+
* its own pages or client assets had no choice but to hand-roll it — reading
|
|
8
|
+
* files itself, bundling on the fly, and reimplementing the caching and
|
|
9
|
+
* containment the handler pipeline already does. `DashboardHandler` is the
|
|
10
|
+
* worked example of that cost.
|
|
11
|
+
*
|
|
12
|
+
* A mount lets a plugin say "paths under `/_dashboard` come from *my*
|
|
13
|
+
* directory", after which the normal handlers (TSX, TS, HTML, static, the
|
|
14
|
+
* compiler, the route cache) treat those files exactly like app files.
|
|
15
|
+
*
|
|
16
|
+
* Containment still applies: `getRoute` receives the mount directory as both
|
|
17
|
+
* the search dir *and* the root, so traversal cannot escape it.
|
|
18
|
+
*/
|
|
19
|
+
export interface RouteMount {
|
|
20
|
+
/** URL prefix, leading slash, no trailing slash — e.g. `/_dashboard`. */
|
|
21
|
+
prefix: string
|
|
22
|
+
/** Absolute directory the prefix resolves against. */
|
|
23
|
+
dir: fs.AbsolutePath
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Registered longest-prefix-first, so nested mounts resolve predictably. */
|
|
27
|
+
const mounts: RouteMount[] = []
|
|
28
|
+
|
|
29
|
+
function normalizePrefix(prefix: string): string {
|
|
30
|
+
const withSlash = prefix.startsWith('/') ? prefix : `/${prefix}`
|
|
31
|
+
return withSlash.length > 1 && withSlash.endsWith('/')
|
|
32
|
+
? withSlash.slice(0, -1)
|
|
33
|
+
: withSlash
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Serve `prefix/*` from `dir`. Call from a plugin's `setup()`.
|
|
38
|
+
*
|
|
39
|
+
* Re-registering the same prefix replaces it, so a reloading dev worker does
|
|
40
|
+
* not accumulate duplicates.
|
|
41
|
+
*/
|
|
42
|
+
export function mountRoutes(prefix: string, dir: string): RouteMount {
|
|
43
|
+
const mount: RouteMount = {
|
|
44
|
+
prefix: normalizePrefix(prefix),
|
|
45
|
+
dir: fs.resolve(dir) as fs.AbsolutePath,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const existing = mounts.findIndex(m => m.prefix === mount.prefix)
|
|
49
|
+
if (existing !== -1) mounts.splice(existing, 1)
|
|
50
|
+
|
|
51
|
+
mounts.push(mount)
|
|
52
|
+
mounts.sort((a, b) => b.prefix.length - a.prefix.length)
|
|
53
|
+
return mount
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mount owning `path`, plus the path relative to it, or null.
|
|
58
|
+
*
|
|
59
|
+
* A prefix matches only on a segment boundary: `/_dash` must not capture
|
|
60
|
+
* `/_dashboard`.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveMount(
|
|
63
|
+
path: string,
|
|
64
|
+
): { mount: RouteMount; rest: string } | null {
|
|
65
|
+
const normalized = path.startsWith('/') ? path : `/${path}`
|
|
66
|
+
|
|
67
|
+
for (const mount of mounts) {
|
|
68
|
+
if (normalized === mount.prefix) return { mount, rest: '' }
|
|
69
|
+
if (normalized.startsWith(`${mount.prefix}/`)) {
|
|
70
|
+
return { mount, rest: normalized.slice(mount.prefix.length + 1) }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every registered mount, longest prefix first. */
|
|
77
|
+
export function getMounts(): readonly RouteMount[] {
|
|
78
|
+
return mounts
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Drop all mounts. Intended for tests. */
|
|
82
|
+
export function clearMounts(): void {
|
|
83
|
+
mounts.length = 0
|
|
84
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { LRUCache } from '../../cache/lru'
|
|
2
|
+
import type { Handler } from './$base'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry ids for `routeCache` keys. A module counter gives the same
|
|
6
|
+
* per-process uniqueness a UUID did (the cache never outlives the process)
|
|
7
|
+
* with a key prefix of a few characters instead of thirty-six.
|
|
8
|
+
*/
|
|
9
|
+
let nextMapId = 0
|
|
10
|
+
|
|
11
|
+
export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
12
|
+
any,
|
|
13
|
+
number
|
|
14
|
+
> {
|
|
15
|
+
public static routeCache = new LRUCache<string, typeof Handler>(
|
|
16
|
+
import.meta.env.THREAD_WORKER ? 500 : 5000,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
private cachedList: T[] | null = null
|
|
20
|
+
private cachedGates: T[] | null = null
|
|
21
|
+
private cachedOrder: Map<any, number> | null = null
|
|
22
|
+
private id = nextMapId++
|
|
23
|
+
|
|
24
|
+
constructor(entries?: readonly (readonly [any, number])[] | null) {
|
|
25
|
+
super()
|
|
26
|
+
if (!entries) return
|
|
27
|
+
|
|
28
|
+
for (const [handlerClass, priority] of entries) {
|
|
29
|
+
this.set(handlerClass, priority)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(handlerClass: any, priority: number = 10): this {
|
|
34
|
+
super.set(handlerClass, priority)
|
|
35
|
+
this.cachedList = null
|
|
36
|
+
this.cachedGates = null
|
|
37
|
+
this.cachedOrder = null
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
add(handlerClass: any, priority?: number): this {
|
|
42
|
+
return this.set(handlerClass, priority)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
list(): T[] {
|
|
46
|
+
if (this.cachedList) {
|
|
47
|
+
return this.cachedList
|
|
48
|
+
}
|
|
49
|
+
this.cachedList = Array.from(this.entries())
|
|
50
|
+
.sort((a, b) => b[1] - a[1])
|
|
51
|
+
.map(entry => entry[0])
|
|
52
|
+
|
|
53
|
+
return this.cachedList
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Handlers that opt out of the cache bypass, in priority order. */
|
|
57
|
+
private gatekeepers(): T[] {
|
|
58
|
+
this.cachedGates ??= this.list().filter(h => (h as any).alwaysResolve)
|
|
59
|
+
return this.cachedGates
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Position of each handler in the priority-sorted list. */
|
|
63
|
+
private order(): Map<any, number> {
|
|
64
|
+
this.cachedOrder ??= new Map(this.list().map((h, i) => [h, i]))
|
|
65
|
+
return this.cachedOrder
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `canHandle` without the microtask hop when the answer is synchronous.
|
|
70
|
+
*
|
|
71
|
+
* Most canHandles are plain predicates, and `await`ing their boolean cost a
|
|
72
|
+
* microtask hop (~426ns) per probe, 2–4 probes per request. Returns a
|
|
73
|
+
* boolean for a sync answer and the promise itself for an async one, so
|
|
74
|
+
* callers only `await` a value that is actually a promise:
|
|
75
|
+
*
|
|
76
|
+
* const r = HandlerMap.probe(...)
|
|
77
|
+
* if (r === true || (r !== false && (await r))) ...
|
|
78
|
+
*/
|
|
79
|
+
private static probe(
|
|
80
|
+
handler: any,
|
|
81
|
+
path: string,
|
|
82
|
+
req: Request | undefined,
|
|
83
|
+
rest: any[],
|
|
84
|
+
): boolean | Promise<any> {
|
|
85
|
+
const r = handler.canHandle(path, req, ...rest)
|
|
86
|
+
return r && typeof (r as any).then === 'function' ? r : Boolean(r)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The first extra argument is the request in every registry's call shape
|
|
91
|
+
* (fetch: `(path, req)`, websocket: `(path, req)`, error:
|
|
92
|
+
* `(path, req, error)`), so it is named rather than fished out of a variadic
|
|
93
|
+
* list with an `instanceof` scan per request. `...rest` carries the error
|
|
94
|
+
* registry's error through to `canHandle`/`handle` untouched.
|
|
95
|
+
*/
|
|
96
|
+
// The `probed` bookkeeping exists because middleware has side effects and
|
|
97
|
+
// must not run twice. That constraint is what the branching encodes.
|
|
98
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
|
|
99
|
+
async resolve(path: string, req?: Request, ...rest: any[]) {
|
|
100
|
+
const host = req?.__hostname || ''
|
|
101
|
+
const pathId = `${this.id}:${host}:${path}`
|
|
102
|
+
const cached: any = HandlerMap.routeCache.get(pathId)
|
|
103
|
+
|
|
104
|
+
// Only tracked once there is a cache hit to skip past. Middleware has
|
|
105
|
+
// side effects (sessions, rate-limit counters), so a handler probed on the
|
|
106
|
+
// cache path must not be probed a second time by the loop below.
|
|
107
|
+
let probed: Set<any> | null = null
|
|
108
|
+
|
|
109
|
+
if (cached) {
|
|
110
|
+
probed = new Set()
|
|
111
|
+
// A cache hit skips every handler above the cached one. Ask the
|
|
112
|
+
// gatekeepers that outrank it first — the same handlers, in the same
|
|
113
|
+
// order, a cold resolve would have reached before the cached one.
|
|
114
|
+
const rank = this.order().get(cached) ?? Number.POSITIVE_INFINITY
|
|
115
|
+
for (const gate of this.gatekeepers()) {
|
|
116
|
+
if ((this.order().get(gate) ?? 0) >= rank) break
|
|
117
|
+
probed.add(gate)
|
|
118
|
+
const r = HandlerMap.probe(gate, path, req, rest)
|
|
119
|
+
if (r === true || (r !== false && (await r))) return gate
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
probed.add(cached)
|
|
123
|
+
const r = HandlerMap.probe(cached, path, req, rest)
|
|
124
|
+
if (r === true || (r !== false && (await r))) return cached
|
|
125
|
+
HandlerMap.routeCache.delete(pathId)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const handler of this.list() as any) {
|
|
129
|
+
if (probed?.has(handler)) continue
|
|
130
|
+
const r = HandlerMap.probe(handler, path, req, rest)
|
|
131
|
+
if (r === true || (r !== false && (await r))) {
|
|
132
|
+
// A gatekeeper's `true` describes this request, not this path. Caching
|
|
133
|
+
// it would both evict the path's real handler and park a per-request
|
|
134
|
+
// decision in a cache that has no TTL.
|
|
135
|
+
if (!handler.alwaysResolve) HandlerMap.routeCache.set(pathId, handler)
|
|
136
|
+
return handler as T
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
initRoutes() {
|
|
144
|
+
return Promise.all(this.list().map(handler => handler.initRoutes()))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
handle(path: string, req?: Request, ...rest: any[]): Handler.Response
|
|
148
|
+
async handle(path: string, req?: Request, ...rest: any[]) {
|
|
149
|
+
const handler: any = await this.resolve(path, req, ...rest)
|
|
150
|
+
|
|
151
|
+
return handler ? handler.handle(path, req, ...rest) : null
|
|
152
|
+
}
|
|
153
|
+
}
|