@bakery-framework/core 2.4.1 → 2.4.3

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.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -333,9 +333,13 @@ if (typeof document !== 'undefined') {
333
333
  // A `source: 'document'` rule with href_matches '/*' and eager eagerness
334
334
  // ignored the exclusions above and fully loaded (and ran the JS of) every
335
335
  // same-origin link, including things like /logout or a destructive GET.
336
+ //
337
+ // Prefetch waits for intent too. With no eagerness a list rule fetches at
338
+ // once, so a sidebar of 32 links asked for all 32 pages at every load (1.5 s
339
+ // and 6 MB on one app); moderate asked for none at load and one on a hover.
336
340
  const urlList = Array.from(urls)
337
341
  specScript.textContent = JSON.stringify({
338
- prefetch: [{ source: 'list', urls: urlList }],
342
+ prefetch: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
339
343
  prerender: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
340
344
  })
341
345
 
@@ -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'
@@ -128,10 +123,14 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
128
123
  * Compile `source`, naming `path` so a failure can say where it happened.
129
124
  *
130
125
  * 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.
126
+ * file to resolve imports against and no caller that can render a 500 (the
127
+ * Vue plugin compiles fragments of an SFC it has already parsed), so a failure
128
+ * logs and hands the source back. `origin.file` names the file such a
129
+ * fragment came from, for that log line only: without it the line read
130
+ * "Failed to compile inline source: Transform failed", and a component that
131
+ * would not parse in the browser had to be found by hand. With a path the
132
+ * failure is a served request, and the answer is `null`: `TSHandler` turns
133
+ * that into the 500 it has always had a branch for.
135
134
  *
136
135
  * It used to be neither. The transpiler was wrapped only on the pathless
137
136
  * branch, so a syntax error in a `.ts` asset threw straight past `compile()`,
@@ -140,22 +139,31 @@ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
140
139
  * `Unhandled Server Error: <message>` with no file and no line while the client
141
140
  * got `An unexpected error occurred.` A total blackout for a one-character typo.
142
141
  */
143
- export function compileText(source: string): Promise<string>
142
+ export function compileText(
143
+ source: string,
144
+ origin?: { file: string },
145
+ ): Promise<string>
144
146
  export function compileText(
145
147
  source: string,
146
148
  path: fs.AbsolutePath,
147
149
  ): 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
- }
150
+ export async function compileText(
151
+ source: string,
152
+ where?: fs.AbsolutePath | { file: string },
153
+ ) {
154
+ if (typeof where !== 'string') {
155
+ const [failed, content] = await Try.catch(
156
+ (await getTranspiler()).transform(source),
157
+ )
158
+ if (!failed) return content!
159
+
160
+ const error = errorWithPosition(failed)
161
+ if (where) compLog.COMPILE_FAIL({ file: where.file, error })
162
+ else compLog.COMPILE_SOURCE_FAIL({ error })
163
+ return source
157
164
  }
158
165
 
166
+ const path = where
159
167
  source = preprocessImports(source, path)
160
168
 
161
169
  const [failed, transformed] = await Try.catch(
@@ -163,8 +171,9 @@ export async function compileText(source: string, path?: fs.AbsolutePath) {
163
171
  )
164
172
 
165
173
  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
174
+ // `errorWithPosition`, not `errorMsg`: what `transform()` rejects with
175
+ // has no stack, so `errorMsg` degrades to a bare message and drops the
176
+ // line number, the only thing that makes this actionable. The file comes
168
177
  // from `{file}`; the diagnostic's own `position.file` is `input.ts`,
169
178
  // because `transform()` was handed a string.
170
179
  compLog.COMPILE_FAIL({ file: path, error: errorWithPosition(failed) })
@@ -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
@@ -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/utils/fs.ts CHANGED
@@ -781,7 +781,10 @@ export namespace FileSystem {
781
781
  if (content == null) return null
782
782
  await mkdir(cacheDir)
783
783
  await rawFile.write(content)
784
- return built(rawFile)
784
+ // A new handle: `rawFile` read its size before the file existed, so serving
785
+ // it answered 200 with `content-length: 0` and no body. Measured on a cold
786
+ // font, which Chrome then reports as failed; the next request read the file.
787
+ return built(Bun.file(rawPath))
785
788
  }
786
789
 
787
790
  /**
@@ -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
 
@@ -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.