@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2
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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +54 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +29 -8
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
|
@@ -17,7 +17,7 @@ const DEFAULT_ERROR: Handler.Error.Data = {
|
|
|
17
17
|
*
|
|
18
18
|
* The copy is the point, not the convenience. `extractErrorData` assigns the
|
|
19
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
|
|
20
|
+
* every `this` it runs under hands back a new object: a plain
|
|
21
21
|
* `= DEFAULT_ERROR` would let any caller write the process-wide default's
|
|
22
22
|
* fields. Both `ErrorHandler` and `DynamicErrorHandler` need the getter and
|
|
23
23
|
* they sit in different class hierarchies, so neither can inherit it from the
|
|
@@ -32,7 +32,7 @@ function defaultErrorData(): Handler.Error.Data {
|
|
|
32
32
|
* route lookup, and the 404 for a path that resolves to nothing.
|
|
33
33
|
*
|
|
34
34
|
* `HTMLHandler`, `TSXHandler` and the Vue plugin's handler render three
|
|
35
|
-
* different file formats and their bodies genuinely differ
|
|
35
|
+
* different file formats and their bodies genuinely differ, but all three
|
|
36
36
|
* reach them this way. `errors` is `undefined` for an ordinary page, and
|
|
37
37
|
* `DEFAULT_ERROR` exists only on the error subclasses, so the fallback stays
|
|
38
38
|
* `undefined` for the ordinary handlers. `DynamicErrorHandler.resolveRoute`
|
|
@@ -63,7 +63,7 @@ export async function beginPageRoute(
|
|
|
63
63
|
* whatever error data gets merged in, and it names the route-relative path.
|
|
64
64
|
* `HTMLHandler` used to set it after the merge and `TSXHandler` before, which
|
|
65
65
|
* comes out the same today only because `publicErrorData` never emits a
|
|
66
|
-
* `__file` key
|
|
66
|
+
* `__file` key: two orderings for one rule is what makes it look like the
|
|
67
67
|
* order might matter. It does not; this is the order, and `params` is the
|
|
68
68
|
* object because TSX also feeds that same object to `injectIfHtml`.
|
|
69
69
|
*/
|
|
@@ -96,7 +96,7 @@ export class HandlerError extends Error {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export class ErrorHandler extends Handler {
|
|
99
|
-
/** A fresh copy every read
|
|
99
|
+
/** A fresh copy every read. See `defaultErrorData`. */
|
|
100
100
|
static get DEFAULT_ERROR() {
|
|
101
101
|
return defaultErrorData()
|
|
102
102
|
}
|
|
@@ -132,7 +132,7 @@ export class ErrorHandler extends Handler {
|
|
|
132
132
|
/**
|
|
133
133
|
* The part of `error` that may cross the wire.
|
|
134
134
|
*
|
|
135
|
-
* `extractErrorData` puts `error.stack` into `errorBody` deliberately
|
|
135
|
+
* `extractErrorData` puts `error.stack` into `errorBody` deliberately: that
|
|
136
136
|
* is what reaches the server log, and losing it would be a regression. But
|
|
137
137
|
* the same field was being handed straight to the client: a `SQLiteError`
|
|
138
138
|
* from a failed query answered a 500 with the failing statement, the table
|
|
@@ -155,13 +155,13 @@ export class ErrorHandler extends Handler {
|
|
|
155
155
|
* `BuildMessage` (a syntax error) and `ResolveMessage` (an import that
|
|
156
156
|
* resolves to nothing) are what the transpiler and the module resolver throw,
|
|
157
157
|
* and neither is `instanceof Error`. They used to reach the `is.object`
|
|
158
|
-
* branch below, which reads only `errorCode`/`errorText`/`errorBody`
|
|
159
|
-
* of which a diagnostic has
|
|
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
160
|
* in a route file, the single most common server-side failure there is,
|
|
161
161
|
* answered `An unexpected error occurred.` in development as well as in
|
|
162
162
|
* production.
|
|
163
163
|
*
|
|
164
|
-
*
|
|
164
|
+
* Recognized by shape rather than by constructor: the classes are not
|
|
165
165
|
* exported, and both report zero own enumerable keys, so a string `message`
|
|
166
166
|
* on a non-`Error` object is the only tell. The error-data keys are checked
|
|
167
167
|
* first by the caller, so a record that carries those still takes its own
|
|
@@ -180,8 +180,8 @@ export class ErrorHandler extends Handler {
|
|
|
180
180
|
return {
|
|
181
181
|
...this.DEFAULT_ERROR,
|
|
182
182
|
errorText: error.message,
|
|
183
|
-
// `errorDetail` is `error.stack` whenever there is one
|
|
184
|
-
// case is untouched
|
|
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
185
|
// for the stackless `AggregateError` that `import()`ing a broken `.tsx`
|
|
186
186
|
// throws, where `String(error)` was a summary count and nothing else.
|
|
187
187
|
errorBody: errorDetail(error) || String(error),
|
|
@@ -193,7 +193,16 @@ export class ErrorHandler extends Handler {
|
|
|
193
193
|
...this.DEFAULT_ERROR,
|
|
194
194
|
errorCode: error.status,
|
|
195
195
|
errorText: error.statusText,
|
|
196
|
-
|
|
196
|
+
// The reason-phrase is optional: `new Response(null, { status })`
|
|
197
|
+
// leaves it `''`, and the framework's own forbidden-path denial is
|
|
198
|
+
// exactly that shape, so the quoting is conditional: a synthesized
|
|
199
|
+
// `403: ""` rendered a literal quoted empty string on the error page.
|
|
200
|
+
// The bare status, not `''`, because this string is also the default
|
|
201
|
+
// `onError` log line (`403 at /path`); an empty body would log
|
|
202
|
+
// ` at /path`.
|
|
203
|
+
errorBody: error.statusText
|
|
204
|
+
? `${error.status}: "${error.statusText}"`
|
|
205
|
+
: String(error.status),
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -204,7 +213,7 @@ export class ErrorHandler extends Handler {
|
|
|
204
213
|
errorObj.errorText !== undefined ||
|
|
205
214
|
errorObj.errorBody !== undefined
|
|
206
215
|
|
|
207
|
-
// Authored error data wins, `message` or no `message
|
|
216
|
+
// Authored error data wins, `message` or no `message`, the branch order
|
|
208
217
|
// and its semantics are unchanged for everything that ever reached it.
|
|
209
218
|
if (!authored && this.isDiagnostic(error)) {
|
|
210
219
|
return {
|
|
@@ -232,7 +241,7 @@ export class ErrorHandler extends Handler {
|
|
|
232
241
|
|
|
233
242
|
export class DynamicErrorHandler extends DynamicHandler {
|
|
234
243
|
/**
|
|
235
|
-
* The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back
|
|
244
|
+
* The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back. See
|
|
236
245
|
* `defaultErrorData`. Declared again rather than inherited because this
|
|
237
246
|
* class descends from `DynamicHandler`, not from `ErrorHandler`.
|
|
238
247
|
*/
|
|
@@ -270,9 +279,12 @@ export class DynamicErrorHandler extends DynamicHandler {
|
|
|
270
279
|
|
|
271
280
|
const defsPage = `${prefix}/error`
|
|
272
281
|
const codePage = `${prefix}/error-${errors.errorCode}`
|
|
282
|
+
// `resolveStaticRoute`, never `resolveRoute`: an error page is a file by
|
|
283
|
+
// name, and letting the dynamic matcher answer meant a root catch-all
|
|
284
|
+
// claimed every one of them. See the note on that method.
|
|
273
285
|
const routeInfo =
|
|
274
|
-
(await super.
|
|
275
|
-
(await super.
|
|
286
|
+
(await super.resolveStaticRoute(codePage)) ||
|
|
287
|
+
(await super.resolveStaticRoute(defsPage))
|
|
276
288
|
if (routeInfo) return routeInfo
|
|
277
289
|
}
|
|
278
290
|
|
|
@@ -286,14 +298,14 @@ export class DynamicErrorHandler extends DynamicHandler {
|
|
|
286
298
|
* `ErrorHandler.publicBody` is the rule; this is it applied to a whole record,
|
|
287
299
|
* for the handlers that hand error data to a *template* rather than rendering
|
|
288
300
|
* a string themselves. `HTMLErrorHandler` and `TSXErrorHandler` merge the
|
|
289
|
-
* record into their page params, and those params reach the document twice
|
|
301
|
+
* record into their page params, and those params reach the document twice,
|
|
290
302
|
* through `{{...}}` substitution and through the `window.__PAGE_PARAMS__`
|
|
291
303
|
* script `DOMTools.params` injects into every page. The second path is why
|
|
292
304
|
* redacting in the template was never enough: an `error.html` that never
|
|
293
305
|
* mentions `errorBody` still published the stack, absolute source paths and
|
|
294
306
|
* all, to any anonymous request in production.
|
|
295
307
|
*
|
|
296
|
-
* `errorText` is deliberately untouched
|
|
308
|
+
* `errorText` is deliberately untouched: `DefaultErrorHandler` shows it in
|
|
297
309
|
* its heading in every mode, and one rule in one place beats two that drift.
|
|
298
310
|
*/
|
|
299
311
|
export function publicErrorData(error: Handler.Error.Data): Handler.Error.Data {
|
|
@@ -11,7 +11,7 @@ import { Handler } from './$base'
|
|
|
11
11
|
* else. `JsonResponseData` is the framework's one-envelope idiom (convention
|
|
12
12
|
* 7) and `processResponse` already renders it with its own `status`, so an
|
|
13
13
|
* `ApiHandler` route returning `response.json.error(401, …)` answered 401
|
|
14
|
-
* while the identical line in a middleware did not
|
|
14
|
+
* while the identical line in a middleware did not: the value was not a
|
|
15
15
|
* `Response`, so the chain ignored it and the request carried on.
|
|
16
16
|
*
|
|
17
17
|
* Widening further, to "anything truthy", is the tempting version and is
|
|
@@ -30,8 +30,8 @@ function isMiddlewareResult(value: unknown): value is MiddlewareResult {
|
|
|
30
30
|
/**
|
|
31
31
|
* Per-request slot for the response produced during `canHandle`, so `handle`
|
|
32
32
|
* can return it without re-running the chain. This was previously a static
|
|
33
|
-
* field, which meant two concurrent requests could swap responses
|
|
34
|
-
* each other's `Set-Cookie` headers
|
|
33
|
+
* field, which meant two concurrent requests could swap responses (including
|
|
34
|
+
* each other's `Set-Cookie` headers) at any `await` boundary.
|
|
35
35
|
*/
|
|
36
36
|
const pending = new WeakMap<Request, MiddlewareResult>()
|
|
37
37
|
|
|
@@ -60,13 +60,13 @@ export class MiddlewareHandler extends Handler {
|
|
|
60
60
|
return cached
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// One config read for both `onRequest` and the middleware chain
|
|
63
|
+
// One config read for both `onRequest` and the middleware chain: same
|
|
64
64
|
// request, same host store, so the snapshot cannot go stale mid-call.
|
|
65
65
|
const config = Bakery.config
|
|
66
66
|
const intercepted = await config.onRequest(req!)
|
|
67
67
|
if (intercepted) {
|
|
68
68
|
// `|| undefined` used to sit here, and `injectIfHtml` returns null for
|
|
69
|
-
// anything that is not HTML
|
|
69
|
+
// anything that is not HTML, so a plain-text 403 from `onRequest`
|
|
70
70
|
// vanished and the request carried on. Inject when it is HTML, keep the
|
|
71
71
|
// original otherwise, exactly as the middleware chain below does.
|
|
72
72
|
return (await injectIfHtml(intercepted)) || intercepted
|
|
@@ -91,7 +91,7 @@ export class MiddlewareHandler extends Handler {
|
|
|
91
91
|
|
|
92
92
|
// An envelope is JSON by construction, so it skips injection rather than
|
|
93
93
|
// paying `DOMTools.isHTML` to be told so. `processResponse` reads its
|
|
94
|
-
// `status` and
|
|
94
|
+
// `status` and serializes it.
|
|
95
95
|
if (data instanceof JsonResponseData) return data
|
|
96
96
|
|
|
97
97
|
const injectedRes = await injectIfHtml(data!)
|
|
@@ -4,7 +4,7 @@ import { fs } from '../../utils'
|
|
|
4
4
|
* Route mounts: map a URL prefix onto a directory outside the app's serve root.
|
|
5
5
|
*
|
|
6
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
|
|
7
|
+
* its own pages or client assets had no choice but to hand-roll it: reading
|
|
8
8
|
* files itself, bundling on the fly, and reimplementing the caching and
|
|
9
9
|
* containment the handler pipeline already does. `DashboardHandler` is the
|
|
10
10
|
* worked example of that cost.
|
|
@@ -17,7 +17,7 @@ import { fs } from '../../utils'
|
|
|
17
17
|
* the search dir *and* the root, so traversal cannot escape it.
|
|
18
18
|
*/
|
|
19
19
|
export interface RouteMount {
|
|
20
|
-
/** URL prefix, leading slash, no trailing slash
|
|
20
|
+
/** URL prefix, leading slash, no trailing slash, e.g. `/_dashboard`. */
|
|
21
21
|
prefix: string
|
|
22
22
|
/** Absolute directory the prefix resolves against. */
|
|
23
23
|
dir: fs.AbsolutePath
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LRUCache } from '../../cache/lru'
|
|
2
|
+
import { hostKey } from '../../core/bakery'
|
|
2
3
|
import type { Handler } from './$base'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -51,8 +52,8 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
51
52
|
* `delete` and `clear` invalidate too, and neither used to.
|
|
52
53
|
*
|
|
53
54
|
* `list()` memoizes the sorted handler array and only `set` cleared it, so a
|
|
54
|
-
* removed handler stayed in the list
|
|
55
|
-
* pipeline*
|
|
55
|
+
* removed handler stayed in the list (and therefore stayed *in the request
|
|
56
|
+
* pipeline*) until something happened to add one. Registration is
|
|
56
57
|
* add-only in a served process, which is why this never bit: it is reachable
|
|
57
58
|
* only by code that unregisters, and the first thing to do that was a test.
|
|
58
59
|
*
|
|
@@ -98,7 +99,7 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
98
99
|
* `canHandle` without the microtask hop when the answer is synchronous.
|
|
99
100
|
*
|
|
100
101
|
* Most canHandles are plain predicates, and `await`ing their boolean cost a
|
|
101
|
-
* microtask hop (~426ns) per probe, 2
|
|
102
|
+
* microtask hop (~426ns) per probe, 2 to 4 probes per request. Returns a
|
|
102
103
|
* boolean for a sync answer and the promise itself for an async one, so
|
|
103
104
|
* callers only `await` a value that is actually a promise:
|
|
104
105
|
*
|
|
@@ -126,8 +127,17 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
126
127
|
// must not run twice. That constraint is what the branching encodes.
|
|
127
128
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
|
|
128
129
|
async resolve(path: string, req?: Request, ...rest: any[]) {
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
// The **resolved** host, not the header. `__hostname` is whatever arrived
|
|
131
|
+
// in `Host`, and `routeCache` is one bounded LRU shared by every tenant, so
|
|
132
|
+
// a client varying that header minted an entry per spelling and walked the
|
|
133
|
+
// real hosts' entries out of the cache for as long as it kept asking. The
|
|
134
|
+
// file cache carried the same bug and was fixed; this one kept it.
|
|
135
|
+
//
|
|
136
|
+
// `hostKey` resolves through the multi-host config the way every other
|
|
137
|
+
// per-tenant key does, so an unknown or unconfigured host collapses to a
|
|
138
|
+
// single entry instead of one per spelling. The shape is unchanged when
|
|
139
|
+
// there is no host: `hostKey` returns the bare path.
|
|
140
|
+
const pathId = `${this.id}:${hostKey(path)}`
|
|
131
141
|
const cached: any = HandlerMap.routeCache.get(pathId)
|
|
132
142
|
|
|
133
143
|
// Only tracked once there is a cache hit to skip past. Middleware has
|
|
@@ -138,7 +148,7 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
138
148
|
if (cached) {
|
|
139
149
|
probed = new Set()
|
|
140
150
|
// A cache hit skips every handler above the cached one. Ask the
|
|
141
|
-
// gatekeepers that outrank it first
|
|
151
|
+
// gatekeepers that outrank it first: the same handlers, in the same
|
|
142
152
|
// order, a cold resolve would have reached before the cached one.
|
|
143
153
|
const rank = this.order().get(cached) ?? Number.POSITIVE_INFINITY
|
|
144
154
|
for (const gate of this.gatekeepers()) {
|
|
@@ -15,14 +15,14 @@ export type RouteScanOptions = {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// `[!.]` keeps catch-alls (`[...name]`) out of the single-param globs: they
|
|
18
|
-
// are matched separately, and last
|
|
18
|
+
// are matched separately, and last. A catch-all is the weakest route form,
|
|
19
19
|
// consulted only after specific files, single-param siblings and child-index
|
|
20
20
|
// descent have all missed.
|
|
21
21
|
const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
|
|
22
22
|
|
|
23
23
|
// The single-param route forms, in the order they are tried: `[name].ext`
|
|
24
24
|
// first, then the literal-asterisk `*.ext`. Built here rather than twice inside
|
|
25
|
-
// `routeGlobs
|
|
25
|
+
// `routeGlobs`: the `dynamicOnly` branch and the combined branch returned
|
|
26
26
|
// character-identical pairs, and the two must stay in step or a route form
|
|
27
27
|
// resolves under one caller and not the other. A function, not a hoisted
|
|
28
28
|
// constant: `ext` varies per handler, and `staticOnly` returns before it needs
|
|
@@ -33,22 +33,22 @@ const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
|
|
|
33
33
|
// a path separator: Bun read `\*.*` as a drive-absolute pattern, ignored the
|
|
34
34
|
// `cwd` in `GETFILE` entirely, and matched files at `C:\`. A route lookup under
|
|
35
35
|
// a serve root six levels down returned `C:\$WINRE_BACKUP_PARTITION.MARKER`,
|
|
36
|
-
// and `fs.isForbidden`
|
|
36
|
+
// and `fs.isForbidden` (whose walk is bounded by `startsWith(root)`) waved it
|
|
37
37
|
// through because an out-of-root path skipped the loop and answered "allowed".
|
|
38
38
|
// That clamp now fails closed, so this is belt and braces; both halves are
|
|
39
39
|
// pinned, and neither test can see the other's bug.
|
|
40
40
|
//
|
|
41
41
|
// Measured with a scan whose cwd was a temp directory holding one file: `[*]`
|
|
42
42
|
// yields nothing, `\*` yields four files from the drive root. The escape is
|
|
43
|
-
// also unreachable on Windows in the direction it was meant for
|
|
43
|
+
// also unreachable on Windows in the direction it was meant for: `*` is a
|
|
44
44
|
// reserved character in a Windows filename, so a route file literally named
|
|
45
45
|
// `*.ts` can only exist on POSIX, where both spellings match it identically
|
|
46
46
|
// (verified on Linux). The character class costs nothing and means the same
|
|
47
47
|
// thing on both platforms.
|
|
48
48
|
//
|
|
49
49
|
// Exported only as a test seam: with the clamp in place no test driving
|
|
50
|
-
// `getRoute` can tell the two spellings apart
|
|
51
|
-
// and watching all 17 pass
|
|
50
|
+
// `getRoute` can tell the two spellings apart (verified by reverting this line
|
|
51
|
+
// and watching all 17 pass), so the pattern has to be asserted directly.
|
|
52
52
|
export const dynamicGlobs = (ext: string) => [
|
|
53
53
|
new Bun.Glob(`[[][!.]*${ext || '.*'}`),
|
|
54
54
|
new Bun.Glob(`[*]${ext || '.*'}`),
|
|
@@ -88,8 +88,7 @@ const routeGlobs = (
|
|
|
88
88
|
* The catch-all fallback for one directory level: `dir/[...name].ext`,
|
|
89
89
|
* containment-checked like every other candidate. Skipped for `staticOnly`
|
|
90
90
|
* (the caller wants a literal file), and it *yields to any real file*: when
|
|
91
|
-
* the request's remaining segments name an existing file under `dir`
|
|
92
|
-
* whatever its extension — the catch-all declines, so a lower-priority
|
|
91
|
+
* the request's remaining segments name an existing file under `dir` ( * whatever its extension) the catch-all declines, so a lower-priority
|
|
93
92
|
* handler (TSHandler, StaticHandler) can serve the file itself. A directory
|
|
94
93
|
* is not a file and does not trigger the yield. `findDynamicRoute` applies
|
|
95
94
|
* the same rule on the cached path; the two must agree.
|
|
@@ -102,7 +101,7 @@ const routeGlobs = (
|
|
|
102
101
|
* `provides.ts` at `/teacher/provides` and `/teacher/provides.js`, so neither
|
|
103
102
|
* spelling named a file on disk and a Vue catch-all above it (priority 58 vs
|
|
104
103
|
* 50) served HTML to a browser that asked for a module. The probe now also
|
|
105
|
-
* tries the registered dynamic extensions against the extensionless base
|
|
104
|
+
* tries the registered dynamic extensions against the extensionless base:
|
|
106
105
|
* the same mapping the serving handlers apply, read from the live registry so
|
|
107
106
|
* a plugin's extension (`.vue`) counts without core naming it.
|
|
108
107
|
*
|
|
@@ -118,7 +117,7 @@ export function servedSourceExists(target: string): boolean {
|
|
|
118
117
|
try {
|
|
119
118
|
exts = (handler as { config?: { ext?: unknown } }).config?.ext
|
|
120
119
|
} catch {
|
|
121
|
-
// A config getter that needs state this process lacks
|
|
120
|
+
// A config getter that needs state this process lacks: a handler with
|
|
122
121
|
// no ext table cannot claim a source file either way.
|
|
123
122
|
continue
|
|
124
123
|
}
|
|
@@ -147,13 +146,13 @@ async function getCatchAllRoute(
|
|
|
147
146
|
const target = fs.resolve(dir, restSegments.join('/'))
|
|
148
147
|
// Stat only inside `dir`. A rest containing `..` resolves outside it, and
|
|
149
148
|
// statting there would make this yield a boolean existence probe for any
|
|
150
|
-
// path on the filesystem
|
|
151
|
-
// URL parsing
|
|
149
|
+
// path on the filesystem: the 404-vs-render difference is observable.
|
|
150
|
+
// URL parsing normalizes `..` and `%2e%2e` away, so no HTTP request
|
|
152
151
|
// reaches here with one; this closes the door for any caller that skips
|
|
153
|
-
// that
|
|
152
|
+
// that normalization. An escape skips the yield rather than refusing, so
|
|
154
153
|
// the catch-all answers exactly as it did before the yield rule existed.
|
|
155
154
|
// `dir` comes from `fs.resolve`, so the separator-suffixed prefix test is
|
|
156
|
-
// exact
|
|
155
|
+
// exact: plain `startsWith(dir)` would also accept a sibling directory
|
|
157
156
|
// whose name merely begins with it.
|
|
158
157
|
if (
|
|
159
158
|
(target === dir || target.startsWith(`${dir}/`)) &&
|
|
@@ -169,7 +168,7 @@ async function getCatchAllRoute(
|
|
|
169
168
|
|
|
170
169
|
// A bare-directory request (`/docs` with no index) reaches here with no
|
|
171
170
|
// rest segments, and only the `[...name!]` spelling opted into claiming
|
|
172
|
-
// it
|
|
171
|
+
// it: the plain form keeps requiring at least one segment.
|
|
173
172
|
if (!restSegments.length && !info.optionalCatchAll) return null
|
|
174
173
|
|
|
175
174
|
return info
|
|
@@ -183,7 +182,7 @@ export async function getRoute(
|
|
|
183
182
|
root?: fs.AbsolutePath,
|
|
184
183
|
options: RouteScanOptions = {},
|
|
185
184
|
): Promise<Handler.Route.Info | null> {
|
|
186
|
-
// Both used to default to `Bakery.serveRoot` independently
|
|
185
|
+
// Both used to default to `Bakery.serveRoot` independently. Two reads of
|
|
187
186
|
// the config getter (an AsyncLocalStorage getStore each) when a caller
|
|
188
187
|
// omits both. One read now covers whichever is missing; the values are
|
|
189
188
|
// unchanged.
|
|
@@ -234,7 +233,7 @@ export async function getRoute(
|
|
|
234
233
|
// statement is `isForbidden(dir, root)` with these exact arguments, and its
|
|
235
234
|
// `null` reaches the same `return null` this function ends on. Checking
|
|
236
235
|
// here as well made every level of a nested route pay two identical
|
|
237
|
-
// directory tree-walks
|
|
236
|
+
// directory tree-walks, and `isForbidden` walks from the file to the root
|
|
238
237
|
// doing an existsSync at each level, so that is the most expensive thing
|
|
239
238
|
// this function does. `src/tests/forbidden.test.ts` pins both branches.
|
|
240
239
|
if (first !== 'index') {
|
|
@@ -246,7 +245,7 @@ export async function getRoute(
|
|
|
246
245
|
// `first === 'index'` is the bare-directory request (`/docs` arrives here
|
|
247
246
|
// as an injected 'index' segment, after no index file matched). The plain
|
|
248
247
|
// `[...name]` pattern requires at least one rest segment and cannot claim
|
|
249
|
-
// it; `[...name!]` exists to
|
|
248
|
+
// it; `[...name!]` exists to: `getCatchAllRoute` tells them apart.
|
|
250
249
|
if (!options.staticOnly) {
|
|
251
250
|
return await getCatchAllRoute(
|
|
252
251
|
ext,
|
|
@@ -263,7 +262,7 @@ export async function getRoute(
|
|
|
263
262
|
if (route) return route
|
|
264
263
|
|
|
265
264
|
// The walk above descends one real segment at a time and dead-ends when
|
|
266
|
-
// the next directory does not exist
|
|
265
|
+
// the next directory does not exist, which for a catch-all request
|
|
267
266
|
// (`/docs/a/b/c` against `docs/[...slug].tsx`) is the common case. Each
|
|
268
267
|
// level unwinds through here, so the deepest existing directory gets the
|
|
269
268
|
// first chance to claim the rest.
|
|
@@ -4,14 +4,13 @@ import { fs } from '../../utils/fs'
|
|
|
4
4
|
import { resolveMount } from './$mounts'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Literal file resolution
|
|
7
|
+
* Literal file resolution: the static counterpart to `getRoute`.
|
|
8
8
|
*
|
|
9
9
|
* `getRoute` answers "which file backs this *route*", with extensionless URLs,
|
|
10
10
|
* dynamic `[id]` segments and glob scanning. This answers the simpler question
|
|
11
11
|
* "which file is at this path, and am I allowed to serve it".
|
|
12
12
|
*
|
|
13
|
-
* It exists because four handlers were each spelling that out themselves
|
|
14
|
-
* StaticHandler, PublicHandler, ImageHandler and NMHandler — in four slightly
|
|
13
|
+
* It exists because four handlers were each spelling that out themselves ( * StaticHandler, PublicHandler, ImageHandler and NMHandler) in four slightly
|
|
15
14
|
* different ways, and only some of them checked everything. Once route mounts
|
|
16
15
|
* arrived the drift got worse: a mount-aware StaticHandler sitting next to a
|
|
17
16
|
* mount-blind PublicHandler is the kind of inconsistency that turns into a bug
|
|
@@ -19,7 +18,7 @@ import { resolveMount } from './$mounts'
|
|
|
19
18
|
*
|
|
20
19
|
* **Three of those four go through this; NMHandler does not, on purpose.** Its
|
|
21
20
|
* entry point is resolved by `Bun.build`, not by the filesystem, so
|
|
22
|
-
* `/_nm/pkg/sub` legitimately means `pkg/sub/index.js
|
|
21
|
+
* `/_nm/pkg/sub` legitimately means `pkg/sub/index.js`: a path this function
|
|
23
22
|
* answers `null` for, because it is a directory. It therefore repeats the
|
|
24
23
|
* containment test and calls `fs.isForbidden` itself; the reasoning, and the
|
|
25
24
|
* test that pins the divergence, are in `handlers/assets/nm.ts`. It is the one
|
|
@@ -43,7 +42,7 @@ export interface StaticTarget {
|
|
|
43
42
|
* mounted plugin cannot be traversed out of.
|
|
44
43
|
*
|
|
45
44
|
* Roots are tried in order, and a root that fails containment is skipped
|
|
46
|
-
* rather than aborting
|
|
45
|
+
* rather than aborting: mirroring ImageHandler, which legitimately looks in
|
|
47
46
|
* both the serve root and the public root.
|
|
48
47
|
*/
|
|
49
48
|
export async function getStatic(
|
|
@@ -64,23 +63,23 @@ export async function getStatic(
|
|
|
64
63
|
const file = fs.resolve(root, candidate.relative)
|
|
65
64
|
|
|
66
65
|
// Two checks, not one: the prefix test catches a resolved path that
|
|
67
|
-
// escaped the root, and isForbidden additionally
|
|
68
|
-
// markers. It does *not* apply the blocked globs
|
|
66
|
+
// escaped the root, and isForbidden additionally honors `.forbidden`
|
|
67
|
+
// markers. It does *not* apply the blocked globs: `Bakery.config.blocked`
|
|
69
68
|
// is matched against the request path in `router.ts` and again in
|
|
70
|
-
// `handlers/assets/static.ts`, never against a resolved file path
|
|
71
|
-
// caller reaching for `getStatic` as a one-stop
|
|
69
|
+
// `handlers/assets/static.ts`, never against a resolved file path, so a
|
|
70
|
+
// caller reaching for `getStatic` as a one-stop authorization check is
|
|
72
71
|
// getting containment and `.forbidden` only.
|
|
73
72
|
//
|
|
74
|
-
// Containment stays first
|
|
73
|
+
// Containment stays first: nothing stats a path that escaped the root.
|
|
75
74
|
// After that the order is by cost: `isForbidden` walks every directory
|
|
76
75
|
// between the file and the root, so running it before the existence check
|
|
77
|
-
// paid for a whole tree-walk on every candidate that simply is not there
|
|
76
|
+
// paid for a whole tree-walk on every candidate that simply is not there,
|
|
78
77
|
// which is most of them, since a miss here is how each root in the list,
|
|
79
78
|
// and every extensionless probe, gets ruled out. All checks are pure and
|
|
80
79
|
// all `continue`, so the candidate chosen is unchanged.
|
|
81
80
|
//
|
|
82
81
|
// One stat answers both questions. This used to be `fs.exists` (a stat)
|
|
83
|
-
// then `fs.isDir` (which is `fs.exists` plus a second stat of its own)
|
|
82
|
+
// then `fs.isDir` (which is `fs.exists` plus a second stat of its own):
|
|
84
83
|
// up to three stats per candidate at ~28us each on Bun/Windows. A failed
|
|
85
84
|
// stat means "nothing servable here" and falls through to the next
|
|
86
85
|
// candidate, exactly as the exists check did.
|
|
@@ -46,19 +46,19 @@ export class ApiHandler extends DynamicHandler {
|
|
|
46
46
|
if (result !== null && result !== undefined) return result
|
|
47
47
|
|
|
48
48
|
// Two different faults used to share one bare 404, and neither channel
|
|
49
|
-
// named the file
|
|
49
|
+
// named the file, so "No response from handler" sent the developer
|
|
50
50
|
// hunting for a route that was sitting on disk the whole time.
|
|
51
51
|
//
|
|
52
52
|
// `null` and `undefined` are what tell them apart, and the split is exact
|
|
53
53
|
// rather than incidental: `DynamicHandler.executeModule` *throws* when the
|
|
54
54
|
// import fails (that is the 500 path), returns a literal `null` for
|
|
55
55
|
// "module loaded, no `default`", and otherwise returns whatever the
|
|
56
|
-
// handler produced
|
|
56
|
+
// handler produced, `undefined` when it produced nothing.
|
|
57
57
|
if (result === null) {
|
|
58
58
|
// A 500, not a 404: the route resolved, the file exists, and the server
|
|
59
59
|
// could not answer with it. That is a server fault, and the message says
|
|
60
|
-
// what to add. PROD still redacts it
|
|
61
|
-
// body
|
|
60
|
+
// what to add. PROD still redacts it (`publicBody` replaces any 5xx
|
|
61
|
+
// body), so naming the file here discloses nothing to a client.
|
|
62
62
|
handlerLog.API_NO_DEFAULT({ file: filePath })
|
|
63
63
|
return response.error(
|
|
64
64
|
`API route has no export default: ${info.path}`,
|
|
@@ -88,7 +88,7 @@ export class ApiErrorHandler extends ErrorHandler {
|
|
|
88
88
|
|
|
89
89
|
static handle(_p: string, _r: Request, error: Handler.Error.Data) {
|
|
90
90
|
// `publicBody`, not `errorBody`: in production the latter is the stack of
|
|
91
|
-
// whatever threw. The full trace still reaches the log
|
|
91
|
+
// whatever threw. The full trace still reaches the log: `handleRequestError`
|
|
92
92
|
// passes the unredacted data to `config.onError` before this runs.
|
|
93
93
|
return response.json.error(error.errorCode, ErrorHandler.publicBody(error))
|
|
94
94
|
}
|
|
@@ -78,8 +78,8 @@ async function sharedHandler(
|
|
|
78
78
|
// through `{{...}}` *and* through the `__PAGE_PARAMS__` script injected
|
|
79
79
|
// into every page, so the raw stack was published in PROD even by a
|
|
80
80
|
// template that never mentioned it. Guarded because `errorData` is
|
|
81
|
-
// `undefined` for an ordinary page
|
|
82
|
-
// error handler
|
|
81
|
+
// `undefined` for an ordinary page (`DEFAULT_ERROR` only exists on the
|
|
82
|
+
// error handler), and the spread below tolerates that where the helper,
|
|
83
83
|
// deliberately strict about the shape it redacts, does not.
|
|
84
84
|
// Annotated because `beginPageRoute` hands back real `Handler.Error.Data`
|
|
85
85
|
// where this used to read an untyped `(this as any).DEFAULT_ERROR`: the
|
|
@@ -63,7 +63,7 @@ export class ProxyHandler extends Handler {
|
|
|
63
63
|
|
|
64
64
|
const resHeaders = new Headers(proxyRes.headers)
|
|
65
65
|
// Bun already decompressed the body, so the upstream's content-encoding is
|
|
66
|
-
// wrong
|
|
66
|
+
// wrong, and so is its content-length, which described the compressed size.
|
|
67
67
|
resHeaders.delete('content-encoding')
|
|
68
68
|
resHeaders.delete('content-length')
|
|
69
69
|
return new Response(proxyRes.body, {
|
package/src/logger/clients.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebSocket clients subscribed to the live log stream.
|
|
3
3
|
*
|
|
4
|
-
* `LiveReloadHandler` owns membership
|
|
4
|
+
* `LiveReloadHandler` owns membership: it adds a socket on
|
|
5
5
|
* `subscribe_logger` and removes it on close. Consumers (the dashboard's
|
|
6
6
|
* log broadcast, the analytics `activeLoggers` gauge) only read.
|
|
7
7
|
*
|
|
8
8
|
* This lives in core rather than a plugin deliberately: it used to be
|
|
9
9
|
* defined in `plugins/analytics/core`, which made core import from a
|
|
10
|
-
* plugin
|
|
10
|
+
* plugin, a backwards edge that blocks packaging core on its own.
|
|
11
11
|
*/
|
|
12
12
|
export const connectedLoggers = new Set<any>()
|
package/src/logger/logger.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function setLogCallback(cb: (entry: LoggerEntry) => void) {
|
|
|
46
46
|
*
|
|
47
47
|
* Three call sites used to spell this as `Promise.try(() =>
|
|
48
48
|
* onLogCallback?.(…)).catch(() => {})`, which allocated two promises and
|
|
49
|
-
* scheduled a microtask for *every* line
|
|
49
|
+
* scheduled a microtask for *every* line, including every line logged while
|
|
50
50
|
* no sink was registered at all. `Try` swallows a synchronous throw and a
|
|
51
51
|
* rejected promise alike (see `utils/isomorphic/try.ts`) without allocating
|
|
52
52
|
* anything on the synchronous path, which is the only path a sink typed
|
|
@@ -61,7 +61,7 @@ function emit(entry: LoggerEntry): void {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Hoisted: both were rebuilt on every call, and neither depends on the input.
|
|
64
|
-
// The
|
|
64
|
+
// The color table is a 23-property literal and the pattern a regex literal,
|
|
65
65
|
// so a process logging steadily paid for both on every line.
|
|
66
66
|
const COLORS: MapOf<string> = {
|
|
67
67
|
r: '\x1b[31m', // Red
|
|
@@ -299,7 +299,7 @@ type Messages<T extends MapOf<string>> = {
|
|
|
299
299
|
const RX_PARAM = /\{([^}]+)\}/g
|
|
300
300
|
|
|
301
301
|
type ParsedMessage = {
|
|
302
|
-
/** The string this was parsed from
|
|
302
|
+
/** The string this was parsed from: the cache's validity check. */
|
|
303
303
|
raw: string
|
|
304
304
|
level: LogLevels
|
|
305
305
|
template: string
|
|
@@ -332,7 +332,7 @@ export function messageLogger<T extends MapOf<string>>(
|
|
|
332
332
|
targetMsgs: T,
|
|
333
333
|
) {
|
|
334
334
|
/**
|
|
335
|
-
* One entry per *declared* key, so it is bounded by the message table
|
|
335
|
+
* One entry per *declared* key, so it is bounded by the message table: the
|
|
336
336
|
* tables in `serve-log.ts` are literals of a dozen-odd entries each.
|
|
337
337
|
* Undeclared props are deliberately left uncached: the trap fabricates a
|
|
338
338
|
* message for those, and a caller reading arbitrary property names off the
|