@bakery-framework/core 1.2.3 → 2.0.0-alpha.11

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.
@@ -1,6 +1,6 @@
1
1
  import { bundleModule } from '../../compiler'
2
2
  import { Bakery } from '../../core/bakery'
3
- import { toHash } from '../../utils/common'
3
+ import { Try, toHash } from '../../utils/common'
4
4
  import { fs } from '../../utils/fs'
5
5
  import { Handler } from '../core/$base'
6
6
 
@@ -13,6 +13,52 @@ export class NMHandler extends Handler {
13
13
  return path.startsWith('/_nm/')
14
14
  }
15
15
 
16
+ /**
17
+ * The file a `/_nm/` path names.
18
+ *
19
+ * **The literal path wins whenever it exists, and the order is the whole
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
22
+ * the CJS one behind its `node` condition. `Bun.resolveSync` has no such
23
+ * knob: it answers with Bun's own server conditions. Resolving here first and
24
+ * handing `Bun.build` the concrete file therefore silently downgraded every
25
+ * package with a `browser`/`node` split — `/_nm/vue` came back as
26
+ * `index.mjs` re-exporting `vue.cjs.js`, and the browser then rejected
27
+ * `import { Fragment } from 'vue'`.
28
+ *
29
+ * So the literal path goes first whenever it is on disk, and `resolveSync` is
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 —
32
+ * `@vue-material/core` maps `"./utils"` to `./dist/utils/index.js`, so the
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
35
+ * `exports`, where `Bun.build` on the directory answers `ModuleNotFound`
36
+ * because it looks for `main`/`module` and finds neither.
37
+ *
38
+ * Hence a list rather than one answer: the caller bundles the first candidate
39
+ * that builds. Both orders are wrong on their own.
40
+ */
41
+ private static resolveEntry(nmPath: string, nmRoot: string): string[] {
42
+ const literal = fs.resolve(Bakery.root, nmPath)
43
+ const candidates = fs.exists(literal) ? [literal] : []
44
+
45
+ const specifier = nmPath.replace(/^node_modules\//, '')
46
+
47
+ try {
48
+ const resolved = fs.resolve(Bun.resolveSync(specifier, Bakery.root))
49
+ // A resolver answer still has to be inside `node_modules`: an `exports`
50
+ // map and a `browser` field can both point outside the package, and this
51
+ // path is reachable from a URL.
52
+ if (resolved.startsWith(`${nmRoot}/`) && resolved !== literal) {
53
+ candidates.push(resolved)
54
+ }
55
+ } catch {
56
+ // Not exposed by the map, or no map to consult.
57
+ }
58
+
59
+ return candidates.length ? candidates : [literal]
60
+ }
61
+
16
62
  /**
17
63
  * Serve a browser-ready bundle of a file inside `node_modules`.
18
64
  *
@@ -21,13 +67,15 @@ export class NMHandler extends Handler {
21
67
  * comment names now go through it. This one cannot, and the reason is not
22
68
  * the root (it takes a `roots` argument) — it is that `getStatic` answers
23
69
  * "is there a plain file literally at this path", and `/_nm/` deliberately
24
- * asks a different question. The entry here goes to `Bun.build`, which does
25
- * Node resolution: `/_nm/pkg/sub` resolves to `pkg/sub/index.js`, and that is
26
- * precisely what the import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry
27
- * (`utils/http/dom.ts`) produces for an extensionless subpath import.
28
- * `getStatic` returns `null` for that path — it is a directory — so routing
29
- * through it would turn every extensionless subpath import into a 204.
30
- * `nm.test.ts` pins both halves of that divergence.
70
+ * asks a different question: what would an importer of this specifier get?
71
+ * `resolveEntry` answers it, applying the package's `exports` map, and
72
+ * `Bun.build` then does directory-index resolution on whatever survives — so
73
+ * `/_nm/pkg/sub` reaches `pkg/sub/index.js`, which is precisely what the
74
+ * import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry (`utils/http/dom.ts`)
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
77
+ * extensionless subpath import into a 204. `nm.test.ts` pins both halves of
78
+ * that divergence.
31
79
  *
32
80
  * What *was* missing is the second half of the pair. Containment was spelled
33
81
  * out here and was correct, but `.forbidden` was never checked, so `/_nm/*`
@@ -43,16 +91,21 @@ export class NMHandler extends Handler {
43
91
  static async handle(path: string) {
44
92
  const nmPath = path.replace(/^\/_nm\//, 'node_modules/')
45
93
  const nmRoot = fs.resolve(Bakery.root, 'node_modules')
46
- const nodeModulesPath = fs.resolve(Bakery.root, nmPath)
94
+ const candidates = this.resolveEntry(nmPath, nmRoot)
47
95
 
48
96
  // Strictly below the root, so `node_modules` itself is not an entry point.
49
97
  // `getStatic` admits `file === root` and then rejects it as a directory;
50
98
  // here the same answer has to come from the containment test, because
51
- // nothing downstream stats the path.
52
- if (!nodeModulesPath.startsWith(`${nmRoot}/`)) return undefined
53
- if (fs.isForbidden(nodeModulesPath, nmRoot)) return undefined
99
+ // nothing downstream stats the path. Every candidate is checked: the
100
+ // resolver's answer is no more trusted than the URL's.
101
+ const allowed = candidates.filter(
102
+ candidate =>
103
+ candidate.startsWith(`${nmRoot}/`) &&
104
+ !fs.isForbidden(candidate, nmRoot),
105
+ )
106
+ if (!allowed.length) return undefined
54
107
 
55
- const nmFile = Bun.file(nodeModulesPath)
108
+ const nmFile = Bun.file(allowed[0])
56
109
  const sourceMtime = fs.exists(nmFile) ? nmFile.lastModified : null
57
110
 
58
111
  const cacheId = toHash(nmPath)
@@ -63,8 +116,14 @@ export class NMHandler extends Handler {
63
116
  cacheName,
64
117
  sourceMtime,
65
118
  async () => {
66
- const module = await bundleModule(nodeModulesPath)
67
- return module.success && module.content ? module.content : null
119
+ for (const candidate of allowed) {
120
+ // Wrapped: an entry point Bun cannot resolve is a *throw*, not a
121
+ // `success: false`, and that is the expected outcome of the first
122
+ // candidate for a package whose root exists only in `exports`.
123
+ const [err, module] = await Try.catch(() => bundleModule(candidate))
124
+ if (!err && module?.success && module.content) return module.content
125
+ }
126
+ return null
68
127
  },
69
128
  )
70
129
 
@@ -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'
@@ -57,30 +57,73 @@ export class DefaultErrorHandler extends ErrorHandler {
57
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
  }
@@ -6,21 +6,43 @@ import { processBody } from '../../utils/http'
6
6
  const RX_PARAM = /[[\]{}()*+?.\\^$|]/g
7
7
  export const RX_DYNAMIC = /\[([\w$]+)\]/
8
8
  export const RX_CATCHALL = /\[\.\.\.([\w$]+)\]/
9
+ export const RX_OPT_CATCHALL = /\[\.\.\.([\w$]+)!\]/
9
10
 
10
11
  export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
11
12
  const cleanPath = path.replace(/\\/g, '/').replace(/^\/+/, '')
12
13
  if (!cleanPath) return null
13
- if (!RX_DYNAMIC.test(cleanPath) && !RX_CATCHALL.test(cleanPath)) return null
14
+ // Three disjoint spellings: `!` before the `]` keeps `[...x!]` from
15
+ // matching either of the other two, so each test sees only its own form.
16
+ if (
17
+ !RX_DYNAMIC.test(cleanPath) &&
18
+ !RX_CATCHALL.test(cleanPath) &&
19
+ !RX_OPT_CATCHALL.test(cleanPath)
20
+ ) {
21
+ return null
22
+ }
14
23
 
15
24
  const params: string[] = []
16
25
  const segments = cleanPath.split('/')
17
26
  const last = segments.length - 1
18
27
  let catchAll = false
28
+ let optionalCatchAll = false
19
29
 
20
30
  const mappedPaths: string[] = []
21
31
  for (let i = 0; i < segments.length; i++) {
22
32
  const segment = segments[i]
23
33
 
34
+ const optionalMatch = segment.match(RX_OPT_CATCHALL)
35
+ if (optionalMatch) {
36
+ if (i !== last) return null
37
+ params.push(optionalMatch[1])
38
+ // The whole segment — separator included — is optional, so the pattern
39
+ // is assembled below rather than pushed here: `docs/[...slug!]` has to
40
+ // match `/docs` itself, which `/docs/(.*)` cannot.
41
+ catchAll = true
42
+ optionalCatchAll = true
43
+ continue
44
+ }
45
+
24
46
  const catchAllMatch = segment.match(RX_CATCHALL)
25
47
  if (catchAllMatch) {
26
48
  // Only terminal: a segment after `[...x]` has no unambiguous meaning
@@ -30,7 +52,9 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
30
52
  params.push(catchAllMatch[1])
31
53
  // `.+` rather than `.*`: the catch-all requires at least one segment,
32
54
  // so `docs/[...slug]` does not shadow a `docs/index` sibling for
33
- // `/docs` itself.
55
+ // `/docs` itself. `[...slug!]` is the spelling that opts into the
56
+ // bare directory — and an index sibling still wins there, because
57
+ // static discovery runs before dynamic in `resolveRouteFile`.
34
58
  mappedPaths.push('(.+)')
35
59
  catchAll = true
36
60
  continue
@@ -46,10 +70,16 @@ export function getDynamicRoute(path: string): Handler.Dynamic.Route | null {
46
70
  mappedPaths.push(segment.replace(RX_PARAM, '\\$&'))
47
71
  }
48
72
 
73
+ const joined = mappedPaths.join('/')
74
+ const body = optionalCatchAll
75
+ ? `${joined ? `/${joined}` : ''}(?:/(.*))?`
76
+ : `/${joined}`
77
+
49
78
  return {
50
- pattern: new RegExp(`^/${mappedPaths.join('/')}(?:\\.([a-z]*))?$`),
79
+ pattern: new RegExp(`^${body}(?:\\.([a-z]*))?$`),
51
80
  params,
52
81
  catchAll,
82
+ optionalCatchAll,
53
83
  }
54
84
  }
55
85
 
@@ -62,8 +92,9 @@ export namespace RouteData {
62
92
  readonly valid: boolean
63
93
  readonly isDynamic: boolean
64
94
  readonly catchAll: boolean
95
+ readonly optionalCatchAll: boolean
65
96
  readonly regex: RegExp | null
66
- getParams(path: string): MapOf<string> | null
97
+ getParams(path: string): MapOf<string | string[]> | null
67
98
  }
68
99
 
69
100
  export type Meta = {
@@ -92,6 +123,7 @@ export class RouteData {
92
123
  readonly path: fs.RelativePath
93
124
  readonly regex: RegExp | null
94
125
  readonly catchAll: boolean
126
+ readonly optionalCatchAll: boolean
95
127
 
96
128
  constructor(filePath: fs.AbsolutePath, path: fs.RelativePath) {
97
129
  this.filePath = fs.resolve(filePath) as fs.AbsolutePath
@@ -101,6 +133,7 @@ export class RouteData {
101
133
  this.regex = route?.pattern || null
102
134
  this.params = route?.params || []
103
135
  this.catchAll = route?.catchAll || false
136
+ this.optionalCatchAll = route?.optionalCatchAll || false
104
137
  }
105
138
 
106
139
  get file() {
@@ -115,17 +148,28 @@ export class RouteData {
115
148
  return this.regex !== null
116
149
  }
117
150
 
118
- getParams(path: string): MapOf<string> | null {
151
+ getParams(path: string): MapOf<string | string[]> | null {
119
152
  if (!this.regex) return null
120
153
  const cleanPath = path.startsWith('/') ? path : `/${path}`
121
154
  const match = cleanPath.match(this.regex)
122
155
  if (!match) return null
123
156
 
124
- const boundParams: MapOf<string> = {}
157
+ const boundParams: MapOf<string | string[]> = {}
125
158
  for (let i = 0; i < this.params.length; i++) {
126
- boundParams[this.params[i]] = match[i + 1]
159
+ const value = match[i + 1]
160
+ // The catch-all is always terminal, so it is always the last param —
161
+ // and it binds as the *segments*, not the joined string: every
162
+ // consumer was calling `.split('/')` on it anyway, and the joined
163
+ // form silently conflated `/docs/a%2Fb` with `/docs/a/b`. A bare
164
+ // directory under `[...name!]` binds `[]`, which is also what makes
165
+ // "no rest" distinguishable from a single empty segment.
166
+ if (this.catchAll && i === this.params.length - 1) {
167
+ boundParams[this.params[i]] = value ? value.split('/') : []
168
+ continue
169
+ }
170
+ boundParams[this.params[i]] = value
127
171
  }
128
- return boundParams as MapOf<string>
172
+ return boundParams
129
173
  }
130
174
  }
131
175
  }
@@ -147,6 +191,8 @@ export namespace Handler {
147
191
  params: string[]
148
192
  /** True when the final segment is a `[...name]` multi-segment matcher. */
149
193
  catchAll?: boolean
194
+ /** True for the `[[...name]]` form, which also matches its bare directory. */
195
+ optionalCatchAll?: boolean
150
196
  }
