@bakery-framework/core 2.5.0-alpha.1 → 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.1",
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",
@@ -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
 
@@ -32,7 +32,8 @@ async function buildDefines(): Promise<MapOf<string>> {
32
32
  const file = await Bun.file(`${process.cwd()}/package.json`).json()
33
33
  if (file?.version) bakeryVersion = file.version
34
34
  } catch {
35
- // 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.
36
37
  }
37
38
 
38
39
  return {
@@ -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
@@ -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
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
  */