@bakery-framework/core 2.0.0-alpha.9 → 2.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +54 -8
  18. package/src/core/define-route.ts +5 -6
  19. package/src/core/index.ts +29 -8
  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 @@ const RE_TRAILING_WILDCARD = /\/?\*?$/
17
17
  const RE_ABSOLUTE = /^([A-Za-z]:)?[\\/]/
18
18
 
19
19
  // The application's tsconfig, resolved against its cwd. This used to write into
20
- // a tsconfig *inside the framework* — app-specific paths mutating a shipped
20
+ // a tsconfig *inside the framework*: app-specific paths mutating a shipped
21
21
  // package file, which is also why running the test suite dirtied the tree.
22
22
  const APP_CONFIG_PATH = fs.resolve(process.cwd(), 'tsconfig.json')
23
23
  const APP_DIR = process.cwd()
@@ -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
  */
@@ -93,7 +100,7 @@ export function coreProjects(): PluginTsProject[] {
93
100
  include: [`${root}/**/*.ts`],
94
101
  exclude: [`${root}/**/api/**/*.ts`],
95
102
  // The only project that gets `importMap` aliases, because the import map
96
- // is a browser mechanism — see `importMapPaths` on `PluginTsProject`.
103
+ // is a browser mechanism. See `importMapPaths` on `PluginTsProject`.
97
104
  importMapPaths: true,
98
105
  },
99
106
  ]
