@bakery-framework/core 2.0.0-alpha.9 → 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.
Files changed (78) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -5
  3. package/src/cache/lru.ts +2 -2
  4. package/src/cache/shared-db.ts +81 -11
  5. package/src/cache/string.ts +4 -0
  6. package/src/cache/tiered.ts +11 -5
  7. package/src/client/globals.d.ts +7 -7
  8. package/src/client/livereload.ts +2 -2
  9. package/src/client/utils.ts +11 -6
  10. package/src/compiler/compiler.ts +48 -44
  11. package/src/compiler/dev-service.ts +29 -30
  12. package/src/compiler/prompt-tracker.ts +2 -2
  13. package/src/compiler/tsconfig-sync.ts +130 -59
  14. package/src/core/bakery.ts +6 -3
  15. package/src/core/cache-version.ts +68 -18
  16. package/src/core/config.ts +16 -16
  17. package/src/core/context.ts +8 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +19 -7
  20. package/src/core/init.ts +38 -35
  21. package/src/core/jsx.ts +19 -4
  22. package/src/core/paths.ts +3 -1
  23. package/src/core/plugins.ts +15 -4
  24. package/src/core/port.ts +6 -6
  25. package/src/global.d.ts +36 -18
  26. package/src/handlers/assets/google-font.ts +6 -6
  27. package/src/handlers/assets/image.ts +36 -3
  28. package/src/handlers/assets/nm.ts +7 -7
  29. package/src/handlers/assets/static.ts +55 -12
  30. package/src/handlers/assets/ts.ts +3 -3
  31. package/src/handlers/assets/tsx.ts +1 -1
  32. package/src/handlers/assets/virtual-asset.ts +1 -1
  33. package/src/handlers/core/$base.ts +24 -8
  34. package/src/handlers/core/$dynamic.ts +66 -23
  35. package/src/handlers/core/$error.ts +29 -17
  36. package/src/handlers/core/$middleware.ts +6 -6
  37. package/src/handlers/core/$mounts.ts +2 -2
  38. package/src/handlers/core/$registry.ts +16 -6
  39. package/src/handlers/core/$routing.ts +18 -19
  40. package/src/handlers/core/$static.ts +11 -12
  41. package/src/handlers/routes/api.ts +5 -5
  42. package/src/handlers/routes/html.ts +2 -2
  43. package/src/handlers/routes/proxy.ts +1 -1
  44. package/src/logger/clients.ts +2 -2
  45. package/src/logger/logger.ts +4 -4
  46. package/src/logger/serve-log.ts +32 -18
  47. package/src/plugins/index.ts +1 -1
  48. package/src/plugins/routes.ts +7 -7
  49. package/src/plugins/types.ts +13 -11
  50. package/src/router.ts +12 -12
  51. package/src/session.ts +41 -23
  52. package/src/shared.d.ts +5 -5
  53. package/src/startup.ts +3 -3
  54. package/src/types.d.ts +10 -10
  55. package/src/utils/common/case.ts +1 -1
  56. package/src/utils/common/match.ts +1 -1
  57. package/src/utils/common/misc.ts +1 -1
  58. package/src/utils/constants.ts +15 -15
  59. package/src/utils/fs.ts +97 -39
  60. package/src/utils/http/authorize.ts +24 -17
  61. package/src/utils/http/cors.ts +6 -6
  62. package/src/utils/http/credential.ts +4 -4
  63. package/src/utils/http/csrf.ts +6 -7
  64. package/src/utils/http/dom.ts +9 -9
  65. package/src/utils/http/etag.ts +54 -13
  66. package/src/utils/http/html.ts +29 -20
  67. package/src/utils/http/ip.ts +1 -1
  68. package/src/utils/http/sse.ts +11 -11
  69. package/src/utils/http/url.ts +4 -4
  70. package/src/utils/http/validate.ts +3 -4
  71. package/src/utils/isomorphic/escape.ts +1 -1
  72. package/src/utils/isomorphic/is.ts +2 -2
  73. package/src/utils/isomorphic/misc.ts +1 -1
  74. package/src/utils/isomorphic/stringify.ts +2 -3
  75. package/src/utils/isomorphic/try.ts +1 -1
  76. package/src/utils/shared-pool.ts +23 -16
  77. package/tsconfig.app.json +3 -3
  78. package/tsconfig.vue.json +3 -3
@@ -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 — `fromFile` and the negotiation
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` — the only writer of these
82
- * files — writes the raw file and every variant together in one
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` — in both cases the set can
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` — which stats a throwaway instance —
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` — which is process-global and never
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 — one branch per encoding outcome
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 — a wiped cache
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 — no request,
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 — and returns a Promise only when compression actually
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 — no compression yet. The ext suffix the etag embeds
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 — numbers on
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(
@@ -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
- function injectBrand(res: Response) {
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 — and a plain rebuild silently drops the `__injected__` marker,
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 honoured.
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 — see `streamedResponse` for why that is not
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 — the __PAGE_PARAMS__ script is a
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 — main path and the
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 — the streamed path must not defer this
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 — and
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 — the two passes
265
- // that force a scan to the end of the document — run as chunk-boundary-safe
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 — any incomplete match prefix
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 — the no-head fallback prepends to the very start of the document,
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 — in which case the source must be cancelled too.
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 — the insert slices around the match rather
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 — matching
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 — the pattern has no unbounded or trailing
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 — a match *prefix* dangling at the window edge
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 — the
481
- * thing this path exists to avoid — and deriving one from anything less (say,
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 — a stale page, not just a
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')
@@ -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 — `getHostname` and the
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.
@@ -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
- * — no upgrade, no protocol, reconnects handled by the browser.
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 — a payload
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
- /** Serialised with `JSON.stringify` unless it is already a string. */
18
+ /** Serialized with `JSON.stringify` unless it is already a string. */
19
19
  data: unknown
20
- /** `event:` — the client listens for this name instead of `message`. */
20
+ /** `event:`: the client listens for this name instead of `message`. */
21
21
  event?: string
22
- /** `id:` — echoed back as `Last-Event-ID` when the browser reconnects. */
22
+ /** `id:`: echoed back as `Last-Event-ID` when the browser reconnects. */
23
23
  id?: string
24
- /** `retry:` — how long the browser waits before reconnecting, in ms. */
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–60s, and a dead SSE stream is invisible: the browser
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 — the rest is read as a new field, and
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 — the event
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 — the stream appears to work in development and
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
  },
@@ -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 — a
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 — a caller's
18
- * `Request` goes in and comes back unchanged — and holds its keys weakly, so
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`) — the shared interface zod, valibot and
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 — the spec allows both, and a
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 — this module is compiled into the browser bundle as-is.
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 — notably `is.object([]) === true`, which
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 — see the note above before changing this. */
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 — reported from an app. `crypto` is a global in
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
- * the `$fmt` one shipped with a one-parameter replacer that made every object
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.
@@ -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
- constructor(sizeOrBuffer: number | SharedArrayBuffer = 1024 * 1024) {
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 + 1024)
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
- const size = Atomics.load(this.header, 1) || this.buffer.byteLength
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** — the plain `.ts` under your source root that ends up",
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 — server code extends",
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 — no Bun, no node builtins, no DOM — which is what makes them safe to pull into a project that has no bun-types.",
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 — a `<script>` block",
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 — neither of which is a typecheck.",
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 — an SFC can reach Bakery, AppConfig and the JSX namespace.",
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"],