@bakery-framework/core 1.1.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -7,19 +7,39 @@ import { fs } from '../utils'
7
7
  /**
8
8
  * The tiered cache's spill-to-disk store — sessions and LRU overflow.
9
9
  *
10
- * Under `Bakery.cacheDir`, not `dataDir`, because every row in it is
11
- * rebuildable: a session is a cookie plus whatever the app chose to hang off
12
- * it, and the LRU tier re-fills from source on the next miss. It used to sit
13
- * beside `server.db` in the data directory, which put a disposable file behind
14
- * the one durability guarantee the framework makes.
15
- *
16
- * **The consequence is that sessions do not survive a framework upgrade.**
17
- * `checkCacheVersion` wipes the whole cache directory on every version bump and
18
- * every dev<->prod switch, and this file goes with it — users are logged out.
19
- * That is intended: the alternative is a cache format from an older framework
20
- * version being read by a newer one, which is exactly the failure the version
21
- * wipe exists to prevent. Anything that must outlive an upgrade belongs in the
22
- * app's own tables under `Bakery.dataDir`.
10
+ * Under `Bakery.dataDir`, beside `server.db`. Rows here are *rebuildable* a
11
+ * session is a cookie plus whatever the app hung off it, and the LRU tier
12
+ * refills from source on the next miss but rebuildable is not the same as
13
+ * disposable-on-a-schedule, which is what living in `cacheDir` amounted to.
14
+ * See the reversal below.
15
+ *
16
+ * **Sessions used to be wiped on every framework upgrade. They are not now, and
17
+ * this paragraph is the reversal.**
18
+ *
19
+ * The file lived under `Bakery.cacheDir`, so `checkCacheVersion` deleted it on
20
+ * every version bump and every dev<->prod switch logging every user out. The
21
+ * argument for that was sound: a cache format written by an older framework
22
+ * must never be read by a newer one, and the version wipe is what guarantees
23
+ * it.
24
+ *
25
+ * What changed is how often a version bumps. That reasoning was written when
26
+ * releases were cut by hand and rare; releases are now computed from commit
27
+ * messages and published automatically, so a patch ships whenever a `fix:`
28
+ * lands. Logging out every user of every app on every patch is a far worse
29
+ * trade than it was, and it was never the *goal* — only the consequence of
30
+ * keying durability on a version number that had nothing to do with the stored
31
+ * format.
32
+ *
33
+ * So the file moved to `Bakery.dataDir` and carries its own `SCHEMA_VERSION`.
34
+ * The safety property is unchanged and now keyed on the thing that governs it:
35
+ * a schema mismatch drops the tables, a framework bump does not.
36
+ *
37
+ * Two consequences worth knowing. An app with no ORM now creates a `bakery/`
38
+ * directory where it previously created none — it is storing durable data, so
39
+ * that is honest, but it means "no `bakery/` directory" is no longer evidence
40
+ * that `initDB` never ran. And the LRU spill tier shares this file, so overflow
41
+ * entries also survive; they are still rebuildable, just no longer discarded on
42
+ * a schedule nobody chose.
23
43
  *
24
44
  * **That was aspirational until 2026-08-09, and the `await` below is what makes
25
45
  * it true.** The check ran from `initConfig()`, while this module opens the
@@ -33,10 +53,45 @@ import { fs } from '../utils'
33
53
  */
34
54
  await checkCacheVersion()
35
55
 
36
- const dbFilePath = `${Bakery.cacheDir}/shared-cache.db`
56
+ /**
57
+ * Bumped only when the *shape* of what is stored here changes.
58
+ *
59
+ * This is what replaced "wipe on every framework version". A schema change is
60
+ * rare and deliberate; a framework patch is neither, and tying the two together
61
+ * meant every patch logged out every user.
62
+ */
63
+ const SCHEMA_VERSION = 1
64
+
65
+ const dbFilePath = `${Bakery.dataDir}/sessions.db`
37
66
  if (!fs.exists(dbFilePath)) await fs.mkdir(dirname(dbFilePath))
38
67
 
39
68
  export const cacheDb = new Database(dbFilePath, { create: true })
