@bakery-framework/core 1.0.0

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 (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
@@ -0,0 +1,121 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { matchBlocked } from '../utils/constants'
3
+ import { fs } from '../utils/fs'
4
+
5
+ export type HostContext = {
6
+ config: Readonly<ProcessedAppConfig>
7
+ hostname: string
8
+ /**
9
+ * Per-request memo of raw `.forbidden` marker probes, keyed by the marker
10
+ * path (`<dir>/.forbidden`). Lazily created by `fs.isForbidden` and never
11
+ * read anywhere else. Scoped to one request by construction: every
12
+ * `hostStore.run` in the tree wraps a single request, WebSocket event or
13
+ * error dispatch, so the map dies with the store. That is what keeps this
14
+ * distinct from the cross-request cache `fs.isForbidden`'s regression test
15
+ * forbids — see the block comment there. Bounded (convention 6) by request
16
+ * lifetime: one request touches a handful of paths, each of bounded depth.
17
+ */
18
+ forbiddenProbes?: Map<string, boolean>
19
+ /**
20
+ * Per-request memo of `matchBlocked` verdicts keyed by request path — the
21
+ * router runs the check before dispatch and `StaticHandler.handle` must
22
+ * keep its own (direct callers bypass the router gate), so without this the
23
+ * same globs matched the same path twice per static request. Sound to key
24
+ * by path alone because both call sites pass the ambient host config's
25
+ * globs, which are frozen for the life of the store.
26
+ */
27
+ blockedPaths?: Map<string, boolean>
28
+ }
29
+
30
+ export const hostStore = new AsyncLocalStorage<HostContext>()
31
+
32
+ /**
33
+ * `matchBlocked`, deduplicated within the current request.
34
+ *
35
+ * Outside a request store (tests, direct handler calls) this is exactly
36
+ * `matchBlocked` — no caching, fail closed on nothing, because nothing is
37
+ * skipped. See `HostContext.blockedPaths` for the scoping argument.
38
+ */
39
+ export function matchBlockedCached(
40
+ blocked: { match(path: string): boolean } | undefined,
41
+ path: string,
42
+ ): boolean {
43
+ const store = hostStore.getStore()
44
+ const seen = store ? (store.blockedPaths ??= new Map()) : null
45
+
46
+ let verdict = seen?.get(path)
47
+ if (verdict === undefined) {
48
+ verdict = matchBlocked(blocked, path)
49
+ seen?.set(path, verdict)
50
+ }
51
+ return verdict
52
+ }
53
+
54
+ /**
55
+ * The **application's** version, from `<cwd>/package.json`.
56
+ *
57
+ * Named `getBakeryVersion` until 2026-08-09, which is exactly the wrong name:
58
+ * it reads the package.json of whatever is being served, not the framework's.
59
+ * That misnomer hid a real bug for as long as it existed — see
60
+ * {@link getFrameworkVersion}.
61
+ */
62
+ /**
63
+ * What each version reader returns when it cannot read its manifest.
64
+ *
65
+ * They must be **distinct from each other and impossible as a real version**,
66
+ * and both halves are load-bearing. Distinct, because the two readers exist to
67
+ * be different files and a shared fallback would let one silently stand in for
68
+ * the other. Impossible, because they used to be plain `'1.0.0'` and `'0.0.0'`
69
+ * — and when the framework was renumbered to 1.0.0 for its first publish, the
70
+ * app fallback became a legitimate framework version. Nothing broke at runtime,
71
+ * but `cache-version.test.ts` could no longer tell "read the manifest" from
72
+ * "fell back to the other one", so the guard was disarmed by a version bump.
73
+ *
74
+ * A prerelease suffix keeps them valid semver while making the collision
75
+ * unrepeatable: no published version can equal these.
76
+ */
77
+ const UNKNOWN_APP = '0.0.0-unknown-app'
78
+ const UNKNOWN_FW = '0.0.0-unknown-framework'
79
+
80
+ let _appVersion: string | null = null
81
+ export function getAppVersion() {
82
+ if (_appVersion) return _appVersion
83
+ try {
84
+ const content = fs.readFileSync(fs.resolve(fs.cwd, 'package.json'))
85
+ if (content) _appVersion = JSON.parse(content).version || UNKNOWN_APP
86
+ } catch {
87
+ // Missing or malformed package.json. The version is cosmetic and the
88
+ // fallback below is the answer either way.
89
+ }
90
+ return _appVersion || UNKNOWN_APP
91
+ }
92
+
93
+ /**
94
+ * The **framework's** version, from `@bakery-framework/core`'s own package.json.
95
+ *
96
+ * Resolved from this module's location rather than by package specifier: a
97
+ * package self-referencing by name works only via the `exports` field and is
98
+ * one config edit away from breaking, while `../../package.json` is the same
99
+ * relative path in the repo and in the published tarball (`files` keeps both
100
+ * `src/` and the manifest).
101
+ *
102
+ * This exists because `.cache/` invalidation was keyed on the *app's* version
103
+ * alone, so upgrading `@bakery-framework/*` left a cache compiled by the previous
104
+ * framework version in place. In-repo that was invisible: `apps/example`'s
105
+ * version tracks the framework's, so bumping both wiped it anyway.
106
+ */
107
+ let _frameworkVersion: string | null = null
108
+ export function getFrameworkVersion() {
109
+ if (_frameworkVersion) return _frameworkVersion
110
+ try {
111
+ const content = fs.readFileSync(
112
+ fs.resolve(import.meta.dir, '../../package.json'),
113
+ )
114
+ if (content) _frameworkVersion = JSON.parse(content).version || UNKNOWN_FW
115
+ } catch {
116
+ // Same reasoning as above, with one addition: a fallback that never
117
+ // changes would silently stop invalidating the cache, so it is a distinct
118
+ // value from the app fallback.
119
+ }
120
+ return _frameworkVersion || UNKNOWN_FW
121
+ }
@@ -0,0 +1,61 @@
1
+ import { Logger, log } from '../logger'
2
+ import { definePlugin as _definePlugin } from '../plugins/types'
3
+ import type { RouteHandler } from '../types'
4
+ import { Case, is, Math2, match, Try } from '../utils/common'
5
+ import { response } from '../utils/http'
6
+ import Bakery, { getHostname, hostKey, hostStore } from './bakery'
7
+ import { getConfig, NOOP } from './config'
8
+ import { createElement, Fragment, html } from './jsx'
9
+
10
+ export const defineConfig = <T extends AppConfig>(config: T): T => config
11
+ export const definePlugin = _definePlugin
12
+
13
+ /**
14
+ * Identity at runtime, like `defineConfig`; exists so a route module can
15
+ * declare its body shape once and have the whole signature inferred:
16
+ *
17
+ * export default defineRoute<{ id: string }>((req, body) => …)
18
+ *
19
+ * `defineRoute`, not `defineHandler` — "handler" already means a registered
20
+ * `Handler` subclass in this framework, and this defines a route module.
21
+ */
22
+ export const defineRoute = <P = {}>(fn: RouteHandler<P>): RouteHandler<P> => fn
23
+
24
+ /**
25
+ * Helper types, previously ambient globals. Importable so an app that declares
26
+ * its own `MapOf` is not met with a redeclaration error it cannot opt out of.
27
+ * The Route* types are the app-facing typing surface for route modules.
28
+ */
29
+ export type {
30
+ MapOf,
31
+ MixedPromise,
32
+ RouteBody,
33
+ RouteHandler,
34
+ RouteResponse,
35
+ Wrapped,
36
+ } from '../types'
37
+
38
+ export {
39
+ Bakery,
40
+ Case,
41
+ createElement,
42
+ Fragment,
43
+ getConfig,
44
+ // Multi-host helpers. Documented in docs/configuration/multi-host.md, and
45
+ // the only reason `./core/bakery` had to be a subpath of its own.
46
+ getHostname,
47
+ hostKey,
48
+ hostStore,
49
+ html,
50
+ html as HTMLBody,
51
+ is,
52
+ Logger,
53
+ log,
54
+ Math2,
55
+ match,
56
+ NOOP,
57
+ response,
58
+ Try,
59
+ }
60
+
61
+ export default Bakery
@@ -0,0 +1,90 @@
1
+ import { createElement, Fragment, html } from './jsx'
2
+
3
+ const hasDevWorkerArg = process.argv.includes('--dev-worker')
4
+ const isThreadWorker =
5
+ process.argv.includes('--thread-worker') || process.env.THREAD_WORKER === '1'
6
+ const isDev = process.argv.includes('--dev') || hasDevWorkerArg
7
+ const isTest = process.env.NODE_ENV === 'test' || Bun.env.NODE_ENV === 'test'
8
+ const mode = hasDevWorkerArg
9
+ ? 'dev-worker'
10
+ : isThreadWorker
11
+ ? 'thread-worker'
12
+ : isDev
13
+ ? 'development'
14
+ : 'production'
15
+
16
+ const getArgValue = (name: string) => {
17
+ const prefix = `${name}=`
18
+ const found = process.argv.find(a => a.startsWith(prefix))
19
+ if (found) return found.slice(prefix.length)
20
+ const idx = process.argv.indexOf(name)
21
+ return idx !== -1 && idx + 1 < process.argv.length
22
+ ? process.argv[idx + 1]
23
+ : null
24
+ }
25
+
26
+ const threadId = process.env.THREAD_ID ?? getArgValue('--thread-id') ?? '0'
27
+
28
+ /**
29
+ * An accessor pair, not a bare getter.
30
+ *
31
+ * These are `process.env` properties, and a getter with no setter is readonly:
32
+ * in strict-mode ESM an assignment to one throws
33
+ * `TypeError: Attempted to assign to readonly property`. `threads.ts` assigns
34
+ * `THREAD_ID = '0'` on the single-worker/clamped path (deliberately not
35
+ * `THREAD_WORKER` — a cluster of one must keep full-size caches). When the
36
+ * assignment was getter-only it was wrapped in `Try(...)`, so the throw was
37
+ * swallowed and the flags never moved — `reusePort`, the per-worker cache
38
+ * scaling and the startup banner all silently read the master's values. The
39
+ * `Try(...)` was what made a dead code path look deliberate.
40
+ */
41
+ const accessor = (initial: any) => {
42
+ let value = initial
43
+ return {
44
+ get: () => value,
45
+ set: (next: any) => {
46
+ value = next
47
+ },
48
+ enumerable: true,
49
+ configurable: true,
50
+ }
51
+ }
52
+
53
+ Object.defineProperties(process.env, {
54
+ DEV: accessor(isDev),
55
+ TEST: accessor(isTest),
56
+ PROD: accessor(!isDev && !hasDevWorkerArg),
57
+ WORKER: accessor(hasDevWorkerArg || isThreadWorker),
58
+ DEV_WORKER: accessor(hasDevWorkerArg),
59
+ THREAD_WORKER: accessor(isThreadWorker),
60
+ THREAD_ID: accessor(threadId),
61
+ MODE: accessor(mode),
62
+ })
63
+
64
+ /**
65
+ * "This process is the worker of a *development* server."
66
+ *
67
+ * `DEV_WORKER` alone would answer the same — `isDev` above is
68
+ * `--dev || --dev-worker`, so a dev worker always carries `DEV` too — but the
69
+ * conjunction is the condition the call sites were written against, and it
70
+ * says what it means. Exported from here rather than recomputed per module
71
+ * because three of them branch on it (`cli/worker.ts`,
72
+ * `compiler/dev-service.ts`, the dashboard plugin's `setup.ts`) and a
73
+ * byte-identical expression in three files is three chances to drift.
74
+ *
75
+ * Read once, at the moment the accessors above are installed: the flags do not
76
+ * move afterwards, so this is the same value each copy computed at its own
77
+ * load time.
78
+ */
79
+ export const isDevWorker = Boolean(
80
+ import.meta.env.DEV_WORKER && import.meta.env.DEV,
81
+ )
82
+
83
+ Object.assign(globalThis, {
84
+ createElement,
85
+ Fragment,
86
+ html,
87
+ })
88
+
89
+ process.on('SIGHUP', () => {})
90
+ process.on('SIGBREAK', () => {})
@@ -0,0 +1,152 @@
1
+ import type { MapOf, RouteBody } from '../types'
2
+ import { is } from '../utils/common/misc'
3
+ import { Bakery } from './bakery'
4
+
5
+ type Server = Bun.Server<any>
6
+
7
+ const VOID_ELEMENTS = new Set([
8
+ 'area',
9
+ 'base',
10
+ 'br',
11
+ 'col',
12
+ 'embed',
13
+ 'hr',
14
+ 'img',
15
+ 'input',
16
+ 'link',
17
+ 'meta',
18
+ 'param',
19
+ 'source',
20
+ 'track',
21
+ 'wbr',
22
+ ])
23
+
24
+ export const Fragment = ({ children }: { children?: any }) =>
25
+ raw(renderChildren([children]))
26
+
27
+ /**
28
+ * Markup that is already HTML and must not be escaped again.
29
+ *
30
+ * A String subclass rather than a plain string, because `createElement`
31
+ * composes by returning strings: without a marker there is no way to tell
32
+ * "HTML produced by a child component" from "text a user typed". It behaves
33
+ * like a string everywhere (`.trim()`, concatenation, `String()`), so callers
34
+ * are unaffected.
35
+ */
36
+ export class SafeHtml extends String {}
37
+
38
+ /** Mark a string as trusted HTML, exempting it from escaping. */
39
+ export function raw(value: unknown): string {
40
+ return new SafeHtml(value ?? '') as unknown as string
41
+ }
42
+
43
+ /** True for markup this module produced, or that a caller vouched for. */
44
+ export function isSafeHtml(value: unknown): boolean {
45
+ return value instanceof SafeHtml
46
+ }
47
+
48
+ const TEXT_ESCAPES: Record<string, string> = {
49
+ '&': '&amp;',
50
+ '<': '&lt;',
51
+ '>': '&gt;',
52
+ '"': '&quot;',
53
+ "'": '&#39;',
54
+ }
55
+
56
+ function escapeText(value: unknown): string {
57
+ return String(value).replace(/[&<>"']/g, ch => TEXT_ESCAPES[ch] || ch)
58
+ }
59
+
60
+ /**
61
+ * Children are escaped unless they are SafeHtml. Nested `createElement` calls
62
+ * return SafeHtml, so composition still works; a bare string interpolated from
63
+ * data is treated as text, which is the safe default.
64
+ */
65
+ function renderChildren(children: any[]): string {
66
+ return children
67
+ .flat(10)
68
+ .map(c => {
69
+ if (c === null || c === undefined || is.boolean(c)) return ''
70
+ return isSafeHtml(c) ? String(c) : escapeText(c)
71
+ })
72
+ .join('')
73
+ }
74
+
75
+ export const createElement = (
76
+ tag: any,
77
+ props: MapOf<any> | null,
78
+ ...children: any[]
79
+ ): string => {
80
+ if (is.function(tag)) return tag({ ...props, children })
81
+
82
+ const childStr = renderChildren(children)
83
+
84
+ let attrStr = ''
85
+ for (const [key, value] of Object.entries(props || {})) {
86
+ if (key === 'children') continue
87
+ if (value === false || value === null || value === undefined) continue
88
+
89
+ if (value === true) {
90
+ attrStr += ` ${key}`
91
+ continue
92
+ }
93
+
94
+ let attrKey = key
95
+
96
+ if (key === 'className') attrKey = 'class'
97
+ else if (key === 'htmlFor') attrKey = 'for'
98
+
99
+ const safeValue = escapeText(value)
100
+ attrStr += ` ${attrKey}="${safeValue}"`
101
+ }
102
+
103
+ const isVoid = VOID_ELEMENTS.has(tag)
104
+
105
+ return raw(
106
+ isVoid ? `<${tag}${attrStr}>` : `<${tag}${attrStr}>${childStr}</${tag}>`,
107
+ )
108
+ }
109
+
110
+ type RenderFn<P = {}> = (
111
+ req: Request,
112
+ body: RouteBody<P>,
113
+ server: Server,
114
+ ) => string | Promise<string> | Promise<Response> | Response
115
+
116
+ /**
117
+ * `<P>` is type-level only — declare the route's params once and `body` is
118
+ * typed inside the render function: `html<{ id: string }>((req, body) => …)`.
119
+ * `RouteBody<{}>` is `MapOf<any>`, so an unparameterised call is unchanged.
120
+ */
121
+ export function html<P = {}>(render: RenderFn<P>) {
122
+ return async (req: Request, body: RouteBody<P>) => {
123
+ const rendered = await render(req, body, Bakery.server!)
124
+ const rawDom = rendered instanceof Response ? rendered : String(rendered)
125
+
126
+ if (rawDom instanceof Response) {
127
+ return rawDom
128
+ }
129
+
130
+ if (rawDom.trim().toLowerCase().startsWith('<html'))
131
+ return `<!DOCTYPE html>\n${rawDom}`
132
+ if (rawDom.trim().toLowerCase().startsWith('<!doctype')) return rawDom
133
+
134
+ let title = 'Document'
135
+ const dom = rawDom.replace(/<title>(.*?)<\/title>/i, (_, t) => {
136
+ title = t
137
+ return ''
138
+ })
139
+
140
+ return `
141
+ <!DOCTYPE html>
142
+ <html>
143
+ <head>
144
+ <title>${title}</title>
145
+ </head>
146
+ <body>
147
+ ${dom}
148
+ </body>
149
+ </html>
150
+ `
151
+ }
152
+ }
@@ -0,0 +1,24 @@
1
+ import { FileSystem as fs } from '../utils/fs'
2
+
3
+ /**
4
+ * Where the framework's own files live, as opposed to where the application
5
+ * lives.
6
+ *
7
+ * Two roots were previously conflated on `process.cwd()`: the app root (its
8
+ * `src/`, `public/`, `api/`, `schema.ts`) and the framework root (the client
9
+ * runtime it serves at `/_client/*`, the dashboard's assets, the tsconfig
10
+ * templates). Framework assets were addressed as `<cwd>/.server/...`, which
11
+ * only holds while the framework is a directory inside the app.
12
+ *
13
+ * Derived from this module's own location, so it keeps working once the
14
+ * framework is a workspace package or resolved out of `node_modules`.
15
+ *
16
+ * Use `frameworkPath()` for files the framework ships. Use `Bakery.root` (cwd)
17
+ * for anything the application owns.
18
+ */
19
+ export const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
20
+
21
+ /** Resolve a path against the framework's own root. */
22
+ export function frameworkPath(...segments: string[]): string {
23
+ return fs.resolve(frameworkRoot, ...segments)
24
+ }
@@ -0,0 +1,120 @@
1
+ import type { Handler } from '../handlers'
2
+ import { errorMsg, pluginLog, serveLog } from '../logger'
3
+ import { Try } from '../utils'
4
+ import { is } from '../utils/common'
5
+ import { injectIfHtml, response } from '../utils/http'
6
+ import { Bakery } from './bakery'
7
+
8
+ function getPlugins() {
9
+ return Bakery.config.plugins
10
+ }
11
+
12
+ async function normalizePluginResult(result: Handler.Response) {
13
+ if (result instanceof Response) {
14
+ const injectedRes = await injectIfHtml(result)
15
+ return injectedRes || result
16
+ }
17
+
18
+ if (result !== undefined && result !== null) {
19
+ return is.object(result)
20
+ ? response.json(200, 'OK', result)
21
+ : response.text(String(result))
22
+ }
23
+
24
+ return null
25
+ }
26
+
27
+ export namespace PluginHooks {
28
+ export async function setup() {
29
+ for (const plugin of getPlugins()) {
30
+ const [err] = await Try.catch(() => plugin.setup?.(Bakery.config))
31
+ if (err) {
32
+ serveLog.UNHANDLED_ERR({
33
+ error: `Plugin setup error (${plugin.name}): ${errorMsg(err)}`,
34
+ })
35
+ }
36
+ }
37
+ }
38
+
39
+ export async function onRequest(req: Request) {
40
+ for (const plugin of getPlugins()) {
41
+ const [err, result] = await Try.catch(plugin.onRequest?.(req))
42
+ if (err) {
43
+ serveLog.UNHANDLED_ERR({
44
+ error: `Plugin request error (${plugin.name}): ${errorMsg(err)}`,
45
+ })
46
+ return response.json.error(
47
+ 500,
48
+ 'Internal Server Error',
49
+ ) as unknown as Response
50
+ }
51
+
52
+ const normalized = await normalizePluginResult(result)
53
+ if (normalized) return normalized
54
+ }
55
+ return null
56
+ }
57
+
58
+ export async function onRoute(req: Request) {
59
+ for (const plugin of getPlugins()) {
60
+ const [err] = await Try.catch(() => plugin.onRoute?.(req))
61
+
62
+ if (err) {
63
+ pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
64
+ }
65
+ }
66
+ }
67
+
68
+ export async function onStart(server: any) {
69
+ for (const plugin of getPlugins()) {
70
+ const [err] = await Try.catch(() => plugin.onStart?.(server))
71
+
72
+ if (err) {
73
+ pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
74
+ }
75
+ }
76
+ }
77
+
78
+ export async function onError(error: Handler.Error.Data, req?: Request) {
79
+ for (const plugin of getPlugins()) {
80
+ if (!plugin.onError) continue
81
+ const [err, result] = await Try.catch(() => plugin.onError!(error, req))
82
+
83
+ if (err) {
84
+ pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
85
+ continue
86
+ }
87
+
88
+ const normalized = await normalizePluginResult(result)
89
+ if (normalized) return normalized
90
+ }
91
+ return null
92
+ }
93
+
94
+ export async function onShutdown() {
95
+ for (const plugin of getPlugins()) {
96
+ const [err] = await Try.catch(() => plugin.onShutdown?.())
97
+ if (err) {
98
+ pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
99
+ }
100
+ }
101
+ }
102
+
103
+ export async function onCompile(
104
+ content: string,
105
+ path: string,
106
+ ): Promise<string> {
107
+ for (const plugin of getPlugins()) {
108
+ if (!plugin.onCompile) continue
109
+ const [err, res] = await Try.catch(() => plugin.onCompile!(content, path))
110
+ if (err) {
111
+ pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
112
+ continue
113
+ }
114
+ if (typeof res === 'string') {
115
+ content = res
116
+ }
117
+ }
118
+ return content
119
+ }
120
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one answer to "what port are we on".
3
+ *
4
+ * This was written three times, with three different rules, in three files that
5
+ * all have to agree or the framework lies to the developer:
6
+ *
7
+ * - `cli/worker.ts` decides the port `Bun.serve` actually binds.
8
+ * - `startup.ts` decides the port the startup banner prints.
9
+ * - `compiler/dev-service.ts` decides the URL the dev master advertises.
10
+ *
11
+ * They drifted on two axes. `parseInt` (startup, dev-service) reads `3000x` as
12
+ * `3000`; `Number` (worker) reads it as `NaN`, and `Bun.serve({port: NaN})`
13
+ * quietly binds an **ephemeral** port — so `PORT=3000x` printed
14
+ * `http://localhost:3000/` while the server was listening on 51570. And the
15
+ * final fallbacks differed (`Bakery.server?.port || 0` against a literal
16
+ * `3000`), so the two survivors of a bad parse disagreed again.
17
+ *
18
+ * The rule here is `Number` plus an explicit range check, and a malformed
19
+ * `PORT` is an **error** rather than a guess. Convention 2 is about guards, but
20
+ * the same instinct applies: a value the operator plainly meant as a port and
21
+ * which is not one has no safe default. Silently binding 3000 hides a typo in a
22
+ * deploy script until something else is already on 3000; silently binding a
23
+ * random port hides it until a health check times out. Failing at boot names
24
+ * the variable and its value while the operator is still looking at the
25
+ * terminal.
26
+ *
27
+ * `0` is deliberately *not* an error — it is the documented "let the OS pick
28
+ * one" port, and `startup.ts` prefers `Bakery.server.port` precisely so the
29
+ * banner prints the port that was picked rather than the `0` that was asked
30
+ * for.
31
+ */
32
+
33
+ /** Where `PORT` and a portless `server.config.ts` both land. */
34
+ export const DEFAULT_PORT = 3000
35
+
36
+ /** Highest port number a TCP socket can bind. */
37
+ const MAX_PORT = 65535
38
+
39
+ // 🚀 Hoisted Regex
40
+ const RE_DECIMAL = /^\d+$/
41
+
42
+ /**
43
+ * Resolve `PORT` → `configPort` → {@link DEFAULT_PORT}.
44
+ *
45
+ * An unset *or empty* `PORT` counts as absent: `PORT=` is how a shell and a
46
+ * process manager both spell "no value", and all three call sites already
47
+ * treated it that way.
48
+ *
49
+ * @throws if `PORT` is set to anything that is not an integer in `0..65535`.
50
+ */
51
+ export function resolvePort(configPort?: number | null): number {
52
+ const raw = process.env.PORT
53
+
54
+ if (raw !== undefined && raw.trim() !== '') {
55
+ const trimmed = raw.trim()
56
+ // Decimal digits only, deliberately narrower than `Number`: `Number` also
57
+ // accepts `0x1f` (31), `1e3` (1000) and `+80`, none of which anyone types
58
+ // into a `PORT` on purpose, and all of which would resolve to a port the
59
+ // operator did not write down.
60
+ const parsed = RE_DECIMAL.test(trimmed) ? Number(trimmed) : Number.NaN
61
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_PORT) {
62
+ throw new Error(
63
+ `Invalid PORT: ${JSON.stringify(raw)} is not an integer between 0 and ${MAX_PORT}`,
64
+ )
65
+ }
66
+ return parsed
67
+ }
68
+
69
+ // `||`, not `??`: a config that says `port: 0` has always meant "unset" here
70
+ // rather than "ephemeral", and only `PORT=0` asks for an ephemeral port.
71
+ // Changing that is a separate decision from unifying the three call sites.
72
+ return configPort || DEFAULT_PORT
73
+ }