@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.
Files changed (78) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -5
  3. package/src/cache/lru.ts +2 -2
  4. package/src/cache/shared-db.ts +81 -11
  5. package/src/cache/string.ts +4 -0
  6. package/src/cache/tiered.ts +11 -5
  7. package/src/client/globals.d.ts +7 -7
  8. package/src/client/livereload.ts +2 -2
  9. package/src/client/utils.ts +11 -6
  10. package/src/compiler/compiler.ts +48 -44
  11. package/src/compiler/dev-service.ts +29 -30
  12. package/src/compiler/prompt-tracker.ts +2 -2
  13. package/src/compiler/tsconfig-sync.ts +130 -59
  14. package/src/core/bakery.ts +6 -3
  15. package/src/core/cache-version.ts +68 -18
  16. package/src/core/config.ts +16 -16
  17. package/src/core/context.ts +54 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +29 -8
  20. package/src/core/init.ts +38 -35
  21. package/src/core/jsx.ts +19 -4
  22. package/src/core/paths.ts +3 -1
  23. package/src/core/plugins.ts +15 -4
  24. package/src/core/port.ts +6 -6
  25. package/src/global.d.ts +36 -18
  26. package/src/handlers/assets/google-font.ts +6 -6
  27. package/src/handlers/assets/image.ts +36 -3
  28. package/src/handlers/assets/nm.ts +7 -7
  29. package/src/handlers/assets/static.ts +55 -12
  30. package/src/handlers/assets/ts.ts +3 -3
  31. package/src/handlers/assets/tsx.ts +1 -1
  32. package/src/handlers/assets/virtual-asset.ts +1 -1
  33. package/src/handlers/core/$base.ts +24 -8
  34. package/src/handlers/core/$dynamic.ts +66 -23
  35. package/src/handlers/core/$error.ts +29 -17
  36. package/src/handlers/core/$middleware.ts +6 -6
  37. package/src/handlers/core/$mounts.ts +2 -2
  38. package/src/handlers/core/$registry.ts +16 -6
  39. package/src/handlers/core/$routing.ts +18 -19
  40. package/src/handlers/core/$static.ts +11 -12
  41. package/src/handlers/routes/api.ts +5 -5
  42. package/src/handlers/routes/html.ts +2 -2
  43. package/src/handlers/routes/proxy.ts +1 -1
  44. package/src/logger/clients.ts +2 -2
  45. package/src/logger/logger.ts +4 -4
  46. package/src/logger/serve-log.ts +32 -18
  47. package/src/plugins/index.ts +1 -1
  48. package/src/plugins/routes.ts +7 -7
  49. package/src/plugins/types.ts +13 -11
  50. package/src/router.ts +12 -12
  51. package/src/session.ts +41 -23
  52. package/src/shared.d.ts +5 -5
  53. package/src/startup.ts +3 -3
  54. package/src/types.d.ts +10 -10
  55. package/src/utils/common/case.ts +1 -1
  56. package/src/utils/common/match.ts +1 -1
  57. package/src/utils/common/misc.ts +1 -1
  58. package/src/utils/constants.ts +15 -15
  59. package/src/utils/fs.ts +97 -39
  60. package/src/utils/http/authorize.ts +24 -17
  61. package/src/utils/http/cors.ts +6 -6
  62. package/src/utils/http/credential.ts +4 -4
  63. package/src/utils/http/csrf.ts +6 -7
  64. package/src/utils/http/dom.ts +9 -9
  65. package/src/utils/http/etag.ts +54 -13
  66. package/src/utils/http/html.ts +29 -20
  67. package/src/utils/http/ip.ts +1 -1
  68. package/src/utils/http/sse.ts +11 -11
  69. package/src/utils/http/url.ts +4 -4
  70. package/src/utils/http/validate.ts +3 -4
  71. package/src/utils/isomorphic/escape.ts +1 -1
  72. package/src/utils/isomorphic/is.ts +2 -2
  73. package/src/utils/isomorphic/misc.ts +1 -1
  74. package/src/utils/isomorphic/stringify.ts +2 -3
  75. package/src/utils/isomorphic/try.ts +1 -1
  76. package/src/utils/shared-pool.ts +23 -16
  77. package/tsconfig.app.json +3 -3
  78. package/tsconfig.vue.json +3 -3
@@ -7,7 +7,40 @@ import { response } from '../../utils/http'
7
7
  import { Handler, type Route } from '../core/$base'