69
+
70
+ /**
71
+ * Drop everything if the stored schema version is not this one.
72
+ *
73
+ * The safety property the cache wipe provided — a newer framework never reads
74
+ * an older format — is preserved exactly, just keyed on the thing that actually
75
+ * governs compatibility. A mismatch logs users out, which is the same outcome
76
+ * as before; the difference is that it now happens when the format changes
77
+ * rather than when any version number does.
78
+ */
79
+ cacheDb.run('CREATE TABLE IF NOT EXISTS __schema (version INTEGER NOT NULL)')
80
+ const stored = cacheDb
81
+ .query<{ version: number }, []>('SELECT version FROM __schema LIMIT 1')
82
+ .get()
83
+
84
+ if (!stored) {
85
+ cacheDb.run('INSERT INTO __schema (version) VALUES (?)', [SCHEMA_VERSION])
86
+ } else if (stored.version !== SCHEMA_VERSION) {
87
+ const tables = cacheDb
88
+ .query<{ name: string }, []>(
89
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '__schema'",
90
+ )
91
+ .all()
92
+ for (const { name } of tables) cacheDb.run(`DROP TABLE IF EXISTS "${name}"`)
93
+ cacheDb.run('UPDATE __schema SET version = ?', [SCHEMA_VERSION])
94
+ }
40
95
  const journalMode = process.platform === 'win32' ? 'DELETE' : 'WAL'
41
96
  cacheDb.run(`PRAGMA journal_mode = ${journalMode};`)
42
97
  cacheDb.run('PRAGMA synchronous = NORMAL;')
