@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.
Files changed (78) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -5
  3. package/src/cache/lru.ts +2 -2
  4. package/src/cache/shared-db.ts +81 -11
  5. package/src/cache/string.ts +4 -0
  6. package/src/cache/tiered.ts +11 -5
  7. package/src/client/globals.d.ts +7 -7
  8. package/src/client/livereload.ts +2 -2
  9. package/src/client/utils.ts +11 -6
  10. package/src/compiler/compiler.ts +48 -44
  11. package/src/compiler/dev-service.ts +29 -30
  12. package/src/compiler/prompt-tracker.ts +2 -2
  13. package/src/compiler/tsconfig-sync.ts +130 -59
  14. package/src/core/bakery.ts +6 -3
  15. package/src/core/cache-version.ts +68 -18
  16. package/src/core/config.ts +16 -16
  17. package/src/core/context.ts +54 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +29 -8
  20. package/src/core/init.ts +38 -35
  21. package/src/core/jsx.ts +19 -4
  22. package/src/core/paths.ts +3 -1
  23. package/src/core/plugins.ts +15 -4
  24. package/src/core/port.ts +6 -6
  25. package/src/global.d.ts +36 -18
  26. package/src/handlers/assets/google-font.ts +6 -6
  27. package/src/handlers/assets/image.ts +36 -3
  28. package/src/handlers/assets/nm.ts +7 -7
  29. package/src/handlers/assets/static.ts +55 -12
  30. package/src/handlers/assets/ts.ts +3 -3
  31. package/src/handlers/assets/tsx.ts +1 -1
  32. package/src/handlers/assets/virtual-asset.ts +1 -1
  33. package/src/handlers/core/$base.ts +24 -8
  34. package/src/handlers/core/$dynamic.ts +66 -23
  35. package/src/handlers/core/$error.ts +29 -17
  36. package/src/handlers/core/$middleware.ts +6 -6
  37. package/src/handlers/core/$mounts.ts +2 -2
  38. package/src/handlers/core/$registry.ts +16 -6
  39. package/src/handlers/core/$routing.ts +18 -19
  40. package/src/handlers/core/$static.ts +11 -12
  41. package/src/handlers/routes/api.ts +5 -5
  42. package/src/handlers/routes/html.ts +2 -2
  43. package/src/handlers/routes/proxy.ts +1 -1
  44. package/src/logger/clients.ts +2 -2
  45. package/src/logger/logger.ts +4 -4
  46. package/src/logger/serve-log.ts +32 -18
  47. package/src/plugins/index.ts +1 -1
  48. package/src/plugins/routes.ts +7 -7
  49. package/src/plugins/types.ts +13 -11
  50. package/src/router.ts +12 -12
  51. package/src/session.ts +41 -23
  52. package/src/shared.d.ts +5 -5
  53. package/src/startup.ts +3 -3
  54. package/src/types.d.ts +10 -10
  55. package/src/utils/common/case.ts +1 -1
  56. package/src/utils/common/match.ts +1 -1
  57. package/src/utils/common/misc.ts +1 -1
  58. package/src/utils/constants.ts +15 -15
  59. package/src/utils/fs.ts +97 -39
  60. package/src/utils/http/authorize.ts +24 -17
  61. package/src/utils/http/cors.ts +6 -6
  62. package/src/utils/http/credential.ts +4 -4
  63. package/src/utils/http/csrf.ts +6 -7
  64. package/src/utils/http/dom.ts +9 -9
  65. package/src/utils/http/etag.ts +54 -13
  66. package/src/utils/http/html.ts +29 -20
  67. package/src/utils/http/ip.ts +1 -1
  68. package/src/utils/http/sse.ts +11 -11
  69. package/src/utils/http/url.ts +4 -4
  70. package/src/utils/http/validate.ts +3 -4
  71. package/src/utils/isomorphic/escape.ts +1 -1
  72. package/src/utils/isomorphic/is.ts +2 -2
  73. package/src/utils/isomorphic/misc.ts +1 -1
  74. package/src/utils/isomorphic/stringify.ts +2 -3
  75. package/src/utils/isomorphic/try.ts +1 -1
  76. package/src/utils/shared-pool.ts +23 -16
  77. package/tsconfig.app.json +3 -3
  78. package/tsconfig.vue.json +3 -3
