@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 +1 -1
- package/src/client/utils.ts +5 -1
- package/src/compiler/compiler.ts +2 -1
- package/src/compiler/dev-service.ts +2 -1
- package/src/core/define-route.ts +2 -1
- package/src/handlers/assets/google-font.ts +46 -6
- package/src/handlers/core/$base.ts +2 -1
- package/src/handlers/core/$routing.ts +2 -1
- package/src/handlers/core/$static.ts +2 -1
- package/src/utils/fs.ts +4 -1
- package/src/utils/http/csrf.ts +31 -2
- package/src/utils/http/etag.ts +2 -1
- package/src/utils/http/html.ts +3 -1
- package/src/utils/http/sse.ts +21 -5
- package/src/utils/http/validate.ts +4 -3
- package/src/utils/isomorphic/stringify.ts +2 -1
package/package.json
CHANGED
package/src/client/utils.ts
CHANGED
|
@@ -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
|
|
package/src/compiler/compiler.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/core/define-route.ts
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/utils/http/csrf.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/src/utils/http/etag.ts
CHANGED
|
@@ -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)
|
|
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
|
*/
|
package/src/utils/http/html.ts
CHANGED
|
@@ -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`
|
|
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
|
package/src/utils/http/sse.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (keepAliveTimer) clearInterval(keepAliveTimer)
|
|
133
|
+
function runCleanup() {
|
|
134
|
+
const pending = cleanup
|
|
135
|
+
cleanup = undefined
|
|
130
136
|
try {
|
|
131
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* to hand-check or not, and most did
|
|
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
|
|
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
|
*/
|