@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
package/src/core/context.ts
CHANGED
|
@@ -12,12 +12,12 @@ export type HostContext = {
|
|
|
12
12
|
* `hostStore.run` in the tree wraps a single request, WebSocket event or
|
|
13
13
|
* error dispatch, so the map dies with the store. That is what keeps this
|
|
14
14
|
* distinct from the cross-request cache `fs.isForbidden`'s regression test
|
|
15
|
-
* forbids
|
|
15
|
+
* forbids. See the block comment there. Bounded (convention 6) by request
|
|
16
16
|
* lifetime: one request touches a handful of paths, each of bounded depth.
|
|
17
17
|
*/
|
|
18
18
|
forbiddenProbes?: Map<string, boolean>
|
|
19
19
|
/**
|
|
20
|
-
* Per-request memo of `matchBlocked` verdicts keyed by request path
|
|
20
|
+
* Per-request memo of `matchBlocked` verdicts keyed by request path: the
|
|
21
21
|
* router runs the check before dispatch and `StaticHandler.handle` must
|
|
22
22
|
* keep its own (direct callers bypass the router gate), so without this the
|
|
23
23
|
* same globs matched the same path twice per static request. Sound to key
|
|
@@ -33,7 +33,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
33
33
|
* The two runtime directories, defined here rather than on `Bakery`.
|
|
34
34
|
*
|
|
35
35
|
* `Bakery.cacheDir` / `Bakery.dataDir` remain the way application and framework
|
|
36
|
-
* code reads them
|
|
36
|
+
* code reads them: these are the single definition those two forward to, and
|
|
37
37
|
* still the only writer of either path. They live in this module because it is
|
|
38
38
|
* low enough to be imported without pulling in `core/config`, and therefore
|
|
39
39
|
* without pulling in the logger: `compiler/prompt-tracker.ts` needs the cache
|
|
@@ -43,7 +43,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
43
43
|
* **Functions, not constants, and that is not a style choice.** `utils/fs.ts`
|
|
44
44
|
* imports this module for `hostStore`, so the two are themselves a cycle: a
|
|
45
45
|
* top-level `` `${fs.cwd}/.cache` `` here is evaluated with `fs` still
|
|
46
|
-
*
|
|
46
|
+
* uninitialized whenever `core/context` is reached first, and throws
|
|
47
47
|
* `TypeError: undefined is not an object`. Reading `fs.cwd` at call time is
|
|
48
48
|
* what makes the order irrelevant.
|
|
49
49
|
*
|
|
@@ -53,7 +53,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
53
53
|
* bump and dev<->prod switch, so a `rm -rf .*` or a "clean out the dotfiles"
|
|
54
54
|
* sweep does exactly what the framework already does. The database is not
|
|
55
55
|
* disposable, so it does not live behind a leading dot where such a sweep can
|
|
56
|
-
* reach it, and never under `.cache
|
|
56
|
+
* reach it, and never under `.cache`: clearing a cache must not destroy data.
|
|
57
57
|
*/
|
|
58
58
|
export function cacheDir(): string {
|
|
59
59
|
return `${fs.cwd}/.cache`
|
|
@@ -67,7 +67,7 @@ export function dataDir(): string {
|
|
|
67
67
|
* `matchBlocked`, deduplicated within the current request.
|
|
68
68
|
*
|
|
69
69
|
* Outside a request store (tests, direct handler calls) this is exactly
|
|
70
|
-
* `matchBlocked
|
|
70
|
+
* `matchBlocked`: no caching, fail closed on nothing, because nothing is
|
|
71
71
|
* skipped. See `HostContext.blockedPaths` for the scoping argument.
|
|
72
72
|
*/
|
|
73
73
|
export function matchBlockedCached(
|
|
@@ -92,7 +92,7 @@ export function matchBlockedCached(
|
|
|
92
92
|
* and both halves are load-bearing. Distinct, because the two readers exist to
|
|
93
93
|
* be different files and a shared fallback would let one silently stand in for
|
|
94
94
|
* the other. Impossible, because they used to be plain `'1.0.0'` and `'0.0.0'`
|
|
95
|
-
|
|
95
|
+
*, and when the framework was renumbered to 1.0.0 for its first publish, the
|
|
96
96
|
* app fallback became a legitimate framework version. Nothing broke at runtime,
|
|
97
97
|
* but `cache-version.test.ts` could no longer tell "read the manifest" from
|
|
98
98
|
* "fell back to the other one", so the guard was disarmed by a version bump.
|
|
@@ -108,7 +108,7 @@ let _appVersion: string | null = null
|
|
|
108
108
|
/**
|
|
109
109
|
* The **application's** version, from `<cwd>/package.json`.
|
|
110
110
|
*
|
|
111
|
-
* Not the framework's
|
|
111
|
+
* Not the framework's: this reads the manifest of whatever is being served.
|
|
112
112
|
* The distinction is load-bearing for cache invalidation; see
|
|
113
113
|
* {@link getFrameworkVersion}.
|
|
114
114
|
*/
|
package/src/core/define-route.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { type Validator, validate } from '../utils/http/validate'
|
|
|
5
5
|
/**
|
|
6
6
|
* `defineRoute`, in its own module rather than in `core/index.ts`.
|
|
7
7
|
*
|
|
8
|
-
* `core/index.ts` is a barrel
|
|
9
|
-
* so anything importing it from *inside* core closes a cycle and dies with
|
|
8
|
+
* `core/index.ts` is a barrel (it pulls in the logger, plugins, jsx and utils), * so anything importing it from *inside* core closes a cycle and dies with
|
|
10
9
|
* `ReferenceError: Cannot access 'Logger' before initialization`. That is
|
|
11
10
|
* recorded in CLAUDE.md as costing 67 tests once; it cost this file's own tests
|
|
12
11
|
* a second time, which is what moved it here. Consumers still reach it through
|
|
@@ -19,9 +18,9 @@ import { type Validator, validate } from '../utils/http/validate'
|
|
|
19
18
|
*
|
|
20
19
|
* export default defineRoute<{ id: string }>((req, body) => …)
|
|
21
20
|
*
|
|
22
|
-
* With a validator, it also *enforces* that shape
|
|
21
|
+
* With a validator, it also *enforces* that shape. See the overload below.
|
|
23
22
|
*
|
|
24
|
-
* `defineRoute`, not `defineHandler
|
|
23
|
+
* `defineRoute`, not `defineHandler`: "handler" already means a registered
|
|
25
24
|
* `Handler` subclass in this framework, and this defines a route module.
|
|
26
25
|
*/
|
|
27
26
|
export function defineRoute<P = {}>(fn: RouteHandler<P>): RouteHandler<P>
|
|
@@ -30,7 +29,7 @@ export function defineRoute<P = {}>(fn: RouteHandler<P>): RouteHandler<P>
|
|
|
30
29
|
*
|
|
31
30
|
* export default defineRoute({ body: schema }, (req, body) => …)
|
|
32
31
|
*
|
|
33
|
-
* `body` is a Standard Schema (zod, valibot, arktype
|
|
32
|
+
* `body` is a Standard Schema (zod, valibot, arktype, Bakery imports none of
|
|
34
33
|
* them) or a plain function returning the parsed value. A rejection answers
|
|
35
34
|
* `400` through the framework's JSON envelope and the handler never runs.
|
|
36
35
|
*/
|
|
@@ -42,7 +41,7 @@ export function defineRoute(
|
|
|
42
41
|
a: RouteHandler<any> | { body: Validator<any> },
|
|
43
42
|
b?: RouteHandler<any>,
|
|
44
43
|
): RouteHandler<any> {
|
|
45
|
-
// One argument: identity, unchanged. Types only, no runtime cost
|
|
44
|
+
// One argument: identity, unchanged. Types only, no runtime cost: every
|
|
46
45
|
// existing route keeps working byte for byte.
|
|
47
46
|
if (typeof a === 'function') return a
|
|
48
47
|
|
package/src/core/index.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { Case, is, Math2, match, Try } from '../utils/common'
|
|
|
4
4
|
import { encodeSSE, response, sse } from '../utils/http'
|
|
5
5
|
import Bakery, { getHostname, hostKey, hostStore } from './bakery'
|
|
6
6
|
import { getConfig, NOOP } from './config'
|
|
7
|
-
// `./context` is already in this barrel's graph
|
|
8
|
-
// `./bakery`, which re-exports `hostStore` from exactly here
|
|
7
|
+
// `./context` is already in this barrel's graph: line 5 reaches it through
|
|
8
|
+
// `./bakery`, which re-exports `hostStore` from exactly here, so naming it
|
|
9
9
|
// adds no module edge, only a name.
|
|
10
10
|
import { getFrameworkVersion } from './context'
|
|
11
|
-
import { createElement, Fragment, html } from './jsx'
|
|
11
|
+
import { createElement, Fragment, html, raw } from './jsx'
|
|
12
12
|
|
|
13
13
|
export const defineConfig = <T extends AppConfig>(config: T): T => config
|
|
14
14
|
export const definePlugin = _definePlugin
|
|
@@ -43,7 +43,7 @@ export type {
|
|
|
43
43
|
*
|
|
44
44
|
* export default defineRoute<{ id: string }>((req, body) => …)
|
|
45
45
|
*
|
|
46
|
-
* `defineRoute`, not `defineHandler
|
|
46
|
+
* `defineRoute`, not `defineHandler`. "handler" already means a registered
|
|
47
47
|
* `Handler` subclass in this framework, and this defines a route module.
|
|
48
48
|
*/
|
|
49
49
|
export { defineRoute } from './define-route'
|
|
@@ -57,8 +57,8 @@ export { defineRoute } from './define-route'
|
|
|
57
57
|
* into the module graph and reorders evaluation enough to close the cycle this
|
|
58
58
|
* barrel is always one step away from: it typechecks, and then 47 tests fail
|
|
59
59
|
* with `ReferenceError: Cannot access 'Logger' before initialization`. Going
|
|
60
|
-
* through the `utils/http` index
|
|
61
|
-
*
|
|
60
|
+
* through the `utils/http` index (which line 4 already imports for `response`
|
|
61
|
+
*) adds no edge at all. Every other value here follows the same rule.
|
|
62
62
|
*/
|
|
63
63
|
export {
|
|
64
64
|
Bakery,
|
|
@@ -67,11 +67,23 @@ export {
|
|
|
67
67
|
encodeSSE,
|
|
68
68
|
Fragment,
|
|
69
69
|
getConfig,
|
|
70
|
+
/**
|
|
71
|
+
* Opt a string out of JSX escaping.
|
|
72
|
+
*
|
|
73
|
+
* Here because it had nowhere else to be. `createElement` escapes children
|
|
74
|
+
* unless they came from itself, so `raw` is the documented way to
|
|
75
|
+
* interpolate markup an application already trusts, and it was reachable
|
|
76
|
+
* only through a `./jsx` subpath that existed to alias one file. The
|
|
77
|
+
* routing guide pointed at `@bakery-framework/core/core/jsx`, which the
|
|
78
|
+
* export map never named at all, so the documented import could not resolve
|
|
79
|
+
* for a consumer either way.
|
|
80
|
+
*/
|
|
81
|
+
raw,
|
|
70
82
|
/**
|
|
71
83
|
* The version of `@bakery-framework/core` itself, read from its own manifest.
|
|
72
84
|
*
|
|
73
85
|
* **Not the app's version**, which is what `import.meta.env.BAKERY_VERSION`
|
|
74
|
-
* and `getAppVersion()` report
|
|
86
|
+
* and `getAppVersion()` report: the compiler reads those from
|
|
75
87
|
* `<cwd>/package.json`, so in an application they answer with the
|
|
76
88
|
* application's number. The name is a long-standing misnomer and this is the
|
|
77
89
|
* one that means what "Bakery version" sounds like it means.
|
package/src/core/init.ts
CHANGED
|
@@ -27,46 +27,49 @@ const getArgValue = (name: string) => {
|
|
|
27
27
|
const threadId = process.env.THREAD_ID ?? getArgValue('--thread-id') ?? '0'
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* The mode flags are **strings on `process.env`**: `'1'` for true, `''` for
|
|
31
|
+
* false. They were booleans behind an accessor pair until Bun 1.4.
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* **Bun 1.4.0 rejects accessor descriptors on `process.env` outright.**
|
|
34
|
+
* `Object.defineProperty(process.env, 'X', { get })` throws
|
|
35
|
+
* `ERR_INVALID_OBJECT_DEFINE_PROPERTY`. This block runs at import time in every
|
|
36
|
+
* entry, so the whole framework died on `import` under current Bun: a
|
|
37
|
+
* scaffolded app could not reach step 2 of its own quick start. Data
|
|
38
|
+
* descriptors are accepted and coerce anyway (`{ value: false }` reads back as
|
|
39
|
+
* `"false"`), so a boolean here is no longer expressible at all; and
|
|
40
|
+
* `import.meta.env === process.env` in Bun, so there is no second object to put
|
|
41
|
+
* one on.
|
|
42
|
+
*
|
|
43
|
+
* **`'1'` / `''`, never `'true'` / `'false'`.** Truthiness has to survive the
|
|
44
|
+
* coercion: `"false"` is a truthy string, so every `if (import.meta.env.DEV)`
|
|
45
|
+
* in the codebase would have inverted silently rather than failed. `''` also
|
|
46
|
+
* keeps the key *present*: `'PROD' in process.env` stays true, which is what
|
|
47
|
+
* separates "explicitly not production" from "never booted", a distinction
|
|
48
|
+
* `utils/http/authorize.ts` depends on to fail closed.
|
|
49
|
+
*
|
|
50
|
+
* Plain assignment, so `threads.ts` can still assign `THREAD_ID = '0'` on the
|
|
51
|
+
* single-worker/clamped path (deliberately not `THREAD_WORKER`: a cluster of
|
|
52
|
+
* one must keep full-size caches). That assignment is why these were an
|
|
53
|
+
* accessor *pair* rather than a bare getter: a getter with no setter is
|
|
54
|
+
* readonly, the assignment threw, and the throw was swallowed by a `Try(...)`
|
|
55
|
+
* that made a dead code path look deliberate.
|
|
41
56
|
*/
|
|
42
|
-
const
|
|
43
|
-
let value = initial
|
|
44
|
-
return {
|
|
45
|
-
get: () => value,
|
|
46
|
-
set: (next: any) => {
|
|
47
|
-
value = next
|
|
48
|
-
},
|
|
49
|
-
enumerable: true,
|
|
50
|
-
configurable: true,
|
|
51
|
-
}
|
|
52
|
-
}
|
|
57
|
+
const flag = (on: boolean) => (on ? '1' : '')
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
MODE: accessor(mode),
|
|
63
|
-
})
|
|
59
|
+
process.env.DEV = flag(isDev)
|
|
60
|
+
process.env.TEST = flag(isTest)
|
|
61
|
+
process.env.PROD = flag(!isDev && !hasDevWorkerArg)
|
|
62
|
+
process.env.WORKER = flag(hasDevWorkerArg || isThreadWorker)
|
|
63
|
+
process.env.DEV_WORKER = flag(hasDevWorkerArg)
|
|
64
|
+
process.env.THREAD_WORKER = flag(isThreadWorker)
|
|
65
|
+
process.env.THREAD_ID = threadId
|
|
66
|
+
process.env.MODE = mode
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* "This process is the worker of a *development* server."
|
|
67
70
|
*
|
|
68
|
-
* `DEV_WORKER` alone would answer the same
|
|
69
|
-
* `--dev || --dev-worker`, so a dev worker always carries `DEV` too
|
|
71
|
+
* `DEV_WORKER` alone would answer the same (`isDev` above is
|
|
72
|
+
* `--dev || --dev-worker`, so a dev worker always carries `DEV` too), but the
|
|
70
73
|
* conjunction is the condition the call sites were written against, and it
|
|
71
74
|
* says what it means. Exported from here rather than recomputed per module
|
|
72
75
|
* because three of them branch on it (`cli/worker.ts`,
|
|
@@ -86,8 +89,8 @@ Object.assign(globalThis, {
|
|
|
86
89
|
Fragment,
|
|
87
90
|
html,
|
|
88
91
|
// The same value the browser runtime binds (`client/utils.ts`), so code
|
|
89
|
-
// that moves between an SFC's browser script and its server block
|
|
90
|
-
// it runs as a bare global either way
|
|
92
|
+
// that moves between an SFC's browser script and its server block (where
|
|
93
|
+
// it runs as a bare global either way) does not lose the name. Declared
|
|
91
94
|
// once, in `shared.d.ts`.
|
|
92
95
|
randomId,
|
|
93
96
|
})
|
package/src/core/jsx.ts
CHANGED
|
@@ -114,9 +114,9 @@ type RenderFn<P = {}> = (
|
|
|
114
114
|
) => string | Promise<string> | Promise<Response> | Response
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* `<P>` is type-level only
|
|
117
|
+
* `<P>` is type-level only. Declare the route's params once and `body` is
|
|
118
118
|
* typed inside the render function: `html<{ id: string }>((req, body) => …)`.
|
|
119
|
-
* `RouteBody<{}>` is `MapOf<any>`, so an
|
|
119
|
+
* `RouteBody<{}>` is `MapOf<any>`, so an unparameterized call is unchanged.
|
|
120
120
|
*/
|
|
121
121
|
export function html<P = {}>(render: RenderFn<P>) {
|
|
122
122
|
return async (req: Request, body: RouteBody<P>) => {
|
|
@@ -127,9 +127,24 @@ export function html<P = {}>(render: RenderFn<P>) {
|
|
|
127
127
|
return rawDom
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Both questions are about the first nine characters, so only the first
|
|
131
|
+
// nine are trimmed and lowercased. The form this replaces built two whole
|
|
132
|
+
// trimmed, lowercased copies of the document to answer them: on a 124 KB
|
|
133
|
+
// page that was 159 us per render against 0.17 us here, a 920x difference
|
|
134
|
+
// on a path every server-rendered page goes through. Measured interleaved
|
|
135
|
+
// with a CPU-bound control that stayed flat at 29-34 ms, and the two forms
|
|
136
|
+
// agree on 18 hand-picked shapes and 200,000 fuzzed strings.
|
|
137
|
+
//
|
|
138
|
+
// The search scans the leading whitespace and stops, so the cost is the
|
|
139
|
+
// indentation rather than the document. `<!doctype` is the longer of the
|
|
140
|
+
// two prefixes at nine characters, which is the whole window needed.
|
|
141
|
+
const startsAt = rawDom.search(/\S/)
|
|
142
|
+
const prefix =
|
|
143
|
+
startsAt < 0 ? '' : rawDom.slice(startsAt, startsAt + 9).toLowerCase()
|
|
144
|
+
|
|
145
|
+
if (prefix.startsWith('<html'))
|
|
131
146
|
return `<!DOCTYPE html>\n${rawDom}`
|
|
132
|
-
if (
|
|
147
|
+
if (prefix.startsWith('<!doctype')) return rawDom
|
|
133
148
|
|
|
134
149
|
let title = 'Document'
|
|
135
150
|
const dom = rawDom.replace(/<title>(.*?)<\/title>/i, (_, t) => {
|
package/src/core/paths.ts
CHANGED
|
@@ -16,7 +16,9 @@ import { FileSystem as fs } from '../utils/fs'
|
|
|
16
16
|
* Use `frameworkPath()` for files the framework ships. Use `Bakery.root` (cwd)
|
|
17
17
|
* for anything the application owns.
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
/** File-local: `core/paths.ts` is not a published subpath and nothing
|
|
20
|
+
* outside this file read it. `frameworkPath()` below is the way in. */
|
|
21
|
+
const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
|
|
20
22
|
|
|
21
23
|
/** Resolve a path against the framework's own root. */
|
|
22
24
|
export function frameworkPath(...segments: string[]): string {
|
package/src/core/plugins.ts
CHANGED
|
@@ -27,7 +27,8 @@ async function normalizePluginResult(result: Handler.Response) {
|
|
|
27
27
|
export namespace PluginHooks {
|
|
28
28
|
export async function setup() {
|
|
29
29
|
for (const plugin of getPlugins()) {
|
|
30
|
-
|
|
30
|
+
if (!plugin.setup) continue
|
|
31
|
+
const [err] = await Try.catch(() => plugin.setup!(Bakery.config))
|
|
31
32
|
if (err) {
|
|
32
33
|
serveLog.UNHANDLED_ERR({
|
|
33
34
|
error: `Plugin setup error (${plugin.name}): ${errorMsg(err)}`,
|
|
@@ -38,7 +39,15 @@ export namespace PluginHooks {
|
|
|
38
39
|
|
|
39
40
|
export async function onRequest(req: Request) {
|
|
40
41
|
for (const plugin of getPlugins()) {
|
|
41
|
-
|
|
42
|
+
// Skipped rather than awaited. `Try.catch` of an absent hook still
|
|
43
|
+
// allocates a promise and costs a microtask turn, and this loop runs on
|
|
44
|
+
// every request against every registered plugin - most of which declare
|
|
45
|
+
// no `onRequest` at all. Measured with four plugins and none declaring
|
|
46
|
+
// one, 1.33 us per request against 0.24 us; with one declaring it,
|
|
47
|
+
// 1.23 us against 0.60 us. Small, and `onError` below already reads
|
|
48
|
+
// this way, so the loops now share one idiom instead of three.
|
|
49
|
+
if (!plugin.onRequest) continue
|
|
50
|
+
const [err, result] = await Try.catch(plugin.onRequest(req))
|
|
42
51
|
if (err) {
|
|
43
52
|
serveLog.UNHANDLED_ERR({
|
|
44
53
|
error: `Plugin request error (${plugin.name}): ${errorMsg(err)}`,
|
|
@@ -57,7 +66,8 @@ export namespace PluginHooks {
|
|
|
57
66
|
|
|
58
67
|
export async function onRoute(req: Request) {
|
|
59
68
|
for (const plugin of getPlugins()) {
|
|
60
|
-
|
|
69
|
+
if (!plugin.onRoute) continue
|
|
70
|
+
const [err] = await Try.catch(() => plugin.onRoute!(req))
|
|
61
71
|
|
|
62
72
|
if (err) {
|
|
63
73
|
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
@@ -67,7 +77,8 @@ export namespace PluginHooks {
|
|
|
67
77
|
|
|
68
78
|
export async function onStart(server: any) {
|
|
69
79
|
for (const plugin of getPlugins()) {
|
|
70
|
-
|
|
80
|
+
if (!plugin.onStart) continue
|
|
81
|
+
const [err] = await Try.catch(() => plugin.onStart!(server))
|
|
71
82
|
|
|
72
83
|
if (err) {
|
|
73
84
|
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
package/src/core/port.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* They drifted on two axes. `parseInt` (startup, dev-service) reads `3000x` as
|
|
12
12
|
* `3000`; `Number` (worker) reads it as `NaN`, and `Bun.serve({port: NaN})`
|
|
13
|
-
* quietly binds an **ephemeral** port
|
|
13
|
+
* quietly binds an **ephemeral** port, so `PORT=3000x` printed
|
|
14
14
|
* `http://localhost:3000/` while the server was listening on 51570. And the
|
|
15
15
|
* final fallbacks differed (`Bakery.server?.port || 0` against a literal
|
|
16
16
|
* `3000`), so the two survivors of a bad parse disagreed again.
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* the variable and its value while the operator is still looking at the
|
|
25
25
|
* terminal.
|
|
26
26
|
*
|
|
27
|
-
* `0` is deliberately *not* an error
|
|
27
|
+
* `0` is deliberately *not* an error: it is the documented "let the OS pick
|
|
28
28
|
* one" port, and `startup.ts` prefers `Bakery.server.port` precisely so the
|
|
29
29
|
* banner prints the port that was picked rather than the `0` that was asked
|
|
30
30
|
* for.
|
|
@@ -76,7 +76,7 @@ export function resolvePort(configPort?: number | null): number {
|
|
|
76
76
|
* `--port 8080`, `--port=8080`, `-p 8080`, `-p=8080`.
|
|
77
77
|
*
|
|
78
78
|
* Returns the raw string so the caller can validate it through the one rule
|
|
79
|
-
* above rather than a second one
|
|
79
|
+
* above rather than a second one: a flag that accepted `0x1f` where `PORT`
|
|
80
80
|
* rejects it would be exactly the drift this module exists to end.
|
|
81
81
|
*/
|
|
82
82
|
function portFlagValue(argv: string[]): string | null {
|
|
@@ -95,10 +95,10 @@ function portFlagValue(argv: string[]): string | null {
|
|
|
95
95
|
* **Why the env rather than a parameter.** The port is read in three places
|
|
96
96
|
* (see the note at the top of this file) across up to three *processes*: the
|
|
97
97
|
* dev master, the dev worker it spawns, and N cluster workers. The spawn sites
|
|
98
|
-
* pass `env: {...process.env}` and build their argv explicitly
|
|
99
|
-
* forwards `--dev`, `--dev-worker` and `--sync`, and nothing else
|
|
98
|
+
* pass `env: {...process.env}` and build their argv explicitly (`dev-service`
|
|
99
|
+
* forwards `--dev`, `--dev-worker` and `--sync`, and nothing else), so a flag
|
|
100
100
|
* would have to be threaded through each of them and kept in step forever,
|
|
101
|
-
* while an environment variable already propagates to all of them.
|
|
101
|
+
* while an environment variable already propagates to all of them. Normalizing
|
|
102
102
|
* once, in the entry, means every existing reader is already correct.
|
|
103
103
|
*
|
|
104
104
|
* **Precedence: flag beats `PORT` beats config.** That is what `--port` means
|
package/src/global.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ declare global {
|
|
|
21
21
|
// runtime resolves those names in global scope.
|
|
22
22
|
//
|
|
23
23
|
// `var req: Request` and `var body: any` used to live here too. Nothing ever
|
|
24
|
-
// assigned them
|
|
24
|
+
// assigned them, not init.ts, not client/utils.ts, nothing, so
|
|
25
25
|
// `req.headers.get('host')` typechecked anywhere in the codebase and threw
|
|
26
26
|
// `ReferenceError: req is not defined` when it ran. A global that only
|
|
27
27
|
// exists in the type system is worse than no global at all.
|
|
@@ -29,7 +29,7 @@ declare global {
|
|
|
29
29
|
var Fragment: typeof import('./core/jsx').Fragment
|
|
30
30
|
var html: typeof import('./core/jsx').html
|
|
31
31
|
|
|
32
|
-
// JsonResponse and ISFunction are declared once, in shared.d.ts
|
|
32
|
+
// JsonResponse and ISFunction are declared once, in shared.d.ts: the
|
|
33
33
|
// browser runtime needs them too. MapOf, Wrapped and MixedPromise are not
|
|
34
34
|
// global at all any more; they are imported from ./types above.
|
|
35
35
|
//
|
|
@@ -46,8 +46,8 @@ declare global {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* What a middleware may return to stop the chain: a `Response`, or a
|
|
49
|
-
* `response.json.*` envelope. Anything else
|
|
50
|
-
* string
|
|
49
|
+
* `response.json.*` envelope. Anything else (including a bare object or a
|
|
50
|
+
* string) is ignored and the next middleware runs. See `$middleware.ts`.
|
|
51
51
|
*/
|
|
52
52
|
type MiddlewareResponse =
|
|
53
53
|
| Response
|
|
@@ -64,7 +64,7 @@ declare global {
|
|
|
64
64
|
onError?(error: Handler.Error.Data): MixedPromise<any>
|
|
65
65
|
/**
|
|
66
66
|
* Cross-origin resource sharing. Absent means no CORS headers at all,
|
|
67
|
-
* which is the browser default and the safe one
|
|
67
|
+
* which is the browser default and the safe one: there is deliberately no
|
|
68
68
|
* permissive default, not even in development.
|
|
69
69
|
*/
|
|
70
70
|
cors?: import('./utils/http/cors').CorsOptions | null
|
|
@@ -96,7 +96,7 @@ declare global {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Cross-origin resource sharing. Absent means no CORS headers at all,
|
|
99
|
-
* which is the browser default and the safe one
|
|
99
|
+
* which is the browser default and the safe one: there is deliberately no
|
|
100
100
|
* permissive default, not even in development.
|
|
101
101
|
*/
|
|
102
102
|
cors?: import('./utils/http/cors').CorsOptions | null
|
|
@@ -125,7 +125,7 @@ declare global {
|
|
|
125
125
|
|
|
126
126
|
// `maxCacheSize?: number` was declared and defaulted to 500 here, and read
|
|
127
127
|
// by nothing: the route LRUs size themselves and the tiered cache takes its
|
|
128
|
-
// own options. Deleted rather than wired up
|
|
128
|
+
// own options. Deleted rather than wired up: there is no single cache it
|
|
129
129
|
// plausibly governs, and a knob that silently does nothing is worse than an
|
|
130
130
|
// absent one. Removing it before the first publish is a docs note; after,
|
|
131
131
|
// it is a breaking change.
|
|
@@ -143,14 +143,14 @@ declare global {
|
|
|
143
143
|
trustProxy?: boolean
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Where the ORM finds the app's schema
|
|
146
|
+
* Where the ORM finds the app's schema: a file, or a folder containing
|
|
147
147
|
* `index.ts`. Relative paths resolve against the app's cwd. Omit it (or
|
|
148
148
|
* leave it empty) to auto-detect `orm/` then `schema.ts`.
|
|
149
149
|
*
|
|
150
150
|
* A plain string, deliberately: core must never depend on `@bakery-framework/orm`,
|
|
151
151
|
* which depends on core, and a path carries no type from it. The ORM reads
|
|
152
152
|
* this through `schemaFromConfig`. A configured path that does not exist is
|
|
153
|
-
* a hard error rather than a fall back to auto-detect
|
|
153
|
+
* a hard error rather than a fall back to auto-detect: otherwise a typo
|
|
154
154
|
* makes the sync engine generate a fresh schema at the wrong path while the
|
|
155
155
|
* real one sits untouched.
|
|
156
156
|
*/
|
|
@@ -339,19 +339,37 @@ declare global {
|
|
|
339
339
|
|
|
340
340
|
// Defined as getters on process.env by core/init.ts, and substituted into
|
|
341
341
|
// browser bundles by the compiler's `defines`. The client used to redeclare
|
|
342
|
-
// `ImportMeta.env` with its own incompatible shape, which is TS2687/TS2717
|
|
342
|
+
// `ImportMeta.env` with its own incompatible shape, which is TS2687/TS2717:
|
|
343
343
|
// suppressed, like everything else in a .d.ts, by skipLibCheck.
|
|
344
344
|
interface ImportMetaEnv {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
345
|
+
// `'1'` or `''`, not `true` or `false`. These live on `process.env`, and
|
|
346
|
+
// Bun 1.4 stopped accepting accessor descriptors there: a boolean is no
|
|
347
|
+
// longer expressible on that object at all, since data descriptors coerce
|
|
348
|
+
// too. See the encoding block in `core/init.ts`.
|
|
349
|
+
//
|
|
350
|
+
// Typed as the literal pair rather than `string` on purpose: it makes
|
|
351
|
+
// `import.meta.env.PROD !== false` a *compile* error. That comparison was
|
|
352
|
+
// real, in the dashboard's fail-closed gate, and under the string encoding
|
|
353
|
+
// it is true for every value including the `''` a development server sets,
|
|
354
|
+
// so the gate would have denied on loopback in development while still
|
|
355
|
+
// reading as correct. A type that only said `string` would not have caught
|
|
356
|
+
// it either.
|
|
357
|
+
//
|
|
358
|
+
// In a **browser** bundle these are real booleans: `compiler.ts` substitutes
|
|
359
|
+
// `JSON.stringify(!!import.meta.env.DEV)` at build time, so the literal
|
|
360
|
+
// `true`/`false` is inlined and this declaration is a slight lie there. It
|
|
361
|
+
// is the harmless direction (both encodings agree on truthiness, which is
|
|
362
|
+
// all client code tests), and the server is where the mistakes happen.
|
|
363
|
+
readonly DEV: '1' | ''
|
|
364
|
+
readonly PROD: '1' | ''
|
|
365
|
+
readonly WORKER: '1' | ''
|
|
366
|
+
readonly DEV_WORKER: '1' | ''
|
|
367
|
+
readonly THREAD_WORKER: '1' | ''
|
|
350
368
|
readonly THREAD_ID: string
|
|
351
|
-
readonly TEST:
|
|
369
|
+
readonly TEST: '1' | ''
|
|
352
370
|
readonly MODE: 'production' | 'development' | 'dev-worker' | 'thread-worker'
|
|
353
|
-
// `readonly SERVE_ROOT: string` was declared here. Nothing defines it
|
|
354
|
-
// not init.ts, not the compiler's `defines
|
|
371
|
+
// `readonly SERVE_ROOT: string` was declared here. Nothing defines it,
|
|
372
|
+
// not init.ts, not the compiler's `defines`, and nothing reads it, so any
|
|
355
373
|
// code that trusted the declaration would have got `undefined` typed as
|
|
356
374
|
// `string`. The resolved root is `Bakery.serveRoot`; use that.
|
|
357
375
|
/** Compiler define; the only one the browser bundle reads. */
|
|
@@ -9,8 +9,8 @@ import { Handler } from '../core/$base'
|
|
|
9
9
|
* The Google Fonts CSS endpoints this proxy is willing to forward to.
|
|
10
10
|
*
|
|
11
11
|
* `css` is the v1 API, `css2` the current one, `icon` the Material icon
|
|
12
|
-
* stylesheet. Everything else on `fonts.googleapis.com`
|
|
13
|
-
* once the path is attacker-supplied
|
|
12
|
+
* stylesheet. Everything else on `fonts.googleapis.com` (and anything at all
|
|
13
|
+
* once the path is attacker-supplied) is refused, because this handler is an
|
|
14
14
|
* unauthenticated outbound fetch from the server's own IP and the path used to
|
|
15
15
|
* be forwarded verbatim.
|
|
16
16
|
*/
|
|
@@ -22,7 +22,7 @@ const GF_CSS_PATHS = new Set(['css', 'css2', 'icon'])
|
|
|
22
22
|
* Google answers `200` for a request carrying parameters it does not know, so
|
|
23
23
|
* before this list every `?family=Roboto&junk=<n>` was a fresh cache key *and*
|
|
24
24
|
* a fresh outbound request: six junk requests were measured producing six keys
|
|
25
|
-
* and eighteen files. Names are compared raw, without percent-decoding
|
|
25
|
+
* and eighteen files. Names are compared raw, without percent-decoding: a
|
|
26
26
|
* name spelled `%66amily` simply fails the list, which is the safe direction.
|
|
27
27
|
*/
|
|
28
28
|
const GF_CSS_PARAMS = new Set([
|
|
@@ -39,7 +39,7 @@ const GF_MAX_QUERY = 512
|
|
|
39
39
|
const GF_MAX_GSTATIC_PATH = 256
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* A path under `fonts.gstatic.com`, as the rewritten CSS references it
|
|
42
|
+
* A path under `fonts.gstatic.com`, as the rewritten CSS references it:
|
|
43
43
|
* `s/roboto/v47/KFOmCnqEu92Fr1Mu4mxK.woff2`.
|
|
44
44
|
*
|
|
45
45
|
* Every segment must start with an alphanumeric, which is what rules out `..`
|
|
@@ -54,7 +54,7 @@ const GF_GSTATIC_PATH =
|
|
|
54
54
|
*
|
|
55
55
|
* `getOrCreateCachedFile` writes three files per entry (raw, `.zst`, `.gz`)
|
|
56
56
|
* and nothing ever removed them, so the cache directory grew with the number
|
|
57
|
-
* of distinct request URLs
|
|
57
|
+
* of distinct request URLs, which is to say, without bound. The allow-lists
|
|
58
58
|
* above shrink the key space but do not close it: a family *name* is still
|
|
59
59
|
* free text, and a wrong one costs a 400 from Google rather than a rejection
|
|
60
60
|
* here. This is the bound, in the shape convention 6 asks for.
|
|
@@ -86,7 +86,7 @@ function trackCacheEntry(cacheDir: string, cacheName: string): void {
|
|
|
86
86
|
* Test seams for the bound, in the `__`-prefixed style of `__setTestConfig`.
|
|
87
87
|
*
|
|
88
88
|
* Every other branch of this handler is reachable from a test through
|
|
89
|
-
* `handle()
|
|
89
|
+
* `handle()`: the allow-lists refuse before anything is fetched. The eviction
|
|
90
90
|
* path is not: reaching it legitimately means 257 successful round trips to
|
|
91
91
|
* Google, which is not a unit test. These let one assert the bound holds and
|
|
92
92
|
* that an evicted entry takes its `.zst`/`.gz` companions with it.
|
|
@@ -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
|
|