@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.
- package/package.json +2 -2
- package/src/cache/shared-db.ts +7 -9
- package/src/client/globals.d.ts +1 -1
- package/src/client/utils.ts +41 -9
- package/src/compiler/compiler.ts +458 -39
- package/src/compiler/prompt-tracker.ts +19 -2
- package/src/compiler/tsconfig-sync.ts +116 -45
- package/src/core/bakery.ts +11 -12
- package/src/core/context.ts +42 -8
- package/src/core/index.ts +18 -0
- package/src/core/init.ts +40 -31
- package/src/global.d.ts +24 -6
- package/src/handlers/assets/nm.ts +74 -15
- package/src/handlers/assets/static.ts +53 -10
- package/src/handlers/core/$base.ts +71 -8
- package/src/handlers/core/$dynamic.ts +21 -10
- package/src/handlers/core/$error.ts +10 -1
- package/src/handlers/core/$registry.ts +31 -2
- package/src/handlers/core/$routing.ts +82 -11
- package/src/handlers/routes/api.ts +2 -2
- package/src/handlers/routes/proxy.ts +2 -1
- package/src/logger/serve-log.ts +21 -0
- package/src/plugins/types.ts +7 -5
- package/src/router.ts +5 -5
- package/src/session.ts +0 -3
- package/src/shared.d.ts +7 -0
- package/src/types.d.ts +11 -0
- package/src/utils/fs.ts +29 -0
- package/src/utils/http/authorize.ts +136 -0
- package/src/utils/http/body.ts +2 -2
- package/src/utils/http/credential.ts +70 -0
- package/src/utils/http/dom.ts +73 -64
- package/src/utils/http/etag.ts +27 -0
- package/src/utils/http/html.ts +16 -5
- package/src/utils/http/index.ts +3 -0
- package/src/utils/http/url.ts +35 -0
- package/src/utils/isomorphic/misc.ts +16 -0
|
@@ -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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
|
67
|
-
|
|
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}
|
|
118
|
+
<title>Error ${error.errorCode}</title>
|
|
74
119
|
</head>
|
|
75
120
|
<body style="margin: 2rem; font-family: sans-serif;">
|
|
76
|
-
<h1>${
|
|
77
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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]'
|
|
77
|
-
// '/docs/[...slug]') addresses the template file,
|
|
78
|
-
|
|
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,
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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'`
|
|
181
|
-
//
|
|
182
|
-
// least one rest segment
|
|
183
|
-
//
|
|
184
|
-
if (!options.staticOnly
|
|
185
|
-
return await getCatchAllRoute(
|
|
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
|
|