@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.1
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 +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- 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
|
@@ -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.
|
|
@@ -17,7 +17,7 @@ const DEFAULT_ERROR: Handler.Error.Data = {
|
|
|
17
17
|
*
|
|
18
18
|
* The copy is the point, not the convenience. `extractErrorData` assigns the
|
|
19
19
|
* getter's result to a local and then *mutates* it, which is safe only while
|
|
20
|
-
* every `this` it runs under hands back a new object
|
|
20
|
+
* every `this` it runs under hands back a new object: a plain
|
|
21
21
|
* `= DEFAULT_ERROR` would let any caller write the process-wide default's
|
|
22
22
|
* fields. Both `ErrorHandler` and `DynamicErrorHandler` need the getter and
|
|
23
23
|
* they sit in different class hierarchies, so neither can inherit it from the
|
|
@@ -32,7 +32,7 @@ function defaultErrorData(): Handler.Error.Data {
|
|
|
32
32
|
* route lookup, and the 404 for a path that resolves to nothing.
|
|
33
33
|
*
|
|
34
34
|
* `HTMLHandler`, `TSXHandler` and the Vue plugin's handler render three
|
|
35
|
-
* different file formats and their bodies genuinely differ
|
|
35
|
+
* different file formats and their bodies genuinely differ, but all three
|
|
36
36
|
* reach them this way. `errors` is `undefined` for an ordinary page, and
|
|
37
37
|
* `DEFAULT_ERROR` exists only on the error subclasses, so the fallback stays
|
|
38
38
|
* `undefined` for the ordinary handlers. `DynamicErrorHandler.resolveRoute`
|
|
@@ -63,7 +63,7 @@ export async function beginPageRoute(
|
|
|
63
63
|
* whatever error data gets merged in, and it names the route-relative path.
|
|
64
64
|
* `HTMLHandler` used to set it after the merge and `TSXHandler` before, which
|
|
65
65
|
* comes out the same today only because `publicErrorData` never emits a
|
|
66
|
-
* `__file` key
|
|
66
|
+
* `__file` key: two orderings for one rule is what makes it look like the
|
|
67
67
|
* order might matter. It does not; this is the order, and `params` is the
|
|
68
68
|
* object because TSX also feeds that same object to `injectIfHtml`.
|
|
69
69
|
*/
|
|
@@ -96,7 +96,7 @@ export class HandlerError extends Error {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export class ErrorHandler extends Handler {
|
|
99
|
-
/** A fresh copy every read
|
|
99
|
+
/** A fresh copy every read. See `defaultErrorData`. */
|
|
100
100
|
static get DEFAULT_ERROR() {
|
|
101
101
|
return defaultErrorData()
|
|
102
102
|
}
|
|
@@ -132,7 +132,7 @@ export class ErrorHandler extends Handler {
|
|
|
132
132
|
/**
|
|
133
133
|
* The part of `error` that may cross the wire.
|
|
134
134
|
*
|
|
135
|
-
* `extractErrorData` puts `error.stack` into `errorBody` deliberately
|
|
135
|
+
* `extractErrorData` puts `error.stack` into `errorBody` deliberately: that
|
|
136
136
|
* is what reaches the server log, and losing it would be a regression. But
|
|
137
137
|
* the same field was being handed straight to the client: a `SQLiteError`
|
|
138
138
|
* from a failed query answered a 500 with the failing statement, the table
|
|
@@ -155,13 +155,13 @@ export class ErrorHandler extends Handler {
|
|
|
155
155
|
* `BuildMessage` (a syntax error) and `ResolveMessage` (an import that
|
|
156
156
|
* resolves to nothing) are what the transpiler and the module resolver throw,
|
|
157
157
|
* and neither is `instanceof Error`. They used to reach the `is.object`
|
|
158
|
-
* branch below, which reads only `errorCode`/`errorText`/`errorBody`
|
|
159
|
-
* of which a diagnostic has
|
|
158
|
+
* branch below, which reads only `errorCode`/`errorText`/`errorBody` (none
|
|
159
|
+
* of which a diagnostic has), and so returned the untouched default. A typo
|
|
160
160
|
* in a route file, the single most common server-side failure there is,
|
|
161
161
|
* answered `An unexpected error occurred.` in development as well as in
|
|
162
162
|
* production.
|
|
163
163
|
*
|
|
164
|
-
*
|
|
164
|
+
* Recognized by shape rather than by constructor: the classes are not
|
|
165
165
|
* exported, and both report zero own enumerable keys, so a string `message`
|
|
166
166
|
* on a non-`Error` object is the only tell. The error-data keys are checked
|
|
167
167
|
* first by the caller, so a record that carries those still takes its own
|
|
@@ -180,8 +180,8 @@ export class ErrorHandler extends Handler {
|
|
|
180
180
|
return {
|
|
181
181
|
...this.DEFAULT_ERROR,
|
|
182
182
|
errorText: error.message,
|
|
183
|
-
// `errorDetail` is `error.stack` whenever there is one
|
|
184
|
-
// case is untouched
|
|
183
|
+
// `errorDetail` is `error.stack` whenever there is one (so the common
|
|
184
|
+
// case is untouched), and falls back to the aggregated sub-diagnostics
|
|
185
185
|
// for the stackless `AggregateError` that `import()`ing a broken `.tsx`
|
|
186
186
|
// throws, where `String(error)` was a summary count and nothing else.
|
|
187
187
|
errorBody: errorDetail(error) || String(error),
|
|
@@ -193,7 +193,16 @@ export class ErrorHandler extends Handler {
|
|
|
193
193
|
...this.DEFAULT_ERROR,
|
|
194
194
|
errorCode: error.status,
|
|
195
195
|
errorText: error.statusText,
|
|
196
|
-
|
|
196
|
+
// The reason-phrase is optional: `new Response(null, { status })`
|
|
197
|
+
// leaves it `''`, and the framework's own forbidden-path denial is
|
|
198
|
+
// exactly that shape, so the quoting is conditional: a synthesized
|
|
199
|
+
// `403: ""` rendered a literal quoted empty string on the error page.
|
|
200
|
+
// The bare status, not `''`, because this string is also the default
|
|
201
|
+
// `onError` log line (`403 at /path`); an empty body would log
|
|
202
|
+
// ` at /path`.
|
|
203
|
+
errorBody: error.statusText
|
|
204
|
+
? `${error.status}: "${error.statusText}"`
|
|
205
|
+
: String(error.status),
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -204,7 +213,7 @@ export class ErrorHandler extends Handler {
|
|
|
204
213
|
errorObj.errorText !== undefined ||
|
|
205
214
|
errorObj.errorBody !== undefined
|
|
206
215
|
|
|
207
|
-
// Authored error data wins, `message` or no `message
|
|
216
|
+
// Authored error data wins, `message` or no `message`, the branch order
|
|
208
217
|
// and its semantics are unchanged for everything that ever reached it.
|
|
209
218
|
if (!authored && this.isDiagnostic(error)) {
|
|
210
219
|
return {
|
|
@@ -232,7 +241,7 @@ export class ErrorHandler extends Handler {
|
|
|
232
241
|
|
|
233
242
|
export class DynamicErrorHandler extends DynamicHandler {
|
|
234
243
|
/**
|
|
235
|
-
* The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back
|
|
244
|
+
* The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back. See
|
|
236
245
|
* `defaultErrorData`. Declared again rather than inherited because this
|
|
237
246
|
* class descends from `DynamicHandler`, not from `ErrorHandler`.
|
|
238
247
|
*/
|
|
@@ -270,9 +279,12 @@ export class DynamicErrorHandler extends DynamicHandler {
|
|
|
270
279
|
|
|
271
280
|
const defsPage = `${prefix}/error`
|
|
272
281
|
const codePage = `${prefix}/error-${errors.errorCode}`
|
|
282
|
+
// `resolveStaticRoute`, never `resolveRoute`: an error page is a file by
|
|
283
|
+
// name, and letting the dynamic matcher answer meant a root catch-all
|
|
284
|
+
// claimed every one of them. See the note on that method.
|
|
273
285
|
const routeInfo =
|
|
274
|
-
(await super.
|
|
275
|
-
(await super.
|
|
286
|
+
(await super.resolveStaticRoute(codePage)) ||
|
|
287
|
+
(await super.resolveStaticRoute(defsPage))
|
|
276
288
|
if (routeInfo) return routeInfo
|
|
277
289
|
}
|
|
278
290
|
|
|
@@ -286,14 +298,14 @@ export class DynamicErrorHandler extends DynamicHandler {
|
|
|
286
298
|
* `ErrorHandler.publicBody` is the rule; this is it applied to a whole record,
|
|
287
299
|
* for the handlers that hand error data to a *template* rather than rendering
|
|
288
300
|
* a string themselves. `HTMLErrorHandler` and `TSXErrorHandler` merge the
|
|
289
|
-
* record into their page params, and those params reach the document twice
|
|
301
|
+
* record into their page params, and those params reach the document twice,
|
|
290
302
|
* through `{{...}}` substitution and through the `window.__PAGE_PARAMS__`
|
|
291
303
|
* script `DOMTools.params` injects into every page. The second path is why
|
|
292
304
|
* redacting in the template was never enough: an `error.html` that never
|
|
293
305
|
* mentions `errorBody` still published the stack, absolute source paths and
|
|
294
306
|
* all, to any anonymous request in production.
|
|
295
307
|
*
|
|
296
|
-
* `errorText` is deliberately untouched
|
|
308
|
+
* `errorText` is deliberately untouched: `DefaultErrorHandler` shows it in
|
|
297
309
|
* its heading in every mode, and one rule in one place beats two that drift.
|
|
298
310
|
*/
|
|
299
311
|
export function publicErrorData(error: Handler.Error.Data): Handler.Error.Data {
|
|
@@ -11,7 +11,7 @@ import { Handler } from './$base'
|
|
|
11
11
|
* else. `JsonResponseData` is the framework's one-envelope idiom (convention
|
|
12
12
|
* 7) and `processResponse` already renders it with its own `status`, so an
|
|
13
13
|
* `ApiHandler` route returning `response.json.error(401, …)` answered 401
|
|
14
|
-
* while the identical line in a middleware did not
|
|
14
|
+
* while the identical line in a middleware did not: the value was not a
|
|
15
15
|
* `Response`, so the chain ignored it and the request carried on.
|
|
16
16
|
*
|
|
17
17
|
* Widening further, to "anything truthy", is the tempting version and is
|
|
@@ -30,8 +30,8 @@ function isMiddlewareResult(value: unknown): value is MiddlewareResult {
|
|
|
30
30
|
/**
|
|
31
31
|
* Per-request slot for the response produced during `canHandle`, so `handle`
|
|
32
32
|
* can return it without re-running the chain. This was previously a static
|
|
33
|
-
* field, which meant two concurrent requests could swap responses
|
|
34
|
-
* each other's `Set-Cookie` headers
|
|
33
|
+
* field, which meant two concurrent requests could swap responses (including
|
|
34
|
+
* each other's `Set-Cookie` headers) at any `await` boundary.
|
|
35
35
|
*/
|
|
36
36
|
const pending = new WeakMap<Request, MiddlewareResult>()
|
|
37
37
|
|
|
@@ -60,13 +60,13 @@ export class MiddlewareHandler extends Handler {
|
|
|
60
60
|
return cached
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// One config read for both `onRequest` and the middleware chain
|
|
63
|
+
// One config read for both `onRequest` and the middleware chain: same
|
|
64
64
|
// request, same host store, so the snapshot cannot go stale mid-call.
|
|
65
65
|
const config = Bakery.config
|
|
66
66
|
const intercepted = await config.onRequest(req!)
|
|
67
67
|
if (intercepted) {
|
|
68
68
|
// `|| undefined` used to sit here, and `injectIfHtml` returns null for
|
|
69
|
-
// anything that is not HTML
|
|
69
|
+
// anything that is not HTML, so a plain-text 403 from `onRequest`
|
|
70
70
|
// vanished and the request carried on. Inject when it is HTML, keep the
|
|
71
71
|
// original otherwise, exactly as the middleware chain below does.
|
|
72
72
|
return (await injectIfHtml(intercepted)) || intercepted
|
|
@@ -91,7 +91,7 @@ export class MiddlewareHandler extends Handler {
|
|
|
91
91
|
|
|
92
92
|
// An envelope is JSON by construction, so it skips injection rather than
|
|
93
93
|
// paying `DOMTools.isHTML` to be told so. `processResponse` reads its
|
|
94
|
-
// `status` and
|
|
94
|
+
// `status` and serializes it.
|
|
95
95
|
if (data instanceof JsonResponseData) return data
|
|
96
96
|
|
|
97
97
|
const injectedRes = await injectIfHtml(data!)
|