8
8
  import { getStatic } from '../core/$static'
9
9
 
10
- const IS_IMAGE_REGEX = /(.*)\/(.*)(;(\d+))?\.(png|jpg|jpeg|webp|gif|bmp)$/i
10
+ /**
11
+ * Does this path name an image?
12
+ *
13
+ * **`[^/]*`, not `.*`, and the difference is a denial of service.** This read
14
+ * `/(.*)\\/(.*)(;(\\d+))?\\.(png|…)$/`, whose two greedy groups could each match
15
+ * the separator, so every `/` in the path doubled the ways the engine could
16
+ * split it. The work is superlinear and it is paid by paths that do **not**
17
+ * match, because a failing match is the one that has to try every split before
18
+ * it can say no, and `canHandle` runs on every route-cache miss, above the
19
+ * handlers that serve ordinary pages.
20
+ *
21
+ * Measured on Bun 1.4.0, one call, path of `/` + `a/` × n + `x.txt`:
22
+ *
23
+ * slashes chars old new
24
+ * 64 134 0.98 ms 0.00091 ms
25
+ * 128 262 7.05 ms 0.00146 ms
26
+ * 256 518 63.88 ms 0.00272 ms
27
+ * 512 1030 512.55 ms 0.00564 ms
28
+ *
29
+ * One unauthenticated request with a 1 KB path stalled the event loop for half
30
+ * a second, and the rate limiter cannot help at one request per second. A
31
+ * filename cannot contain a separator, so the second group never should have
32
+ * been able to: bounding it removes the ambiguity and the cost with it.
33
+ *
34
+ * The captures are gone because nothing read them: `canHandle` only calls
35
+ * `.test()`, and `IMAGE_CAPTURE` below is what parses the parts. The trailing
36
+ * `(;(\\d+))?` was dead for the same reason: `.*` already covered `;800`.
37
+ *
38
+ * Equivalence is not an argument from reading: old and new agree on a 40-path
39
+ * corpus and on 200,000 random strings over the alphabet that makes the shapes
40
+ * interesting ([ab/.;1png-_]), with zero disagreements. `image.test.ts` keeps
41
+ * both halves.
42
+ */
43
+ const IS_IMAGE_REGEX = /\/[^/]*\.(?:png|jpg|jpeg|webp|gif|bmp)$/i
11
44
  const IMAGE_CAPTURE = /^(.+?)([^/;.]+)(?:;(\d+))?\.([a-zA-Z0-9]+)$/i
12
45
 