151
197
  }
152
198
 
@@ -207,6 +253,23 @@ export class Handler {
207
253
  */
208
254
  static servesFiles = true
209
255
 
256
+ /**
257
+ * The URL prefix this handler owns as a complete user-facing surface —
258
+ * `'/_dashboard'`, `'/_db'` — or `null` for the ordinary case of a handler
259
+ * that answers by extension or content rather than by prefix.
260
+ *
261
+ * This exists so one surface can ask whether another is mounted without
262
+ * probing it. The dashboard used to detect the explorer by calling every
263
+ * registered handler's `canHandle('/_db')` with a control path to exclude
264
+ * the priority-0 catch-all — it worked, and the `as any` it needed was the
265
+ * tell that the registry could not say what a handler serves. A declaration
266
+ * is that answer. It is deliberately *not* consulted by routing: `canHandle`
267
+ * stays the authority on requests, so a stale or missing namespace can
268
+ * misdescribe a handler to the dashboard nav but can never misroute a
269
+ * request.
270
+ */
271
+ static namespace: string | null = null
272
+
210
273
  protected constructor() {}
211
274
 
212
275
  static get cache(): HandlerCache<string, Route.Info> {
@@ -10,9 +10,10 @@ import {
10
10
  type Route,
11
11
  RX_CATCHALL,
12
12
  RX_DYNAMIC,
13
+ RX_OPT_CATCHALL,
13
14
  } from './$base'
14
15
  import { resolveMount } from './$mounts'
15
- import { getRoute } from './$routing'
16
+ import { getRoute, servedSourceExists } from './$routing'
16
17
 
17
18
  const dynamicCaches = new Map<any, HandlerCache<RegExp, Route.Info>>()
18
19
 
@@ -73,9 +74,17 @@ export class DynamicHandler extends Handler {
73
74
 
74
75
  static canHandle(path: string, req?: Request): MixedPromise<boolean>
75
76
  static async canHandle(path: string) {
76
- // A request path spelled like a route template ('/blog/[id]' or
77
- // '/docs/[...slug]') addresses the template file, not a route.
78
- if (RX_DYNAMIC.test(path) || RX_CATCHALL.test(path)) return false
77
+ // A request path spelled like a route template ('/blog/[id]',
78
+ // '/docs/[...slug]' or '/docs/[...slug!]') addresses the template file,
79
+ // not a route. `RX_OPT_CATCHALL` is tested too because the `!` keeps the
80
+ // optional spelling from matching `RX_CATCHALL`.
81
+ if (
82
+ RX_DYNAMIC.test(path) ||
83
+ RX_CATCHALL.test(path) ||
84
+ RX_OPT_CATCHALL.test(path)
85
+ ) {
86
+ return false
87
+ }
79
88
  if (this.cache.has(hostKey(path))) return true
80
89
  // The dynamic half of the line above. A dynamic route is never written to
81
90
  // `this.cache`, so without this every request to one ran `resolveRoute`
@@ -149,18 +158,20 @@ export class DynamicHandler extends Handler {
149
158
  }
150
159
  if (deferred) {
151
160
  // A real file always beats a catch-all, whatever handler would serve
152
- // it: when the requested path names an existing file, every catch-all
153
- // declines so the file's own handler (possibly lower-priority — CSS
154
- // falls all the way to StaticHandler) gets asked. One stat, paid only
155
- // when a catch-all is about to answer. `getCatchAllRoute` applies the
156
- // same rule on the discovery path; the two must agree — including the
161
+ // it: when the requested path names an existing file — literally, or
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
165
+ // StaticHandler) gets asked. A handful of stats, paid only when a
166
+ // catch-all is about to answer. `getCatchAllRoute` applies the same
167
+ // rule on the discovery path; the two must agree — including the
157
168
  // containment clamp: `root + path` is unresolved, so a `..` in the
158
169
  // path would have `statSync` resolve it outside the root and turn this
159
170
  // into an existence probe. See the comment there.
160
171
  const target = fs.resolve(root, `.${path}`)
161
172
  if (
162
173
  (target === root || target.startsWith(`${root}/`)) &&
163
- fs.isFileSync(target)
174
+ servedSourceExists(target)
164
175
  ) {
165
176
  return null
166
177
  }
@@ -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
- errorBody: `${error.status}: "${error.statusText}"`,
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
 
@@ -30,11 +30,16 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
30
30
  }
31
31
  }
32
32
 
33
- set(handlerClass: any, priority: number = 10): this {
34
- super.set(handlerClass, priority)
33
+ /** Drop the derived views. Every mutation has to call this, not just `set`. */
34
+ private invalidate(): void {
35
35
  this.cachedList = null
36
36
  this.cachedGates = null
37
37
  this.cachedOrder = null
38
+ }
39
+
40
+ set(handlerClass: any, priority: number = 10): this {
41
+ super.set(handlerClass, priority)
42
+ this.invalidate()
38
43
  return this
39
44
  }
40
45
 
@@ -42,6 +47,30 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
42
47
  return this.set(handlerClass, priority)
43
48
  }
44
49
 
50
+ /**
51
+ * `delete` and `clear` invalidate too, and neither used to.
52
+ *
53
+ * `list()` memoizes the sorted handler array and only `set` cleared it, so a
54
+ * removed handler stayed in the list — and therefore stayed *in the request
55
+ * pipeline* — until something happened to add one. Registration is
56
+ * add-only in a served process, which is why this never bit: it is reachable
57
+ * only by code that unregisters, and the first thing to do that was a test.
58
+ *
59
+ * A cache that survives the removal of its input is wrong regardless of who
60
+ * currently calls it, and "nothing removes handlers today" is a property of
61
+ * the callers rather than of this class.
62
+ */
63
+ override delete(handlerClass: any): boolean {
64
+ const removed = super.delete(handlerClass)
65
+ if (removed) this.invalidate()
66
+ return removed
67
+ }
68
+
69
+ override clear(): void {
70
+ super.clear()
71
+ this.invalidate()
72
+ }
73
+
45
74
  list(): T[] {
46
75
  if (this.cachedList) {
47
76
  return this.cachedList
@@ -21,15 +21,37 @@ export type RouteScanOptions = {
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
- // first, then the escaped-literal `*.ext`. Built here rather than twice inside
24
+ // first, then the literal-asterisk `*.ext`. Built here rather than twice inside
25
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
29
29
  // them at all.
30
- const dynamicGlobs = (ext: string) => [
30
+ //
31
+ // **The literal asterisk is a character class, `[*]`, never the escape `\*`.**
32
+ // A backslash is the obvious spelling and is unusable on Windows, where `\` is
33
+ // a path separator: Bun read `\*.*` as a drive-absolute pattern, ignored the
34
+ // `cwd` in `GETFILE` entirely, and matched files at `C:\`. A route lookup under
35
+ // a serve root six levels down returned `C:\$WINRE_BACKUP_PARTITION.MARKER`,
36
+ // and `fs.isForbidden` — whose walk is bounded by `startsWith(root)` — waved it
37
+ // through because an out-of-root path skipped the loop and answered "allowed".
38
+ // That clamp now fails closed, so this is belt and braces; both halves are
39
+ // pinned, and neither test can see the other's bug.
40
+ //
41
+ // Measured with a scan whose cwd was a temp directory holding one file: `[*]`
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 — `*` is a
44
+ // reserved character in a Windows filename, so a route file literally named
45
+ // `*.ts` can only exist on POSIX, where both spellings match it identically
46
+ // (verified on Linux). The character class costs nothing and means the same
47
+ // thing on both platforms.
48
+ //
49
+ // Exported only as a test seam: with the clamp in place no test driving
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
+ export const dynamicGlobs = (ext: string) => [
31
53
  new Bun.Glob(`[[][!.]*${ext || '.*'}`),
32
- new Bun.Glob(`\\*${ext || '.*'}`),
54
+ new Bun.Glob(`[*]${ext || '.*'}`),
33
55
  ]
34
56
 
35
57
  const routeGlobs = (
@@ -72,6 +94,43 @@ const routeGlobs = (
72
94
  * is not a file and does not trigger the yield. `findDynamicRoute` applies
73
95
  * the same rule on the cached path; the two must agree.
74
96
  */
97
+ /**
98
+ * Does the requested path name a file some handler serves at that URL?
99
+ *
100
+ * The "a real file always beats a catch-all" rule used to stat the literal
101
+ * path only, which misses every *compiled* URL: `TSHandler` serves
102
+ * `provides.ts` at `/teacher/provides` and `/teacher/provides.js`, so neither
103
+ * spelling named a file on disk and a Vue catch-all above it (priority 58 vs
104
+ * 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 —
106
+ * the same mapping the serving handlers apply, read from the live registry so
107
+ * a plugin's extension (`.vue`) counts without core naming it.
108
+ *
109
+ * The caller clamps `target` inside the root before asking; appending an
110
+ * extension cannot escape it.
111
+ */
112
+ export function servedSourceExists(target: string): boolean {
113
+ if (fs.isFileSync(target)) return true
114
+
115
+ const base = target.endsWith('.js') ? target.slice(0, -3) : target
116
+ for (const handler of Bakery.handlers.fetch.keys()) {
117
+ let exts: unknown
118
+ try {
119
+ exts = (handler as { config?: { ext?: unknown } }).config?.ext
120
+ } catch {
121
+ // A config getter that needs state this process lacks — a handler with
122
+ // no ext table cannot claim a source file either way.
123
+ continue
124
+ }
125
+ if (!Array.isArray(exts)) continue
126
+ for (const ext of exts) {
127
+ if (fs.isFileSync(`${base}.${ext}`)) return true
128
+ }
129
+ }
130
+
131
+ return false
132
+ }
133
+
75
134
  async function getCatchAllRoute(
76
135
  ext: string,
77
136
  dir: fs.AbsolutePath,
@@ -98,7 +157,7 @@ async function getCatchAllRoute(
98
157
  // whose name merely begins with it.
99
158
  if (
100
159
  (target === dir || target.startsWith(`${dir}/`)) &&
101
- fs.isFileSync(target)
160
+ servedSourceExists(target)
102
161
  ) {
103
162
  return null
104
163
  }
@@ -106,7 +165,14 @@ async function getCatchAllRoute(
106
165
 
107
166
  const file = fs.resolve(found.value)
108
167
  if (fs.isForbidden(file, root)) return null
109
- return new RouteData.Info(file, fs.relative(root, file))
168
+ const info = new RouteData.Info(file, fs.relative(root, file))
169
+
170
+ // A bare-directory request (`/docs` with no index) reaches here with no
171
+ // rest segments, and only the `[...name!]` spelling opted into claiming
172
+ // it — the plain form keeps requiring at least one segment.
173
+ if (!restSegments.length && !info.optionalCatchAll) return null
174
+
175
+ return info
110
176
  }
111
177
 
112
178
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request-to-route dispatcher
@@ -177,12 +243,17 @@ export async function getRoute(
177
243
  if (route) return route
178
244
  }
179
245
 
180
- // `first === 'index'` covers the bare-directory request (`/docs` arrives
181
- // here as an injected 'index' segment): the catch-all pattern requires at
182
- // least one rest segment, so it cannot match that request — returning the
183
- // Info anyway would claim the route with null params.
184
- if (!options.staticOnly && first !== 'index') {
185
- return await getCatchAllRoute(ext, dir, root, [first])
246
+ // `first === 'index'` is the bare-directory request (`/docs` arrives here
247
+ // as an injected 'index' segment, after no index file matched). The plain
248
+ // `[...name]` pattern requires at least one rest segment and cannot claim
249
+ // it; `[...name!]` exists to `getCatchAllRoute` tells them apart.
250
+ if (!options.staticOnly) {
251
+ return await getCatchAllRoute(
252
+ ext,
253
+ dir,
254
+ root,
255
+ first === 'index' ? [] : [first],
256
+ )
186
257
  }
187
258
  }
188
259