@bakery-framework/core 2.0.0-alpha.8 → 2.0.0-rc.1
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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
package/src/utils/http/etag.ts
CHANGED
|
@@ -17,7 +17,7 @@ export namespace ETag {
|
|
|
17
17
|
return `W/"${hash.toString(36)}"`
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The one writer of the file-etag format
|
|
20
|
+
/** The one writer of the file-etag format: `fromFile` and the negotiation
|
|
21
21
|
* memo below must agree byte-for-byte, or a warm hit would break every
|
|
22
22
|
* `If-None-Match` a cold response handed out. */
|
|
23
23
|
function fileTag(size: number, mtime: number): string {
|
|
@@ -78,8 +78,8 @@ export namespace ETag {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Per-base-path record of which compressed siblings exist, anchored on the
|
|
81
|
-
* base file's mtime. `getOrCreateCachedFile`
|
|
82
|
-
* files
|
|
81
|
+
* base file's mtime. `getOrCreateCachedFile` (the only writer of these
|
|
82
|
+
* files) writes the raw file and every variant together in one
|
|
83
83
|
* `Promise.all`, so the base mtime changes whenever the variant set does;
|
|
84
84
|
* that makes the mtime the invalidation signal, and no write-side hook is
|
|
85
85
|
* needed (fs.ts stays unaware of this module, keeping the dependency arrow
|
|
@@ -92,7 +92,7 @@ export namespace ETag {
|
|
|
92
92
|
*
|
|
93
93
|
* Only *complete* variant sets are recorded. A partial set means either a
|
|
94
94
|
* build caught mid-write (the trio lands over a few ms) or user-authored
|
|
95
|
-
* precompressed siblings next to a served `.gz
|
|
95
|
+
* precompressed siblings next to a served `.gz`: in both cases the set can
|
|
96
96
|
* change without the base mtime moving, so those fall back to per-request
|
|
97
97
|
* probing, which is exactly the pre-memo behavior.
|
|
98
98
|
*/
|
|
@@ -102,8 +102,7 @@ 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`
|
|
106
|
-
* made every served variant pay a second, identical stat in `fromFile`.
|
|
105
|
+
* routing the probe through `fs.exists` (which stats a throwaway instance) * made every served variant pay a second, identical stat in `fromFile`.
|
|
107
106
|
* Same existence predicate as `fs.exists`; note a missing file reports
|
|
108
107
|
* size 0 and a far-future sentinel `lastModified`, never 0.
|
|
109
108
|
*/
|
|
@@ -119,7 +118,7 @@ export namespace ETag {
|
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
120
|
* Test seam (convention 9): lets etag.test.ts count sibling probes without
|
|
122
|
-
* module-mocking `bun` or `node:fs
|
|
121
|
+
* module-mocking `bun` or `node:fs`, which is process-global and never
|
|
123
122
|
* unwinds. Same shape as `fs.__setForbiddenProbe`.
|
|
124
123
|
*/
|
|
125
124
|
export function __setVariantProbe(fn: (path: string) => VariantStat | null) {
|
|
@@ -136,7 +135,7 @@ export namespace ETag {
|
|
|
136
135
|
negotiationMemo.clear()
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation
|
|
138
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: content negotiation, one branch per encoding outcome
|
|
140
139
|
function negotiateFile(file: Bun.BunFile, req?: Request) {
|
|
141
140
|
const fileName = file.name || 'file'
|
|
142
141
|
|
|
@@ -174,7 +173,7 @@ export namespace ETag {
|
|
|
174
173
|
)
|
|
175
174
|
|
|
176
175
|
// The memo is consulted only for a base that exists right now, and only
|
|
177
|
-
// when its recorded mtime matches the anchor exactly
|
|
176
|
+
// when its recorded mtime matches the anchor exactly: a wiped cache
|
|
178
177
|
// dir (missing base reports the far-future sentinel mtime) or any
|
|
179
178
|
// rewrite falls through to a fresh probe.
|
|
180
179
|
let entry = baseExists ? negotiationMemo.get(basePath) : undefined
|
|
@@ -231,13 +230,55 @@ export namespace ETag {
|
|
|
231
230
|
if (conditionalRes) return conditionalRes
|
|
232
231
|
}
|
|
233
232
|
|
|
233
|
+
// Range handling itself lives in Bun.serve, not here: any Response whose
|
|
234
|
+
// body is a *path-backed* BunFile is sliced by the runtime (206 with
|
|
235
|
+
// Content-Range on a satisfiable single range, 416 past EOF), including
|
|
236
|
+
// the negotiated compressed variants above (the range then addresses the
|
|
237
|
+
// encoded bytes, which is what RFC 9110 says a range means). What the
|
|
238
|
+
// runtime does not do is advertise: a plain 200 or a HEAD said nothing,
|
|
239
|
+
// so players that probe HEAD for `Accept-Ranges` before attempting seeks
|
|
240
|
+
// never tried. Advertised here because this is the one funnel every
|
|
241
|
+
// file-serving handler's BunFile passes through, and only here: an
|
|
242
|
+
// in-memory Blob or a `sendText` string body ignores Range entirely
|
|
243
|
+
// (served whole, 200), which is why `.name` gates the claim.
|
|
244
|
+
//
|
|
245
|
+
// Skipped when Bun's own range path is about to answer (a GET carrying a
|
|
246
|
+
// single-range `Range`): that path appends its own `Accept-Ranges: bytes`
|
|
247
|
+
// to the 206/416, and setting it here too emitted `bytes, bytes`.
|
|
248
|
+
//
|
|
249
|
+
// A **multipart** range (`bytes=0-1,10-11`) is carved back out of the
|
|
250
|
+
// skip: Bun does not do multipart (it serves the whole file as a 200 and
|
|
251
|
+
// appends nothing), so the skip left that response advertising nothing at
|
|
252
|
+
// all, which reads as "ranges not supported" to the one client that just
|
|
253
|
+
// demonstrated it wants them. Found by a downstream smoke test. The comma
|
|
254
|
+
// is the entire multipart grammar, so this cannot drift from Bun's own
|
|
255
|
+
// parse the way a real Range parser here could; RFC-legal multipart gets
|
|
256
|
+
// the 200-with-advertisement it deserves. What stays excluded-and-silent
|
|
257
|
+
// is a *malformed* single range (`Range: potato`), also served whole by
|
|
258
|
+
// Bun, but that requester sent garbage, and mirroring Bun's full validity
|
|
259
|
+
// judgment here is exactly the second-parser drift this comment refuses.
|
|
260
|
+
// HEAD ignores `Range` and gets the header even when one is present.
|
|
261
|
+
const rangeValue = req?.headers.get('range')
|
|
262
|
+
if (
|
|
263
|
+
resolvedFile.name &&
|
|
264
|
+
!(
|
|
265
|
+
req &&
|
|
266
|
+
req.method === 'GET' &&
|
|
267
|
+
rangeValue !== null &&
|
|
268
|
+
rangeValue !== undefined &&
|
|
269
|
+
!rangeValue.includes(',')
|
|
270
|
+
)
|
|
271
|
+
) {
|
|
272
|
+
headers['Accept-Ranges'] = 'bytes'
|
|
273
|
+
}
|
|
274
|
+
|
|
234
275
|
return new Response(resolvedFile, { headers })
|
|
235
276
|
}
|
|
236
277
|
|
|
237
278
|
/**
|
|
238
|
-
* Returns synchronously on every path that does not compress
|
|
279
|
+
* Returns synchronously on every path that does not compress: no request,
|
|
239
280
|
* small body, unsupported Accept-Encoding, and crucially the 304
|
|
240
|
-
* short-circuit
|
|
281
|
+
* short-circuit, and returns a Promise only when compression actually
|
|
241
282
|
* runs on a body large enough to offload (`ASYNC_COMPRESSION_MIN`). The
|
|
242
283
|
* one call site that can receive either (`processResponse`) awaits.
|
|
243
284
|
*/
|
|
@@ -265,7 +306,7 @@ export namespace ETag {
|
|
|
265
306
|
'Content-Type': type,
|
|
266
307
|
}
|
|
267
308
|
|
|
268
|
-
// Negotiation only
|
|
309
|
+
// Negotiation only: no compression yet. The ext suffix the etag embeds
|
|
269
310
|
// is decided by the Accept-Encoding header alone, so the 304 check can
|
|
270
311
|
// run first: a client that already holds the body must not cost a
|
|
271
312
|
// zstd pass whose output is then thrown away.
|
|
@@ -302,7 +343,7 @@ export namespace ETag {
|
|
|
302
343
|
|
|
303
344
|
// Off the request thread for large bodies: the sync pass measured
|
|
304
345
|
// ~700us of event-loop stall per 135KB response. Below the cutoff the
|
|
305
|
-
// pool round-trip costs more than the stall it removes
|
|
346
|
+
// pool round-trip costs more than the stall it removes: numbers on
|
|
306
347
|
// `ASYNC_COMPRESSION_MIN` in fs.ts.
|
|
307
348
|
if (encoderAsync && text.length >= ASYNC_COMPRESSION_MIN) {
|
|
308
349
|
return encoderAsync(text).then(
|
package/src/utils/http/html.ts
CHANGED
|
@@ -4,7 +4,16 @@ import { Try } from '../common/try'
|
|
|
4
4
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Exported for the one caller that means "never", not "already":
|
|
11
|
+
* `DefaultErrorHandler` brands its production error page so the import map is
|
|
12
|
+
* not spliced into it. That map names every installed package (an inventory
|
|
13
|
+
* of the app's module layout), and the page carries no scripts for it (or the
|
|
14
|
+
* client bundle) to serve anyway.
|
|
15
|
+
*/
|
|
16
|
+
export function injectBrand(res: Response) {
|
|
8
17
|
return Object.defineProperty(res, '__injected__', {
|
|
9
18
|
value: true,
|
|
10
19
|
enumerable: false,
|
|
@@ -19,7 +28,7 @@ function isInjected(res: Response) {
|
|
|
19
28
|
* Rebuild `res` with `status`, carrying the injection brand across.
|
|
20
29
|
*
|
|
21
30
|
* `Response.status` is read-only, so changing it means constructing a new
|
|
22
|
-
* Response
|
|
31
|
+
* Response, and a plain rebuild silently drops the `__injected__` marker,
|
|
23
32
|
* which is the only thing stopping `processResponse` running the page through
|
|
24
33
|
* `injectIfHtml` a second time and emitting two import maps and two copies of
|
|
25
34
|
* the client bundle. That is why this lives next to the brand rather than at
|
|
@@ -40,7 +49,7 @@ export function withStatus(res: Response, status: number): Response {
|
|
|
40
49
|
/**
|
|
41
50
|
* Server-controlled markup injected into the document. Deliberately a separate
|
|
42
51
|
* argument from `params`: `params` is request-derived (for GET it is the query
|
|
43
|
-
* string), so `$$head`-style keys arriving from a client must never be
|
|
52
|
+
* string), so `$$head`-style keys arriving from a client must never be honored.
|
|
44
53
|
*/
|
|
45
54
|
export interface HtmlInjects {
|
|
46
55
|
head?: string
|
|
@@ -73,7 +82,7 @@ export interface HtmlInjects {
|
|
|
73
82
|
* have sent
|
|
74
83
|
*
|
|
75
84
|
* Buffered responses carry a strong content ETag for the 304 machinery.
|
|
76
|
-
* Streamed responses carry none
|
|
85
|
+
* Streamed responses carry none. See `streamedResponse` for why that is not
|
|
77
86
|
* faked.
|
|
78
87
|
*/
|
|
79
88
|
export const STREAM_THRESHOLD_BYTES = 64 * 1024
|
|
@@ -85,7 +94,7 @@ export async function injectIfHtml(
|
|
|
85
94
|
): Promise<Response | null> {
|
|
86
95
|
if (data instanceof Response && isInjected(data)) return data
|
|
87
96
|
|
|
88
|
-
// {{param}} substitution (and only that
|
|
97
|
+
// {{param}} substitution (and only that: the __PAGE_PARAMS__ script is a
|
|
89
98
|
// head fragment either way) needs the whole document in one string, so a
|
|
90
99
|
// params-bearing call takes the buffered path regardless of size.
|
|
91
100
|
const hasParams = !!params && Object.keys(params).length > 0
|
|
@@ -123,7 +132,7 @@ export async function injectIfHtml(
|
|
|
123
132
|
return bufferedResponse(content, params, injects, responseInit)
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
/** The one writer of the buffered injection Response
|
|
135
|
+
/** The one writer of the buffered injection Response: main path and the
|
|
127
136
|
* streamed path's fallback both come through here. */
|
|
128
137
|
function bufferedResponse(
|
|
129
138
|
content: string,
|
|
@@ -178,7 +187,7 @@ function getConfigInjects() {
|
|
|
178
187
|
/**
|
|
179
188
|
* The final head/body fragments for one document: config injects + the
|
|
180
189
|
* __PAGE_PARAMS__ script + per-call injects, with `<!--prio-->` resolved.
|
|
181
|
-
* Computed once per response, up front
|
|
190
|
+
* Computed once per response, up front: the streamed path must not defer this
|
|
182
191
|
* into a pull callback, where the AsyncLocalStorage host context that
|
|
183
192
|
* `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
|
|
184
193
|
*/
|
|
@@ -227,7 +236,7 @@ function spliceBodyEnd(html: string, body: string): string {
|
|
|
227
236
|
* each window and the final carry. */
|
|
228
237
|
function rewriteFontsUrls(html: string): string {
|
|
229
238
|
// Both alternatives in RX_GFONTS require the literal `fonts.goog`, so this
|
|
230
|
-
// substring check cannot skip a document the regex would have matched
|
|
239
|
+
// substring check cannot skip a document the regex would have matched, and
|
|
231
240
|
// most documents have no Google Fonts link at all.
|
|
232
241
|
if (!html.includes('fonts.goog')) return html
|
|
233
242
|
return html.replace(RX_GFONTS, () => GFONTS_REWRITE)
|
|
@@ -261,8 +270,8 @@ export function assembleHtml(
|
|
|
261
270
|
// The exact same three rewrites as `assembleHtml`, in the same order, without
|
|
262
271
|
// holding the document in memory. The head splice happens on a bounded probe
|
|
263
272
|
// (the tag is near the start of any real document) using the same
|
|
264
|
-
// `spliceHead`; the body-end insert and the fonts rewrite
|
|
265
|
-
// that force a scan to the end of the document
|
|
273
|
+
// `spliceHead`; the body-end insert and the fonts rewrite (the two passes
|
|
274
|
+
// that force a scan to the end of the document) run as chunk-boundary-safe
|
|
266
275
|
// text stages over the same regexes. Content inserted by the body stage flows
|
|
267
276
|
// through the fonts stage but not back through the body stage, which is
|
|
268
277
|
// exactly the buffered path's head → body → fonts ordering.
|
|
@@ -272,7 +281,7 @@ export function assembleHtml(
|
|
|
272
281
|
* that can be left dangling at a chunk boundary. */
|
|
273
282
|
const BODY_END_HOLDBACK = '</body>'.length - 1
|
|
274
283
|
|
|
275
|
-
/** Longest text RX_GFONTS can match, minus one
|
|
284
|
+
/** Longest text RX_GFONTS can match, minus one: any incomplete match prefix
|
|
276
285
|
* at a window edge is at most this long, so holding back this many characters
|
|
277
286
|
* guarantees no match is ever split across two windows. */
|
|
278
287
|
const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
|
|
@@ -281,7 +290,7 @@ const FONTS_HOLDBACK = 'https://fonts.googleapis.com/css2'.length - 1
|
|
|
281
290
|
* Read the response body until either EOF or enough bytes to commit to
|
|
282
291
|
* streaming. Streaming needs two things the probe establishes: proof the body
|
|
283
292
|
* is actually large (unless Content-Length already said so), and a `<head…>`
|
|
284
|
-
* match
|
|
293
|
+
* match, the no-head fallback prepends to the very start of the document,
|
|
285
294
|
* which a stream that has begun emitting can no longer do. Anything that fails
|
|
286
295
|
* those checks is drained and handed to the buffered path, whose output is the
|
|
287
296
|
* long-standing behavior.
|
|
@@ -367,7 +376,7 @@ async function* tailText(
|
|
|
367
376
|
}
|
|
368
377
|
} finally {
|
|
369
378
|
// Reached on normal EOF (no-op) and when the consumer cancels the response
|
|
370
|
-
// mid-stream
|
|
379
|
+
// mid-stream: in which case the source must be canceled too.
|
|
371
380
|
await Try(() => reader.cancel())
|
|
372
381
|
}
|
|
373
382
|
}
|
|
@@ -382,9 +391,9 @@ async function* withPrefix(
|
|
|
382
391
|
|
|
383
392
|
/**
|
|
384
393
|
* Streaming equivalent of `spliceBodyEnd`: insert before the first literal
|
|
385
|
-
* `</body>` (in original casing
|
|
394
|
+
* `</body>` (in original casing, the insert slices around the match rather
|
|
386
395
|
* than reconstructing it), else append at the very end. Once inserted the
|
|
387
|
-
* stage is a passthrough, so content it inserted is never rescanned
|
|
396
|
+
* stage is a passthrough, so content it inserted is never rescanned: matching
|
|
388
397
|
* the buffered path's single non-global replace.
|
|
389
398
|
*/
|
|
390
399
|
async function* bodyEndStage(
|
|
@@ -424,7 +433,7 @@ async function* bodyEndStage(
|
|
|
424
433
|
/**
|
|
425
434
|
* Streaming equivalent of `rewriteFontsUrls`. Complete matches that start in
|
|
426
435
|
* the emittable region are rewritten (a complete match cannot be invalidated
|
|
427
|
-
* or extended by later input
|
|
436
|
+
* or extended by later input: the pattern has no unbounded or trailing
|
|
428
437
|
* parts); matches or match prefixes inside the holdback stay in the carry for
|
|
429
438
|
* the next window, and the final carry goes through the shared
|
|
430
439
|
* `rewriteFontsUrls` itself.
|
|
@@ -452,7 +461,7 @@ async function* fontsStage(
|
|
|
452
461
|
|
|
453
462
|
// The same fast gate the buffered path uses: no `fonts.goog` in the
|
|
454
463
|
// window means no match in it, and the emit/carry split below does not
|
|
455
|
-
// depend on match presence
|
|
464
|
+
// depend on match presence, a match *prefix* dangling at the window edge
|
|
456
465
|
// sits inside the holdback either way.
|
|
457
466
|
if (window.includes('fonts.goog')) {
|
|
458
467
|
rx.lastIndex = 0
|
|
@@ -477,8 +486,8 @@ async function* fontsStage(
|
|
|
477
486
|
/**
|
|
478
487
|
* Wrap the staged text into a streaming Response.
|
|
479
488
|
*
|
|
480
|
-
* Deliberately no ETag: a content hash would require buffering the body
|
|
481
|
-
* thing this path exists to avoid
|
|
489
|
+
* Deliberately no ETag: a content hash would require buffering the body (the
|
|
490
|
+
* thing this path exists to avoid), and deriving one from anything less (say,
|
|
482
491
|
* source file size+mtime) would keep serving 304s after the config-injected
|
|
483
492
|
* head/body fragments change. Large streamed pages are also where conditional
|
|
484
493
|
* revalidation matters least. Callers relying on the 304 machinery get it on
|
|
@@ -494,7 +503,7 @@ function streamedResponse(
|
|
|
494
503
|
// Buffered responses get `Cache-Control: no-cache` from ETag.sendResponse
|
|
495
504
|
// (guarded the same way: only when the handler set nothing itself). With no
|
|
496
505
|
// ETag that hook never fires here, and a validator-less page left without a
|
|
497
|
-
// cache policy is open to heuristic caching
|
|
506
|
+
// cache policy is open to heuristic caching: a stale page, not just a
|
|
498
507
|
// missed 304.
|
|
499
508
|
if (!headers.has('Cache-Control')) {
|
|
500
509
|
headers.set('Cache-Control', 'no-cache')
|
package/src/utils/http/ip.ts
CHANGED
|
@@ -15,7 +15,7 @@ const TRUSTED_HEADERS = [
|
|
|
15
15
|
export function getClientIp(req: Request): string {
|
|
16
16
|
// `Bakery.config`, not `getConfig()`: this runs inside the request's host
|
|
17
17
|
// store, and the process config is the wrong answer under `hosts`. It was
|
|
18
|
-
// the only reader of the two that disagreed
|
|
18
|
+
// the only reader of the two that disagreed: `getHostname` and the
|
|
19
19
|
// session's `Secure` flag both read the host's `trustProxy`, so one host
|
|
20
20
|
// could have its hostname and cookie resolved from its own config while
|
|
21
21
|
// its client IP was resolved from the base one.
|
package/src/utils/http/sse.ts
CHANGED
|
@@ -3,25 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Bakery had WebSockets and nothing for one-way streaming, which is the cheaper
|
|
5
5
|
* half of that pair and the right shape for progress, notifications and tailing
|
|
6
|
-
|
|
6
|
+
*: no upgrade, no protocol, reconnects handled by the browser.
|
|
7
7
|
*
|
|
8
8
|
* A route could always return a `Response` wrapping a `ReadableStream` and it
|
|
9
9
|
* would reach the client intact: `ETag.sendResponse` returns early without an
|
|
10
10
|
* `ETag` header, and `injectIfHtml` ignores anything that is not HTML. What was
|
|
11
|
-
* missing is the framing, and framing is where this goes wrong
|
|
11
|
+
* missing is the framing, and framing is where this goes wrong: a payload
|
|
12
12
|
* containing a newline silently truncates unless every line is prefixed, and a
|
|
13
13
|
* write after the client has gone throws where nobody is catching.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** One event. Every field is optional except the payload. */
|
|
17
17
|
export interface SSEMessage {
|
|
18
|
-
/**
|
|
18
|
+
/** Serialized with `JSON.stringify` unless it is already a string. */
|
|
19
19
|
data: unknown
|
|
20
|
-
/** `event
|
|
20
|
+
/** `event:`: the client listens for this name instead of `message`. */
|
|
21
21
|
event?: string
|
|
22
|
-
/** `id
|
|
22
|
+
/** `id:`: echoed back as `Last-Event-ID` when the browser reconnects. */
|
|
23
23
|
id?: string
|
|
24
|
-
/** `retry
|
|
24
|
+
/** `retry:`: how long the browser waits before reconnecting, in ms. */
|
|
25
25
|
retry?: number
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -42,7 +42,7 @@ export interface SSEOptions {
|
|
|
42
42
|
* Milliseconds between automatic keep-alive comments. `0` disables them.
|
|
43
43
|
*
|
|
44
44
|
* Defaults to 15s because idle proxies and load balancers commonly cut a
|
|
45
|
-
* connection at 30
|
|
45
|
+
* connection at 30 to 60s, and a dead SSE stream is invisible: the browser
|
|
46
46
|
* reconnects silently, so the symptom is duplicated work on the server rather
|
|
47
47
|
* than an error anyone sees.
|
|
48
48
|
*/
|
|
@@ -55,7 +55,7 @@ export interface SSEOptions {
|
|
|
55
55
|
* Encode one message as an SSE frame.
|
|
56
56
|
*
|
|
57
57
|
* **Every line of `data` is prefixed.** A payload containing a newline is
|
|
58
|
-
* otherwise cut short at that newline
|
|
58
|
+
* otherwise cut short at that newline: the rest is read as a new field, and
|
|
59
59
|
* the client sees a truncated message rather than an error. Pretty-printed JSON
|
|
60
60
|
* and stack traces both hit this.
|
|
61
61
|
*/
|
|
@@ -72,7 +72,7 @@ export function encodeSSE(message: SSEMessage): string {
|
|
|
72
72
|
: JSON.stringify(message.data)
|
|
73
73
|
|
|
74
74
|
// `?? ''` rather than skipping: `JSON.stringify(undefined)` is undefined, and
|
|
75
|
-
// a frame with no `data:` line at all is a comment to the client
|
|
75
|
+
// a frame with no `data:` line at all is a comment to the client. The event
|
|
76
76
|
// would vanish rather than arrive empty.
|
|
77
77
|
for (const line of (payload ?? '').split('\n')) lines.push(`data: ${line}`)
|
|
78
78
|
|
|
@@ -116,7 +116,7 @@ export function sse(
|
|
|
116
116
|
controller.enqueue(encoder.encode(chunk))
|
|
117
117
|
} catch {
|
|
118
118
|
// The client went away between the `closed` check and the enqueue. That
|
|
119
|
-
// is a normal race on every disconnect, not an error worth surfacing
|
|
119
|
+
// is a normal race on every disconnect, not an error worth surfacing,
|
|
120
120
|
// and throwing here would reject inside a timer callback, where nothing
|
|
121
121
|
// is catching.
|
|
122
122
|
finish()
|
|
@@ -192,7 +192,7 @@ export function sse(
|
|
|
192
192
|
'Cache-Control': 'no-cache, no-transform',
|
|
193
193
|
Connection: 'keep-alive',
|
|
194
194
|
// nginx buffers proxied responses by default, which holds every event
|
|
195
|
-
// until the buffer fills
|
|
195
|
+
// until the buffer fills: the stream appears to work in development and
|
|
196
196
|
// to hang in production. This is the documented opt-out.
|
|
197
197
|
'X-Accel-Buffering': 'no',
|
|
198
198
|
},
|
package/src/utils/http/url.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The request's parsed URL, parsed at most once.
|
|
3
3
|
*
|
|
4
4
|
* Why memoize at all: `new URL` measures ~1.7µs, and the router, the body
|
|
5
|
-
* parser and the proxy handler all want the same parse of the same request
|
|
5
|
+
* parser and the proxy handler all want the same parse of the same request,
|
|
6
6
|
* so the parse was hoisted into the server's `fetch` in `packages/cli/src/worker.ts`
|
|
7
7
|
* and shared.
|
|
8
8
|
*
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* `(req as any).__parsedUrl`: two writers, six readers across three packages,
|
|
11
11
|
* every reader spelled `(req as any).__parsedUrl || new URL(req.url)` so that
|
|
12
12
|
* it silently re-parsed whenever the writer had not run first. That is a
|
|
13
|
-
* writer/reader ordering hazard with no way to observe it going wrong
|
|
13
|
+
* writer/reader ordering hazard with no way to observe it going wrong: a
|
|
14
14
|
* missed write costs microseconds, not correctness, so nothing ever fails.
|
|
15
15
|
* One function collapses both roles: there is no ordering left to get wrong,
|
|
16
16
|
* no `any` cast anywhere, and no writer to forget. The map also keeps the
|
|
17
|
-
* framework from mutating a `Request` object it does not own
|
|
18
|
-
* `Request` goes in and comes back unchanged
|
|
17
|
+
* framework from mutating a `Request` object it does not own (a caller's
|
|
18
|
+
* `Request` goes in and comes back unchanged), and holds its keys weakly, so
|
|
19
19
|
* an entry dies with the request rather than needing eviction (convention 6).
|
|
20
20
|
*
|
|
21
21
|
* There is deliberately no setter. Nothing outside this module needs to say
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Request-body validation for route modules.
|
|
3
3
|
*
|
|
4
|
-
* `defineRoute<{ title: string }>` declares a body shape and enforces nothing
|
|
5
|
-
* the scaffolder's own template says so twice: *"declares the contract — it
|
|
4
|
+
* `defineRoute<{ title: string }>` declares a body shape and enforces nothing ( * the scaffolder's own template says so twice: *"declares the contract) it
|
|
6
5
|
* does not validate it. The body is still client input."* Every route was left
|
|
7
6
|
* to hand-check or not, and most did not.
|
|
8
7
|
*
|
|
@@ -10,7 +9,7 @@
|
|
|
10
9
|
* core` has zero runtime dependencies and that is worth keeping, so validation
|
|
11
10
|
* accepts two shapes it can consume without knowing who produced them:
|
|
12
11
|
*
|
|
13
|
-
* - **Standard Schema** (`~standard`)
|
|
12
|
+
* - **Standard Schema** (`~standard`): the shared interface zod, valibot and
|
|
14
13
|
* arktype all implement. Bring your own library; Bakery never imports it.
|
|
15
14
|
* - **A plain function** that returns the parsed value or throws.
|
|
16
15
|
*
|
|
@@ -59,7 +58,7 @@ function isStandardSchema<T>(v: Validator<T>): v is StandardSchemaLike<T> {
|
|
|
59
58
|
/**
|
|
60
59
|
* Render a Standard Schema path as dotted notation.
|
|
61
60
|
*
|
|
62
|
-
* Segments may be plain keys or `{ key }` objects
|
|
61
|
+
* Segments may be plain keys or `{ key }` objects: the spec allows both, and a
|
|
63
62
|
* library that uses the object form would otherwise render as `[object Object]`
|
|
64
63
|
* in the very message meant to tell someone which field is wrong.
|
|
65
64
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* These live in `isomorphic/` because both sides need them: the HTML pipeline
|
|
5
5
|
* and the Vue plugin escape on the way out, and the client bundle escapes when
|
|
6
6
|
* building DOM strings. Nothing here may reference `Bun.*`, node builtins, or
|
|
7
|
-
* DOM globals
|
|
7
|
+
* DOM globals: this module is compiled into the browser bundle as-is.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const HTML_ESCAPES: Record<string, string> = {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two implementations of this used to exist. This one keeps the *callable*
|
|
5
5
|
* shape from the client copy (`is(x, 'string')` as well as `is.string(x)`,
|
|
6
6
|
* which is what the global `ISFunction` type has always described) and the
|
|
7
|
-
* *semantics* of the server copy
|
|
7
|
+
* *semantics* of the server copy, notably `is.object([]) === true`, which
|
|
8
8
|
* `misc.test.ts` asserts and which callers like `router.ts` rely on to
|
|
9
9
|
* JSON-encode array response bodies.
|
|
10
10
|
*/
|
|
@@ -16,7 +16,7 @@ const checks = {
|
|
|
16
16
|
bigint: (v: any): v is bigint => typeof v === 'bigint',
|
|
17
17
|
symbol: (v: any): v is symbol => typeof v === 'symbol',
|
|
18
18
|
function: (v: any): v is Function => typeof v === 'function',
|
|
19
|
-
/** Arrays count as objects here
|
|
19
|
+
/** Arrays count as objects here. See the note above before changing this. */
|
|
20
20
|
object: (v: any): v is Record<string, any> =>
|
|
21
21
|
v !== null && typeof v === 'object',
|
|
22
22
|
array: Array.isArray,
|
|
@@ -20,7 +20,7 @@ export function any<T = any>(value: any): T {
|
|
|
20
20
|
*
|
|
21
21
|
* Isomorphic, and moving it here is what made it so: it lived in
|
|
22
22
|
* `client/utils.ts` as a browser global only, so the same call in a server
|
|
23
|
-
* block was a ReferenceError
|
|
23
|
+
* block was a ReferenceError, reported from an app. `crypto` is a global in
|
|
24
24
|
* both runtimes; nothing here is browser-specific.
|
|
25
25
|
*/
|
|
26
26
|
export function randomId(length = 8) {
|
|
@@ -2,9 +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
|
|
6
|
-
*
|
|
7
|
-
* render as `""` — so the kernel lives here once and callers layer their own
|
|
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
|
|
6
|
+
* render as `""`), so the kernel lives here once and callers layer their own
|
|
8
7
|
* extras on top.
|
|
9
8
|
*/
|
|
10
9
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MixedPromise, Wrapped } from '../../types'
|
|
2
2
|
import { is } from './is'
|
|
3
3
|
|
|
4
|
-
// These were declared again, here, byte-for-byte identical to `types.d.ts
|
|
4
|
+
// These were declared again, here, byte-for-byte identical to `types.d.ts`:
|
|
5
5
|
// the second copy convention 5 exists to prevent. `isomorphic.test.ts` catches
|
|
6
6
|
// a duplicated *value* by reference identity, and a type has no runtime
|
|
7
7
|
// identity to compare, so nothing saw it.
|
package/src/utils/shared-pool.ts
CHANGED
|
@@ -22,11 +22,29 @@ export class SharedMemoryPool {
|
|
|
22
22
|
header!: Int32Array
|
|
23
23
|
counters!: Int32Array
|
|
24
24
|
rateLimits!: Int32Array
|
|
25
|
-
dataPool!: Uint8Array
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* **The default is the layout's own size, not a megabyte.**
|
|
28
|
+
*
|
|
29
|
+
* It was `1024 * 1024`, and the layout uses 9,280 bytes of it: a 64-byte
|
|
30
|
+
* header, 1 KB of counters and 8 KB of rate-limit slots. The remaining
|
|
31
|
+
* 1,039,296 bytes were a `dataPool` region that **nothing read** - grep it
|
|
32
|
+
* across every package and app, and the only reference outside this file was
|
|
33
|
+
* a test asserting it was a `Uint8Array`. So 99.1% of a `SharedArrayBuffer`
|
|
34
|
+
* allocated at import in every server process existed for one assertion.
|
|
35
|
+
*
|
|
36
|
+
* The region is gone rather than shrunk. A scratch area with no consumer is
|
|
37
|
+
* not a feature waiting to be used; it is a number nobody can size, because
|
|
38
|
+
* there is nothing to size it against. Something that needs shared scratch
|
|
39
|
+
* later adds it along with the code that reads it.
|
|
40
|
+
*
|
|
41
|
+
* A larger size is still accepted and still honored, because `bind()` reads
|
|
42
|
+
* the size out of the header rather than trusting `byteLength` - so a
|
|
43
|
+
* cluster master that allocates more and shares it still works.
|
|
44
|
+
*/
|
|
45
|
+
constructor(sizeOrBuffer: number | SharedArrayBuffer = BUFFER_START_OFFSET) {
|
|
28
46
|
if (typeof sizeOrBuffer === 'number') {
|
|
29
|
-
const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET
|
|
47
|
+
const size = Math.max(sizeOrBuffer, BUFFER_START_OFFSET)
|
|
30
48
|
this.buffer = new SharedArrayBuffer(size)
|
|
31
49
|
this.header = new Int32Array(this.buffer, 0, HEADER_INT_COUNT)
|
|
32
50
|
this.counters = new Int32Array(
|
|
@@ -39,17 +57,11 @@ export class SharedMemoryPool {
|
|
|
39
57
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
40
58
|
RATE_LIMIT_SLOT_COUNT * 2,
|
|
41
59
|
)
|
|
42
|
-
this.dataPool = new Uint8Array(
|
|
43
|
-
this.buffer,
|
|
44
|
-
BUFFER_START_OFFSET,
|
|
45
|
-
size - BUFFER_START_OFFSET,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
60
|
Atomics.store(this.header, 0, 0x42414b45)
|
|
49
61
|
Atomics.store(this.header, 1, size)
|
|
50
62
|
Atomics.store(this.header, 2, BUFFER_START_OFFSET)
|
|
51
63
|
} else {
|
|
52
|
-
// Adopting a buffer someone else laid out is exactly what `bind` does
|
|
64
|
+
// Adopting a buffer someone else laid out is exactly what `bind` does:
|
|
53
65
|
// it read the header for the size rather than trusting `byteLength`, and
|
|
54
66
|
// so did the copy that used to sit here, character for character.
|
|
55
67
|
this.bind(sizeOrBuffer)
|
|
@@ -70,12 +82,7 @@ export class SharedMemoryPool {
|
|
|
70
82
|
HEADER_BYTES + COUNTERS_BYTES,
|
|
71
83
|
RATE_LIMIT_SLOT_COUNT * 2,
|
|
72
84
|
)
|
|
73
|
-
|
|
74
|
-
this.dataPool = new Uint8Array(
|
|
75
|
-
this.buffer,
|
|
76
|
-
BUFFER_START_OFFSET,
|
|
77
|
-
size - BUFFER_START_OFFSET,
|
|
78
|
-
)
|
|
85
|
+
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
incrementCounter(slot: number, delta = 1): number {
|
package/tsconfig.app.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": [
|
|
3
|
-
"**Client-side code
|
|
3
|
+
"**Client-side code**, the plain `.ts` under your source root that ends up",
|
|
4
4
|
"in the browser bundle. Extend it:",
|
|
5
5
|
" { \"extends\": \"@bakery-framework/core/tsconfig.app.json\" }",
|
|
6
6
|
"",
|
|
7
7
|
"BREAKING, 2.0.0: this file used to be the single config every app",
|
|
8
8
|
"extended, and it carried `bun-types` and the JSX settings. It is now the",
|
|
9
|
-
"*browser* half of a three-way split
|
|
9
|
+
"*browser* half of a three-way split: server code extends",
|
|
10
10
|
"tsconfig.server.json, Vue SFCs extend tsconfig.vue.json.",
|
|
11
11
|
"",
|
|
12
12
|
"**`types` is deliberately empty.** That is the entire reason the split",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"tsconfig.server.json; a `.tsx` file compiled under this config would pick up",
|
|
19
19
|
"the automatic runtime and fail at request time while typechecking clean."
|
|
20
20
|
],
|
|
21
|
-
"_files_note": "client/globals.d.ts declares the globals the browser runtime binds onto globalThis (request, escapeHTML, is, Try, Case, match, …). It states each as `typeof import(...)` of the isomorphic module that implements it, so the declaration cannot drift from the value. Those modules are pure by convention
|
|
21
|
+
"_files_note": "client/globals.d.ts declares the globals the browser runtime binds onto globalThis (request, escapeHTML, is, Try, Case, match, …). It states each as `typeof import(...)` of the isomorphic module that implements it, so the declaration cannot drift from the value. Those modules are pure by convention (no Bun, no node builtins, no DOM), which is what makes them safe to pull into a project that has no bun-types.",
|
|
22
22
|
"files": [
|
|
23
23
|
"./src/client/globals.d.ts",
|
|
24
24
|
"./src/shared.d.ts",
|
package/tsconfig.vue.json
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
" { \"extends\": \"@bakery-framework/core/tsconfig.vue.json\" }",
|
|
5
5
|
"",
|
|
6
6
|
"SFCs get `bun-types` like server code does, because @bakery-framework/",
|
|
7
|
-
"plugin-vue compiles and can render them on the server
|
|
7
|
+
"plugin-vue compiles and can render them on the server, a `<script>` block",
|
|
8
8
|
"is not browser-only the way a plain `.ts` under the source root is.",
|
|
9
9
|
"",
|
|
10
10
|
"**`tsc` cannot parse `.vue`.** This config is for `vue-tsc`, which wraps tsc",
|
|
11
11
|
"and understands SFCs. Pointing plain `tsc` at a project whose `include`",
|
|
12
12
|
"matches `**/*.vue` gets you either silence or a parse error, depending on",
|
|
13
|
-
"version
|
|
13
|
+
"version, neither of which is a typecheck.",
|
|
14
14
|
"",
|
|
15
15
|
"`jsx: preserve` rather than Bakery's classic runtime: inside an SFC, JSX (if",
|
|
16
16
|
"used at all) belongs to Vue's own transform, not to `createElement`."
|
|
17
17
|
],
|
|
18
|
-
"_files_note": "Server ambients, same as tsconfig.server.json
|
|
18
|
+
"_files_note": "Server ambients, same as tsconfig.server.json. An SFC can reach Bakery, AppConfig and the JSX namespace.",
|
|
19
19
|
"files": ["./src/global.d.ts", "./src/shared.d.ts", "./src/types.d.ts"],
|
|
20
20
|
"compilerOptions": {
|
|
21
21
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|