@bakery-framework/core 2.0.0-alpha.1 → 2.0.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -68,6 +68,6 @@
68
68
  "!src/tests"
69
69
  ],
70
70
  "engines": {
71
- "bun": ">=1.3.14"
71
+ "bun": ">=1.4.0"
72
72
  }
73
73
  }
@@ -61,6 +61,13 @@ const PROJECT_DIR = fs.resolve(APP_DIR, '.cache/tsconfig')
61
61
  * Before this existed, one config covered everything and `Bun.hash()` in a
62
62
  * client file typechecked clean and failed in the browser.
63
63
  *
64
+ * The includes overlap the app's own tsconfig, and that is fine *because
65
+ * nothing references these projects*: each is a standalone projection of one
66
+ * concern, pointed at directly (`tsc -p .cache/tsconfig/client.json`), and
67
+ * subtracting the app's include would gut them into projects that check
68
+ * nothing. What must never come back is the `references` wiring that composed
69
+ * them with the app project — see {@link syncTSConfigProjects}.
70
+ *
64
71
  * Globs are app-relative here and rewritten to be relative to the generated
65
72
  * file, which sits two levels down.
66
73
  */
@@ -322,22 +329,81 @@ function mapPaths(paths: MapOf<string[]>): MapOf<string[]> {
322
329
  }
323
330
 
324
331
  /**
325
- * The app's root tsconfig with `references` set, and nothing else touched.
332
+ * A `references` entry the generator wrote in a previous release, as opposed
333
+ * to one the developer owns. Ours always pointed into `.cache/tsconfig/`, and
334
+ * nothing else has a reason to: `.cache/` is the disposable runtime directory,
335
+ * and every project inside it is regenerated on boot.
336
+ */
337
+ const RE_GENERATED_REF = /^(\.[\\/])?\.cache[\\/]tsconfig[\\/]/
338
+
339
+ function isGeneratedReference(entry: unknown): boolean {
340
+ if (typeof entry !== 'object' || entry === null) return false
341
+ const path = (entry as { path?: unknown }).path
342
+ return typeof path === 'string' && RE_GENERATED_REF.test(path)
343
+ }
344
+
345
+ /**
346
+ * The root config with the generator's own `references` removed, or `null`
347
+ * when there is nothing to repair.
348
+ *
349
+ * Until 2026-08-27 `syncTSConfigProjects` *added* those references, wiring the
350
+ * generated projects into the app's project graph. That glue is what broke
351
+ * `tsc -p <app>` for every consumer who had booted once. Measured directly
352
+ * (TypeScript 6.0.3):
353
+ *
354
+ * - `tsc -p` verifies every referenced project whenever the referencing
355
+ * program has input files of its own: a reference to a non-composite
356
+ * project is TS6306 and to a `noEmit` one TS6310 — **even when the
357
+ * referenced project's include is disjoint from the root's, and even when
358
+ * it matches zero files**. No include shape survives.
359
+ * - Each root file a referenced project also claims is redirected to that
360
+ * project's declaration output, which `noEmit` guarantees was never built:
361
+ * TS6305, once per overlapping file — `src/**`, `server.config.ts`, every
362
+ * `.tsx` page.
363
+ *
364
+ * The generated projects extend `noEmit` bases and rely on
365
+ * `allowImportingTsExtensions`, so they are unbuildable by design. Making them
366
+ * `composite` instead would trade the errors above for a `tsc -b` build-order
367
+ * requirement no consumer runs — and TS6305 would still fire for any root file
368
+ * importing into one while unbuilt. The only reference shape `tsc -p`
369
+ * tolerates from a root that has files of its own is no reference at all, so
370
+ * the projects stand alone now and this strips what previous releases wrote.
371
+ *
372
+ * Everything the developer owns is preserved: only entries into
373
+ * `.cache/tsconfig/` are removed, a real project reference (say `../shared`)
374
+ * stays, and the `references` key itself survives unless the generator wrote
375
+ * every entry in it.
376
+ *
377
+ * Pure, and exported, so the repair can be tested without a function that
378
+ * writes to `process.cwd()`. The property that matters is negative — *no key
379
+ * the developer wrote is lost* — which a shape assertion on the source cannot
380
+ * check.
381
+ */
382
+ export function stripGeneratedReferences(
383
+ current: Record<string, unknown>,
384
+ ): Record<string, unknown> | null {
385
+ const refs = current.references
386
+ if (!Array.isArray(refs)) return null
387
+
388
+ const kept = refs.filter(entry => !isGeneratedReference(entry))
389
+ if (kept.length === refs.length) return null
390
+
391
+ const repaired = { ...current }
392
+ if (kept.length) repaired.references = kept
393
+ else delete repaired.references
394
+ return repaired
395
+ }
396
+
397
+ /**
398
+ * The root config written when the app has none at all.
326
399
  *
327
- * Pure, and exported, so the merge can be tested without a function that writes
328
- * to `process.cwd()`. The property that matters is negative — *no key the
329
- * developer wrote is lost* which is the kind of thing a shape assertion on the
330
- * source cannot check.
400
+ * The generated projects do not help Bun's runtime it reads
401
+ * `compilerOptions.jsx*` from the root `tsconfig.json` and follows `extends`
402
+ * only into a relative path, never a package specifier so the file this
403
+ * writes has to carry the JSX options itself, inline, exactly as the
404
+ * scaffolder spells them.
331
405
  */