13
46
  export class ImageHandler extends Handler {
@@ -53,7 +86,7 @@ export class ImageHandler extends Handler {
53
86
  const rel = `${dir.slice(1)}/${name}.${ext}`
54
87
 
55
88
  // This handler outranks PublicHandler, so the containment and `.forbidden`
56
- // checks must still apply — otherwise a protected image under public/ is
89
+ // checks must still apply: otherwise a protected image under public/ is
57
90
  // served anyway, despite PublicHandler correctly refusing it. getStatic
58
91
  // carries those checks so all the file-serving handlers share one spelling.
59
92
  const resolved = await getStatic(`/${rel}`, [
@@ -87,7 +120,7 @@ export class ImageHandler extends Handler {
87
120
  const cacheDir = fs.resolve(Bakery.cacheDir, 'images')
88
121
  // Key on the resolved source, not the request path. The path carries the
89
122
  // caller's raw `;NNN` size suffix *before* clamping, so `;16`, `;17`, `;18`…
90
- // each produced a distinct id and re-encoded the whole source to WebP —
123
+ // each produced a distinct id and re-encoded the whole source to WebP:
91
124
  // an unbounded cache and unbounded CPU from one image URL.
92
125
  const imageCacheId = Bun.hash(hostKey(source)).toString(36)
93
126
 
@@ -18,20 +18,20 @@ export class NMHandler extends Handler {
18
18
  *
19
19
  * **The literal path wins whenever it exists, and the order is the whole
20
20
  * point.** `Bun.build` resolves a directory or package entry itself, with
21
- * *browser* conditions — which is what picks `vue`'s `esm-bundler` build over
21
+ * *browser* conditions, which is what picks `vue`'s `esm-bundler` build over
22
22
  * the CJS one behind its `node` condition. `Bun.resolveSync` has no such
23
23
  * knob: it answers with Bun's own server conditions. Resolving here first and
24
24
  * handing `Bun.build` the concrete file therefore silently downgraded every
25
- * package with a `browser`/`node` split — `/_nm/vue` came back as
25
+ * package with a `browser`/`node` split: `/_nm/vue` came back as
26
26
  * `index.mjs` re-exporting `vue.cjs.js`, and the browser then rejected
27
27
  * `import { Fragment } from 'vue'`.
28
28
  *
29
29
  * So the literal path goes first whenever it is on disk, and `resolveSync` is
30
30
  * the second candidate rather than the first. It is still needed, for two
31
- * distinct cases: a public subpath that does not match the physical layout —
31
+ * distinct cases: a public subpath that does not match the physical layout,
32
32
  * `@vue-material/core` maps `"./utils"` to `./dist/utils/index.js`, so the
33
33
  * documented `@vue-material/core/utils` has no `utils` directory to find and
34
- * used to 500 — and a package whose root is declared *only* through
34
+ * used to 500, and a package whose root is declared *only* through
35
35
  * `exports`, where `Bun.build` on the directory answers `ModuleNotFound`
36
36
  * because it looks for `main`/`module` and finds neither.
37
37
  *
@@ -65,15 +65,15 @@ export class NMHandler extends Handler {
65
65
  * **Why this does not call `getStatic`.** `getStatic` is the single writer of
66
66
  * the containment + `.forbidden` pair, and three of the four handlers its doc
67
67
  * comment names now go through it. This one cannot, and the reason is not
68
- * the root (it takes a `roots` argument) — it is that `getStatic` answers
68
+ * the root (it takes a `roots` argument): it is that `getStatic` answers
69
69
  * "is there a plain file literally at this path", and `/_nm/` deliberately
70
70
  * asks a different question: what would an importer of this specifier get?
71
71
  * `resolveEntry` answers it, applying the package's `exports` map, and
72
- * `Bun.build` then does directory-index resolution on whatever survives — so
72
+ * `Bun.build` then does directory-index resolution on whatever survives, so
73
73
  * `/_nm/pkg/sub` reaches `pkg/sub/index.js`, which is precisely what the
74
74
  * import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry (`utils/http/dom.ts`)
75
75
  * produces for an extensionless subpath import. `getStatic` returns `null`
76
- * for that path — it is a directory — so routing through it would turn every
76
+ * for that path (it is a directory), so routing through it would turn every
77
77
  * extensionless subpath import into a 204. `nm.test.ts` pins both halves of
78
78
  * that divergence.
79
79
  *
@@ -2,7 +2,7 @@ import { Bakery, hostKey } from '../../core/bakery'
2
2
  import { matchBlockedCached } from '../../core/context'
3
3
  import { toHash } from '../../utils'
4
4
  import { fs } from '../../utils/fs'
5
- import { response } from '../../utils/http'
5
+ import { injectBrand, response } from '../../utils/http'
6
6
  import { Handler } from '../core/$base'
7
7
  import { ErrorHandler } from '../core/$error'
8
8
  import { getStatic } from '../core/$static'
@@ -22,7 +22,7 @@ export class StaticHandler extends Handler {
22
22
  // without the router gate. `matchBlockedCached` makes the overlap cost a
23
23
  // per-request map hit rather than a second pair of glob matches.
24
24
  // One read of the config getter: `blocked` and the serve root come from
25
- // the same snapshot — getStatic's default `roots` would re-read it.
25
+ // the same snapshot. GetStatic's default `roots` would re-read it.
26
26
  const cfg = Bakery.config
27
27
  if (matchBlockedCached(cfg.blocked, path)) {
28
28
  return response.error('Forbidden', 403)
@@ -54,33 +54,76 @@ export class DefaultErrorHandler extends ErrorHandler {
54
54
  /**
55
55
  * This is the fallback for every path with no more specific error handler,
56
56
  * so it renders through the inherited `publicBody` like the rest of the
57
- * error surface — `errorBody` carries the thrown error's stack (that is
57
+ * error surface: `errorBody` carries the thrown error's stack (that is
58
58
  * what the log wants), and handing it to the client verbatim gave any
59
59
  * anonymous request source paths and query text in PROD.
60
+ *
61
+ * Two pages, split on the same gate `publicBody` uses, failing the same
62
+ * direction: only an explicit DEV gets the diagnostics page, so an
63
+ * indeterminate mode discloses nothing. DEV keeps the branded title, the
64
+ * body in a `<pre>`, and the requester/date footer, and `processResponse`
65
+ * injects the import map and live reload into it like any page, which is
66
+ * what makes the overlay work on an error. The production page is the
67
+ * status line and the public body, nothing else: the footer echoed the
68
+ * requester's own IP and a server timestamp to anyone who triggered an
69
+ * error, and the page is branded with `injectBrand` because the injected
70
+ * import map names every installed package. See the note on the export.
60
71
  */
61
72
  static handle(_path: string, req: Request, error?: Handler.Error.Data) {
62
- const ip = Bakery.server?.requestIP(req)?.address || 'Unknown'
63
- const date = new Date().toDateString()
64
-
65
73
  error ||= this.DEFAULT_ERROR
66
74
 
75
+ // No separator without text to separate: an empty-message denial used to
76
+ // render `<h1>403 - </h1>`. Same rule for the body below: empty renders
77
+ // as no element, not as a dangling `<pre></pre>`.
78
+ const heading = Bun.escapeHTML(
79
+ error.errorText
80
+ ? `${error.errorCode} - ${error.errorText}`
81
+ : `${error.errorCode}`,
82
+ )
83
+ const body = this.publicBody(error)
84
+
85
+ if (import.meta.env.DEV) {
86
+ const ip = Bakery.server?.requestIP(req)?.address || 'Unknown'
87
+ const date = new Date().toDateString()
88
+
89
+ const errorPage = `
90
+ <!DOCTYPE html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="UTF-8" />
94
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
95
+ <title>Error ${error.errorCode} | Bakery 🚀</title>
96
+ </head>
97
+ <body style="margin: 2rem; font-family: sans-serif;">
98
+ <h1>${heading}</h1>
99
+ ${body ? `<pre>${Bun.escapeHTML(body)}</pre>` : ''}
100
+ <hr />
101
+ <small>${Bun.escapeHTML(date)} - ${Bun.escapeHTML(ip)}</small>
102
+ </body>
103
+ </html>
104
+ `
105
+
106
+ return response.html(errorPage, error.errorCode)
107
+ }
108
+
109
+ // `<p>`, not `<pre>`: outside DEV the body is prose by construction,
110
+ // `publicBody` replaces a 5xx stack with the generic sentence, and a 4xx
111
+ // body is authored text.
67
112
  const errorPage = `
68
113
  <!DOCTYPE html>
69
114
  <html lang="en">
70
115
  <head>
71
116
  <meta charset="UTF-8" />
72
117
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
73
- <title>Error ${error.errorCode} | Bakery 🚀</title>
118
+ <title>Error ${error.errorCode}</title>
74
119
  </head>
75
120
  <body style="margin: 2rem; font-family: sans-serif;">
76
- <h1>${Bun.escapeHTML(`${error.errorCode} - ${error.errorText}`)}</h1>
77
- <pre>${Bun.escapeHTML(this.publicBody(error))}</pre>
78
- <hr />
79
- <small>${Bun.escapeHTML(date)} - ${Bun.escapeHTML(ip)}</small>
121
+ <h1>${heading}</h1>
122
+ ${body ? `<p>${Bun.escapeHTML(body)}</p>` : ''}
80
123
  </body>
81
124
  </html>
82
125
  `
83
126
 
84
- return response.html(errorPage, error.errorCode)
127
+ return injectBrand(response.html(errorPage, error.errorCode))
85
128
  }
86
129
  }
@@ -46,15 +46,15 @@ export class TSHandler extends DynamicHandler {
46
46
  //
47
47
  // The route is named because this branch was unreachable until
48
48
  // `compileText` stopped throwing past it, and a nameless "Compilation
49
- // Failed" is only half an answer: the diagnostic — file, line, column,
50
- // source line — goes to the log as `compLog.COMPILE_FAIL`, and the two
49
+ // Failed" is only half an answer: the diagnostic (file, line, column,
50
+ // source line) goes to the log as `compLog.COMPILE_FAIL`, and the two
51
51
  // have to be joinable. Naming it discloses nothing a client did not
52
52
  // already send: `info.path` is what it asked for. In production
53
53
  // `publicBody` replaces the whole 5xx body anyway.
54
54
  return cached || response.error(`Compilation Failed: ${info.path}`, 500)
55
55
  }
56
56
 
57
- /** Compile seam — ts.test.ts substitutes it to exercise the failure branch. */
57
+ /** Compile seam, ts.test.ts substitutes it to exercise the failure branch. */
58
58
  static compileRoute(file: fs.AbsolutePath): Promise<string | null> {
59
59
  return compile(file)
60
60
  }
@@ -59,7 +59,7 @@ async function sharedHandler(
59
59
  const modulePath = fs.resolve(Bakery.serveRoot, filePath)
60
60
  const params = info.getParams(path) || {}
61
61
  markDevFile(params, filePath)
62
- // Redacted before it becomes page data — see `publicErrorData`. The params
62
+ // Redacted before it becomes page data. See `publicErrorData`. The params
63
63
  // are both substituted into the render and injected as `__PAGE_PARAMS__`.
64
64
  // `errorData` is undefined for an ordinary page; Object.assign ignores that.
65
65
  const finalParams = Object.assign(
@@ -46,7 +46,7 @@ export class VirtualAssetHandler extends Handler {
46
46
  async function bundleClient() {
47
47
  // Bundled, not merely transpiled: these are browser entry points, and
48
48
  // the browser cannot resolve the relative imports a transpile leaves
49
- // behind — they resolve against the page URL, return the HTML
49
+ // behind, they resolve against the page URL, return the HTML
50
50
  // fallback, and fail strict MIME checking for module scripts.
51
51
  const built = await bundleModule(masterPath as fs.AbsolutePath)
52
52
  return built.success && built.content ? built.content : null
@@ -35,7 +35,7 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
35
35
  if (optionalMatch) {
36
36
  if (i !== last) return null
37
37
  params.push(optionalMatch[1])
38
- // The whole segment — separator included — is optional, so the pattern
38
+ // The whole segment (separator included) is optional, so the pattern
39
39
  // is assembled below rather than pushed here: `docs/[...slug!]` has to
40
40
  // match `/docs` itself, which `/docs/(.*)` cannot.
41
41
  catchAll = true
@@ -46,14 +46,14 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
46
46
  const catchAllMatch = segment.match(RX_CATCHALL)
47
47
  if (catchAllMatch) {
48
48
  // Only terminal: a segment after `[...x]` has no unambiguous meaning
49
- // (which segments belong to the rest?), so the file stays inert — the
49
+ // (which segments belong to the rest?), so the file stays inert, the
50
50
  // same behavior it had before catch-alls existed.
51
51
  if (i !== last) return null
52
52
  params.push(catchAllMatch[1])
53
53
  // `.+` rather than `.*`: the catch-all requires at least one segment,
54
54
  // so `docs/[...slug]` does not shadow a `docs/index` sibling for
55
55
  // `/docs` itself. `[...slug!]` is the spelling that opts into the
56
- // bare directory — and an index sibling still wins there, because
56
+ // bare directory, and an index sibling still wins there, because
57
57
  // static discovery runs before dynamic in `resolveRouteFile`.
58
58
  mappedPaths.push('(.+)')
59
59
  catchAll = true
@@ -115,7 +115,7 @@ export namespace Route {
115
115
  // namespace for its nested classes; `const RouteData = { Info: class {} }`
116
116
  // provides only the value, and every one of those type references stops
117
117
  // resolving.
118
- // biome-ignore lint/complexity/noStaticOnlyClass: also a type namespace — see above
118
+ // biome-ignore lint/complexity/noStaticOnlyClass: also a type namespace. See above
119
119
  export class RouteData {
120
120
  static Info = class Info {
121
121
  readonly params: string[]
@@ -157,7 +157,7 @@ export class RouteData {
157
157
  const boundParams: MapOf<string | string[]> = {}
158
158
  for (let i = 0; i < this.params.length; i++) {
159
159
  const value = match[i + 1]
160
- // The catch-all is always terminal, so it is always the last param —
160
+ // The catch-all is always terminal, so it is always the last param,
161
161
  // and it binds as the *segments*, not the joined string: every
162
162
  // consumer was calling `.split('/')` on it anyway, and the joined
163
163
  // form silently conflated `/docs/a%2Fb` with `/docs/a/b`. A bare
@@ -214,7 +214,7 @@ export namespace Handler {
214
214
  * Keyed by class identity rather than stored as a `${name}_cache` dynamic
215
215
  * property: the getter runs several times per request, and the template
216
216
  * string + megamorphic property lookup showed up in profiles. A `Map` (not
217
- * `WeakMap`) is fine — handler classes live for the process.
217
+ * `WeakMap`) is fine: handler classes live for the process.
218
218
  */
219
219
  const handlerCaches = new Map<any, HandlerCache<string, Route.Info>>()
220
220
 
@@ -246,13 +246,29 @@ export class Handler {
246
246
  * source file by being handed a path that looks like one, and applying the
247
247
  * deny-list to them made `/api/manifest.json` a 403 no config could undo.
248
248
  *
249
- * Deny by default: anything that does not opt out — including a plugin's
250
- * handler — keeps the check. This is the single source of truth for that
249
+ * Deny by default: anything that does not opt out (including a plugin's
250
+ * handler) keeps the check. This is the single source of truth for that
251
251
  * question; `router.ts` gates its request-path check on it, and
252
252
  * `DynamicHandler.resolveRoute` gates the resolved-file check on it.
253
253
  */
254
254
  static servesFiles = true
255
255
 
256
+ /**
257
+ * The URL prefix this handler owns as a complete user-facing surface ( * `'/_dashboard'`, `'/_db'`), or `null` for the ordinary case of a handler
258
+ * that answers by extension or content rather than by prefix.
259
+ *
260
+ * This exists so one surface can ask whether another is mounted without
261
+ * probing it. The dashboard used to detect the explorer by calling every
262
+ * registered handler's `canHandle('/_db')` with a control path to exclude
263
+ * the priority-0 catch-all: it worked, and the `as any` it needed was the
264
+ * tell that the registry could not say what a handler serves. A declaration
265
+ * is that answer. It is deliberately *not* consulted by routing: `canHandle`
266
+ * stays the authority on requests, so a stale or missing namespace can
267
+ * misdescribe a handler to the dashboard nav but can never misroute a
268
+ * request.
269
+ */
270
+ static namespace: string | null = null
271
+
256
272
  protected constructor() {}
257
273
 
258
274
  static get cache(): HandlerCache<string, Route.Info> {
@@ -22,7 +22,7 @@ const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
22
22
  *
23
23
  * The suffix makes Bun's module registry treat every edit as a new module,
24
24
  * which is what lets an edited route (a `.ts` API handler, a `.tsx` page) take
25
- * the cheap dev path — route-cache flush plus browser reload — instead of the
25
+ * the cheap dev path (route-cache flush plus browser reload) instead of the
26
26
  * full process restart it used to force. It also costs a stat and a string
27
27
  * alloc per request and permanently retains each superseded module: acceptable
28
28
  * in DEV, pure waste in PROD where route files cannot change. So PROD imports
@@ -31,7 +31,7 @@ const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
31
31
  * operationally since PROD runs no watcher.
32
32
  *
33
33
  * `&& !TEST` is load-bearing: `init.ts` defaults `PROD` to true whenever
34
- * `--dev` is absent, and `bun test` loads init via the CLI package's tests — so
34
+ * `--dev` is absent, and `bun test` loads init via the CLI package's tests, so
35
35
  * a bare `PROD` gate flipped mid-suite and broke the reload tests in files
36
36
  * loaded after it, while passing in isolation.
37
37
  *
@@ -40,8 +40,8 @@ const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
40
40
  * `docs/getting-started/first-app.md`.
41
41
  *
42
42
  * One implementation on purpose. `ApiHandler` and `TSXHandler`/`TSXErrorHandler`
43
- * both need it, and they carried a byte-identical copy each — including this
44
- * reasoning — which is two places for the `!TEST` clause to be dropped from.
43
+ * both need it, and they carried a byte-identical copy each (including this
44
+ * reasoning), which is two places for the `!TEST` clause to be dropped from.
45
45
  */
46
46
  export function bustInDev(file: fs.AbsolutePath): fs.AbsolutePath {
47
47
  if (import.meta.env.PROD && !import.meta.env.TEST) return file
@@ -58,7 +58,7 @@ export class DynamicHandler extends Handler {
58
58
  }
59
59
 
60
60
  static get dynamicCache(): HandlerCache<RegExp, Route.Info> {
61
- // Same per-class Map pattern as `Handler.cache` — see the comment there.
61
+ // Same per-class Map pattern as `Handler.cache`. See the comment there.
62
62
  let cache = dynamicCaches.get(this)
63
63
  if (!cache) {
64
64
  cache = new HandlerCache()
@@ -88,7 +88,7 @@ export class DynamicHandler extends Handler {
88
88
  if (this.cache.has(hostKey(path))) return true
89
89
  // The dynamic half of the line above. A dynamic route is never written to
90
90
  // `this.cache`, so without this every request to one ran `resolveRoute`
91
- // twice — once here and once in `handle` — and each run is two globbing
91
+ // twice (once here and once in `handle`), and each run is two globbing
92
92
  // `getRoute` scans. `findDynamicRoute` is a regex test per cached route
93
93
  // plus one stat for the match, and a hit here implies `resolveRoute` is
94
94
  // about to be truthy too: it consults the same cache and returns that
@@ -107,7 +107,7 @@ export class DynamicHandler extends Handler {
107
107
  // A broken route module is a server fault, not a missing route: `null`
108
108
  // means 404 to every caller, so after logging the file for context the
109
109
  // failure is rethrown. `extractErrorData` turns the throw into a 500 whose
110
- // `errorBody` carries the stack — shown in DEV, redacted by `publicBody`
110
+ // `errorBody` carries the stack: shown in DEV, redacted by `publicBody`
111
111
  // in PROD. Only the genuinely-missing cases below return null.
112
112
  const [error, mod] = await Try.catch(import(file))
113
113
  if (error) {
@@ -137,13 +137,13 @@ export class DynamicHandler extends Handler {
137
137
  // Cheapest filter first. `valid` is a stat and `isForbidden` walks every
138
138
  // directory between the file and the root doing an existsSync at each
139
139
  // level, and both ran for every cached route before anything asked
140
- // whether the route even matched the path — so a miss cost one tree-walk
140
+ // whether the route even matched the path, so a miss cost one tree-walk
141
141
  // per entry and a hit cost one per entry ahead of the match. The regex is
142
142
  // pure and all four filters still `continue`, so a matching-but-rejected
143
143
  // entry does not shadow a later one; `$dynamic.test.ts` pins that.
144
144
  if (!info.getParams(path)) continue
145
145
  // Every single-segment route outranks every catch-all, whatever order
146
- // the cache filled in — so catch-alls are set aside, and their stat and
146
+ // the cache filled in, so catch-alls are set aside, and their stat and
147
147
  // tree-walk filters run only after the loop finds no specific match.
148
148
  if (info.catchAll) {
149
149
  ;(deferred ??= []).push(info)
@@ -151,20 +151,20 @@ export class DynamicHandler extends Handler {
151
151
  }
152
152
  if (!info.valid) continue
153
153
  // `filePath` is already `fs.resolve`d by the Info constructor; resolving
154
- // it again here re-normalised an identical string.
154
+ // it again here re-normalized an identical string.
155
155
  if (!info.filePath!.startsWith(`${root}/`)) continue
156
156
  if (fs.isForbidden(info.filePath!, root)) continue
157
157
  return info
158
158
  }
159
159
  if (deferred) {
160
160
  // A real file always beats a catch-all, whatever handler would serve
161
- // it: when the requested path names an existing file — literally, or
161
+ // it: when the requested path names an existing file, literally, or
162
162
  // through a compiled extension like `provides.ts` at `/provides.js`
163
- // (see `servedSourceExists`) — every catch-all declines so the file's
164
- // own handler (possibly lower-priority — CSS falls all the way to
163
+ // (see `servedSourceExists`) (every catch-all declines so the file's
164
+ // own handler (possibly lower-priority) CSS falls all the way to
165
165
  // StaticHandler) gets asked. A handful of stats, paid only when a
166
166
  // catch-all is about to answer. `getCatchAllRoute` applies the same
167
- // rule on the discovery path; the two must agree — including the
167
+ // rule on the discovery path; the two must agree, including the
168
168
  // containment clamp: `root + path` is unresolved, so a `..` in the
169
169
  // path would have `statSync` resolve it outside the root and turn this
170
170
  // into an existence probe. See the comment there.
@@ -219,7 +219,7 @@ export class DynamicHandler extends Handler {
219
219
  *
220
220
  * The deny-list is matched against the *request path* in `router.ts`, but
221
221
  * `routeGlobs` deliberately answers a request with a file of a different
222
- * extension — `/schema.css` and `/schema` both resolve to `schema.ts` via
222
+ * extension: `/schema.css` and `/schema` both resolve to `schema.ts` via
223
223
  * stem+ext substitution. So the router's check was being asked about a
224
224
  * string that named no file: it said "not blocked", and `TSHandler`
225
225
  * compiled and served the very file the default list exists to protect.
@@ -228,26 +228,64 @@ export class DynamicHandler extends Handler {
228
228
  *
229
229
  * Re-running the check against what actually resolved is the fix. It is
230
230
  * gated on `servesFiles` so route-only handlers keep their exemption, and
231
- * it wraps every branch — cached, static, dynamic, catch-all — rather than
231
+ * it wraps every branch (cached, static, dynamic, catch-all), rather than
232
232
  * each return point, so a future branch cannot forget it.
233
233
  */
234
234
  static resolveRoute(path: string): Promise<Route.Info | null>
235
235
  static async resolveRoute(path: string) {
236
- const info = await this.resolveRouteFile(path)
237
- if (!info) return null
236
+ return this.checkBlocked(await this.resolveRouteFile(path))
237
+ }
238
238
 
239
+ /**
240
+ * Resolve `path` to a **literal file only**, never a dynamic or catch-all
241
+ * match.
242
+ *
243
+ * For the error handlers, which look up `error` and `error-<code>` pages by
244
+ * name. Asking the ordinary resolver for those was wrong twice over.
245
+ *
246
+ * **It served the wrong page.** `resolveRoute` falls through to dynamic
247
+ * matching, so an app with a root `[...slug].tsx` had every error page
248
+ * claimed by the catch-all, and `/blog/[id].tsx` answered a 404 under
249
+ * `/blog` by rendering a post with `id = 'error'`. An error page is a file
250
+ * an application put there on purpose; nothing about it should be pattern
251
+ * matched.
252
+ *
253
+ * **And it was most of the cost of a 404.** `DynamicErrorHandler` walks the
254
+ * path prefixes, so a miss ran two full resolutions per segment: each one a
255
+ * glob scan plus an `isForbidden` tree-walk, and each one guaranteed to fail
256
+ * for an app with no error pages. That is why a 404 cost ten to thirty times
257
+ * a served page and grew with depth: 64 segments measured at over ten
258
+ * seconds. Static-only makes each probe one glob and no dynamic scan.
259
+ */
260
+ protected static resolveStaticRoute(path: string): Promise<Route.Info | null>
261
+ protected static async resolveStaticRoute(path: string) {
262
+ return this.checkBlocked(await this.resolveRouteFile(path, true))
263
+ }
264
+
265
+ /**
266
+ * The deny-list check both resolvers end in.
267
+ *
268
+ * Extracted rather than repeated: it is the clause that stops a resolved
269
+ * *file* from being served when the request path alone looked innocent (see
270
+ * `resolveRouteFile`'s note), and two copies is two places for it to be
271
+ * dropped from.
272
+ */
273
+ private static checkBlocked(info: Route.Info | null) {
274
+ if (!info) return null
239
275
  if (
240
276
  this.servesFiles &&
241
277
  matchBlockedCached(Bakery.config.blocked, `/${info.path}`)
242
278
  ) {
243
279
  return null
244
280
  }
245
-
246
281
  return info
247
282
  }
248
283
 
249
- protected static resolveRouteFile(path: string): Promise<Route.Info | null>
250
- protected static async resolveRouteFile(path: string) {
284
+ protected static resolveRouteFile(
285
+ path: string,
286
+ staticOnly?: boolean,
287
+ ): Promise<Route.Info | null>
288
+ protected static async resolveRouteFile(path: string, staticOnly = false) {
251
289
  const cached = this.getCachedRoute(path)
252
290
  if (cached) return cached
253
291
 
@@ -269,10 +307,15 @@ export class DynamicHandler extends Handler {
269
307
  return this.cacheStaticRoute(path, staticInfo)
270
308
  }
271
309
 
310
+ // A literal file was all that was asked for. The dynamic passes below are
311
+ // not merely skipped as an optimization: for an error page they would be
312
+ // wrong, and `resolveStaticRoute` says why.
313
+ if (staticOnly) return null
314
+
272
315
  // `findDynamicRoute` already required `valid` and `!isForbidden` against
273
316
  // `Bakery.serveRoot` before returning, so wrapping it in
274
- // `validateCachedRoute` — which asserts exactly those two, against exactly
275
- // that root — re-ran a stat and a directory tree-walk on a value that had
317
+ // `validateCachedRoute` (which asserts exactly those two, against exactly
318
+ // that root) re-ran a stat and a directory tree-walk on a value that had
276
319
  // just passed both, and its eviction branch was unreachable from here.
277
320
  // `getCachedRoute` still needs the wrapper; entries read from `this.cache`
278
321
  // have not been checked.