@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +54 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +29 -8
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
50
|
-
// source line
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
250
|
-
* handler
|
|
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
|
|
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
|
|
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
|
|
44
|
-
* reasoning
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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`)
|
|
164
|
-
// own handler (possibly lower-priority
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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(
|
|
250
|
-
|
|
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`
|
|
275
|
-
// that root
|
|
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.
|