332
- export function mergeRootConfig(
333
- current: Record<string, unknown> | null,
334
- references: { path: string }[],
335
- ): Record<string, unknown> {
336
- // Spread first so `references` is the only key this owns.
337
- if (current) return { ...current, references }
338
-
339
- // No root config at all. The generated projects do not help Bun's runtime, so
340
- // the one this writes has to carry the JSX options itself.
406
+ export function defaultRootConfig(): Record<string, unknown> {
341
407
  return {
342
408
  extends: '@bakery-framework/core/tsconfig.server.json',
343
409
  compilerOptions: {
@@ -345,58 +411,63 @@ export function mergeRootConfig(
345
411
  jsxFactory: 'createElement',
346
412
  jsxFragmentFactory: 'Fragment',
347
413
  },
348
- references,
349
414
  }
350
415
  }
351
416
 
352
417
  /**
353
- * Generate the project configs and add `references` to the app's root tsconfig.
418
+ * Generate the project configs, and keep the app's root tsconfig viable —
419
+ * created with the runtime JSX options when the app has none, and stripped of
420
+ * the `references` a previous release wrote into it.
354
421
  *
355
422
  * Separate from `syncTSConfigPaths` because an app can reasonably want one and
356
423
  * not the other: the paths sync has existed for a long time and rewrites a file
357
424
  * people keep in git, while this owns a directory nobody edits.
358
425
  *
359
- * **It used to replace the root config with a references-only stub, and that
360
- * silently broke every `.tsx` page in the app.** The reasoning was tidy — the
361
- * generated projects carry the JSX options, so the root does not need them — and
362
- * it was wrong about *who reads the root*. `tsc` follows `references`; **Bun's
363
- * runtime does not.** Bun reads `compilerOptions.jsx*` from the root
364
- * `tsconfig.json` and nothing else, so a references-only root means pages get
365
- * transpiled against the automatic JSX runtime instead of Bakery's
366
- * `createElement`.
367
- *
368
- * The symptom is worse than the 500 that mistake usually produces. Measured on a
369
- * scratch app: `GET /` answered **200** with
370
- * `{"type":"html","props":{…},"_owner":null,"_store":{}}` a React element tree,
371
- * JSON-encoded, because the handler received an object where it expects a
372
- * `SafeHtml` string. Nothing logs, nothing throws, the status is fine.
426
+ * **The generated projects are standalone on purpose; the root config does not
427
+ * reference them.** They exist for direct invocation against the one concern
428
+ * each covers: `vue-tsc -p .cache/tsconfig/vue.json` is the only way an SFC
429
+ * typechecks at all, `tsc -p .cache/tsconfig/client.json` proves browser code
430
+ * clean of `Bun.*`, and a plugin's project carries its own ambients the same
431
+ * way. Their `include` deliberately overlaps the app's own project they are
432
+ * alternate projections of the same files, used *instead of* the root for
433
+ * their slice, never composed with it. Wiring them in as `references` broke
434
+ * `tsc -p <app>` for any consumer who had booted once (TS6305/6306/6310 — the
435
+ * measurements are on {@link stripGeneratedReferences}), and what the wiring
436
+ * bought was less than it looked: tsserver routes a file to the project whose
437
+ * `include` claims it, so for everything a scaffolded root claims — `src/**`,
438
+ * `server.config.ts`, `orm/**` the reference walk never ran anyway.
373
439
  *
374
- * So the root is now *merged*, not replaced: every key the developer wrote stays,
375
- * and only `references` is ours. That also settles the contradiction with the
376
- * scaffolder, which writes those JSX options under a comment saying to keep them
377
- * and with the three documentation pages that repeat it.
440
+ * Two earlier lessons still bind the root-config half. It used to be
441
+ * *replaced* with a references-only stub, which silently broke every `.tsx`
442
+ * page: Bun's runtime reads `compilerOptions.jsx*` from the root and does not
443
+ * follow `references`, so pages transpiled against the automatic JSX runtime
444
+ * and `GET /` answered 200 with a JSON-encoded React element tree. Hence
445
+ * {@link defaultRootConfig} when no root exists, and surgical repair — never
446
+ * wholesale rewrite — when one does. And a boot that dirties git every time
447
+ * trains people to ignore the diff, so an already-clean root is not rewritten.
378
448
  */
379
449
  export async function syncTSConfigProjects(): Promise<void> {
380
450
  try {
381
451
  const written = await writeProjects(buildPaths())
382
452
 
383
- const references = written.map(name => ({
384
- path: `./.cache/tsconfig/${name}.json`,
385
- }))
386
-
387
453
  const current = fs.exists(APP_CONFIG_PATH)
388
454
  ? parseJSONC(await Bun.file(APP_CONFIG_PATH).text())
389
455
  : null
390
456
 
391
- // Only rewrite when the reference set actually changed. The root config is
392
- // committed, and a dev boot that dirties git every time trains people to
393
- // ignore the diff.
394
- if (current && Bun.deepEquals(current.references, references)) return
457
+ if (!current) {
458
+ await Bun.write(
459
+ APP_CONFIG_PATH,
460
+ `${JSON.stringify(defaultRootConfig(), null, 2)}\n`,
461
+ )
462
+ serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
463
+ return
464
+ }
395
465
 
396
- const root = mergeRootConfig(current, references)
466
+ const repaired = stripGeneratedReferences(current)
467
+ if (!repaired) return
397
468
 
398
- await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(root, null, 2)}\n`)
399
- serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
469
+ await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(repaired, null, 2)}\n`)
470
+ serveLog.TSCONFIG_REFERENCES_REMOVED()
400
471
  } catch (err: any) {
401
472
  serveLog.UNHANDLED_ERR({
402
473
  error: `TSConfig project sync error: ${errorMsg(err)}`,
package/src/core/index.ts CHANGED
@@ -4,6 +4,10 @@ import { Case, is, Math2, match, Try } from '../utils/common'
4
4
  import { encodeSSE, response, sse } from '../utils/http'
5
5
  import Bakery, { getHostname, hostKey, hostStore } from './bakery'
6
6
  import { getConfig, NOOP } from './config'
7
+ // `./context` is already in this barrel's graph — line 5 reaches it through
8
+ // `./bakery`, which re-exports `hostStore` from exactly here — so naming it
9
+ // adds no module edge, only a name.
10
+ import { getFrameworkVersion } from './context'
7
11
  import { createElement, Fragment, html } from './jsx'
8
12
 
9
13
  export const defineConfig = <T extends AppConfig>(config: T): T => config
@@ -63,6 +67,19 @@ export {
63
67
  encodeSSE,
64
68
  Fragment,
65
69
  getConfig,
70
+ /**
71
+ * The version of `@bakery-framework/core` itself, read from its own manifest.
72
+ *
73
+ * **Not the app's version**, which is what `import.meta.env.BAKERY_VERSION`
74
+ * and `getAppVersion()` report — the compiler reads those from
75
+ * `<cwd>/package.json`, so in an application they answer with the
76
+ * application's number. The name is a long-standing misnomer and this is the
77
+ * one that means what "Bakery version" sounds like it means.
78
+ *
79
+ * Exported because a plugin rendering framework chrome has no other way to
80
+ * ask. The dashboard's footer showed a hardcoded `v3` for want of it.
81
+ */
82
+ getFrameworkVersion,
66
83
  // Multi-host helpers. Documented in docs/configuration/multi-host.md, and
67
84
  // the only reason `./core/bakery` had to be a subpath of its own.
68
85
  getHostname,
package/src/core/init.ts CHANGED
@@ -27,40 +27,43 @@ const getArgValue = (name: string) => {
27
27
  const threadId = process.env.THREAD_ID ?? getArgValue('--thread-id') ?? '0'
28
28
 
29
29
  /**
30
- * An accessor pair, not a bare getter.
30
+ * The mode flags are **strings on `process.env`** — `'1'` for true, `''` for
31
+ * false. They were booleans behind an accessor pair until Bun 1.4.
31
32
  *
32
- * These are `process.env` properties, and a getter with no setter is readonly:
33
- * in strict-mode ESM an assignment to one throws
34
- * `TypeError: Attempted to assign to readonly property`. `threads.ts` assigns
35
- * `THREAD_ID = '0'` on the single-worker/clamped path (deliberately not
36
- * `THREAD_WORKER` a cluster of one must keep full-size caches). When the
37
- * assignment was getter-only it was wrapped in `Try(...)`, so the throw was
38
- * swallowed and the flags never moved `reusePort`, the per-worker cache
39
- * scaling and the startup banner all silently read the master's values. The
40
- * `Try(...)` was what made a dead code path look deliberate.
33
+ * **Bun 1.4.0 rejects accessor descriptors on `process.env` outright.**
34
+ * `Object.defineProperty(process.env, 'X', { get })` throws
35
+ * `ERR_INVALID_OBJECT_DEFINE_PROPERTY`. This block runs at import time in every
36
+ * entry, so the whole framework died on `import` under current Bun — a
37
+ * scaffolded app could not reach step 2 of its own quick start. Data
38
+ * descriptors are accepted and coerce anyway (`{ value: false }` reads back as
39
+ * `"false"`), so a boolean here is no longer expressible at all; and
40
+ * `import.meta.env === process.env` in Bun, so there is no second object to put
41
+ * one on.
42
+ *
43
+ * **`'1'` / `''`, never `'true'` / `'false'`.** Truthiness has to survive the
44
+ * coercion: `"false"` is a truthy string, so every `if (import.meta.env.DEV)`
45
+ * in the codebase would have inverted silently rather than failed. `''` also
46
+ * keeps the key *present* — `'PROD' in process.env` stays true — which is what
47
+ * separates "explicitly not production" from "never booted", a distinction
48
+ * `utils/http/authorize.ts` depends on to fail closed.
49
+ *
50
+ * Plain assignment, so `threads.ts` can still assign `THREAD_ID = '0'` on the
51
+ * single-worker/clamped path (deliberately not `THREAD_WORKER` — a cluster of
52
+ * one must keep full-size caches). That assignment is why these were an
53
+ * accessor *pair* rather than a bare getter: a getter with no setter is
54
+ * readonly, the assignment threw, and the throw was swallowed by a `Try(...)`
55
+ * that made a dead code path look deliberate.
41
56
  */
42
- const accessor = (initial: any) => {
43
- let value = initial
44
- return {
45
- get: () => value,
46
- set: (next: any) => {
47
- value = next
48
- },
49
- enumerable: true,
50
- configurable: true,
51
- }
52
- }
57
+ const flag = (on: boolean) => (on ? '1' : '')
53
58
 
54
- Object.defineProperties(process.env, {
55
- DEV: accessor(isDev),
56
- TEST: accessor(isTest),
57
- PROD: accessor(!isDev && !hasDevWorkerArg),
58
- WORKER: accessor(hasDevWorkerArg || isThreadWorker),
59
- DEV_WORKER: accessor(hasDevWorkerArg),
60
- THREAD_WORKER: accessor(isThreadWorker),
61
- THREAD_ID: accessor(threadId),
62
- MODE: accessor(mode),
63
- })
59
+ process.env.DEV = flag(isDev)
60
+ process.env.TEST = flag(isTest)
61
+ process.env.PROD = flag(!isDev && !hasDevWorkerArg)
62
+ process.env.WORKER = flag(hasDevWorkerArg || isThreadWorker)
63
+ process.env.DEV_WORKER = flag(hasDevWorkerArg)
64
+ process.env.THREAD_WORKER = flag(isThreadWorker)
65
+ process.env.THREAD_ID = threadId
66
+ process.env.MODE = mode
64
67
 
65
68
  /**
66
69
  * "This process is the worker of a *development* server."
package/src/global.d.ts CHANGED
@@ -342,13 +342,31 @@ declare global {
342
342
  // `ImportMeta.env` with its own incompatible shape, which is TS2687/TS2717 —
343
343
  // suppressed, like everything else in a .d.ts, by skipLibCheck.
344
344
  interface ImportMetaEnv {
345
- readonly DEV: boolean
346
- readonly PROD: boolean
347
- readonly WORKER: boolean
348
- readonly DEV_WORKER: boolean
349
- readonly THREAD_WORKER: boolean
345
+ // `'1'` or `''`, not `true` or `false`. These live on `process.env`, and
346
+ // Bun 1.4 stopped accepting accessor descriptors there — a boolean is no
347
+ // longer expressible on that object at all, since data descriptors coerce
348
+ // too. See the encoding block in `core/init.ts`.
349
+ //
350
+ // Typed as the literal pair rather than `string` on purpose: it makes
351
+ // `import.meta.env.PROD !== false` a *compile* error. That comparison was
352
+ // real, in the dashboard's fail-closed gate, and under the string encoding
353
+ // it is true for every value including the `''` a development server sets —
354
+ // so the gate would have denied on loopback in development while still
355
+ // reading as correct. A type that only said `string` would not have caught
356
+ // it either.
357
+ //
358
+ // In a **browser** bundle these are real booleans: `compiler.ts` substitutes
359
+ // `JSON.stringify(!!import.meta.env.DEV)` at build time, so the literal
360
+ // `true`/`false` is inlined and this declaration is a slight lie there. It
361
+ // is the harmless direction — both encodings agree on truthiness, which is
362
+ // all client code tests — and the server is where the mistakes happen.
363
+ readonly DEV: '1' | ''
364
+ readonly PROD: '1' | ''
365
+ readonly WORKER: '1' | ''
366
+ readonly DEV_WORKER: '1' | ''
367
+ readonly THREAD_WORKER: '1' | ''
350
368
  readonly THREAD_ID: string
351
- readonly TEST: boolean
369
+ readonly TEST: '1' | ''
352
370
  readonly MODE: 'production' | 'development' | 'dev-worker' | 'thread-worker'
353
371
  // `readonly SERVE_ROOT: string` was declared here. Nothing defines it —
354
372
  // not init.ts, not the compiler's `defines` — and nothing reads it, so any
@@ -2,7 +2,7 @@ import { Bakery, hostKey } from '../../core/bakery'
2
2
  import { matchBlockedCached } from '../../core/context'
3
3
  import { toHash } from '../../utils'
4
4
  import { fs } from '../../utils/fs'
5
- import { response } from '../../utils/http'
5
+ import { injectBrand, response } from '../../utils/http'
6
6
  import { Handler } from '../core/$base'
7
7
  import { ErrorHandler } from '../core/$error'
8
8
  import { getStatic } from '../core/$static'
@@ -57,30 +57,73 @@ export class DefaultErrorHandler extends ErrorHandler {
57
57
  * error surface — `errorBody` carries the thrown error's stack (that is
58
58
  * what the log wants), and handing it to the client verbatim gave any
59
59
  * anonymous request source paths and query text in PROD.
60
+ *
61
+ * Two pages, split on the same gate `publicBody` uses, failing the same
62
+ * direction: only an explicit DEV gets the diagnostics page, so an
63
+ * indeterminate mode discloses nothing. DEV keeps the branded title, the
64
+ * body in a `<pre>`, and the requester/date footer — and `processResponse`
65
+ * injects the import map and live reload into it like any page, which is
66
+ * what makes the overlay work on an error. The production page is the
67
+ * status line and the public body, nothing else: the footer echoed the
68
+ * requester's own IP and a server timestamp to anyone who triggered an
69
+ * error, and the page is branded with `injectBrand` because the injected
70
+ * import map names every installed package — see the note on the export.
60
71
  */
61
72
  static handle(_path: string, req: Request, error?: Handler.Error.Data) {
62
- const ip = Bakery.server?.requestIP(req)?.address || 'Unknown'
63
- const date = new Date().toDateString()
64
-
65
73
  error ||= this.DEFAULT_ERROR
66
74
 
75
+ // No separator without text to separate — an empty-message denial used to
76
+ // render `<h1>403 - </h1>`. Same rule for the body below: empty renders
77
+ // as no element, not as a dangling `<pre></pre>`.
78
+ const heading = Bun.escapeHTML(
79
+ error.errorText
80
+ ? `${error.errorCode} - ${error.errorText}`
81
+ : `${error.errorCode}`,
82
+ )
83
+ const body = this.publicBody(error)
84
+
85
+ if (import.meta.env.DEV) {
86
+ const ip = Bakery.server?.requestIP(req)?.address || 'Unknown'
87
+ const date = new Date().toDateString()
88
+
89
+ const errorPage = `
90
+ <!DOCTYPE html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="UTF-8" />
94
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
95
+ <title>Error ${error.errorCode} | Bakery 🚀</title>
96
+ </head>
97
+ <body style="margin: 2rem; font-family: sans-serif;">
98
+ <h1>${heading}</h1>
99
+ ${body ? `<pre>${Bun.escapeHTML(body)}</pre>` : ''}
100
+ <hr />
101
+ <small>${Bun.escapeHTML(date)} - ${Bun.escapeHTML(ip)}</small>
102
+ </body>
103
+ </html>
104
+ `
105
+
106
+ return response.html(errorPage, error.errorCode)
107
+ }
108
+
109
+ // `<p>`, not `<pre>`: outside DEV the body is prose by construction —
110
+ // `publicBody` replaces a 5xx stack with the generic sentence, and a 4xx
111
+ // body is authored text.
67
112
  const errorPage = `
68
113
  <!DOCTYPE html>
69
114
  <html lang="en">
70
115
  <head>
71
116
  <meta charset="UTF-8" />
72
117
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
73
- <title>Error ${error.errorCode} | Bakery 🚀</title>
118
+ <title>Error ${error.errorCode}</title>
74
119
  </head>
75
120
  <body style="margin: 2rem; font-family: sans-serif;">
76
- <h1>${Bun.escapeHTML(`${error.errorCode} - ${error.errorText}`)}</h1>
77
- <pre>${Bun.escapeHTML(this.publicBody(error))}</pre>
78
- <hr />
79
- <small>${Bun.escapeHTML(date)} - ${Bun.escapeHTML(ip)}</small>
121
+ <h1>${heading}</h1>
122
+ ${body ? `<p>${Bun.escapeHTML(body)}</p>` : ''}
80
123
  </body>
81
124
  </html>
82
125
  `
83
126
 
84
- return response.html(errorPage, error.errorCode)
127
+ return injectBrand(response.html(errorPage, error.errorCode))
85
128
  }
86
129
  }
@@ -253,6 +253,23 @@ export class Handler {
253
253
  */
254
254
  static servesFiles = true
255
255
 
256
+ /**
257
+ * The URL prefix this handler owns as a complete user-facing surface —
258
+ * `'/_dashboard'`, `'/_db'` — or `null` for the ordinary case of a handler
259
+ * that answers by extension or content rather than by prefix.
260
+ *
261
+ * This exists so one surface can ask whether another is mounted without
262
+ * probing it. The dashboard used to detect the explorer by calling every
263
+ * registered handler's `canHandle('/_db')` with a control path to exclude
264
+ * the priority-0 catch-all — it worked, and the `as any` it needed was the
265
+ * tell that the registry could not say what a handler serves. A declaration
266
+ * is that answer. It is deliberately *not* consulted by routing: `canHandle`
267
+ * stays the authority on requests, so a stale or missing namespace can
268
+ * misdescribe a handler to the dashboard nav but can never misroute a
269
+ * request.
270
+ */
271
+ static namespace: string | null = null
272
+
256
273
  protected constructor() {}
257
274
 
258
275
  static get cache(): HandlerCache<string, Route.Info> {
@@ -193,7 +193,16 @@ export class ErrorHandler extends Handler {
193
193
  ...this.DEFAULT_ERROR,
194
194
  errorCode: error.status,
195
195
  errorText: error.statusText,
196
- errorBody: `${error.status}: "${error.statusText}"`,
196
+ // The reason-phrase is optional — `new Response(null, { status })`
197
+ // leaves it `''`, and the framework's own forbidden-path denial is
198
+ // exactly that shape — so the quoting is conditional: a synthesized
199
+ // `403: ""` rendered a literal quoted empty string on the error page.
200
+ // The bare status, not `''`, because this string is also the default
201
+ // `onError` log line (`403 at /path`); an empty body would log
202
+ // ` at /path`.
203
+ errorBody: error.statusText
204
+ ? `${error.status}: "${error.statusText}"`
205
+ : String(error.status),
197
206
  }
198
207
  }
199
208
 
@@ -30,11 +30,16 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
30
30
  }
31
31
  }
32
32
 
33
- set(handlerClass: any, priority: number = 10): this {
34
- super.set(handlerClass, priority)
33
+ /** Drop the derived views. Every mutation has to call this, not just `set`. */
34
+ private invalidate(): void {
35
35
  this.cachedList = null
36
36
  this.cachedGates = null
37
37
  this.cachedOrder = null
38
+ }
39
+
40
+ set(handlerClass: any, priority: number = 10): this {
41
+ super.set(handlerClass, priority)
42
+ this.invalidate()
38
43
  return this
39
44
  }
40
45
 
@@ -42,6 +47,30 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
42
47
  return this.set(handlerClass, priority)
43
48
  }
44
49
 
50
+ /**
51
+ * `delete` and `clear` invalidate too, and neither used to.
52
+ *
53
+ * `list()` memoizes the sorted handler array and only `set` cleared it, so a
54
+ * removed handler stayed in the list — and therefore stayed *in the request
55
+ * pipeline* — until something happened to add one. Registration is
56
+ * add-only in a served process, which is why this never bit: it is reachable
57
+ * only by code that unregisters, and the first thing to do that was a test.
58
+ *
59
+ * A cache that survives the removal of its input is wrong regardless of who
60
+ * currently calls it, and "nothing removes handlers today" is a property of
61
+ * the callers rather than of this class.
62
+ */
63
+ override delete(handlerClass: any): boolean {
64
+ const removed = super.delete(handlerClass)
65
+ if (removed) this.invalidate()
66
+ return removed
67
+ }
68
+
69
+ override clear(): void {
70
+ super.clear()
71
+ this.invalidate()
72
+ }
73
+
45
74
  list(): T[] {
46
75
  if (this.cachedList) {
47
76
  return this.cachedList
@@ -21,15 +21,37 @@ export type RouteScanOptions = {
21
21
  const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
22
22
 
23
23
  // The single-param route forms, in the order they are tried: `[name].ext`
24
- // first, then the escaped-literal `*.ext`. Built here rather than twice inside
24
+ // first, then the literal-asterisk `*.ext`. Built here rather than twice inside
25
25
  // `routeGlobs` — the `dynamicOnly` branch and the combined branch returned
26
26
  // character-identical pairs, and the two must stay in step or a route form
27
27
  // resolves under one caller and not the other. A function, not a hoisted
28
28
  // constant: `ext` varies per handler, and `staticOnly` returns before it needs
29
29
  // them at all.
30
- const dynamicGlobs = (ext: string) => [
30
+ //
31
+ // **The literal asterisk is a character class, `[*]`, never the escape `\*`.**
32
+ // A backslash is the obvious spelling and is unusable on Windows, where `\` is
33
+ // a path separator: Bun read `\*.*` as a drive-absolute pattern, ignored the
34
+ // `cwd` in `GETFILE` entirely, and matched files at `C:\`. A route lookup under
35
+ // a serve root six levels down returned `C:\$WINRE_BACKUP_PARTITION.MARKER`,
36
+ // and `fs.isForbidden` — whose walk is bounded by `startsWith(root)` — waved it
37
+ // through because an out-of-root path skipped the loop and answered "allowed".
38
+ // That clamp now fails closed, so this is belt and braces; both halves are
39
+ // pinned, and neither test can see the other's bug.
40
+ //
41
+ // Measured with a scan whose cwd was a temp directory holding one file: `[*]`
42
+ // yields nothing, `\*` yields four files from the drive root. The escape is
43
+ // also unreachable on Windows in the direction it was meant for — `*` is a
44
+ // reserved character in a Windows filename, so a route file literally named
45
+ // `*.ts` can only exist on POSIX, where both spellings match it identically
46
+ // (verified on Linux). The character class costs nothing and means the same
47
+ // thing on both platforms.
48
+ //
49
+ // Exported only as a test seam: with the clamp in place no test driving
50
+ // `getRoute` can tell the two spellings apart — verified by reverting this line
51
+ // and watching all 17 pass — so the pattern has to be asserted directly.
52
+ export const dynamicGlobs = (ext: string) => [
31
53
  new Bun.Glob(`[[][!.]*${ext || '.*'}`),
32
- new Bun.Glob(`\\*${ext || '.*'}`),
54
+ new Bun.Glob(`[*]${ext || '.*'}`),
33
55
  ]
34
56
 
35
57
  const routeGlobs = (
@@ -2,6 +2,7 @@ import { Bakery } from '../../core/bakery'
2
2
  import { handlerLog } from '../../logger/serve-log'
3
3
  import { FileSystem } from '../../utils/fs'
4
4
  import { checkCsrf, response } from '../../utils/http'
5
+ import { parsedUrl } from '../../utils/http/url'
5
6
  import type { Handler } from '../core/$base'
6
7
  import { bustInDev, DynamicHandler } from '../core/$dynamic'
7
8
  import { ErrorHandler } from '../core/$error'
@@ -29,8 +30,7 @@ export class ApiHandler extends DynamicHandler {
29
30
  static async handle(path: string, req: Request) {
30
31
  // State-changing methods must be same-origin. SameSite=Lax alone does not
31
32
  // cover this: a cross-site form POST is a CORS-simple request.
32
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
33
- const csrf = checkCsrf(req, url)
33
+ const csrf = checkCsrf(req, parsedUrl(req))
34
34
  if (csrf) return response.json.error(403, csrf) as unknown as Response
35
35
 
36
36
  const info = await this.resolveRoute(path)
@@ -1,6 +1,7 @@
1
1
  import { Bakery } from '../../core/bakery'
2
2
  import { handlerLog } from '../../logger'
3
3
  import { response } from '../../utils/http'
4
+ import { parsedUrl } from '../../utils/http/url'
4
5
  import { Handler } from '../core/$base'
5
6
 
6
7
  export class ProxyHandler extends Handler {
@@ -30,7 +31,7 @@ export class ProxyHandler extends Handler {
30
31
  baseTarget +
31
32
  (trailingPath.startsWith('/') ? '' : '/') +
32
33
  trailingPath +
33
- ((req as any).__parsedUrl || new URL(req.url)).search
34
+ parsedUrl(req).search
34
35
  break
35
36
  }
36
37
 
@@ -27,6 +27,12 @@ const serveMsgs = {
27
27
  'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
28
  TSCONFIG_PROJECTS_WRITTEN:
29
29
  'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
30
+ // One-time repair. Previous releases wired the generated projects into the
31
+ // app's tsconfig.json as `references`, which broke `tsc -p <app>`
32
+ // (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
33
+ // line saying why it happened.
34
+ TSCONFIG_REFERENCES_REMOVED:
35
+ 'I Removed generated %yreferences%* from %ytsconfig.json%* — the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
30
36
  // A plugin asking for a project name that is taken. Named rather than
31
37
  // silent: the symptom otherwise is one plugin's types quietly not applying,
32
38
  // discovered much later and blamed on the wrong thing.
@@ -6,11 +6,13 @@ export type ValidResponses = Handler.Response
6
6
  /**
7
7
  * A TypeScript project a plugin contributes to the app.
8
8
  *
9
- * Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
10
- * from the app's root `tsconfig.json`, so the editor typechecks each file under
11
- * the project that owns it. Regenerated rather than committed: it is derived
12
- * from the plugin list and the app's config, and `.cache/` is the disposable
13
- * half of the two runtime directories.
9
+ * Written to `.cache/tsconfig/<name>.json` on every dev boot, as a standalone
10
+ * project invoked directly (`vue-tsc -p .cache/tsconfig/vue.json`). It is
11
+ * deliberately *not* referenced from the app's root `tsconfig.json` a
12
+ * `references` entry to an unbuilt `noEmit` project makes `tsc -p <app>` fail
13
+ * (TS6305/6306/6310; see `compiler/tsconfig-sync.ts`). Regenerated rather
14
+ * than committed: it is derived from the plugin list and the app's config, and
15
+ * `.cache/` is the disposable half of the two runtime directories.
14
16
  */
15
17
  export interface PluginTsProject {
16
18
  /** File name under `.cache/tsconfig/`, and the project's identity. */
package/src/router.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  withStatus,
20
20
  } from './utils/http'
21
21
  import { applyCors, preflightResponse } from './utils/http/cors'
22
+ import { parsedUrl } from './utils/http/url'
22
23
 
23
24
  /**
24
25
  * Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
@@ -39,7 +40,7 @@ export async function upgradeWebsocket(
39
40
  req: Request,
40
41
  path: string,
41
42
  ): Promise<boolean | undefined> {
42
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
43
+ const url = parsedUrl(req)
43
44
  const denied = checkWebSocketOrigin(req, url)
44
45
  if (denied) {
45
46
  serveLog.WEBSOCKET_ERR({
@@ -85,10 +86,9 @@ export function handleRequest(
85
86
  req: Request,
86
87
  ): Handler.Response | MixedPromise<symbol>
87
88
  export async function handleRequest(req: Request) {
88
- // worker.ts parses and attaches this before calling in; the fallback keeps
89
- // direct callers (tests, embedders) working.
90
- const url: URL = (req as any).__parsedUrl || new URL(req.url)
91
- ;(req as any).__parsedUrl = url
89
+ // Shared with worker.ts, which has already asked for the same parse; a
90
+ // direct caller (test, embedder) gets it parsed here instead.
91
+ const url = parsedUrl(req)
92
92
  const path = url.pathname
93
93
 
94
94
  // One read of the config getter, not three: `Bakery.serveRoot` walks
package/src/utils/fs.ts CHANGED
@@ -395,6 +395,35 @@ export namespace FileSystem {
395
395
  let curr = safeResolve(pathToCheck)
396
396
  const normalizedRoot = safeResolve(root)
397
397
 
398
+ // A path outside `root` is forbidden, and this is the clause that says so.
399
+ //
400
+ // The walk below is bounded by `curr.startsWith(normalizedRoot)`, so an
401
+ // out-of-root path skipped the loop body entirely and fell through to
402
+ // `return false` — "not forbidden". That is a guard failing *open* on the
403
+ // one input it most needs to refuse, and it made every caller that relied
404
+ // on this for containment (all of them: `constants.ts` said so in as many
405
+ // words) incapable of catching an escape.
406
+ //
407
+ // Not theoretical. `$routing.ts` builds its route globs with `\*` to mean a
408
+ // literal asterisk; on Windows a backslash is a path *separator*, so Bun
409
+ // read the pattern as drive-absolute, ignored `cwd`, and matched files at
410
+ // `C:\`. `getRoute` resolved one, asked this function, was told "allowed",
411
+ // and returned an `Info` pointing six levels above the serve root. The glob
412
+ // is fixed too, but the glob was only how it was reached — a resolved file
413
+ // outside the root has to be refused here whatever produced it.
414
+ //
415
+ // The separator suffix matters: a bare `startsWith` would also accept a
416
+ // sibling directory whose name merely begins with the root's. A root that
417
+ // is already a filesystem root ends in `/` and must not gain a second one —
418
+ // `C://` matches nothing, which would make every path under `C:/` read as
419
+ // an escape. `fs.test.ts` drives both through its root corpus.
420
+ const rootPrefix = normalizedRoot.endsWith('/')
421
+ ? normalizedRoot
422
+ : `${normalizedRoot}/`
423
+ if (curr !== normalizedRoot && !curr.startsWith(rootPrefix)) {
424
+ return true
425
+ }
426
+
398
427
  const store = hostStore.getStore()
399
428
  const seen = store ? (store.forbiddenProbes ??= new Map()) : null
400
429
 
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The request-predicate half of plugin authorization.
3
+ *
4
+ * One implementation, deliberately in core, for the same reason
5
+ * `credential.ts` is here: the dashboard, db-explorer and analytics plugins
6
+ * each need this guard, and per-plugin copies of security code drift. These
7
+ * three had already drifted — one coerced its predicate's return with
8
+ * `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
9
+ * `getClientIp` throw into an empty string and carried on. Each divergence is
10
+ * resolved below toward the fail-closed reading, and each is marked as a
11
+ * decision rather than left to look like a style choice.
12
+ *
13
+ * It owns *only* the predicate — no logins, no sessions, no backoff. The shared
14
+ * key path is `credential.ts`; a plugin that offers both doors composes them.
15
+ *
16
+ * `getClientIp` is imported from `./ip` directly, never through this
17
+ * directory's barrel or `@bakery-framework/core`: a core module that reaches for a
18
+ * barrel closes an import cycle, which is how 67 tests once failed with
19
+ * `ReferenceError: Cannot access 'Logger' before initialization`.
20
+ */
21
+
22
+ import { getClientIp } from './ip'
23
+
24
+ /**
25
+ * A host application's access predicate. Returning `true` admits; returning
26
+ * anything else, or throwing, denies (convention 2).
27
+ *
28
+ * The framework authenticates nobody. The application, which already knows who
29
+ * its users are, supplies this.
30
+ */
31
+ export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
32
+
33
+ /**
34
+ * Addresses only, never hostnames. Module-private: neither plugin copy
35
+ * exported it, and the membership test is the whole of the useful surface.
36
+ *
37
+ * `'localhost'` used to be a member, back when the request's *hostname* was
38
+ * compared against this set as well. A peer address is never the string
39
+ * `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
40
+ * loopback under `trustProxy` — and, worse, that `new URL(req.url).hostname`
41
+ * did too. Bun builds that from the client's own `Host` header and
42
+ * `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
43
+ * `Host: localhost` and be handed a database browser.
44
+ */
45
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
46
+
47
+ /** True when the request came from this machine. */
48
+ export function isLoopback(req: Request): boolean {
49
+ // The peer address is the only evidence here the client does not choose.
50
+ //
51
+ // DECISION (divergence 3): a throw returns `false` immediately rather than
52
+ // falling through with `ip = ''`. `getClientIp` reads config and the live
53
+ // server, either of which may be absent (tests, early boot). The two spell
54
+ // the same answer today, because `LOOPBACK.has('')` is false — but only by
55
+ // coincidence, and the fall-through invites a later edit that adds a second
56
+ // source of evidence below this line and silently consults it on the
57
+ // indeterminate path. Returning here says the answer is settled: no address
58
+ // means no evidence, and per convention 2 an indeterminate answer is a
59
+ // denial, not a reason to ask something the requester controls.
60
+ try {
61
+ return LOOPBACK.has(getClientIp(req))
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The default when an application configures no predicate: loopback in
69
+ * development, nobody in production. Forgetting to configure a plugin
70
+ * therefore cannot expose it to the internet.
71
+ */
72
+ export function defaultAuthorize(req: Request): boolean {
73
+ // DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
74
+ // time — the mode flags are accessors on `process.env` (`core/init.ts`), so
75
+ // they are process state and tests flip them.
76
+ //
77
+ // The two spellings look interchangeable because `init.ts` derives them as
78
+ // complements (`PROD = !isDev && !--dev-worker`). They are not, because they
79
+ // are *independently settable* accessors and the test fixtures set them one
80
+ // at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` — which
81
+ // under `bun test` is `true` — in place. In that state `!DEV` admits a
82
+ // loopback caller and `PROD` denies, so `PROD` is both the fail-closed
83
+ // reading and the one that literally names the condition it means, rather
84
+ // than inferring production from the absence of development.
85
+ //
86
+ // Hence a test for the *positive* development marker rather than a truthiness
87
+ // test on `PROD`, which is the one place a bare `PROD` gate would be weaker
88
+ // than `!DEV`. The flags do not exist until `core/init.ts` has run, and
89
+ // `if (undefined)` falls straight through to the loopback check — so an
90
+ // uninitialised process would open the door an initialised production one
91
+ // keeps shut. Unset is not evidence of development; it is no evidence at all.
92
+ //
93
+ // This read `PROD !== false` while the flags were real booleans. Bun 1.4
94
+ // rejects accessor descriptors on `process.env`, so they are `'1'`/`''`
95
+ // strings now (see `core/init.ts`) — and `!== false` is true for *every*
96
+ // string, including the `''` a development server sets, so the gate would
97
+ // have denied on loopback in development while still looking correct.
98
+ // `DEV === '1'` is the same statement in the encoding that survives: only a
99
+ // booted development server sets it, and production (`''`) and never-booted
100
+ // (`undefined`) both fail it.
101
+ //
102
+ // `isLoopback` would very likely deny on its own there, having no config or
103
+ // server to read an address from. That is a second line of defence, not this
104
+ // one's excuse: a guard should not depend on another guard's failure mode.
105
+ if (import.meta.env.DEV !== '1') return false
106
+ return isLoopback(req)
107
+ }
108
+
109
+ /** The configured predicate, or the fail-closed default. */
110
+ export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
111
+ return fn ?? defaultAuthorize
112
+ }
113
+
114
+ /**
115
+ * Run a predicate without letting a broken one grant access. Guard semantics
116
+ * per convention 2: the *authorizer* may throw or answer nonsense, and the
117
+ * answer to any indeterminate state is denial.
118
+ */
119
+ export async function isAuthorized(
120
+ authorize: AuthorizeFn,
121
+ req: Request,
122
+ ): Promise<boolean> {
123
+ try {
124
+ // DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
125
+ // comes from application code and `AuthorizeFn`'s return type is only
126
+ // advice — an untyped, transpiled or `as any` predicate can hand back
127
+ // anything. `Boolean` admits every truthy non-boolean, so a check that
128
+ // answers with a status string denies on `""` and *grants* on `"no"`, and
129
+ // one that answers with a count grants on any non-zero. Admission is the
130
+ // expensive direction to get wrong; require the exact affirmative.
131
+ return (await authorize(req)) === true
132
+ } catch {
133
+ // A predicate that throws is indeterminate, and indeterminate is denied.
134
+ return false
135
+ }
136
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MapOf } from '../../types'
2
+ import { parsedUrl } from './url'
2
3
 
3
4
  export async function processBody(req: Request): Promise<MapOf<any>> {
4
5
  const getParsedBody = async (): Promise<MapOf<any>> => {
@@ -18,8 +19,7 @@ export async function processBody(req: Request): Promise<MapOf<any>> {
18
19
  }
19
20
 
20
21
  function getBodyFromURI(req: Request): MapOf<any> {
21
- const url = (req as any).__parsedUrl || new URL(req.url)
22
- const searchParams = url.searchParams
22
+ const searchParams = parsedUrl(req).searchParams
23
23
  return Object.fromEntries(searchParams.entries())
24
24
  }
25
25
 
@@ -231,6 +231,33 @@ export namespace ETag {
231
231
  if (conditionalRes) return conditionalRes
232
232
  }
233
233
 
234
+ // Range handling itself lives in Bun.serve, not here: any Response whose
235
+ // body is a *path-backed* BunFile is sliced by the runtime — 206 with
236
+ // Content-Range on a satisfiable single range, 416 past EOF — including
237
+ // the negotiated compressed variants above (the range then addresses the
238
+ // encoded bytes, which is what RFC 9110 says a range means). What the
239
+ // runtime does not do is advertise: a plain 200 or a HEAD said nothing,
240
+ // so players that probe HEAD for `Accept-Ranges` before attempting seeks
241
+ // never tried. Advertised here because this is the one funnel every
242
+ // file-serving handler's BunFile passes through, and only here — an
243
+ // in-memory Blob or a `sendText` string body ignores Range entirely
244
+ // (served whole, 200), which is why `.name` gates the claim.
245
+ //
246
+ // Skipped when Bun's own range path is about to answer (a GET carrying
247
+ // `Range`): that path appends its own `Accept-Ranges: bytes` to the
248
+ // 206/416, and setting it here too emitted `bytes, bytes`. The trade,
249
+ // measured on Bun 1.4.0: a GET whose Range is malformed or multipart is
250
+ // served whole with no advertisement from either side — acceptable,
251
+ // since a client that already sent `Range` is not the one this probe
252
+ // header exists for. HEAD ignores `Range` and gets the header even when
253
+ // one is present.
254
+ if (
255
+ resolvedFile.name &&
256
+ !(req && req.method === 'GET' && req.headers.has('range'))
257
+ ) {
258
+ headers['Accept-Ranges'] = 'bytes'
259
+ }
260
+
234
261
  return new Response(resolvedFile, { headers })
235
262
  }
236
263
 
@@ -4,7 +4,18 @@ 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` —
9
+ * and therefore `processResponse`, which every response funnels through —
10
+ * hands it back untouched.
11
+ *
12
+ * Exported for the one caller that means "never", not "already":
13
+ * `DefaultErrorHandler` brands its production error page so the import map is
14
+ * not spliced into it. That map names every installed package — an inventory
15
+ * of the app's module layout — and the page carries no scripts for it (or the
16
+ * client bundle) to serve anyway.
17
+ */
18
+ export function injectBrand(res: Response) {
8
19
  return Object.defineProperty(res, '__injected__', {
9
20
  value: true,
10
21
  enumerable: false,
@@ -1,3 +1,4 @@
1
+ export * from './authorize'
1
2
  export * from './body'
2
3
  export * from './credential'
3
4
  export * from './csrf'
@@ -8,3 +9,4 @@ export * from './html'
8
9
  export * from './ip'
9
10
  export * from './response'
10
11
  export * from './sse'
12
+ export * from './url'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The request's parsed URL, parsed at most once.
3
+ *
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 —
6
+ * so the parse was hoisted into the server's `fetch` in `packages/cli/src/worker.ts`
7
+ * and shared.
8
+ *
9
+ * Why a `WeakMap` rather than the property it replaces. The memo used to be
10
+ * `(req as any).__parsedUrl`: two writers, six readers across three packages,
11
+ * every reader spelled `(req as any).__parsedUrl || new URL(req.url)` so that
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
14
+ * missed write costs microseconds, not correctness, so nothing ever fails.
15
+ * One function collapses both roles: there is no ordering left to get wrong,
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
19
+ * an entry dies with the request rather than needing eviction (convention 6).
20
+ *
21
+ * There is deliberately no setter. Nothing outside this module needs to say
22
+ * what a request's URL is; `req.url` already does.
23
+ */
24
+
25
+ const cache = new WeakMap<Request, URL>()
26
+
27
+ /** The parsed `req.url`, cached per request. */
28
+ export function parsedUrl(req: Request): URL {
29
+ const cached = cache.get(req)
30
+ if (cached) return cached
31
+
32
+ const url = new URL(req.url)
33
+ cache.set(req, url)
34
+ return url
35
+ }