@bakery-framework/core 2.0.0-alpha.8 → 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.
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 +8 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +19 -7
  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
@@ -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 — see the block comment there. Bounded (convention 6) by request
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 — the
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 — these are the single definition those two forward to, and
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
- * uninitialised whenever `core/context` is reached first, and throws
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` — clearing a cache must not destroy data.
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` — no caching, fail closed on nothing, because nothing is
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
- * — and when the framework was renumbered to 1.0.0 for its first publish, the
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 — this reads the manifest of whatever is being served.
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
  */
@@ -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
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` — "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.
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.
@@ -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
- const IS_IMAGE_REGEX = /(.*)\/(.*)(;(\d+))?\.(png|jpg|jpeg|webp|gif|bmp)$/i
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 — otherwise a protected image under public/ is
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