@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 +1 -1
- package/src/client/utils.ts +5 -1
- package/src/compiler/compiler.ts +31 -22
- package/src/handlers/assets/google-font.ts +46 -6
- package/src/logger/serve-log.ts +10 -2
- package/src/utils/fs.ts +4 -1
- package/src/utils/http/csrf.ts +29 -1
- package/src/utils/http/sse.ts +21 -5
package/package.json
CHANGED
package/src/client/utils.ts
CHANGED
|
@@ -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
|
|
package/src/compiler/compiler.ts
CHANGED
|
@@ -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
|
|
132
|
-
* fragments of an SFC it has already parsed), so a failure
|
|
133
|
-
* source back.
|
|
134
|
-
*
|
|
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(
|
|
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(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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`:
|
|
167
|
-
// stack, so `errorMsg` degrades to
|
|
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
|
-
|
|
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
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
/**
|
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
|
|
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.
|