@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
package/src/core/context.ts
CHANGED
|
@@ -5,6 +5,16 @@ import { fs } from '../utils/fs'
|
|
|
5
5
|
export type HostContext = {
|
|
6
6
|
config: Readonly<ProcessedAppConfig>
|
|
7
7
|
hostname: string
|
|
8
|
+
/**
|
|
9
|
+
* The request this store was entered for, when there is one.
|
|
10
|
+
*
|
|
11
|
+
* Optional because two of the three `hostStore.run` sites have no request
|
|
12
|
+
* to offer: a WebSocket event carries `ws.data` rather than a `Request`,
|
|
13
|
+
* and the server's `error` hook receives one only sometimes. `getRequest()`
|
|
14
|
+
* below is what callers should use, and it reports the absence rather than
|
|
15
|
+
* handing back `undefined` for them to trip over.
|
|
16
|
+
*/
|
|
17
|
+
req?: Request
|
|
8
18
|
/**
|
|
9
19
|
* Per-request memo of raw `.forbidden` marker probes, keyed by the marker
|
|
10
20
|
* path (`<dir>/.forbidden`). Lazily created by `fs.isForbidden` and never
|
|
@@ -12,12 +22,12 @@ export type HostContext = {
|
|
|
12
22
|
* `hostStore.run` in the tree wraps a single request, WebSocket event or
|
|
13
23
|
* error dispatch, so the map dies with the store. That is what keeps this
|
|
14
24
|
* distinct from the cross-request cache `fs.isForbidden`'s regression test
|
|
15
|
-
* forbids
|
|
25
|
+
* forbids. See the block comment there. Bounded (convention 6) by request
|
|
16
26
|
* lifetime: one request touches a handful of paths, each of bounded depth.
|
|
17
27
|
*/
|
|
18
28
|
forbiddenProbes?: Map<string, boolean>
|
|
19
29
|
/**
|
|
20
|
-
* Per-request memo of `matchBlocked` verdicts keyed by request path
|
|
30
|
+
* Per-request memo of `matchBlocked` verdicts keyed by request path: the
|
|
21
31
|
* router runs the check before dispatch and `StaticHandler.handle` must
|
|
22
32
|
* keep its own (direct callers bypass the router gate), so without this the
|
|
23
33
|
* same globs matched the same path twice per static request. Sound to key
|
|
@@ -33,7 +43,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
33
43
|
* The two runtime directories, defined here rather than on `Bakery`.
|
|
34
44
|
*
|
|
35
45
|
* `Bakery.cacheDir` / `Bakery.dataDir` remain the way application and framework
|
|
36
|
-
* code reads them
|
|
46
|
+
* code reads them: these are the single definition those two forward to, and
|
|
37
47
|
* still the only writer of either path. They live in this module because it is
|
|
38
48
|
* low enough to be imported without pulling in `core/config`, and therefore
|
|
39
49
|
* without pulling in the logger: `compiler/prompt-tracker.ts` needs the cache
|
|
@@ -43,7 +53,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
43
53
|
* **Functions, not constants, and that is not a style choice.** `utils/fs.ts`
|
|
44
54
|
* imports this module for `hostStore`, so the two are themselves a cycle: a
|
|
45
55
|
* top-level `` `${fs.cwd}/.cache` `` here is evaluated with `fs` still
|
|
46
|
-
*
|
|
56
|
+
* uninitialized whenever `core/context` is reached first, and throws
|
|
47
57
|
* `TypeError: undefined is not an object`. Reading `fs.cwd` at call time is
|
|
48
58
|
* what makes the order irrelevant.
|
|
49
59
|
*
|
|
@@ -53,7 +63,7 @@ export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
|
53
63
|
* bump and dev<->prod switch, so a `rm -rf .*` or a "clean out the dotfiles"
|
|
54
64
|
* sweep does exactly what the framework already does. The database is not
|
|
55
65
|
* disposable, so it does not live behind a leading dot where such a sweep can
|
|
56
|
-
* reach it, and never under `.cache
|
|
66
|
+
* reach it, and never under `.cache`: clearing a cache must not destroy data.
|
|
57
67
|
*/
|
|
58
68
|
export function cacheDir(): string {
|
|
59
69
|
return `${fs.cwd}/.cache`
|
|
@@ -67,7 +77,7 @@ export function dataDir(): string {
|
|
|
67
77
|
* `matchBlocked`, deduplicated within the current request.
|
|
68
78
|
*
|
|
69
79
|
* Outside a request store (tests, direct handler calls) this is exactly
|
|
70
|
-
* `matchBlocked
|
|
80
|
+
* `matchBlocked`: no caching, fail closed on nothing, because nothing is
|
|
71
81
|
* skipped. See `HostContext.blockedPaths` for the scoping argument.
|
|
72
82
|
*/
|
|
73
83
|
export function matchBlockedCached(
|
|
@@ -92,7 +102,7 @@ export function matchBlockedCached(
|
|
|
92
102
|
* and both halves are load-bearing. Distinct, because the two readers exist to
|
|
93
103
|
* be different files and a shared fallback would let one silently stand in for
|
|
94
104
|
* the other. Impossible, because they used to be plain `'1.0.0'` and `'0.0.0'`
|
|
95
|
-
|
|
105
|
+
*, and when the framework was renumbered to 1.0.0 for its first publish, the
|
|
96
106
|
* app fallback became a legitimate framework version. Nothing broke at runtime,
|
|
97
107
|
* but `cache-version.test.ts` could no longer tell "read the manifest" from
|
|
98
108
|
* "fell back to the other one", so the guard was disarmed by a version bump.
|
|
@@ -108,7 +118,7 @@ let _appVersion: string | null = null
|
|
|
108
118
|
/**
|
|
109
119
|
* The **application's** version, from `<cwd>/package.json`.
|
|
110
120
|
*
|
|
111
|
-
* Not the framework's
|
|
121
|
+
* Not the framework's: this reads the manifest of whatever is being served.
|
|
112
122
|
* The distinction is load-bearing for cache invalidation; see
|
|
113
123
|
* {@link getFrameworkVersion}.
|
|
114
124
|
*/
|
|
@@ -153,3 +163,39 @@ export function getFrameworkVersion() {
|
|
|
153
163
|
}
|
|
154
164
|
return _frameworkVersion || UNKNOWN_FW
|
|
155
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The request being served, from anywhere inside the request.
|
|
169
|
+
*
|
|
170
|
+
* The point is that it needs no ambient declaration to reach a file. The Vue
|
|
171
|
+
* plugin's `<script server>` blocks used to get `req` and `body` as globals
|
|
172
|
+
* declared in `plugin-vue/src/vue.d.ts`, which works only if that file lands
|
|
173
|
+
* in whatever tsconfig project the editor picks for an SFC. It routinely does
|
|
174
|
+
* not: the generated project that names it lives under `.cache/tsconfig/`,
|
|
175
|
+
* which is not an ancestor of `src/`, so no editor resolves an SFC to it, and
|
|
176
|
+
* the symptom is `req` unresolved and `req.session` missing with nothing to
|
|
177
|
+
* point at. An import cannot fail that way.
|
|
178
|
+
*
|
|
179
|
+
* Two things it fixes beyond reachability. The ambient said `Request` while
|
|
180
|
+
* the value the Vue wrapper actually passes is typed `any`, so the promise
|
|
181
|
+
* and the runtime disagreed; this returns the one real type, `session`
|
|
182
|
+
* included. And it works inside a helper the block calls, which a wrapper
|
|
183
|
+
* parameter cannot reach without being threaded through by hand.
|
|
184
|
+
*
|
|
185
|
+
* **Throws when there is no request**, rather than returning `undefined`.
|
|
186
|
+
* There are exactly two such places and neither is application code that
|
|
187
|
+
* meant to ask: a WebSocket event (which has `ws.data`, not a `Request`) and
|
|
188
|
+
* boot-time code running before any request. Handing back `undefined` would
|
|
189
|
+
* push a null check into every call site to serve two cases that are bugs.
|
|
190
|
+
*/
|
|
191
|
+
export function getRequest(): Request {
|
|
192
|
+
const store = hostStore.getStore()
|
|
193
|
+
if (!store?.req) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
'getRequest() was called outside a request. A WebSocket event has no ' +
|
|
196
|
+
'Request (use the handler argument), and boot-time code runs before ' +
|
|
197
|
+
'there is one.',
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return store.req
|
|
201
|
+
}
|
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
|
-
import { getFrameworkVersion } from './context'
|
|
11
|
-
import { createElement, Fragment, html } from './jsx'
|
|
10
|
+
import { getFrameworkVersion, getRequest } from './context'
|
|
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.
|
|
@@ -80,6 +92,15 @@ export {
|
|
|
80
92
|
* ask. The dashboard's footer showed a hardcoded `v3` for want of it.
|
|
81
93
|
*/
|
|
82
94
|
getFrameworkVersion,
|
|
95
|
+
/**
|
|
96
|
+
* The request being served, from anywhere inside the request.
|
|
97
|
+
*
|
|
98
|
+
* Here rather than reachable only through `./core/context` because the
|
|
99
|
+
* whole point is that it needs no ambient declaration and no deep
|
|
100
|
+
* subpath to reach a file: a Vue `<script server>` block, a helper it
|
|
101
|
+
* calls, a `.tsx` page and an API route all import it the same way.
|
|
102
|
+
*/
|
|
103
|
+
getRequest,
|
|
83
104
|
// Multi-host helpers. Documented in docs/configuration/multi-host.md, and
|
|
84
105
|
// the only reason `./core/bakery` had to be a subpath of its own.
|
|
85
106
|
getHostname,
|
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.
|