@bakery-framework/core 1.1.1 → 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.1",
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;')
@@ -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
+ }
package/src/global.d.ts CHANGED
@@ -44,15 +44,30 @@ declare global {
44
44
  inBody?: boolean
45
45
  }
46
46
 
47
+ /**
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`.
51
+ */
52
+ type MiddlewareResponse =
53
+ | Response
54
+ | import('./utils/common/json').JsonResponseData
55
+
47
56
  type HostEntry = {
48
57
  root?: string
49
58
  importMap?: Record<string, string>
50
59
  middleware?: ((
51
60
  req: Request,
52
61
  server: Bun.Server<any>,
53
- ) => MixedPromise<Response | void>)[]
62
+ ) => MixedPromise<MiddlewareResponse | void>)[]
54
63
  onRequest?(req: Request): MixedPromise<any>
55
64
  onError?(error: Handler.Error.Data): MixedPromise<any>
65
+ /**
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
68
+ * permissive default, not even in development.
69
+ */
70
+ cors?: import('./utils/http/cors').CorsOptions | null
56
71
  head?: string
57
72
  body?: string
58
73
  proxy?: Record<string, string>
@@ -79,6 +94,12 @@ declare global {
79
94
 
80
95
  proxy?: Record<string, string>
81
96
 
97
+ /**
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
100
+ * permissive default, not even in development.
101
+ */
102
+ cors?: import('./utils/http/cors').CorsOptions | null
82
103
  head?: string
83
104
 
84
105
  body?: string
@@ -94,7 +115,7 @@ declare global {
94
115
  middleware?: ((
95
116
  req: Request,
96
117
  server: Bun.Server<any>,
97
- ) => MixedPromise<Response | void>)[]
118
+ ) => MixedPromise<MiddlewareResponse | void>)[]
98
119
 
99
120
  plugins?: ServerPlugin[]
100
121
 
@@ -1,15 +1,39 @@
1
1
  import { Bakery } from '../../core/bakery'
2
2
  import { errorMsg, handlerLog } from '../../logger/serve-log'
3
+ import { JsonResponseData } from '../../utils/common/json'
3
4
  import { injectIfHtml, response } from '../../utils/http'
4
5
  import { Handler } from './$base'
5
6
 
7
+ /**
8
+ * What a middleware may return to stop the chain.
9
+ *
10
+ * A `Response` and a `response.json.*` envelope, and deliberately nothing
11
+ * else. `JsonResponseData` is the framework's one-envelope idiom (convention
12
+ * 7) and `processResponse` already renders it with its own `status`, so an
13
+ * `ApiHandler` route returning `response.json.error(401, …)` answered 401
14
+ * while the identical line in a middleware did not — the value was not a
15
+ * `Response`, so the chain ignored it and the request carried on.
16
+ *
17
+ * Widening further, to "anything truthy", is the tempting version and is
18
+ * wrong: a middleware that returns a stray string or object would then halt
19
+ * the request by accident, and returning a value is how middleware signals
20
+ * *nothing* in plenty of code (`arr.map`, an assignment expression, an
21
+ * implicit arrow return). Two named shapes, both of which mean "I am the
22
+ * response".
23
+ */
24
+ type MiddlewareResult = Response | JsonResponseData
25
+
26
+ function isMiddlewareResult(value: unknown): value is MiddlewareResult {
27
+ return value instanceof Response || value instanceof JsonResponseData
28
+ }
29
+
6
30
  /**
7
31
  * Per-request slot for the response produced during `canHandle`, so `handle`
8
32
  * can return it without re-running the chain. This was previously a static
9
33
  * field, which meant two concurrent requests could swap responses — including
10
34
  * each other's `Set-Cookie` headers — at any `await` boundary.
11
35
  */
12
- const pending = new WeakMap<Request, Response>()
36
+ const pending = new WeakMap<Request, MiddlewareResult>()
13
37
 
