@bakery-framework/core 2.0.0-alpha.8 → 2.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
125
|
-
* know where it was installed
|
|
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
|
|
149
|
-
* `shared.d.ts`, `types.d.ts
|
|
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
|
|
157
|
-
* `MapOf`, the JSX namespace
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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
|
|
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
|
|
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
|
-
* **
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* `
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* `
|
|
371
|
-
*
|
|
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
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
466
|
+
const repaired = stripGeneratedReferences(current)
|
|
467
|
+
if (!repaired) return
|
|
397
468
|
|
|
398
|
-
await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(
|
|
399
|
-
serveLog.
|
|
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/bakery.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }),
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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*
|
|
254
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|