@@ -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 — see the block comment there. Bounded (convention 6) by request
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 — the
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 — these are the single definition those two forward to, and
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
- * uninitialised whenever `core/context` is reached first, and throws
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` — clearing a cache must not destroy data.
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` — no caching, fail closed on nothing, because nothing is
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
- * — and when the framework was renumbered to 1.0.0 for its first publish, the
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 — this reads the manifest of whatever is being served.
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
+ }
@@ -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 — it pulls in the logger, plugins, jsx and utils —
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 — see the overload below.
21
+ * With a validator, it also *enforces* that shape. See the overload below.
23
22
  *
24
- * `defineRoute`, not `defineHandler` — "handler" already means a registered
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 — Bakery imports none of
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 — every
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 — line 5 reaches it through
8
- // `./bakery`, which re-exports `hostStore` from exactly here — so naming it
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` — "handler" already means a registered
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 — which line 4 already imports for `response`
61
- * — adds no edge at all. Every other value here follows the same rule.
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 — the compiler reads those from
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
- * An accessor pair, not a bare getter.
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
- * These are `process.env` properties, and a getter with no setter is readonly:
33
- * in strict-mode ESM an assignment to one throws
34
- * `TypeError: Attempted to assign to readonly property`. `threads.ts` assigns
35
- * `THREAD_ID = '0'` on the single-worker/clamped path (deliberately not
36
- * `THREAD_WORKER` — a cluster of one must keep full-size caches). When the
37
- * assignment was getter-only it was wrapped in `Try(...)`, so the throw was
38
- * swallowed and the flags never moved — `reusePort`, the per-worker cache
39
- * scaling and the startup banner all silently read the master's values. The
40
- * `Try(...)` was what made a dead code path look deliberate.
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 accessor = (initial: any) => {
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
- Object.defineProperties(process.env, {
55
- DEV: accessor(isDev),
56
- TEST: accessor(isTest),
57
- PROD: accessor(!isDev && !hasDevWorkerArg),
58
- WORKER: accessor(hasDevWorkerArg || isThreadWorker),
59
- DEV_WORKER: accessor(hasDevWorkerArg),
60
- THREAD_WORKER: accessor(isThreadWorker),
61
- THREAD_ID: accessor(threadId),
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 — `isDev` above is
69
- * `--dev || --dev-worker`, so a dev worker always carries `DEV` too — but the
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 — where
90
- // it runs as a bare global either way — does not lose the name. Declared
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 — declare the route's params once and `body` is
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 unparameterised call is unchanged.
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
- if (rawDom.trim().toLowerCase().startsWith('<html'))
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 (rawDom.trim().toLowerCase().startsWith('<!doctype')) return rawDom
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
- export const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
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 {
@@ -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
- const [err] = await Try.catch(() => plugin.setup?.(Bakery.config))
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
- const [err, result] = await Try.catch(plugin.onRequest?.(req))
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
- const [err] = await Try.catch(() => plugin.onRoute?.(req))
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
- const [err] = await Try.catch(() => plugin.onStart?.(server))
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 — so `PORT=3000x` printed
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 — it is the documented "let the OS pick
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 — a flag that accepted `0x1f` where `PORT`
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 — `dev-service`
99
- * forwards `--dev`, `--dev-worker` and `--sync`, and nothing else — so a flag
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. Normalising
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 — not init.ts, not client/utils.ts, nothing — so
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 — the
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 — including a bare object or a
50
- * string — is ignored and the next middleware runs. See `$middleware.ts`.
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 — there is deliberately no
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 — there is deliberately no
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 — there is no single cache it
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 — a file, or a folder containing
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 — otherwise a typo
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
- readonly DEV: boolean
346
- readonly PROD: boolean
347
- readonly WORKER: boolean
348
- readonly DEV_WORKER: boolean
349
- readonly THREAD_WORKER: boolean
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: boolean
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` — and nothing reads it, so any
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` — and anything at all
13
- * once the path is attacker-supplied — is refused, because this handler is an
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 — a
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 — which is to say, without bound. The allow-lists
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()` — the allow-lists refuse before anything is fetched. The eviction
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.