@bakery-framework/core 2.5.0-alpha.0 → 2.5.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.5.0-alpha.0",
3
+ "version": "2.5.0-alpha.2",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -65,6 +65,6 @@
65
65
  "!src/tests"
66
66
  ],
67
67
  "engines": {
68
- "bun": ">=1.4.0"
68
+ "bun": ">=1.4.4"
69
69
  }
70
70
  }
@@ -331,9 +331,13 @@ if (typeof document !== 'undefined') {
331
331
  // A `source: 'document'` rule with href_matches '/*' and eager eagerness
332
332
  // ignored the exclusions above and fully loaded (and ran the JS of) every
333
333
  // same-origin link, including things like /logout or a destructive GET.
334
+ //
335
+ // Prefetch waits for intent too. With no eagerness a list rule fetches at
336
+ // once, so a sidebar of 32 links asked for all 32 pages at every load (1.5 s
337
+ // and 6 MB on one app); moderate asked for none at load and one on a hover.
334
338
  const urlList = Array.from(urls)
335
339
  specScript.textContent = JSON.stringify({
336
- prefetch: [{ source: 'list', urls: urlList }],
340
+ prefetch: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
337
341
  prerender: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
338
342
  })
339
343
 
@@ -1,12 +1,7 @@
1
1
  import { Strings } from '../cache/string'
2
2
  import { Bakery } from '../core/bakery'
3
3
  import { PluginHooks } from '../core/plugins'
4
- import {
5
- compLog,
6
- errorMsg,
7
- errorWithPosition,
8
- handlerLog,
9
- } from '../logger/serve-log'
4
+ import { compLog, errorWithPosition, handlerLog } from '../logger/serve-log'
10
5
  import type { MapOf } from '../types'
11
6
  import { is, Try, toHash } from '../utils/common'
12
7
  import { FileSystem as fs } from '../utils/fs'
@@ -37,7 +32,8 @@ async function buildDefines(): Promise<MapOf<string>> {
37
32
  const file = await Bun.file(`${process.cwd()}/package.json`).json()
38
33
  if (file?.version) bakeryVersion = file.version
39
34
  } catch {
40
- // No package.json in cwd, or it is unreadable. The version is cosmetic ( // it lands in a banner and a build define), so the default above stands.
35
+ // No package.json in cwd, or it is unreadable. The version is cosmetic
36
+ // (it lands in a banner and a build define), so the default above stands.
41
37
  }
42
38
 
43
39
  return {
@@ -128,10 +124,14 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
128
124
  * Compile `source`, naming `path` so a failure can say where it happened.
129
125
  *
130
126
  * The two branches are not symmetric on purpose. Without a path there is no
131
- * file to name and no caller that can render a 500 (the Vue plugin compiles
132
- * fragments of an SFC it has already parsed), so a failure logs and hands the
133
- * source back. With a path the failure is a served request, and the answer is
134
- * `null`: `TSHandler` turns that into the 500 it has always had a branch for.
127
+ * file to resolve imports against and no caller that can render a 500 (the
128
+ * Vue plugin compiles fragments of an SFC it has already parsed), so a failure
129
+ * logs and hands the source back. `origin.file` names the file such a
130
+ * fragment came from, for that log line only: without it the line read
131
+ * "Failed to compile inline source: Transform failed", and a component that
132
+ * would not parse in the browser had to be found by hand. With a path the
133
+ * failure is a served request, and the answer is `null`: `TSHandler` turns
134
+ * that into the 500 it has always had a branch for.
135
135
  *
136
136
  * It used to be neither. The transpiler was wrapped only on the pathless
137
137
  * branch, so a syntax error in a `.ts` asset threw straight past `compile()`,
@@ -140,22 +140,31 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
140
140
  * `Unhandled Server Error: <message>` with no file and no line while the client
141
141
  * got `An unexpected error occurred.` A total blackout for a one-character typo.
142
142
  */
143
- export function compileText(source: string): Promise<string>
143
+ export function compileText(
144
+ source: string,
145
+ origin?: { file: string },
146
+ ): Promise<string>
144
147
  export function compileText(
145
148
  source: string,
146
149
  path: fs.AbsolutePath,
147
150
  ): Promise<string | null>
148
- export async function compileText(source: string, path?: fs.AbsolutePath) {
149
- if (!path) {
150
- try {
151
- const content = await (await getTranspiler()).transform(source)
152
- return content
153
- } catch (err) {
154
- compLog.COMPILE_SOURCE_FAIL({ error: errorMsg(err) })
155
- return source
156
- }
151
+ export async function compileText(
152
+ source: string,
153
+ where?: fs.AbsolutePath | { file: string },
154
+ ) {
155
+ if (typeof where !== 'string') {
156
+ const [failed, content] = await Try.catch(
157
+ (await getTranspiler()).transform(source),
158
+ )
159
+ if (!failed) return content!
160
+
161
+ const error = errorWithPosition(failed)
162
+ if (where) compLog.COMPILE_FAIL({ file: where.file, error })
163
+ else compLog.COMPILE_SOURCE_FAIL({ error })
164
+ return source
157
165
  }
158
166
 
167
+ const path = where
159
168
  source = preprocessImports(source, path)
160
169
 
161
170
  const [failed, transformed] = await Try.catch(
@@ -163,8 +172,9 @@ export async function compileText(source: string, path?: fs.AbsolutePath) {
163
172
  )
164
173
 
165
174
  if (failed) {
166
- // `errorWithPosition`, not `errorMsg`: the thrown `BuildMessage` has no
167
- // stack, so `errorMsg` degrades to the bare message and the line number ( // the only thing that makes this actionable) is dropped. The file comes
175
+ // `errorWithPosition`, not `errorMsg`: what `transform()` rejects with
176
+ // has no stack, so `errorMsg` degrades to a bare message and drops the
177
+ // line number, the only thing that makes this actionable. The file comes
168
178
  // from `{file}`; the diagnostic's own `position.file` is `input.ts`,
169
179
  // because `transform()` was handed a string.
170
180
  compLog.COMPILE_FAIL({ file: path, error: errorWithPosition(failed) })
@@ -542,7 +542,8 @@ const MODULE_ROUTE_EXT = /\.(tsx|jsx)$/
542
542
  * change and restarts already.
543
543
  *
544
544
  * `rename` is the creation signal. Measured on Windows: an in-place write
545
- * (`Bun.write`, `fs.writeFile`) emits only `change` and keeps the fast path ( * 14-15ms edit-to-served), while creating a file emits `rename` then
545
+ * (`Bun.write`, `fs.writeFile`) emits only `change` and keeps the fast path
546
+ * (14-15ms edit-to-served), while creating a file emits `rename` then
546
547
  * `change`. Writers that replace rather than overwrite report `rename` for an
547
548
  * *edit* too and pay one restart (~440ms): shell redirection (`> file`) does
548
549
  * this, and an editor that saves atomically will as well. That is the safe
@@ -5,7 +5,8 @@ import { type Validator, validate } from '../utils/http/validate'
5
5
  /**
6
6
  * `defineRoute`, in its own module rather than in `core/index.ts`.
7
7
  *
8
- * `core/index.ts` is a barrel (it pulls in the logger, plugins, jsx and utils), * so anything importing it from *inside* core closes a cycle and dies with
8
+ * `core/index.ts` is a barrel (it pulls in the logger, plugins, jsx and utils),
9
+ * so anything importing it from *inside* core closes a cycle and dies with
9
10
  * `ReferenceError: Cannot access 'Logger' before initialization`. That is
10
11
  * recorded in CLAUDE.md as costing 67 tests once; it cost this file's own tests
11
12
  * a second time, which is what moved it here. Consumers still reach it through
package/src/core/jsx.ts CHANGED
@@ -33,7 +33,7 @@ export const Fragment = ({ children }: { children?: any }) =>
33
33
  * like a string everywhere (`.trim()`, concatenation, `String()`), so callers
34
34
  * are unaffected.
35
35
  */
36
- export class SafeHtml extends String {}
36
+ class SafeHtml extends String {}
37
37
 
38
38
  /** Mark a string as trusted HTML, exempting it from escaping. */
39
39
  export function raw(value: unknown): string {
@@ -123,6 +123,39 @@ function canonicalQuery(search: string): string | null {
123
123
  return `?${pairs.sort().join('&')}`
124
124
  }
125
125
 
126
+ /**
127
+ * A `&text=` subset's address, as the CSS endpoint writes it:
128
+ * `l/font?kit=neILzCirqoswsqX9_oC0MuFXPPo&skey=7ed805a363b4659c&v=v32`.
129
+ *
130
+ * It has no extension and names its font in the query alone, so it cannot pass
131
+ * `GF_GSTATIC_PATH`, and every such request answered 404. The three parameters
132
+ * are the only ones allowed through, in a canonical order, and a kit is free
133
+ * text in the way a family name is: `GF_MAX_ENTRIES` is what bounds them.
134
+ */
135
+ const GF_SUBSET_PATH = 'l/font'
136
+ const GF_SUBSET_PARAMS = new Set(['kit', 'skey', 'v'])
137
+ const GF_SUBSET_VALUE = /^[a-zA-Z0-9_-]+$/
138
+
139
+ /** The subset's query in canonical order, or `null` if it is not one. */
140
+ function subsetQuery(search: string): string | null {
141
+ const raw = search.startsWith('?') ? search.slice(1) : search
142
+ if (!raw || raw.length > GF_MAX_GSTATIC_PATH) return null
143
+
144
+ const pairs = raw.split('&')
145
+ const names = new Set<string>()
146
+ for (const pair of pairs) {
147
+ const [name, value, extra] = pair.split('=')
148
+ if (!GF_SUBSET_PARAMS.has(name) || names.has(name)) return null
149
+ if (extra !== undefined || !value || !GF_SUBSET_VALUE.test(value)) {
150
+ return null
151
+ }
152
+ names.add(name)
153
+ }
154
+ if (!names.has('kit')) return null
155
+
156
+ return `?${pairs.sort().join('&')}`
157
+ }
158
+
126
159
  export class GoogleFontHandler extends Handler {
127
160
  static get cacheDir() {
128
161
  return fs.resolve(Bakery.cacheDir, 'gf_cache')
@@ -134,7 +167,7 @@ export class GoogleFontHandler extends Handler {
134
167
 
135
168
  static async handle(path: string, req: Request) {
136
169
  if (path.startsWith('/_gf/gstatic/')) {
137
- return this.handleGstatic(path)
170
+ return this.handleGstatic(path, new URL(req.url).search)
138
171
  }
139
172
 
140
173
  const rawPath = path.slice('/_gf'.length)
@@ -185,28 +218,35 @@ export class GoogleFontHandler extends Handler {
185
218
  return cached
186
219
  }
187
220
 
188
- private static async handleGstatic(path: string) {
221
+ private static async handleGstatic(path: string, search: string) {
189
222
  const gstaticPath = path.slice('/_gf/gstatic/'.length)
223
+ const isSubset = gstaticPath === GF_SUBSET_PATH
224
+ const query = isSubset ? subsetQuery(search) : ''
190
225
 
191
226
  // Refused before `this.cacheDir` is read, so a rejection costs neither a
192
227
  // config lookup nor a path resolution.
193
228
  if (
229
+ query === null ||
194
230
  gstaticPath.length > GF_MAX_GSTATIC_PATH ||
195
- !GF_GSTATIC_PATH.test(gstaticPath)
231
+ (!isSubset && !GF_GSTATIC_PATH.test(gstaticPath))
196
232
  ) {
197
233
  return response.error('Unknown Google Fonts asset', 404)
198
234
  }
199
235
 
200
236
  const cacheDir = fs.resolve(this.cacheDir, 'gstatic')
201
- const cacheExt = `${fs.parse(gstaticPath).ext || '.bin'}`
202
- const cacheName = `${toHash(gstaticPath)}${cacheExt}`
237
+ // The CSS endpoint asks as Chrome, so a subset is always a woff2. It has no
238
+ // extension of its own to read that from.
239
+ const cacheExt = isSubset
240
+ ? '.woff2'
241
+ : `${fs.parse(gstaticPath).ext || '.bin'}`
242
+ const cacheName = `${toHash(`${gstaticPath}${query}`)}${cacheExt}`
203
243
 
204
244
  const cached = await fs.getOrCreateCachedFile(
205
245
  cacheDir,
206
246
  cacheName,
207
247
  null,
208
248
  async () => {
209
- const gfUrl = `https://fonts.gstatic.com/${gstaticPath}`
249
+ const gfUrl = `https://fonts.gstatic.com/${gstaticPath}${query}`
210
250
  const res = await fetch(gfUrl)
211
251
 
212
252
  if (!res.ok) return null
@@ -253,7 +253,8 @@ export class Handler {
253
253
  static servesFiles = true
254
254
 
255
255
  /**
256
- * The URL prefix this handler owns as a complete user-facing surface ( * `'/_dashboard'`, `'/_db'`), or `null` for the ordinary case of a handler
256
+ * The URL prefix this handler owns as a complete user-facing surface
257
+ * (`'/_dashboard'`, `'/_db'`), or `null` for the ordinary case of a handler
257
258
  * that answers by extension or content rather than by prefix.
258
259
  *
259
260
  * This exists so one surface can ask whether another is mounted without
@@ -88,7 +88,8 @@ const routeGlobs = (
88
88
  * The catch-all fallback for one directory level: `dir/[...name].ext`,
89
89
  * containment-checked like every other candidate. Skipped for `staticOnly`
90
90
  * (the caller wants a literal file), and it *yields to any real file*: when
91
- * the request's remaining segments name an existing file under `dir` ( * whatever its extension) the catch-all declines, so a lower-priority
91
+ * the request's remaining segments name an existing file under `dir`
92
+ * (whatever its extension) the catch-all declines, so a lower-priority
92
93
  * handler (TSHandler, StaticHandler) can serve the file itself. A directory
93
94
  * is not a file and does not trigger the yield. `findDynamicRoute` applies
94
95
  * the same rule on the cached path; the two must agree.
@@ -10,7 +10,8 @@ import { resolveMount } from './$mounts'
10
10
  * dynamic `[id]` segments and glob scanning. This answers the simpler question
11
11
  * "which file is at this path, and am I allowed to serve it".
12
12
  *
13
- * It exists because four handlers were each spelling that out themselves ( * StaticHandler, PublicHandler, ImageHandler and NMHandler) in four slightly
13
+ * It exists because four handlers were each spelling that out themselves
14
+ * (StaticHandler, PublicHandler, ImageHandler and NMHandler) in four slightly
14
15
  * different ways, and only some of them checked everything. Once route mounts
15
16
  * arrived the drift got worse: a mount-aware StaticHandler sitting next to a
16
17
  * mount-blind PublicHandler is the kind of inconsistency that turns into a bug
@@ -257,12 +257,20 @@ export function errorDetail(err: any): string {
257
257
  * `position.file` is `input.ts` whenever `transform()` was handed a source
258
258
  * string rather than a path (which is every compile this codebase does), so
259
259
  * printing it beside the real path would contradict it.
260
+ *
261
+ * `transform()` rejects with an `AggregateError` whose own message is only
262
+ * "Transform failed", with no position, and whose `errors` are the
263
+ * diagnostics, so the first of those is the one read. The rest usually follow
264
+ * from it: a `;` inside an object literal is reported as `Expected "}" but
265
+ * found ";"`, then as `Unexpected }` at the brace after it.
260
266
  */
261
267
  export function errorWithPosition(err: any): string {
262
- const position = (err as Diagnostic)?.position
268
+ const first: Diagnostic =
269
+ Array.isArray(err?.errors) && err.errors.length ? err.errors[0] : err
270
+ const position = first?.position
263
271
  if (!position || position.line == null) return errorMsg(err)
264
272
 
265
- const message = (err as Diagnostic).message || errorMsg(err)
273
+ const message = first.message || errorMsg(first)
266
274
  return `${message} (line ${position.line}, column ${position.column ?? 0})`
267
275
  }
268
276
 
package/src/router.ts CHANGED
@@ -289,7 +289,8 @@ export const serveWebSocket: Bun.WebSocketHandler<any> = {
289
289
  try {
290
290
  const h = prepareWSData(ws)
291
291
  if (h) return await h.handler.message(ws, message, h.data)
292
- config.websocket.message(ws, message)
292
+ // Optional since bun-types 1.4.4, like the other three below.
293
+ await config.websocket.message?.(ws, message)
293
294
  } catch (err: any) {
294
295
  serveLog.UNHANDLED_ERR({
295
296
  error: `WebSocket message error: ${errorMsg(err)}`,
package/src/utils/fs.ts CHANGED
@@ -784,7 +784,10 @@ export namespace FileSystem {
784
784
  if (content == null) return null
785
785
  await mkdir(cacheDir)
786
786
  await rawFile.write(content)
787
- return built(rawFile)
787
+ // A new handle: `rawFile` read its size before the file existed, so serving
788
+ // it answered 200 with `content-length: 0` and no body. Measured on a cold
789
+ // font, which Chrome then reports as failed; the next request read the file.
790
+ return built(Bun.file(rawPath))
788
791
  }
789
792
 
790
793
  /**
@@ -7,6 +7,7 @@
7
7
  * arrive with the victim's session attached.
8
8
  */
9
9
 
10
+ import { Bakery } from '../../core/bakery'
10
11
  import { Try } from '../isomorphic/try'
11
12
 
12
13
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
@@ -14,20 +15,47 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
14
15
  /** `Sec-Fetch-Site` values that mean the request did not come from another site. */
15
16
  const SAME_SITE_VALUES = new Set(['same-origin', 'same-site', 'none'])
16
17
 
18
+ /**
19
+ * The origin a browser sees for this request.
20
+ *
21
+ * A proxy that ends TLS leaves the request looking like `http://app.example`
22
+ * while the browser reports `https://app.example`. Under `trustProxy` the
23
+ * proxy's forwarded scheme and host are believed, as `getHostname` and the
24
+ * session's `Secure` flag believe them. Without it the request's own URL
25
+ * decides, since any client could send those headers.
26
+ */
27
+ export function requestOrigin(req: Request, url: URL): string {
28
+ if (!Bakery.config.trustProxy) return url.origin
29
+
30
+ const forwarded = (name: string) =>
31
+ req.headers.get(name)?.split(',')[0].trim()
32
+ const scheme = forwarded('x-forwarded-proto') || url.protocol.slice(0, -1)
33
+ const host = forwarded('x-forwarded-host') || url.host
34
+
35
+ return Try(() => new URL(`${scheme}://${host}`).origin) ?? url.origin
36
+ }
37
+
17
38
  /**
18
39
  * Returns a reason string when the request looks cross-site, or `null` when it
19
40
  * is safe to process. Requests with neither `Origin` nor `Sec-Fetch-Site` (curl,
20
41
  * server-to-server, older clients) are allowed: browsers always send at least
21
42
  * one of them on a cross-origin request.
43
+ *
44
+ * A same-origin `Sec-Fetch-Site` settles it before `Origin` is read. A browser
45
+ * sets that header and page script cannot, and it is right behind a proxy that
46
+ * ends TLS, where `Origin` names a scheme this server never saw: measured, every
47
+ * browser write there answered 403, with `trustProxy` on or off. A client that
48
+ * forges the header could forge `Origin` as well, so nothing is lost.
22
49
  */
23
50
  export function checkSameOrigin(req: Request, url: URL): string | null {
24
51
  const fetchSite = req.headers.get('sec-fetch-site')
25
52
  if (fetchSite && !SAME_SITE_VALUES.has(fetchSite)) {
26
53
  return `cross-site request rejected (Sec-Fetch-Site: ${fetchSite})`
27
54
  }
55
+ if (fetchSite === 'same-origin' || fetchSite === 'none') return null
28
56
 
29
57
  const origin = req.headers.get('origin')
30
- if (origin && origin !== 'null' && origin !== url.origin) {
58
+ if (origin && origin !== 'null' && origin !== requestOrigin(req, url)) {
31
59
  return `cross-origin request rejected (Origin: ${origin})`
32
60
  }
33
61
 
@@ -63,7 +91,8 @@ function safeEcho(value: string): string {
63
91
  *
64
92
  * WebSockets are exempt from the same-origin policy. Any page the developer
65
93
  * visits can open `ws://localhost:3000/_livereload`, and until this existed
66
- * one `{"type":"subscribe_logger"}` bought a live feed of the server log ( * failing SQL, table and column names, absolute source paths) plus the
94
+ * one `{"type":"subscribe_logger"}` bought a live feed of the server log
95
+ * (failing SQL, table and column names, absolute source paths) plus the
67
96
  * ability to reload every open browser. The only thing that separates the
68
97
  * app's own page from an attacker's is `Origin`, which the browser sets on
69
98
  * every handshake and page script cannot override.
@@ -102,7 +102,8 @@ export namespace ETag {
102
102
  /**
103
103
  * One stat, and the values are kept rather than discarded: a `BunFile`
104
104
  * caches its stat on first property access (~66us fresh, ~40ns after), so
105
- * routing the probe through `fs.exists` (which stats a throwaway instance) * made every served variant pay a second, identical stat in `fromFile`.
105
+ * routing the probe through `fs.exists` (which stats a throwaway instance)
106
+ * made every served variant pay a second, identical stat in `fromFile`.
106
107
  * Same existence predicate as `fs.exists`; note a missing file reports
107
108
  * size 0 and a far-future sentinel `lastModified`, never 0.
108
109
  */
@@ -5,7 +5,9 @@ import { DOMTools, headBodyCache } from './dom'
5
5
  import { ETag } from './etag'
6
6
 
7
7
  /**
8
- * Mark `res` as already carrying the head/body injects, so `injectIfHtml` ( * and therefore `processResponse`, which every response funnels through) * hands it back untouched.
8
+ * Mark `res` as already carrying the head/body injects, so `injectIfHtml`
9
+ * (and therefore `processResponse`, which every response funnels through)
10
+ * hands it back untouched.
9
11
  *
10
12
  * Exported for the one caller that means "never", not "already":
11
13
  * `DefaultErrorHandler` brands its production error page so the import map is
@@ -13,6 +13,8 @@
13
13
  * write after the client has gone throws where nobody is catching.
14
14
  */
15
15
 
16
+ import { Bakery } from '../../core/bakery'
17
+
16
18
  /** One event. Every field is optional except the payload. */
17
19
  export interface SSEMessage {
18
20
  /** Serialized with `JSON.stringify` unless it is already a string. */
@@ -105,6 +107,11 @@ export function sse(
105
107
  const encoder = new TextEncoder()
106
108
  const { keepAlive = 15_000, retry } = options
107
109
 
110
+ // A quiet stream is not a finished one, and `Bun.serve` closes a connection
111
+ // that stays quiet past its idle timeout. Lifted for this request only: the
112
+ // stream ends when the client leaves or `close()` runs.
113
+ Bakery.server?.timeout(req, 0)
114
+
108
115
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null
109
116
  let closed = false
110
117
  let cleanup: (() => void) | void
@@ -123,16 +130,22 @@ export function sse(
123
130
  }
124
131
  }
125
132
 
126
- function finish() {
127
- if (closed) return
128
- closed = true
129
- if (keepAliveTimer) clearInterval(keepAliveTimer)
133
+ function runCleanup() {
134
+ const pending = cleanup
135
+ cleanup = undefined
130
136
  try {
131
- cleanup?.()
137
+ pending?.()
132
138
  } catch {
133
139
  // A producer's cleanup that throws must not prevent the stream closing;
134
140
  // the connection is already going away either way.
135
141
  }
142
+ }
143
+
144
+ function finish() {
145
+ if (closed) return
146
+ closed = true
147
+ if (keepAliveTimer) clearInterval(keepAliveTimer)
148
+ runCleanup()
136
149
  try {
137
150
  controller?.close()
138
151
  } catch {
@@ -176,6 +189,9 @@ export function sse(
176
189
 
177
190
  try {
178
191
  cleanup = await producer(stream)
192
+ // The client can leave during that await. `finish` has then run, found
193
+ // no cleanup and will not run again, so this one would never be called.
194
+ if (closed) runCleanup()
179
195
  } catch {
180
196
  // A producer that throws ends the stream rather than leaving the client
181
197
  // hanging on a connection nobody will write to again.
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Request-body validation for route modules.
3
3
  *
4
- * `defineRoute<{ title: string }>` declares a body shape and enforces nothing ( * the scaffolder's own template says so twice: *"declares the contract) it
5
- * does not validate it. The body is still client input."* Every route was left
6
- * to hand-check or not, and most did not.
4
+ * `defineRoute<{ title: string }>` declares a body shape and enforces nothing,
5
+ * as the scaffolder's own template puts it: *"It states the contract; it does
6
+ * not enforce it."* Every route was left to hand-check or not, and most did
7
+ * not.
7
8
  *
8
9
  * **No schema library is bundled, and none is depended on.** `@bakery-framework/
9
10
  * core` has zero runtime dependencies and that is worth keeping, so validation
@@ -2,7 +2,8 @@
2
2
  * Circular-safe JSON helpers.
3
3
  *
4
4
  * Three copies of this logic existed: the client `$fmt` formatter, the
5
- * livereload log serializer, and ad-hoc replacers on the server. They drifted ( * the `$fmt` one shipped with a one-parameter replacer that made every object
5
+ * livereload log serializer, and ad-hoc replacers on the server. They drifted
6
+ * (the `$fmt` one shipped with a one-parameter replacer that made every object
6
7
  * render as `""`), so the kernel lives here once and callers layer their own
7
8
  * extras on top.
8
9
  */