@@ -108,7 +115,7 @@ export function coreProjects(): PluginTsProject[] {
108
115
  export function fromProjectDir(pathOrGlob: string): string {
109
116
  // **`Bakery.config.root` is absolute**, so globs built from it arrive here as
110
117
  // full paths. Prefixing `../../` to one yields
111
- // `../../C:/WebDAV/.../src/**/*.ts`, which matches nothing — and a project
118
+ // `../../C:/WebDAV/.../src/**/*.ts`, which matches nothing, and a project
112
119
  // that matches nothing typechecks clean, so the mistake presents as success.
113
120
  // That is exactly how the first version of this passed with zero files.
114
121
  if (RE_ABSOLUTE.test(pathOrGlob)) {
@@ -121,8 +128,8 @@ export function fromProjectDir(pathOrGlob: string): string {
121
128
  /**
122
129
  * Resolve a `files` entry to something TypeScript will actually load.
123
130
  *
124
- * A package specifier is the useful form for a plugin to write — it does not
125
- * know where it was installed — but `files` is resolved as a path, so
131
+ * A package specifier is the useful form for a plugin to write (it does not
132
+ * know where it was installed), but `files` is resolved as a path, so
126
133
  * `@scope/pkg/x.d.ts` would simply be missing. Resolution failure is not fatal:
127
134
  * a plugin whose declaration cannot be found should degrade to "no types" and
128
135
  * say so, not stop the dev server from booting.
@@ -145,16 +152,16 @@ function resolveFilesEntry(entry: string): string | null {
145
152
  *
146
153
  * **TypeScript's rule is that a child's `files` *replaces* the parent's, and
147
154
  * that rule silently disarmed every project a plugin contributes.**
148
- * `tsconfig.vue.json` lists core's three ambient declarations — `global.d.ts`,
149
- * `shared.d.ts`, `types.d.ts` — which is where `Bakery`, `AppConfig`, the JSX
155
+ * `tsconfig.vue.json` lists core's three ambient declarations: `global.d.ts`,
156
+ * `shared.d.ts`, `types.d.ts`, which is where `Bakery`, `AppConfig`, the JSX
150
157
  * namespace and `Request.session` come from. `@bakery-framework/plugin-vue`
151
158
  * declares one `files` entry of its own for `vue.d.ts`, and that one entry
152
159
  * replaced all three: measured on a real app, the generated `vue` project loaded
153
160
  * **zero** of them.
154
161
  *
155
162
  * It hid because `vue.d.ts` happens to declare `req` and `body` itself, so the
156
- * globals an SFC reaches for most still resolved. Everything else — `Bakery`,
157
- * `MapOf`, the JSX namespace — was quietly missing.
163
+ * globals an SFC reaches for most still resolved. Everything else (`Bakery`,
164
+ * `MapOf`, the JSX namespace) was quietly missing.
158
165
  *
159
166
  * So the base's list is read and merged rather than inherited. Paths inside it
160
167
  * are relative to *that* file, which is the property the whole arrangement rests
@@ -174,7 +181,7 @@ function readBase(extendsSpecifier: string): string[] {
174
181
  })
175
182
  } catch {
176
183
  // A base that cannot be read is not fatal: the project still compiles, it
177
- // just loses the ambients — which is the status quo this repairs, not a
184
+ // just loses the ambients, which is the status quo this repairs, not a
178
185
  // regression. Assume client-side, which is the conservative half.
179
186
  return []
180
187
  }
@@ -187,12 +194,12 @@ function readBase(extendsSpecifier: string): string[] {
187
194
  * it reached exactly one project: `server`, because that is the only one whose
188
195
  * `include` covers `orm/**`. Everywhere else `SchemaRegistry` stayed empty,
189
196
  * `Registered` resolved to `never`, and every table fell back to
190
- * `MapOf<MapOf<any>>` — the ORM's documented untyped mode, arrived at by
197
+ * `MapOf<MapOf<any>>`: the ORM's documented untyped mode, arrived at by
191
198
  * accident. It does not error; it just stops checking.
192
199
  *
193
200
  * **Server-side projects only.** The client project deliberately does not get
194
201
  * it: the ORM is server-only, so a browser file importing `DB` should fail to
195
- * typecheck rather than be helpfully typed. That is not only a preference —
202
+ * typecheck rather than be helpfully typed. That is not only a preference:
196
203
  * `@bakery-framework/orm` ships TypeScript source that calls `Bun.*`, so pulling
197
204
  * it into a config without `bun-types` produces errors from inside the package
198
205
  * rather than types for the app. Measured when this was applied to every
@@ -276,7 +283,7 @@ export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
276
283
  project.server && registrationFile ? [registrationFile] : []
277
284
 
278
285
  // The base's own `files` are merged back in whenever this project declares
279
- // any of its own, because a child's `files` *replaces* the parent's — see
286
+ // any of its own, because a child's `files` *replaces* the parent's. See
280
287
  // `readBase`. Left entirely empty, TypeScript inherits correctly and there
281
288
  // is nothing to repair.
282
289
  const declared = [...own, ...registration]
@@ -292,7 +299,7 @@ export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
292
299
  ...(project.compilerOptions ?? {}),
293
300
  // Only projects that opt in. `importMap` is served to the browser as
294
301
  // `<script type="importmap">`, so its specifiers are resolved there and
295
- // nowhere else — writing them into the server project made an import
302
+ // nowhere else: writing them into the server project made an import
296
303
  // that cannot work on the server typecheck as though it could.
297
304
  ...(project.importMapPaths && Object.keys(paths).length
298
305
  ? { paths: mapPaths(paths) }
@@ -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)}`,
@@ -14,7 +14,7 @@ export { hostStore } from './context'
14
14
  * one collapses to the unprefixed key. That is not cosmetic: this key becomes a
15
15
  * **filename** in five handlers, and `getOrCreateCachedFile` writes three files
16
16
  * per entry with no bound and no eviction. Prefixing the raw `Host` header let
17
- * any client mint an unlimited number of cache entries — 25 requests for one
17
+ * any client mint an unlimited number of cache entries: 25 requests for one
18
18
  * path under 25 made-up hostnames took the cache directory from 9 files to 84.
19
19
  *
20
20
  * `resolveHostConfig` already carries this reasoning for the config cache; the
@@ -69,9 +69,12 @@ export const Bakery: globalThis.Bakery = {
69
69
  get version() {
70
70
  return getAppVersion()
71
71
  },
72
- sharedPool: new SharedMemoryPool(1024 * 1024),
72
+ // No size: the pool's own layout is the default now. It used to be asked for
73
+ // a megabyte, of which 9,280 bytes were the layout and the rest a region
74
+ // nothing read. See the constructor in `utils/shared-pool.ts`.
75
+ sharedPool: new SharedMemoryPool(),
73
76
  // Defined in `core/context.ts`, which is low enough that a module needing a
74
- // path does not have to import `Bakery` to get one — reaching them through
77
+ // path does not have to import `Bakery` to get one: reaching them through
75
78
  // here is what closed the logger cycle. These stay the reading surface for
76
79
  // application and framework code; context is the single definition.
77
80
  //
@@ -8,19 +8,19 @@ import { getAppVersion, getFrameworkVersion } from './context'
8
8
  /**
9
9
  * Discard `.cache/` when the thing that produced it has changed.
10
10
  *
11
- * Its own module, and that is the fix rather than an organisational
11
+ * Its own module, and that is the fix rather than an organizational
12
12
  * preference. This used to live in `config.ts` and run from `initConfig()`,
13
13
  * which is far too late: `cache/shared-db.ts` opens `.cache/shared-cache.db`
14
14
  * at **import** time, so by the time the check ran the process was already
15
15
  * holding a handle to a file inside the directory it was about to delete. On
16
16
  * Windows that delete fails with `EBUSY`, node's recursive walk stops at the
17
- * locked entry, and everything it had not reached yet survived — including
17
+ * locked entry, and everything it had not reached yet survived, including
18
18
  * `html/`, the compiled-page cache, which is exactly what a version bump has
19
19
  * to discard.
20
20
  *
21
21
  * Now `shared-db.ts` awaits this before opening the database, and `initConfig`
22
22
  * still calls it for the case where nothing has touched the cache yet. It is
23
- * memoised, so whichever gets there first does the work and the other is free.
23
+ * memoized, so whichever gets there first does the work and the other is free.
24
24
  */
25
25
  let done: Promise<void> | null = null
26
26
 
@@ -29,6 +29,41 @@ export function checkCacheVersion(): Promise<void> {
29
29
  return done
30
30
  }
31
31
 
32
+ /** Every field that decides whether a compiled artifact is still valid. */
33
+ export interface CacheMarker {
34
+ mode: string
35
+ version: string
36
+ framework: string
37
+ runtime: string
38
+ }
39
+
40
+ /**
41
+ * Whether a marker describes a cache the current process can still use.
42
+ *
43
+ * Exported and called rather than restated, which is the point. The tests
44
+ * beside this file used to write the comparison out a second time and assert
45
+ * on their own copy - so they passed whichever fields the real code compared,
46
+ * and would have gone on passing if one were dropped. Adding `runtime` is what
47
+ * made that visible: three tests agreed the marker "carries all three fields"
48
+ * while the code had four.
49
+ *
50
+ * A missing field reads as stale, which is the intended upgrade path. A marker
51
+ * written before a field existed describes a cache built under conditions
52
+ * nobody recorded, and one wipe is cheaper than guessing.
53
+ */
54
+ export function isCacheStale(
55
+ prev: Partial<CacheMarker> | null | undefined,
56
+ current: CacheMarker,
57
+ ): boolean {
58
+ if (!prev) return true
59
+ return (
60
+ prev.mode !== current.mode ||
61
+ prev.version !== current.version ||
62
+ prev.framework !== current.framework ||
63
+ prev.runtime !== current.runtime
64
+ )
65
+ }
66
+
32
67
  /** Test seam: forget that the check ran, so a fixture can exercise it again. */
33
68
  export function __resetCacheVersionCheck(): void {
34
69
  done = null
@@ -41,7 +76,7 @@ async function run(): Promise<void> {
41
76
 
42
77
  // Read from `Bakery.cacheDir`, never re-derived from `fs.cwd`. This value is
43
78
  // handed straight to a recursive delete below, and it used to be a
44
- // hand-written copy of the constant in `core/bakery.ts` — two sources of
79
+ // hand-written copy of the constant in `core/bakery.ts`: two sources of
45
80
  // truth for a `rm -rf`. Renaming the cache directory in one place and not the
46
81
  // other would have pointed this delete at whatever the stale literal named.
47
82
  const cacheDir = Bakery.cacheDir
@@ -55,17 +90,32 @@ async function run(): Promise<void> {
55
90
  // framework version, with nothing to invalidate it.
56
91
  version: getAppVersion(),
57
92
  framework: getFrameworkVersion(),
93
+ // **And the runtime, for exactly the same reason one level down.**
94
+ //
95
+ // This directory holds *compiled and bundled output* - `html/`, `static/`,
96
+ // `ts_cache/`, the console bundle. Bun's transpiler and bundler produce it,
97
+ // so a Bun upgrade changes what a correct entry looks like, and nothing
98
+ // here used to notice.
99
+ //
100
+ // Not hypothetical, and found the day it happened. Bun 1.4.1 reworked the
101
+ // bundler's barrel optimization across ten commits: 1.4.0 emptied a
102
+ // `sideEffects: false` re-export entry to `export { a };` with no
103
+ // declaration, which is the husk `compiler.ts` carries a repair for. So a
104
+ // cache written by 1.4.0 can hold a husk that the running 1.4.2 would
105
+ // never have produced, and would have served it for the life of the
106
+ // install. 1.4.1 is worse in a quieter way: a bundle it built can rename a
107
+ // `let` onto a function parameter's name and compute **wrong values**
108
+ // while loading perfectly (oven-sh/bun#41354).
109
+ //
110
+ // `Bun.version` rather than `Bun.revision`: a canary build of the same
111
+ // version is a real distinction, but invalidating on every canary would
112
+ // wipe the cache of anyone tracking one, and the failure this prevents is
113
+ // a released bundler change.
114
+ runtime: Bun.version,
58
115
  }
59
116
 
60
117
  const [err, prev] = await Try.catch(() => Bun.file(markerPath).json())
61
- const stale =
62
- err ||
63
- !prev ||
64
- prev.mode !== current.mode ||
65
- prev.version !== current.version ||
66
- prev.framework !== current.framework
67
-
68
- if (!stale) return
118
+ if (!err && !isCacheStale(prev, current)) return
69
119
 
70
120
  const survivors = await wipe(cacheDir)
71
121
  if (!fs.exists(cacheDir)) await fs.mkdir(cacheDir)
@@ -73,7 +123,7 @@ async function run(): Promise<void> {
73
123
  // The marker is written **only** when the directory is actually empty.
74
124
  //
75
125
  // It used to be written unconditionally, right after a delete whose every
76
- // error was swallowed — so a failed wipe produced a file asserting the cache
126
+ // error was swallowed, so a failed wipe produced a file asserting the cache
77
127
  // was current, and nothing ever tried again. A stale compiled page then
78
128
  // outlived the upgrade that was supposed to remove it, silently and
79
129
  // permanently. Leaving the old marker in place costs one retry per boot and
@@ -95,7 +145,7 @@ async function run(): Promise<void> {
95
145
  *
96
146
  * Per-entry rather than one recursive call on the directory itself, because a
97
147
  * single locked file otherwise shields every entry the walk had not yet
98
- * reached — the failure is ordering-dependent, so it presents as "sometimes the
148
+ * reached: the failure is ordering-dependent, so it presents as "sometimes the
99
149
  * cache clears".
100
150
  */
101
151
  export const __wipeCacheDir = wipe
@@ -104,7 +154,7 @@ export const __wipeCacheDir = wipe
104
154
  * The one thing in `.cache/` the wipe must not take.
105
155
  *
106
156
  * **The app's committed `tsconfig.json` *references* `.cache/tsconfig/*.json`,
107
- * so deleting them breaks the editor for the whole project** — not one setting,
157
+ * so deleting them breaks the editor for the whole project**, not one setting,
108
158
  * everything. TypeScript reports `TS6053: File '…/server.json' not found` for
109
159
  * each reference, has no project left to put a file in, and falls back to an
110
160
  * inferred one with no ambients: `req.session`, `Bakery`, the JSX namespace and
@@ -112,13 +162,13 @@ export const __wipeCacheDir = wipe
112
162
  *
113
163
  * That happens on every framework upgrade, because the version wipe is keyed on
114
164
  * the framework version among others. The developer sees their editor lose every
115
- * type the moment they bump a patch, and nothing says why — the files come back
165
+ * type the moment they bump a patch, and nothing says why: the files come back
116
166
  * only on the next `bun run dev`, which is not an obvious remedy for "my types
117
167
  * vanished".
118
168
  *
119
169
  * Keeping them is safe in the direction that matters. A stale project is
120
170
  * regenerated on the next dev boot and is, in the meantime, *approximately
121
- * right* — while a missing one is catastrophically wrong. Nothing is executed
171
+ * right*, while a missing one is catastrophically wrong. Nothing is executed
122
172
  * from these files either: they configure a typechecker, so the "never read a
123
173
  * cache an older framework wrote" rule the wipe exists to enforce does not apply.
124
174
  */
@@ -131,7 +181,7 @@ async function wipe(dir: string): Promise<string[]> {
131
181
 
132
182
  for (const entry of entries) {
133
183
  if (WIPE_KEEP.has(entry)) continue
134
- // Errors are deliberately not swallowed *silently* here — each failure is
184
+ // Errors are deliberately not swallowed *silently* here: each failure is
135
185
  // collected and reported by the caller.
136
186
  await Try.catch(() =>
137
187
  rm(`${dir}/${entry}`, { recursive: true, force: true }),
@@ -38,7 +38,7 @@ const defaultConfig: Required<AppConfig> = {
38
38
  },
39
39
  onShutdown: NOOP,
40
40
  // `maxCacheSize: 500` used to sit here. Nothing in the framework ever read
41
- // it — not the route LRUs, not the tiered cache — so it was a knob that
41
+ // it (not the route LRUs, not the tiered cache), so it was a knob that
42
42
  // typechecked, appeared in completion, and did nothing. Removed from
43
43
  // `AppConfig` at the same time; `defaultConfig` is `Required<AppConfig>`, so
44
44
  // the two can only move together.
@@ -52,7 +52,7 @@ const defaultConfig: Required<AppConfig> = {
52
52
  rateLimit: DEFAULT_RATE_LIMIT,
53
53
  trustProxy: false,
54
54
  // Empty means auto-detect, matching `head`/`body` above. There is no default
55
- // *path* to give here: the default behaviour is the probe itself.
55
+ // *path* to give here: the default behavior is the probe itself.
56
56
  schema: '',
57
57
  root: 'src',
58
58
  hosts: {},
@@ -72,7 +72,7 @@ let configLoadError: string | null = null
72
72
  /**
73
73
  * The import failure recorded by the most recent `initConfig()` run, when a
74
74
  * `server.config.ts` was *present but failed to load*. DEV-only by
75
- * construction — in PROD that same condition throws out of `initConfig()`
75
+ * construction: in PROD that same condition throws out of `initConfig()`
76
76
  * instead of being recorded. Read by `runStartupBanner()` so the warning is
77
77
  * restated where the developer is actually looking, not just in scrollback.
78
78
  */
@@ -81,10 +81,10 @@ export function getConfigLoadError(): string | null {
81
81
  }
82
82
 
83
83
  /**
84
- * `hosts` folded to lower case, memoised against the object it came from.
84
+ * `hosts` folded to lower case, memoized against the object it came from.
85
85
  *
86
86
  * Keyed on identity rather than rebuilt per request because `hosts` is frozen
87
- * for the process lifetime — except under `__setTestConfig`, where a new object
87
+ * for the process lifetime: except under `__setTestConfig`, where a new object
88
88
  * arrives and the identity check rebuilds rather than serving a stale map.
89
89
  */
90
90
  let hostLookup: {
@@ -117,16 +117,16 @@ export async function initConfig(): Promise<Readonly<ProcessedAppConfig>> {
117
117
  const [error, loaded] = await Try.catch(import(configPath))
118
118
  if (error) {
119
119
  // Present-but-broken is not absence. This used to log one line and boot
120
- // on `defaultConfig` — port 3000, no plugins, no hosts — so the
120
+ // on `defaultConfig` (port 3000, no plugins, no hosts), so the
121
121
  // developer debugged the vanished dashboard instead of the config that
122
122
  // never parsed. In PROD that boot must not happen: the throw lands in
123
123
  // the entry's existing catch (prod.ts / threads.ts / worker.ts), which
124
124
  // logs and exits 1. In DEV a crash would just loop the watcher, so boot
125
- // — loudly, and leave the error for the startup banner to restate.
125
+ //: loudly, and leave the error for the startup banner to restate.
126
126
  const message = errorMsg(error)
127
127
  if (import.meta.env.PROD) {
128
128
  throw new Error(
129
- `server.config.ts exists but failed to import — refusing to start on the default config.\n file: ${configPath}\n${message}`,
129
+ `server.config.ts exists but failed to import: refusing to start on the default config.\n file: ${configPath}\n${message}`,
130
130
  )
131
131
  }
132
132
  configLoadError = message
@@ -165,7 +165,7 @@ let testOverrides: Partial<ProcessedAppConfig> | null = null
165
165
  * The resolved config is frozen, so a test that needs to exercise a
166
166
  * config-dependent branch cannot simply assign to it. The alternative was
167
167
  * `mock.module('./core/config', …)`, which is process-global and never
168
- * restored — one file doing that left `Bakery.config` undefined for every test
168
+ * restored: one file doing that left `Bakery.config` undefined for every test
169
169
  * file loaded after it, and the suite stayed green only because Bun happened
170
170
  * to order that file last.
171
171
  *
@@ -246,12 +246,12 @@ function lookupHostEntry(
246
246
 
247
247
  /**
248
248
  * The canonical form of `hostname` if the app declares it under `hosts`, and
249
- * `''` otherwise — including when no `hosts` are configured at all.
249
+ * `''` otherwise, including when no `hosts` are configured at all.
250
250
  *
251
251
  * Same reasoning as `resolveHostConfig` below, which already refuses to cache
252
252
  * an unknown hostname: the value arrives on the `Host` header, so anything
253
- * derived from it that is *kept* — a config entry, a cache key, a file on disk
254
- * — has to be bounded by the configured set, or an unauthenticated client can
253
+ * derived from it that is *kept* (a config entry, a cache key, a file on disk
254
+ *) has to be bounded by the configured set, or an unauthenticated client can
255
255
  * grow it without limit. Collapsing unknown hosts to `''` puts them all in one
256
256
  * bucket, which is correct because they all get the base config and therefore
257
257
  * the same content.
@@ -276,10 +276,10 @@ export function resolveHostConfig(
276
276
  const hosts = base.hosts
277
277
  if (!hosts || !Object.keys(hosts).length) return base
278
278
 
279
- // Hostnames are case-insensitive (RFC 4343) and nothing upstream normalises
279
+ // Hostnames are case-insensitive (RFC 4343) and nothing upstream normalizes
280
280
  // the Host header, so a request to `EXAMPLE.com` used to miss an
281
281
  // `example.com` entry and silently fall back to the base config. Both sides
282
- // fold to lower case — `toLowerCase`, not `toLocaleLowerCase`, which would
282
+ // fold to lower case: `toLowerCase`, not `toLocaleLowerCase`, which would
283
283
  // map `I` to a dotless `ı` in a Turkish locale.
284
284
  const key = hostname.toLowerCase()
285
285
 
@@ -290,9 +290,9 @@ export function resolveHostConfig(
290
290
  if (!entry) {
291
291
  // Deliberately NOT cached. The hostname comes straight from the Host
292
292
  // header, so caching unknown values let any client grow this map without
293
- // bound — one request per made-up hostname until the process runs out of
293
+ // bound: one request per made-up hostname until the process runs out of
294
294
  // memory. Known hosts are a fixed, small set, so only those are cached.
295
- // Normalising above keeps that bound honest: without it, `EXAMPLE.com` and
295
+ // Normalizing above keeps that bound honest: without it, `EXAMPLE.com` and
296
296
  // `example.com` would occupy two entries for one configured host.
297
297
  return base
298
298
  }