@@ -161,7 +161,17 @@ Object.assign(globalThis, {
161
161
  request,
162
162
  randomId,
163
163
  Bakery: {
164
- version: import.meta.env.BAKERY_VERSION,
164
+ // **Cast rather than `import.meta.env` directly.** `ImportMeta.env` is
165
+ // declared by `bun-types`, and this file is compiled by the *client*
166
+ // tsconfig, which deliberately has none — reaching for a Bun-provided type
167
+ // here would undo the split it exists to enforce.
168
+ //
169
+ // Declaring `ImportMeta` in client/globals.d.ts instead would clash with
170
+ // bun-types inside core's own project, which is the incompatible
171
+ // redeclaration this repo has already had once. The value is substituted by
172
+ // the compiler at build time, so the cast describes what is actually there.
173
+ version: (import.meta as { env?: Record<string, string | undefined> }).env
174
+ ?.BAKERY_VERSION,
165
175
  async virtual(path: string) {
166
176
  const response = await fetch(path)
167
177
  if (!response.ok) {
@@ -1,5 +1,6 @@
1
1
  import { Bakery } from '../core/bakery'
2
2
  import { errorMsg, serveLog } from '../logger'
3
+ import type { PluginTsProject } from '../plugins/types'
3
4
  import type { MapOf } from '../types'
4
5
  import { fs } from '../utils/fs'
5
6
  import { parseJSONC } from '../utils/jsonc'
@@ -10,6 +11,9 @@ const RE_HTTP = /^https?:\/\//
10
11
  const RE_LEADING_SLASHES = /^(\.\/|\/)/
11
12
  const RE_RELATIVE = /^\.\.?\//
12
13
  const RE_TRAILING_WILDCARD = /\/?\*?$/
14
+ // Drive letters included: `Bakery.config.root` is absolute, and on Windows
15
+ // that is `C:/…` rather than a leading slash.
16
+ const RE_ABSOLUTE = /^([A-Za-z]:)?[\\/]/
13
17
 
14
18
  // The application's tsconfig, resolved against its cwd. This used to write into
15
19
  // a tsconfig *inside the framework* — app-specific paths mutating a shipped
@@ -45,6 +49,263 @@ function buildPaths(): MapOf<string[]> {
45
49
  return newPaths
46
50
  }
47
51
 
52
+ /** Where the generated projects go, and where the root config points. */
53
+ const PROJECT_DIR = fs.resolve(APP_DIR, '.cache/tsconfig')
54
+
55
+ /**
56
+ * The two projects core always generates.
57
+ *
58
+ * The split is the whole point: only `server` carries `bun-types`, so `Bun.*`
59
+ * in a file bound for the browser is a type error rather than a runtime one.
60
+ * Before this existed, one config covered everything and `Bun.hash()` in a
61
+ * client file typechecked clean and failed in the browser.
62
+ *
63
+ * Globs are app-relative here and rewritten to be relative to the generated
64
+ * file, which sits two levels down.
65
+ */
66
+ export function coreProjects(): PluginTsProject[] {
67
+ const root = Bakery.config.root ?? 'src'
68
+
69
+ return [
70
+ {
71
+ name: 'server',
72
+ extends: '@bakery-framework/core/tsconfig.server.json',
73
+ // Repeated rather than inherited: Bun's runtime does not follow
74
+ // `extends` into a package specifier, only a relative path.
75
+ compilerOptions: {
76
+ jsx: 'react',
77
+ jsxFactory: 'createElement',
78
+ jsxFragmentFactory: 'Fragment',
79
+ },
80
+ include: [
81
+ `${root}/**/api/**/*.ts`,
82
+ `${root}/**/*.tsx`,
83
+ 'server.config.ts',
84
+ 'schema.ts',
85
+ 'orm/**/*.ts',
86
+ ],
87
+ },
88
+ {
89
+ name: 'client',
90
+ extends: '@bakery-framework/core/tsconfig.app.json',
91
+ include: [`${root}/**/*.ts`],
92
+ exclude: [`${root}/**/api/**/*.ts`],
93
+ // The only project that gets `importMap` aliases, because the import map
94
+ // is a browser mechanism — see `importMapPaths` on `PluginTsProject`.
95
+ importMapPaths: true,
96
+ },
97
+ ]
98
+ }
99
+
100
+ /**
101
+ * Turn an app-relative glob into one relative to `.cache/tsconfig/`.
102
+ *
103
+ * Two levels up, and always with a leading `../` so TypeScript reads it as a
104
+ * path rather than resolving it against the project directory.
105
+ */
106
+ export function fromProjectDir(pathOrGlob: string): string {
107
+ // **`Bakery.config.root` is absolute**, so globs built from it arrive here as
108
+ // full paths. Prefixing `../../` to one yields
109
+ // `../../C:/WebDAV/.../src/**/*.ts`, which matches nothing — and a project
110
+ // that matches nothing typechecks clean, so the mistake presents as success.
111
+ // That is exactly how the first version of this passed with zero files.
112
+ if (RE_ABSOLUTE.test(pathOrGlob)) {
113
+ const rel = fs.relative(PROJECT_DIR, pathOrGlob).replace(/\\/g, '/')
114
+ return RE_RELATIVE.test(rel) ? rel : `./${rel}`
115
+ }
116
+ return `../../${pathOrGlob.replace(RE_LEADING_SLASHES, '')}`
117
+ }
118
+
119
+ /**
120
+ * Resolve a `files` entry to something TypeScript will actually load.
121
+ *
122
+ * A package specifier is the useful form for a plugin to write — it does not
123
+ * know where it was installed — but `files` is resolved as a path, so
124
+ * `@scope/pkg/x.d.ts` would simply be missing. Resolution failure is not fatal:
125
+ * a plugin whose declaration cannot be found should degrade to "no types" and
126
+ * say so, not stop the dev server from booting.
127
+ */
128
+ function resolveFilesEntry(entry: string): string | null {
129
+ if (entry.startsWith('.') || entry.startsWith('/')) {
130
+ return fromProjectDir(entry)
131
+ }
132
+ try {
133
+ const abs = Bun.resolveSync(entry, APP_DIR)
134
+ const rel = fs.relative(PROJECT_DIR, abs).replace(/\\/g, '/')
135
+ return RE_RELATIVE.test(rel) ? rel : `./${rel}`
136
+ } catch {
137
+ return null
138
+ }
139
+ }
140
+
141
+ /** Every project: core's two, plus whatever the loaded plugins contribute. */
142
+ function allProjects(): PluginTsProject[] {
143
+ const projects = coreProjects()
144
+ const seen = new Set(projects.map(p => p.name))
145
+
146
+ for (const plugin of Bakery.config.plugins ?? []) {
147
+ const project = plugin?.tsconfig?.project
148
+ if (!project) continue
149
+
150
+ // A plugin cannot silently replace `server` or `client`, or another
151
+ // plugin's project. Skipping with a log beats a collision that presents as
152
+ // "my types stopped working" three plugins later.
153
+ if (seen.has(project.name)) {
154
+ serveLog.TSCONFIG_PROJECT_CLASH({
155
+ plugin: plugin.name,
156
+ project: project.name,
157
+ })
158
+ continue
159
+ }
160
+
161
+ seen.add(project.name)
162
+ projects.push(project)
163
+ }
164
+
165
+ return projects
166
+ }
167
+
168
+ /**
169
+ * Write `.cache/tsconfig/*.json` and point the root config at them.
170
+ *
171
+ * The root becomes references-only. Anything a person had in `compilerOptions`
172
+ * there stops applying, which is why the generated projects carry the JSX
173
+ * options rather than relying on the root.
174
+ */
175
+ export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
176
+ const written: string[] = []
177
+
178
+ for (const project of allProjects()) {
179
+ const files = (project.files ?? [])
180
+ .map(entry => {
181
+ const resolved = resolveFilesEntry(entry)
182
+ if (!resolved) {
183
+ serveLog.TSCONFIG_FILE_UNRESOLVED({ entry })
184
+ }
185
+ return resolved
186
+ })
187
+ .filter((f): f is string => f !== null)
188
+
189
+ const config: Record<string, unknown> = {
190
+ $comment:
191
+ 'GENERATED by Bakery on dev boot. Edits are lost; change the plugin or server.config.ts instead.',
192
+ extends: project.extends,
193
+ compilerOptions: {
194
+ ...(project.compilerOptions ?? {}),
195
+ // Only projects that opt in. `importMap` is served to the browser as
196
+ // `<script type="importmap">`, so its specifiers are resolved there and
197
+ // nowhere else — writing them into the server project made an import
198
+ // that cannot work on the server typecheck as though it could.
199
+ ...(project.importMapPaths && Object.keys(paths).length
200
+ ? { paths: mapPaths(paths) }
201
+ : {}),
202
+ },
203
+ }
204
+
205
+ if (files.length) config.files = files
206
+ if (project.include) config.include = project.include.map(fromProjectDir)
207
+ if (project.exclude) config.exclude = project.exclude.map(fromProjectDir)
208
+
209
+ const target = fs.resolve(PROJECT_DIR, `${project.name}.json`)
210
+ await Bun.write(target, `${JSON.stringify(config, null, 2)}\n`)
211
+ written.push(project.name)
212
+ }
213
+
214
+ return written
215
+ }
216
+
217
+ /** `paths` values are app-relative; the generated files sit two levels down. */
218
+ function mapPaths(paths: MapOf<string[]>): MapOf<string[]> {
219
+ const out: MapOf<string[]> = {}
220
+ for (const [key, values] of Object.entries(paths)) {
221
+ out[key] = values.map(fromProjectDir)
222
+ }
223
+ return out
224
+ }
225
+
226
+ /**
227
+ * The app's root tsconfig with `references` set, and nothing else touched.
228
+ *
229
+ * Pure, and exported, so the merge can be tested without a function that writes
230
+ * to `process.cwd()`. The property that matters is negative — *no key the
231
+ * developer wrote is lost* — which is the kind of thing a shape assertion on the
232
+ * source cannot check.
233
+ */
234
+ export function mergeRootConfig(
235
+ current: Record<string, unknown> | null,
236
+ references: { path: string }[],
237
+ ): Record<string, unknown> {
238
+ // Spread first so `references` is the only key this owns.
239
+ if (current) return { ...current, references }
240
+
241
+ // No root config at all. The generated projects do not help Bun's runtime, so
242
+ // the one this writes has to carry the JSX options itself.
243
+ return {
244
+ extends: '@bakery-framework/core/tsconfig.server.json',
245
+ compilerOptions: {
246
+ jsx: 'react',
247
+ jsxFactory: 'createElement',
248
+ jsxFragmentFactory: 'Fragment',
249
+ },
250
+ references,
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Generate the project configs and add `references` to the app's root tsconfig.
256
+ *
257
+ * Separate from `syncTSConfigPaths` because an app can reasonably want one and
258
+ * not the other: the paths sync has existed for a long time and rewrites a file
259
+ * people keep in git, while this owns a directory nobody edits.
260
+ *
261
+ * **It used to replace the root config with a references-only stub, and that
262
+ * silently broke every `.tsx` page in the app.** The reasoning was tidy — the
263
+ * generated projects carry the JSX options, so the root does not need them — and
264
+ * it was wrong about *who reads the root*. `tsc` follows `references`; **Bun's
265
+ * runtime does not.** Bun reads `compilerOptions.jsx*` from the root
266
+ * `tsconfig.json` and nothing else, so a references-only root means pages get
267
+ * transpiled against the automatic JSX runtime instead of Bakery's
268
+ * `createElement`.
269
+ *
270
+ * The symptom is worse than the 500 that mistake usually produces. Measured on a
271
+ * scratch app: `GET /` answered **200** with
272
+ * `{"type":"html","props":{…},"_owner":null,"_store":{}}` — a React element tree,
273
+ * JSON-encoded, because the handler received an object where it expects a
274
+ * `SafeHtml` string. Nothing logs, nothing throws, the status is fine.
275
+ *
276
+ * So the root is now *merged*, not replaced: every key the developer wrote stays,
277
+ * and only `references` is ours. That also settles the contradiction with the
278
+ * scaffolder, which writes those JSX options under a comment saying to keep them
279
+ * — and with the three documentation pages that repeat it.
280
+ */
281
+ export async function syncTSConfigProjects(): Promise<void> {
282
+ try {
283
+ const written = await writeProjects(buildPaths())
284
+
285
+ const references = written.map(name => ({
286
+ path: `./.cache/tsconfig/${name}.json`,
287
+ }))
288
+
289
+ const current = fs.exists(APP_CONFIG_PATH)
290
+ ? parseJSONC(await Bun.file(APP_CONFIG_PATH).text())
291
+ : null
292
+
293
+ // Only rewrite when the reference set actually changed. The root config is
294
+ // committed, and a dev boot that dirties git every time trains people to
295
+ // ignore the diff.
296
+ if (current && Bun.deepEquals(current.references, references)) return
297
+
298
+ const root = mergeRootConfig(current, references)
299
+
300
+ await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(root, null, 2)}\n`)
301
+ serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
302
+ } catch (err: any) {
303
+ serveLog.UNHANDLED_ERR({
304
+ error: `TSConfig project sync error: ${errorMsg(err)}`,
305
+ })
306
+ }
307
+ }
308
+
48
309
  export async function syncTSConfigPaths(): Promise<void> {
49
310
  try {
50
311
  const newPaths = buildPaths()
@@ -21,6 +21,13 @@ const defaultConfig: Required<AppConfig> = {
21
21
  middleware: [],
22
22
  backups: DEFAULT_DB_BACKUPS,
23
23
  blocked: [],
24
+ // No CORS by default. Not even permissive in development: a framework
25
+
26
+ // that quietly allows every origin teaches people it works, then
27
+
28
+ // surprises them in production.
29
+
30
+ cors: null,
24
31
  head: '',
25
32
  body: '',
26
33
  plugins: [],
@@ -0,0 +1,66 @@
1
+ import type { RouteHandler } from '../types'
2
+ import { response } from '../utils/http'
3
+ import { type Validator, validate } from '../utils/http/validate'
4
+
5
+ /**
6
+ * `defineRoute`, in its own module rather than in `core/index.ts`.
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
10
+ * `ReferenceError: Cannot access 'Logger' before initialization`. That is
11
+ * recorded in CLAUDE.md as costing 67 tests once; it cost this file's own tests
12
+ * a second time, which is what moved it here. Consumers still reach it through
13
+ * the barrel; core's modules and tests import this file directly.
14
+ */
15
+
16
+ /**
17
+ * Identity at runtime in its one-argument form; exists so a route module can
18
+ * declare its body shape once and have the whole signature inferred:
19
+ *
20
+ * export default defineRoute<{ id: string }>((req, body) => …)
21
+ *
22
+ * With a validator, it also *enforces* that shape — see the overload below.
23
+ *
24
+ * `defineRoute`, not `defineHandler` — "handler" already means a registered
25
+ * `Handler` subclass in this framework, and this defines a route module.
26
+ */
27
+ export function defineRoute<P = {}>(fn: RouteHandler<P>): RouteHandler<P>
28
+ /**
29
+ * Validate the body before the handler runs.
30
+ *
31
+ * export default defineRoute({ body: schema }, (req, body) => …)
32
+ *
33
+ * `body` is a Standard Schema (zod, valibot, arktype — Bakery imports none of
34
+ * them) or a plain function returning the parsed value. A rejection answers
35
+ * `400` through the framework's JSON envelope and the handler never runs.
36
+ */
37
+ export function defineRoute<T>(
38
+ options: { body: Validator<T> },
39
+ fn: RouteHandler<T>,
40
+ ): RouteHandler<T>
41
+ export function defineRoute(
42
+ a: RouteHandler<any> | { body: Validator<any> },
43
+ b?: RouteHandler<any>,
44
+ ): RouteHandler<any> {
45
+ // One argument: identity, unchanged. Types only, no runtime cost — every
46
+ // existing route keeps working byte for byte.
47
+ if (typeof a === 'function') return a
48
+
49
+ const { body: validator } = a
50
+ const fn = b as RouteHandler<any>
51
+
52
+ return async function validatedRoute(req, body, server) {
53
+ const result = await validate(validator, body)
54
+ if (!result.ok) {
55
+ // The framework's one JSON envelope, so a validation failure looks like
56
+ // every other error a client receives rather than a special case.
57
+ return response.json.error(400, 'Invalid request body', {
58
+ issues: result.issues,
59
+ })
60
+ }
61
+ // The *parsed* value, not the raw body: a schema that coerces or strips
62
+ // unknown keys is doing so precisely to be used, and handing the handler
63
+ // the original would make the coercion a lie.
64
+ return fn(req, result.value, server)
65
+ }
66
+ }
package/src/core/index.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { Logger, log } from '../logger'
2
2
  import { definePlugin as _definePlugin } from '../plugins/types'
3
- import type { RouteHandler } from '../types'
4
3
  import { Case, is, Math2, match, Try } from '../utils/common'
5
- import { response } from '../utils/http'
4
+ import { encodeSSE, response, sse } from '../utils/http'
6
5
  import Bakery, { getHostname, hostKey, hostStore } from './bakery'
7
6
  import { getConfig, NOOP } from './config'
8
7
  import { createElement, Fragment, html } from './jsx'
@@ -10,17 +9,6 @@ import { createElement, Fragment, html } from './jsx'
10
9
  export const defineConfig = <T extends AppConfig>(config: T): T => config
11
10
  export const definePlugin = _definePlugin
12
11
 
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
12
  /**
25
13
  * Helper types, previously ambient globals. Importable so an app that declares
26
14
  * its own `MapOf` is not met with a redeclaration error it cannot opt out of.
@@ -34,11 +22,44 @@ export type {
34
22
  RouteResponse,
35
23
  Wrapped,
36
24
  } from '../types'
25
+ /** Configured in `server.config.ts`; the type is exported so an app can build one. */
26
+ export type { CorsOptions } from '../utils/http/cors'
27
+ export type { SSEMessage, SSEOptions, SSEStream } from '../utils/http/sse'
28
+ // The validation surface, so an app can type its own schemas and validators.
29
+ export type {
30
+ FunctionValidator,
31
+ StandardSchemaLike,
32
+ ValidationIssue,
33
+ Validator,
34
+ } from '../utils/http/validate'
35
+ /**
36
+ * Identity at runtime, like `defineConfig`; exists so a route module can
37
+ * declare its body shape once and have the whole signature inferred:
38
+ *
39
+ * export default defineRoute<{ id: string }>((req, body) => …)
40
+ *
41
+ * `defineRoute`, not `defineHandler` — "handler" already means a registered
42
+ * `Handler` subclass in this framework, and this defines a route module.
43
+ */
44
+ export { defineRoute } from './define-route'
37
45
 
46
+ /**
47
+ * Note the shape of the two lines below: `sse` and `encodeSSE` are *imported*
48
+ * at the top of this file and re-exported here, not re-exported directly from
49
+ * `../utils/http/sse`.
50
+ *
51
+ * That is not style. `export … from '../utils/http/sse'` adds a second edge
52
+ * into the module graph and reorders evaluation enough to close the cycle this
53
+ * barrel is always one step away from: it typechecks, and then 47 tests fail
54
+ * with `ReferenceError: Cannot access 'Logger' before initialization`. Going
55
+ * through the `utils/http` index — which line 4 already imports for `response`
56
+ * — adds no edge at all. Every other value here follows the same rule.
57
+ */
38
58
  export {
39
59
  Bakery,
40
60
  Case,
41
61
  createElement,
62
+ encodeSSE,
42
63
  Fragment,
43
64
  getConfig,
44
65
  // Multi-host helpers. Documented in docs/configuration/multi-host.md, and
@@ -55,6 +76,7 @@ export {
55
76
  match,
56
77
  NOOP,
57
78
  response,
79
+ sse,
58
80
  Try,
59
81
  }
60
82
 
package/src/core/port.ts CHANGED
@@ -71,3 +71,58 @@ export function resolvePort(configPort?: number | null): number {
71
71
  // Changing that is a separate decision from unifying the three call sites.
72
72
  return configPort || DEFAULT_PORT
73
73
  }
74
+
75
+ /**
76
+ * `--port 8080`, `--port=8080`, `-p 8080`, `-p=8080`.
77
+ *
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`
80
+ * rejects it would be exactly the drift this module exists to end.
81
+ */
82
+ function portFlagValue(argv: string[]): string | null {
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const arg = argv[i]
85
+ if (arg === '--port' || arg === '-p') return argv[i + 1] ?? ''
86
+ if (arg.startsWith('--port=')) return arg.slice('--port='.length)
87
+ if (arg.startsWith('-p=')) return arg.slice('-p='.length)
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * Fold a `--port` flag into `process.env.PORT`, before anything reads it.
94
+ *
95
+ * **Why the env rather than a parameter.** The port is read in three places
96
+ * (see the note at the top of this file) across up to three *processes*: the
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
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
102
+ * once, in the entry, means every existing reader is already correct.
103
+ *
104
+ * **Precedence: flag beats `PORT` beats config.** That is what `--port` means
105
+ * everywhere else a developer has met it (Vite, Next, Astro, Nuxt), and an
106
+ * explicit argument losing to an inherited environment variable would be the
107
+ * surprising order. It is also the recoverable one: a shell with a stale
108
+ * exported `PORT` is fixed by typing the flag, whereas the reverse needs the
109
+ * developer to work out which variable is winning.
110
+ *
111
+ * @throws if the flag is present but its value is not an integer in `0..65535`,
112
+ * including when it is missing entirely (`bakery --port` with nothing after
113
+ * it). A flag typed and then ignored is worse than one that complains.
114
+ */
115
+ export function applyPortFlag(argv: string[] = process.argv.slice(2)): void {
116
+ const raw = portFlagValue(argv)
117
+ if (raw === null) return
118
+
119
+ const trimmed = raw.trim()
120
+ const parsed = RE_DECIMAL.test(trimmed) ? Number(trimmed) : Number.NaN
121
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_PORT) {
122
+ throw new Error(
123
+ `Invalid --port: ${JSON.stringify(raw)} is not an integer between 0 and ${MAX_PORT}`,
124
+ )
125
+ }
126
+
127
+ process.env.PORT = String(parsed)
128
+ }