14
38
  export class MiddlewareHandler extends Handler {
15
39
  /** Answers from app code, not from disk. See `Handler.servesFiles`. */
@@ -48,12 +72,12 @@ export class MiddlewareHandler extends Handler {
48
72
  return (await injectIfHtml(intercepted)) || intercepted
49
73
  }
50
74
 
51
- let data: any
75
+ let data: MiddlewareResult | undefined
52
76
 
53
77
  for (const middleware of config.middleware) {
54
78
  try {
55
79
  const result = await middleware(req, Bakery.server!)
56
- if (result instanceof Response) {
80
+ if (isMiddlewareResult(result)) {
57
81
  data = result
58
82
  break
59
83
  }
@@ -65,7 +89,12 @@ export class MiddlewareHandler extends Handler {
65
89
  }
66
90
  }
67
91
 
68
- const injectedRes = await injectIfHtml(data)
92
+ // An envelope is JSON by construction, so it skips injection rather than
93
+ // paying `DOMTools.isHTML` to be told so. `processResponse` reads its
94
+ // `status` and serialises it.
95
+ if (data instanceof JsonResponseData) return data
96
+
97
+ const injectedRes = await injectIfHtml(data!)
69
98
  return injectedRes || data
70
99
  }
71
100
  }
@@ -25,6 +25,17 @@ const serveMsgs = {
25
25
  WATCHER_ERR: 'E Watcher error: %r{error}%*',
26
26
  TSCONFIG_SYNCED:
27
27
  'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
+ TSCONFIG_PROJECTS_WRITTEN:
29
+ 'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
30
+ // A plugin asking for a project name that is taken. Named rather than
31
+ // silent: the symptom otherwise is one plugin's types quietly not applying,
32
+ // discovered much later and blamed on the wrong thing.
33
+ TSCONFIG_PROJECT_CLASH:
34
+ 'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists — %rskipped%*',
35
+ // Degrades to "no types" rather than failing the boot: a missing declaration
36
+ // is a worse editor experience, not a broken server.
37
+ TSCONFIG_FILE_UNRESOLVED:
38
+ 'W Could not resolve tsconfig files entry %y{entry}%* — %rskipped%*',
28
39
  MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
29
40
  CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
30
41
  // Multi-line on purpose: a present-but-broken config booting on defaults is
@@ -3,9 +3,68 @@ import type { MixedPromise } from '../types'
3
3
 
4
4
  export type ValidResponses = Handler.Response
5
5
 
6
+ /**
7
+ * A TypeScript project a plugin contributes to the app.
8
+ *
9
+ * Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
10
+ * from the app's root `tsconfig.json`, so the editor typechecks each file under
11
+ * the project that owns it. Regenerated rather than committed: it is derived
12
+ * from the plugin list and the app's config, and `.cache/` is the disposable
13
+ * half of the two runtime directories.
14
+ */
15
+ export interface PluginTsProject {
16
+ /** File name under `.cache/tsconfig/`, and the project's identity. */
17
+ name: string
18
+ /** Usually one of core's three bases. Written through as-is. */
19
+ extends: string
20
+ /** Globs, relative to the **app root** — the generator rewrites them. */
21
+ include?: string[]
22
+ exclude?: string[]
23
+ /**
24
+ * Ambient declarations the plugin owns.
25
+ *
26
+ * Package specifiers are allowed here and resolved to real paths before
27
+ * writing, because TypeScript resolves `files` as paths and would treat
28
+ * `@scope/pkg/x.d.ts` as a missing file rather than a module.
29
+ */
30
+ files?: string[]
31
+ compilerOptions?: Record<string, unknown>
32
+ /**
33
+ * Whether this project should receive `paths` derived from `importMap`.
34
+ *
35
+ * Off by default, and that default is the correction: the generator used to
36
+ * write those `paths` into *every* project on the reasoning that an alias is
37
+ * app-wide. It is not. `importMap` is a **browser** import map — the framework
38
+ * serves it as `<script type="importmap">` and the browser is what resolves
39
+ * its specifiers. An alias in the server project therefore typechecks an
40
+ * import that only the browser can satisfy, which is the same class of bug the
41
+ * server/client split was introduced to end.
42
+ *
43
+ * A plugin whose project compiles browser code should set it.
44
+ */
45
+ importMapPaths?: boolean
46
+ }
47
+
48
+ /** What a plugin contributes to the generated tsconfig projects. */
49
+ export interface PluginTsConfig {
50
+ /** A project this plugin owns outright — Vue SFCs, for instance. */
51
+ project?: PluginTsProject
52
+ }
53
+
6
54
  export interface ServerPlugin {
7
55
  name: string
8
56
  setup?(config: ProcessedAppConfig): MixedPromise<void>
57
+
58
+ /**
59
+ * Declarative, unlike every hook below it: read by the tsconfig generator at
60
+ * dev boot, never by the running server.
61
+ *
62
+ * A plugin that brings its own file type or its own ambient globals needs a
63
+ * project to typecheck them under. `@bakery-framework/plugin-vue` is the
64
+ * worked example — it owns `.vue` and declares `req`/`body` for SFC scope,
65
+ * globals core deliberately does not provide.
66
+ */
67
+ tsconfig?: PluginTsConfig
9
68
  onStart?(server: Bun.Server<any>): MixedPromise<void>
10
69
  onRequest?(req: Request): ValidResponses
11
70
  onRoute?(req: Request): MixedPromise<void>
package/src/router.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  response,
19
19
  withStatus,
20
20
  } from './utils/http'
21
+ import { applyCors, preflightResponse } from './utils/http/cors'
21
22
 
22
23
  /**
23
24
  * Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
@@ -97,6 +98,19 @@ export async function handleRequest(req: Request) {
97
98
  // itself, so that was a second full path resolution of the same string on
98
99
  // every request.
99
100
  const config = Bakery.config
101
+
102
+ // Before anything else, including the forbidden-path check: a preflight names
103
+ // the route it is asking about in a *header*, not the path, so running it
104
+ // through routing would answer a question nobody asked. The browser sends no
105
+ // credentials with it either, which is why it cannot be authorised.
106
+ //
107
+ // Only when `cors` is configured. Absent, this is not reached at all and the
108
+ // browser's own default applies.
109
+ if (config.cors) {
110
+ const preflight = preflightResponse(config.cors, req)
111
+ if (preflight) return preflight
112
+ }
113
+
100
114
  const serveRoot = config.root
101
115
  if (fs.isForbidden(serveRoot + path, serveRoot)) {
102
116
  return new Response('Forbidden', { status: 403 })
@@ -338,6 +352,14 @@ export async function processResponse(
338
352
  // append, not set: a handler may already have issued its own Set-Cookie
339
353
  // (e.g. an auth cookie from a login route) that must not be overwritten.
340
354
  sess && resp.headers.append('Set-Cookie', sess)
355
+
356
+ // Every response funnels through here — pages, API JSON, static files, error
357
+ // pages — so this is the one place that cannot miss one. Applied before ETag
358
+ // so the negotiated `Vary` sees the `Origin` entry and merges with it rather
359
+ // than either overwriting the other.
360
+ const cors = Bakery.config.cors
361
+ if (cors) applyCors(cors, req, resp)
362
+
341
363
  const final = ETag.sendResponse(req, resp)
342
364
  if (!(final instanceof Response)) {
343
365
  log({
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Cross-origin resource sharing.
3
+ *
4
+ * Bakery already ships the pieces of an API server — `ApiHandler`, sessions,
5
+ * CSRF, rate limiting — and had no way to serve one to a browser on another
6
+ * origin. This is that.
7
+ *
8
+ * Two halves, and both are needed: a preflight `OPTIONS` has to be answered
9
+ * before routing (there is no route to run, and the browser will not send the
10
+ * real request until it is answered), and every *other* response needs the
11
+ * headers appended on the way out.
12
+ *
13
+ * **Nothing happens unless `cors` is configured.** No default origin, not even
14
+ * a permissive one in development: a framework that quietly allows every origin
15
+ * teaches people it works and then surprises them in production. An absent
16
+ * config means the headers are never written, which is the browser's own
17
+ * default and the safe one.
18
+ */
19
+
20
+ /** What the app writes in `server.config.ts`. */
21
+ export interface CorsOptions {
22
+ /**
23
+ * Origins allowed to read responses.
24
+ *
25
+ * `'*'` is honoured literally, and is refused in combination with
26
+ * `credentials` — see `resolveOrigin`. A function receives the request's
27
+ * `Origin` and returns the value to echo, or `null` to deny.
28
+ */
29
+ origin: string | string[] | ((origin: string) => string | null)
30
+ /** Defaults to the methods a browser will preflight for. */
31
+ methods?: string[]
32
+ /** Request headers the browser may send. Defaults to echoing what it asks. */
33
+ allowHeaders?: string[]
34
+ /** Response headers JavaScript may read. Nothing is exposed by default. */
35
+ exposeHeaders?: string[]
36
+ /** Send `Access-Control-Allow-Credentials`. Incompatible with `origin: '*'`. */
37
+ credentials?: boolean
38
+ /** Preflight cache lifetime in seconds. */
39
+ maxAge?: number
40
+ }
41
+
42
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
43
+
44
+ /**
45
+ * The value for `Access-Control-Allow-Origin`, or `null` to send nothing.
46
+ *
47
+ * **`'*'` with credentials is refused rather than silently downgraded.** The
48
+ * browser rejects that pairing anyway, so honouring it would produce a request
49
+ * that fails in the client with a CORS error and a server that believes it
50
+ * allowed the call. Echoing the origin instead would be a *quiet widening* of
51
+ * what the app asked for. Returning null makes the misconfiguration visible as
52
+ * a denied request, which is the direction a security control should fail in.
53
+ */
54
+ export function resolveOrigin(
55
+ options: CorsOptions,
56
+ requestOrigin: string | null,
57
+ ): string | null {
58
+ const { origin, credentials } = options
59
+
60
+ if (origin === '*') return credentials ? null : '*'
61
+ if (!requestOrigin) return null
62
+
63
+ if (typeof origin === 'function') return origin(requestOrigin)
64
+ if (Array.isArray(origin)) {
65
+ return origin.includes(requestOrigin) ? requestOrigin : null
66
+ }
67
+ return origin === requestOrigin ? requestOrigin : null
68
+ }
69
+
70
+ /**
71
+ * Headers for a non-preflight response, or `null` when the origin is denied.
72
+ *
73
+ * `Vary: Origin` is always set when the allowed origin is anything but `'*'`,
74
+ * because the response now differs per origin and a shared cache that ignored
75
+ * that would serve one origin's response to another.
76
+ */
77
+ export function corsHeaders(
78
+ options: CorsOptions,
79
+ requestOrigin: string | null,
80
+ ): Record<string, string> | null {
81
+ const allowed = resolveOrigin(options, requestOrigin)
82
+ if (allowed === null) return null
83
+
84
+ const headers: Record<string, string> = {
85
+ 'Access-Control-Allow-Origin': allowed,
86
+ }
87
+ if (allowed !== '*') headers.Vary = 'Origin'
88
+ if (options.credentials) {
89
+ headers['Access-Control-Allow-Credentials'] = 'true'
90
+ }
91
+ if (options.exposeHeaders?.length) {
92
+ headers['Access-Control-Expose-Headers'] = options.exposeHeaders.join(', ')
93
+ }
94
+ return headers
95
+ }
96
+
97
+ /**
98
+ * The response to a preflight, or `null` if this is not one.
99
+ *
100
+ * A preflight is `OPTIONS` *with* `Access-Control-Request-Method` — plain
101
+ * `OPTIONS` is a normal request and must fall through to routing, or an app
102
+ * with its own OPTIONS route would find it shadowed.
103
+ *
104
+ * 204 rather than 200: there is no body, and some proxies treat a 200 with no
105
+ * content-length as needing one.
106
+ */
107
+ export function preflightResponse(
108
+ options: CorsOptions,
109
+ req: Request,
110
+ ): Response | null {
111
+ if (req.method !== 'OPTIONS') return null
112
+ if (!req.headers.get('Access-Control-Request-Method')) return null
113
+
114
+ const base = corsHeaders(options, req.headers.get('Origin'))
115
+ // A denied origin still gets an answer, just without the headers that would
116
+ // permit the call. Returning 403 here would be a worse signal: the browser
117
+ // reports a CORS failure either way, and a 403 invites debugging the route.
118
+ if (!base) return new Response(null, { status: 204 })
119
+
120
+ const headers: Record<string, string> = {
121
+ ...base,
122
+ 'Access-Control-Allow-Methods': (options.methods ?? DEFAULT_METHODS).join(
123
+ ', ',
124
+ ),
125
+ }
126
+
127
+ // Echoing the requested headers is what makes a default config usable:
128
+ // enumerating every header a client might send is a list nobody maintains,
129
+ // and getting it wrong fails at request time in the browser only.
130
+ const requested = req.headers.get('Access-Control-Request-Headers')
131
+ const allow = options.allowHeaders?.length
132
+ ? options.allowHeaders.join(', ')
133
+ : requested
134
+ if (allow) headers['Access-Control-Allow-Headers'] = allow
135
+
136
+ if (options.maxAge !== undefined) {
137
+ headers['Access-Control-Max-Age'] = String(options.maxAge)
138
+ }
139
+
140
+ // Vary on the negotiated request headers too, for the same caching reason.
141
+ headers.Vary = [headers.Vary, 'Access-Control-Request-Headers']
142
+ .filter(Boolean)
143
+ .join(', ')
144
+
145
+ return new Response(null, { status: 204, headers })
146
+ }
147
+
148
+ /** Append the headers to a response that already exists. */
149
+ export function applyCors(
150
+ options: CorsOptions,
151
+ req: Request,
152
+ res: Response,
153
+ ): Response {
154
+ const headers = corsHeaders(options, req.headers.get('Origin'))
155
+ if (!headers) return res
156
+
157
+ for (const [key, value] of Object.entries(headers)) {
158
+ // `Vary` may already carry a value from ETag negotiation; appending keeps
159
+ // both rather than dropping whichever ran second.
160
+ if (key === 'Vary' && res.headers.has('Vary')) {
161
+ const existing = res.headers.get('Vary') ?? ''
162
+ if (!existing.split(',').some(v => v.trim() === value)) {
163
+ res.headers.set('Vary', `${existing}, ${value}`)
164
+ }
165
+ continue
166
+ }
167
+ res.headers.set(key, value)
168
+ }
169
+ return res
170
+ }
@@ -6,3 +6,4 @@ export * from './etag'
6
6
  export * from './html'
7
7
  export * from './ip'
8
8
  export * from './response'
9
+ export * from './sse'
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Server-sent events.
3
+ *
4
+ * Bakery had WebSockets and nothing for one-way streaming, which is the cheaper
5
+ * half of that pair and the right shape for progress, notifications and tailing
6
+ * — no upgrade, no protocol, reconnects handled by the browser.
7
+ *
8
+ * A route could always return a `Response` wrapping a `ReadableStream` and it
9
+ * would reach the client intact: `ETag.sendResponse` returns early without an
10
+ * `ETag` header, and `injectIfHtml` ignores anything that is not HTML. What was
11
+ * missing is the framing, and framing is where this goes wrong — a payload
12
+ * containing a newline silently truncates unless every line is prefixed, and a
13
+ * write after the client has gone throws where nobody is catching.
14
+ */
15
+
16
+ /** One event. Every field is optional except the payload. */
17
+ export interface SSEMessage {
18
+ /** Serialised with `JSON.stringify` unless it is already a string. */
19
+ data: unknown
20
+ /** `event:` — the client listens for this name instead of `message`. */
21
+ event?: string
22
+ /** `id:` — echoed back as `Last-Event-ID` when the browser reconnects. */
23
+ id?: string
24
+ /** `retry:` — how long the browser waits before reconnecting, in ms. */
25
+ retry?: number
26
+ }
27
+
28
+ /** The handle a producer writes to. */
29
+ export interface SSEStream {
30
+ /** Send one event. A no-op once the stream is closed. */
31
+ send(message: SSEMessage | unknown): void
32
+ /** Send a `:` comment. Useful as a keep-alive through idle proxies. */
33
+ comment(text?: string): void
34
+ /** Close the stream. Idempotent. */
35
+ close(): void
36
+ /** True once the client has gone or `close()` has run. */
37
+ readonly closed: boolean
38
+ }
39
+
40
+ export interface SSEOptions {
41
+ /**
42
+ * Milliseconds between automatic keep-alive comments. `0` disables them.
43
+ *
44
+ * Defaults to 15s because idle proxies and load balancers commonly cut a
45
+ * connection at 30–60s, and a dead SSE stream is invisible: the browser
46
+ * reconnects silently, so the symptom is duplicated work on the server rather
47
+ * than an error anyone sees.
48
+ */
49
+ keepAlive?: number
50
+ /** Initial `retry:` hint sent once, before any event. */
51
+ retry?: number
52
+ }
53
+
54
+ /**
55
+ * Encode one message as an SSE frame.
56
+ *
57
+ * **Every line of `data` is prefixed.** A payload containing a newline is
58
+ * otherwise cut short at that newline — the rest is read as a new field, and
59
+ * the client sees a truncated message rather than an error. Pretty-printed JSON
60
+ * and stack traces both hit this.
61
+ */
62
+ export function encodeSSE(message: SSEMessage): string {
63
+ const lines: string[] = []
64
+
65
+ if (message.event) lines.push(`event: ${message.event}`)
66
+ if (message.id !== undefined) lines.push(`id: ${message.id}`)
67
+ if (message.retry !== undefined) lines.push(`retry: ${message.retry}`)
68
+
69
+ const payload =
70
+ typeof message.data === 'string'
71
+ ? message.data
72
+ : JSON.stringify(message.data)
73
+
74
+ // `?? ''` rather than skipping: `JSON.stringify(undefined)` is undefined, and
75
+ // a frame with no `data:` line at all is a comment to the client — the event
76
+ // would vanish rather than arrive empty.
77
+ for (const line of (payload ?? '').split('\n')) lines.push(`data: ${line}`)
78
+
79
+ // The blank line terminates the frame. Without it the client buffers forever.
80
+ return `${lines.join('\n')}\n\n`
81
+ }
82
+
83
+ /**
84
+ * Build an SSE response and hand the producer a stream to write to.
85
+ *
86
+ * export default defineRoute(req =>
87
+ * sse(req, stream => {
88
+ * const timer = setInterval(() => stream.send({ now: Date.now() }), 1000)
89
+ * return () => clearInterval(timer)
90
+ * }),
91
+ * )
92
+ *
93
+ * The producer may return a cleanup function, which runs exactly once when the
94
+ * client disconnects or `close()` is called. That is the only reliable place to
95
+ * stop a timer or unsubscribe: without it, a closed connection leaves the
96
+ * interval running for the life of the process, and the leak is silent.
97
+ */
98
+ export function sse(
99
+ req: Request,
100
+ producer: (
101
+ stream: SSEStream,
102
+ ) => void | (() => void) | Promise<void | (() => void)>,
103
+ options: SSEOptions = {},
104
+ ): Response {
105
+ const encoder = new TextEncoder()
106
+ const { keepAlive = 15_000, retry } = options
107
+
108
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null
109
+ let closed = false
110
+ let cleanup: (() => void) | void
111
+ let keepAliveTimer: ReturnType<typeof setInterval> | undefined
112
+
113
+ const write = (chunk: string) => {
114
+ if (closed || !controller) return
115
+ try {
116
+ controller.enqueue(encoder.encode(chunk))
117
+ } catch {
118
+ // The client went away between the `closed` check and the enqueue. That
119
+ // is a normal race on every disconnect, not an error worth surfacing —
120
+ // and throwing here would reject inside a timer callback, where nothing
121
+ // is catching.
122
+ finish()
123
+ }
124
+ }
125
+
126
+ function finish() {
127
+ if (closed) return
128
+ closed = true
129
+ if (keepAliveTimer) clearInterval(keepAliveTimer)
130
+ try {
131
+ cleanup?.()
132
+ } catch {
133
+ // A producer's cleanup that throws must not prevent the stream closing;
134
+ // the connection is already going away either way.
135
+ }
136
+ try {
137
+ controller?.close()
138
+ } catch {
139
+ // Already closed by the runtime when the client disconnected.
140
+ }
141
+ }
142
+
143
+ const stream: SSEStream = {
144
+ send(message) {
145
+ const normalised: SSEMessage =
146
+ message && typeof message === 'object' && 'data' in (message as object)
147
+ ? (message as SSEMessage)
148
+ : { data: message }
149
+ write(encodeSSE(normalised))
150
+ },
151
+ comment(text = '') {
152
+ write(`: ${text}\n\n`)
153
+ },
154
+ close: finish,
155
+ get closed() {
156
+ return closed
157
+ },
158
+ }
159
+
160
+ const body = new ReadableStream<Uint8Array>({
161
+ async start(c) {
162
+ controller = c
163
+
164
+ // The client aborting is the common ending, not an exception. Without
165
+ // this the producer keeps writing into a dead socket.
166
+ req.signal?.addEventListener('abort', finish, { once: true })
167
+ if (req.signal?.aborted) return finish()
168
+
169
+ if (retry !== undefined) write(`retry: ${retry}\n\n`)
170
+ if (keepAlive > 0) {
171
+ keepAliveTimer = setInterval(
172
+ () => stream.comment('keep-alive'),
173
+ keepAlive,
174
+ )
175
+ }
176
+
177
+ try {
178
+ cleanup = await producer(stream)
179
+ } catch {
180
+ // A producer that throws ends the stream rather than leaving the client
181
+ // hanging on a connection nobody will write to again.
182
+ finish()
183
+ }
184
+ },
185
+ cancel: finish,
186
+ })
187
+
188
+ return new Response(body, {
189
+ headers: {
190
+ 'Content-Type': 'text/event-stream; charset=utf-8',
191
+ // A cached event stream is a stream that never arrives.
192
+ 'Cache-Control': 'no-cache, no-transform',
193
+ Connection: 'keep-alive',
194
+ // nginx buffers proxied responses by default, which holds every event
195
+ // until the buffer fills — the stream appears to work in development and
196
+ // to hang in production. This is the documented opt-out.
197
+ 'X-Accel-Buffering': 'no',
198
+ },
199
+ })
200
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Request-body validation for route modules.
3
+ *
4
+ * `defineRoute<{ title: string }>` declares a body shape and enforces nothing —
5
+ * the scaffolder's own template says so twice: *"declares the contract — it
6
+ * does not validate it. The body is still client input."* Every route was left
7
+ * to hand-check or not, and most did not.
8
+ *
9
+ * **No schema library is bundled, and none is depended on.** `@bakery-framework/
10
+ * core` has zero runtime dependencies and that is worth keeping, so validation
11
+ * accepts two shapes it can consume without knowing who produced them:
12
+ *
13
+ * - **Standard Schema** (`~standard`) — the shared interface zod, valibot and
14
+ * arktype all implement. Bring your own library; Bakery never imports it.
15
+ * - **A plain function** that returns the parsed value or throws.
16
+ *
17
+ * The second exists because the first is overkill for `body => { if (!body.id)
18
+ * throw new Error('id required'); return body }`, and a framework that forces a
19
+ * dependency for that has made the common case worse.
20
+ */
21
+
22
+ /** The subset of Standard Schema v1 that validation needs. */
23
+ export interface StandardSchemaLike<T> {
24
+ '~standard': {
25
+ version: 1
26
+ vendor: string
27
+ validate(value: unknown): StandardResult<T> | Promise<StandardResult<T>>
28
+ }
29
+ }
30
+
31
+ type StandardResult<T> =
32
+ | { value: T; issues?: undefined }
33
+ | { issues: readonly StandardIssue[] }
34
+
35
+ interface StandardIssue {
36
+ message: string
37
+ path?: readonly (PropertyKey | { key: PropertyKey })[]
38
+ }
39
+
40
+ /** A function validator: return the parsed value, or throw to reject. */
41
+ export type FunctionValidator<T> = (value: unknown) => T | Promise<T>
42
+
43
+ export type Validator<T> = StandardSchemaLike<T> | FunctionValidator<T>
44
+
45
+ /** One human-readable problem, with the field path when the schema gave one. */
46
+ export interface ValidationIssue {
47
+ path: string
48
+ message: string
49
+ }
50
+
51
+ export type ValidationResult<T> =
52
+ | { ok: true; value: T }
53
+ | { ok: false; issues: ValidationIssue[] }
54
+
55
+ function isStandardSchema<T>(v: Validator<T>): v is StandardSchemaLike<T> {
56
+ return typeof v === 'object' && v !== null && '~standard' in v
57
+ }
58
+
59
+ /**
60
+ * Render a Standard Schema path as dotted notation.
61
+ *
62
+ * Segments may be plain keys or `{ key }` objects — the spec allows both, and a
63
+ * library that uses the object form would otherwise render as `[object Object]`
64
+ * in the very message meant to tell someone which field is wrong.
65
+ */
66
+ function renderPath(path: StandardIssue['path']): string {
67
+ if (!path?.length) return ''
68
+ return path
69
+ .map(seg => (typeof seg === 'object' && seg !== null ? seg.key : seg))
70
+ .join('.')
71
+ }
72
+
73
+ /**
74
+ * Run a validator, never throwing.
75
+ *
76
+ * A function validator that throws is a *rejection*, not a crash: that is the
77
+ * whole idiom for the plain-function form. Its message becomes the issue, so
78
+ * `throw new Error('id must be a number')` reaches the client as written.
79
+ */
80
+ export async function validate<T>(
81
+ validator: Validator<T>,
82
+ value: unknown,
83
+ ): Promise<ValidationResult<T>> {
84
+ if (isStandardSchema(validator)) {
85
+ const result = await validator['~standard'].validate(value)
86
+ if (result.issues) {
87
+ return {
88
+ ok: false,
89
+ issues: result.issues.map(issue => ({
90
+ path: renderPath(issue.path),
91
+ message: issue.message,
92
+ })),
93
+ }
94
+ }
95
+ return { ok: true, value: result.value }
96
+ }
97
+
98
+ try {
99
+ return { ok: true, value: await validator(value) }
100
+ } catch (error: any) {
101
+ return {
102
+ ok: false,
103
+ issues: [{ path: '', message: error?.message ?? 'Invalid request body' }],
104
+ }
105
+ }
106
+ }