@bakery-framework/core 1.1.0 → 1.2.0
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 +1 -1
- package/src/cache/shared-db.ts +69 -14
- package/src/client/utils.ts +11 -1
- package/src/compiler/tsconfig-sync.ts +261 -0
- package/src/core/config.ts +7 -0
- package/src/core/define-route.ts +66 -0
- package/src/core/index.ts +35 -13
- package/src/core/port.ts +55 -0
- package/src/global.d.ts +23 -2
- package/src/handlers/core/$middleware.ts +33 -4
- package/src/logger/serve-log.ts +11 -0
- package/src/plugins/types.ts +59 -0
- package/src/router.ts +22 -0
- package/src/utils/http/cors.ts +170 -0
- package/src/utils/http/index.ts +1 -0
- package/src/utils/http/sse.ts +200 -0
- package/src/utils/http/validate.ts +106 -0
package/package.json
CHANGED
package/src/cache/shared-db.ts
CHANGED
|
@@ -7,19 +7,39 @@ import { fs } from '../utils'
|
|
|
7
7
|
/**
|
|
8
8
|
* The tiered cache's spill-to-disk store — sessions and LRU overflow.
|
|
9
9
|
*
|
|
10
|
-
* Under `Bakery.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* version
|
|
21
|
-
*
|
|
22
|
-
*
|
|
10
|
+
* Under `Bakery.dataDir`, beside `server.db`. Rows here are *rebuildable* — a
|
|
11
|
+
* session is a cookie plus whatever the app hung off it, and the LRU tier
|
|
12
|
+
* refills from source on the next miss — but rebuildable is not the same as
|
|
13
|
+
* disposable-on-a-schedule, which is what living in `cacheDir` amounted to.
|
|
14
|
+
* See the reversal below.
|
|
15
|
+
*
|
|
16
|
+
* **Sessions used to be wiped on every framework upgrade. They are not now, and
|
|
17
|
+
* this paragraph is the reversal.**
|
|
18
|
+
*
|
|
19
|
+
* The file lived under `Bakery.cacheDir`, so `checkCacheVersion` deleted it on
|
|
20
|
+
* every version bump and every dev<->prod switch — logging every user out. The
|
|
21
|
+
* argument for that was sound: a cache format written by an older framework
|
|
22
|
+
* must never be read by a newer one, and the version wipe is what guarantees
|
|
23
|
+
* it.
|
|
24
|
+
*
|
|
25
|
+
* What changed is how often a version bumps. That reasoning was written when
|
|
26
|
+
* releases were cut by hand and rare; releases are now computed from commit
|
|
27
|
+
* messages and published automatically, so a patch ships whenever a `fix:`
|
|
28
|
+
* lands. Logging out every user of every app on every patch is a far worse
|
|
29
|
+
* trade than it was, and it was never the *goal* — only the consequence of
|
|
30
|
+
* keying durability on a version number that had nothing to do with the stored
|
|
31
|
+
* format.
|
|
32
|
+
*
|
|
33
|
+
* So the file moved to `Bakery.dataDir` and carries its own `SCHEMA_VERSION`.
|
|
34
|
+
* The safety property is unchanged and now keyed on the thing that governs it:
|
|
35
|
+
* a schema mismatch drops the tables, a framework bump does not.
|
|
36
|
+
*
|
|
37
|
+
* Two consequences worth knowing. An app with no ORM now creates a `bakery/`
|
|
38
|
+
* directory where it previously created none — it is storing durable data, so
|
|
39
|
+
* that is honest, but it means "no `bakery/` directory" is no longer evidence
|
|
40
|
+
* that `initDB` never ran. And the LRU spill tier shares this file, so overflow
|
|
41
|
+
* entries also survive; they are still rebuildable, just no longer discarded on
|
|
42
|
+
* a schedule nobody chose.
|
|
23
43
|
*
|
|
24
44
|
* **That was aspirational until 2026-08-09, and the `await` below is what makes
|
|
25
45
|
* it true.** The check ran from `initConfig()`, while this module opens the
|
|
@@ -33,10 +53,45 @@ import { fs } from '../utils'
|
|
|
33
53
|
*/
|
|
34
54
|
await checkCacheVersion()
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Bumped only when the *shape* of what is stored here changes.
|
|
58
|
+
*
|
|
59
|
+
* This is what replaced "wipe on every framework version". A schema change is
|
|
60
|
+
* rare and deliberate; a framework patch is neither, and tying the two together
|
|
61
|
+
* meant every patch logged out every user.
|
|
62
|
+
*/
|
|
63
|
+
const SCHEMA_VERSION = 1
|
|
64
|
+
|
|
65
|
+
const dbFilePath = `${Bakery.dataDir}/sessions.db`
|
|
37
66
|
if (!fs.exists(dbFilePath)) await fs.mkdir(dirname(dbFilePath))
|
|
38
67
|
|
|
39
68
|
export const cacheDb = new Database(dbFilePath, { create: true })
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drop everything if the stored schema version is not this one.
|
|
72
|
+
*
|
|
73
|
+
* The safety property the cache wipe provided — a newer framework never reads
|
|
74
|
+
* an older format — is preserved exactly, just keyed on the thing that actually
|
|
75
|
+
* governs compatibility. A mismatch logs users out, which is the same outcome
|
|
76
|
+
* as before; the difference is that it now happens when the format changes
|
|
77
|
+
* rather than when any version number does.
|
|
78
|
+
*/
|
|
79
|
+
cacheDb.run('CREATE TABLE IF NOT EXISTS __schema (version INTEGER NOT NULL)')
|
|
80
|
+
const stored = cacheDb
|
|
81
|
+
.query<{ version: number }, []>('SELECT version FROM __schema LIMIT 1')
|
|
82
|
+
.get()
|
|
83
|
+
|
|
84
|
+
if (!stored) {
|
|
85
|
+
cacheDb.run('INSERT INTO __schema (version) VALUES (?)', [SCHEMA_VERSION])
|
|
86
|
+
} else if (stored.version !== SCHEMA_VERSION) {
|
|
87
|
+
const tables = cacheDb
|
|
88
|
+
.query<{ name: string }, []>(
|
|
89
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != '__schema'",
|
|
90
|
+
)
|
|
91
|
+
.all()
|
|
92
|
+
for (const { name } of tables) cacheDb.run(`DROP TABLE IF EXISTS "${name}"`)
|
|
93
|
+
cacheDb.run('UPDATE __schema SET version = ?', [SCHEMA_VERSION])
|
|
94
|
+
}
|
|
40
95
|
const journalMode = process.platform === 'win32' ? 'DELETE' : 'WAL'
|
|
41
96
|
cacheDb.run(`PRAGMA journal_mode = ${journalMode};`)
|
|
42
97
|
cacheDb.run('PRAGMA synchronous = NORMAL;')
|
package/src/client/utils.ts
CHANGED
|
@@ -161,7 +161,17 @@ Object.assign(globalThis, {
|
|
|
161
161
|
request,
|
|
162
162
|
randomId,
|
|
163
163
|
Bakery: {
|
|
164
|
-
|
|
164
|
+
// **Cast rather than `import.meta.env` directly.** `ImportMeta.env` is
|
|
165
|
+
// declared by `bun-types`, and this file is compiled by the *client*
|
|
166
|
+
// tsconfig, which deliberately has none — reaching for a Bun-provided type
|
|
167
|
+
// here would undo the split it exists to enforce.
|
|
168
|
+
//
|
|
169
|
+
// Declaring `ImportMeta` in client/globals.d.ts instead would clash with
|
|
170
|
+
// bun-types inside core's own project, which is the incompatible
|
|
171
|
+
// redeclaration this repo has already had once. The value is substituted by
|
|
172
|
+
// the compiler at build time, so the cast describes what is actually there.
|
|
173
|
+
version: (import.meta as { env?: Record<string, string | undefined> }).env
|
|
174
|
+
?.BAKERY_VERSION,
|
|
165
175
|
async virtual(path: string) {
|
|
166
176
|
const response = await fetch(path)
|
|
167
177
|
if (!response.ok) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Bakery } from '../core/bakery'
|
|
2
2
|
import { errorMsg, serveLog } from '../logger'
|
|
3
|
+
import type { PluginTsProject } from '../plugins/types'
|
|
3
4
|
import type { MapOf } from '../types'
|
|
4
5
|
import { fs } from '../utils/fs'
|
|
5
6
|
import { parseJSONC } from '../utils/jsonc'
|
|
@@ -10,6 +11,9 @@ const RE_HTTP = /^https?:\/\//
|
|
|
10
11
|
const RE_LEADING_SLASHES = /^(\.\/|\/)/
|
|
11
12
|
const RE_RELATIVE = /^\.\.?\//
|
|
12
13
|
const RE_TRAILING_WILDCARD = /\/?\*?$/
|
|
14
|
+
// Drive letters included: `Bakery.config.root` is absolute, and on Windows
|
|
15
|
+
// that is `C:/…` rather than a leading slash.
|
|
16
|
+
const RE_ABSOLUTE = /^([A-Za-z]:)?[\\/]/
|
|
13
17
|
|
|
14
18
|
// The application's tsconfig, resolved against its cwd. This used to write into
|
|
15
19
|
// a tsconfig *inside the framework* — app-specific paths mutating a shipped
|
|
@@ -45,6 +49,263 @@ function buildPaths(): MapOf<string[]> {
|
|
|
45
49
|
return newPaths
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/** Where the generated projects go, and where the root config points. */
|
|
53
|
+
const PROJECT_DIR = fs.resolve(APP_DIR, '.cache/tsconfig')
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The two projects core always generates.
|
|
57
|
+
*
|
|
58
|
+
* The split is the whole point: only `server` carries `bun-types`, so `Bun.*`
|
|
59
|
+
* in a file bound for the browser is a type error rather than a runtime one.
|
|
60
|
+
* Before this existed, one config covered everything and `Bun.hash()` in a
|
|
61
|
+
* client file typechecked clean and failed in the browser.
|
|
62
|
+
*
|
|
63
|
+
* Globs are app-relative here and rewritten to be relative to the generated
|
|
64
|
+
* file, which sits two levels down.
|
|
65
|
+
*/
|
|
66
|
+
export function coreProjects(): PluginTsProject[] {
|
|
67
|
+
const root = Bakery.config.root ?? 'src'
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
name: 'server',
|
|
72
|
+
extends: '@bakery-framework/core/tsconfig.server.json',
|
|
73
|
+
// Repeated rather than inherited: Bun's runtime does not follow
|
|
74
|
+
// `extends` into a package specifier, only a relative path.
|
|
75
|
+
compilerOptions: {
|
|
76
|
+
jsx: 'react',
|
|
77
|
+
jsxFactory: 'createElement',
|
|
78
|
+
jsxFragmentFactory: 'Fragment',
|
|
79
|
+
},
|
|
80
|
+
include: [
|
|
81
|
+
`${root}/**/api/**/*.ts`,
|
|
82
|
+
`${root}/**/*.tsx`,
|
|
83
|
+
'server.config.ts',
|
|
84
|
+
'schema.ts',
|
|
85
|
+
'orm/**/*.ts',
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'client',
|
|
90
|
+
extends: '@bakery-framework/core/tsconfig.app.json',
|
|
91
|
+
include: [`${root}/**/*.ts`],
|
|
92
|
+
exclude: [`${root}/**/api/**/*.ts`],
|
|
93
|
+
// The only project that gets `importMap` aliases, because the import map
|
|
94
|
+
// is a browser mechanism — see `importMapPaths` on `PluginTsProject`.
|
|
95
|
+
importMapPaths: true,
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Turn an app-relative glob into one relative to `.cache/tsconfig/`.
|
|
102
|
+
*
|
|
103
|
+
* Two levels up, and always with a leading `../` so TypeScript reads it as a
|
|
104
|
+
* path rather than resolving it against the project directory.
|
|
105
|
+
*/
|
|
106
|
+
export function fromProjectDir(pathOrGlob: string): string {
|
|
107
|
+
// **`Bakery.config.root` is absolute**, so globs built from it arrive here as
|
|
108
|
+
// full paths. Prefixing `../../` to one yields
|
|
109
|
+
// `../../C:/WebDAV/.../src/**/*.ts`, which matches nothing — and a project
|
|
110
|
+
// that matches nothing typechecks clean, so the mistake presents as success.
|
|
111
|
+
// That is exactly how the first version of this passed with zero files.
|
|
112
|
+
if (RE_ABSOLUTE.test(pathOrGlob)) {
|
|
113
|
+
const rel = fs.relative(PROJECT_DIR, pathOrGlob).replace(/\\/g, '/')
|
|
114
|
+
return RE_RELATIVE.test(rel) ? rel : `./${rel}`
|
|
115
|
+
}
|
|
116
|
+
return `../../${pathOrGlob.replace(RE_LEADING_SLASHES, '')}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a `files` entry to something TypeScript will actually load.
|
|
121
|
+
*
|
|
122
|
+
* A package specifier is the useful form for a plugin to write — it does not
|
|
123
|
+
* know where it was installed — but `files` is resolved as a path, so
|
|
124
|
+
* `@scope/pkg/x.d.ts` would simply be missing. Resolution failure is not fatal:
|
|
125
|
+
* a plugin whose declaration cannot be found should degrade to "no types" and
|
|
126
|
+
* say so, not stop the dev server from booting.
|
|
127
|
+
*/
|
|
128
|
+
function resolveFilesEntry(entry: string): string | null {
|
|
129
|
+
if (entry.startsWith('.') || entry.startsWith('/')) {
|
|
130
|
+
return fromProjectDir(entry)
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const abs = Bun.resolveSync(entry, APP_DIR)
|
|
134
|
+
const rel = fs.relative(PROJECT_DIR, abs).replace(/\\/g, '/')
|
|
135
|
+
return RE_RELATIVE.test(rel) ? rel : `./${rel}`
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every project: core's two, plus whatever the loaded plugins contribute. */
|
|
142
|
+
function allProjects(): PluginTsProject[] {
|
|
143
|
+
const projects = coreProjects()
|
|
144
|
+
const seen = new Set(projects.map(p => p.name))
|
|
145
|
+
|
|
146
|
+
for (const plugin of Bakery.config.plugins ?? []) {
|
|
147
|
+
const project = plugin?.tsconfig?.project
|
|
148
|
+
if (!project) continue
|
|
149
|
+
|
|
150
|
+
// A plugin cannot silently replace `server` or `client`, or another
|
|
151
|
+
// plugin's project. Skipping with a log beats a collision that presents as
|
|
152
|
+
// "my types stopped working" three plugins later.
|
|
153
|
+
if (seen.has(project.name)) {
|
|
154
|
+
serveLog.TSCONFIG_PROJECT_CLASH({
|
|
155
|
+
plugin: plugin.name,
|
|
156
|
+
project: project.name,
|
|
157
|
+
})
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
seen.add(project.name)
|
|
162
|
+
projects.push(project)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return projects
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write `.cache/tsconfig/*.json` and point the root config at them.
|
|
170
|
+
*
|
|
171
|
+
* The root becomes references-only. Anything a person had in `compilerOptions`
|
|
172
|
+
* there stops applying, which is why the generated projects carry the JSX
|
|
173
|
+
* options rather than relying on the root.
|
|
174
|
+
*/
|
|
175
|
+
export async function writeProjects(paths: MapOf<string[]>): Promise<string[]> {
|
|
176
|
+
const written: string[] = []
|
|
177
|
+
|
|
178
|
+
for (const project of allProjects()) {
|
|
179
|
+
const files = (project.files ?? [])
|
|
180
|
+
.map(entry => {
|
|
181
|
+
const resolved = resolveFilesEntry(entry)
|
|
182
|
+
if (!resolved) {
|
|
183
|
+
serveLog.TSCONFIG_FILE_UNRESOLVED({ entry })
|
|
184
|
+
}
|
|
185
|
+
return resolved
|
|
186
|
+
})
|
|
187
|
+
.filter((f): f is string => f !== null)
|
|
188
|
+
|
|
189
|
+
const config: Record<string, unknown> = {
|
|
190
|
+
$comment:
|
|
191
|
+
'GENERATED by Bakery on dev boot. Edits are lost; change the plugin or server.config.ts instead.',
|
|
192
|
+
extends: project.extends,
|
|
193
|
+
compilerOptions: {
|
|
194
|
+
...(project.compilerOptions ?? {}),
|
|
195
|
+
// Only projects that opt in. `importMap` is served to the browser as
|
|
196
|
+
// `<script type="importmap">`, so its specifiers are resolved there and
|
|
197
|
+
// nowhere else — writing them into the server project made an import
|
|
198
|
+
// that cannot work on the server typecheck as though it could.
|
|
199
|
+
...(project.importMapPaths && Object.keys(paths).length
|
|
200
|
+
? { paths: mapPaths(paths) }
|
|
201
|
+
: {}),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (files.length) config.files = files
|
|
206
|
+
if (project.include) config.include = project.include.map(fromProjectDir)
|
|
207
|
+
if (project.exclude) config.exclude = project.exclude.map(fromProjectDir)
|
|
208
|
+
|
|
209
|
+
const target = fs.resolve(PROJECT_DIR, `${project.name}.json`)
|
|
210
|
+
await Bun.write(target, `${JSON.stringify(config, null, 2)}\n`)
|
|
211
|
+
written.push(project.name)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return written
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `paths` values are app-relative; the generated files sit two levels down. */
|
|
218
|
+
function mapPaths(paths: MapOf<string[]>): MapOf<string[]> {
|
|
219
|
+
const out: MapOf<string[]> = {}
|
|
220
|
+
for (const [key, values] of Object.entries(paths)) {
|
|
221
|
+
out[key] = values.map(fromProjectDir)
|
|
222
|
+
}
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The app's root tsconfig with `references` set, and nothing else touched.
|
|
228
|
+
*
|
|
229
|
+
* Pure, and exported, so the merge can be tested without a function that writes
|
|
230
|
+
* to `process.cwd()`. The property that matters is negative — *no key the
|
|
231
|
+
* developer wrote is lost* — which is the kind of thing a shape assertion on the
|
|
232
|
+
* source cannot check.
|
|
233
|
+
*/
|
|
234
|
+
export function mergeRootConfig(
|
|
235
|
+
current: Record<string, unknown> | null,
|
|
236
|
+
references: { path: string }[],
|
|
237
|
+
): Record<string, unknown> {
|
|
238
|
+
// Spread first so `references` is the only key this owns.
|
|
239
|
+
if (current) return { ...current, references }
|
|
240
|
+
|
|
241
|
+
// No root config at all. The generated projects do not help Bun's runtime, so
|
|
242
|
+
// the one this writes has to carry the JSX options itself.
|
|
243
|
+
return {
|
|
244
|
+
extends: '@bakery-framework/core/tsconfig.server.json',
|
|
245
|
+
compilerOptions: {
|
|
246
|
+
jsx: 'react',
|
|
247
|
+
jsxFactory: 'createElement',
|
|
248
|
+
jsxFragmentFactory: 'Fragment',
|
|
249
|
+
},
|
|
250
|
+
references,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generate the project configs and add `references` to the app's root tsconfig.
|
|
256
|
+
*
|
|
257
|
+
* Separate from `syncTSConfigPaths` because an app can reasonably want one and
|
|
258
|
+
* not the other: the paths sync has existed for a long time and rewrites a file
|
|
259
|
+
* people keep in git, while this owns a directory nobody edits.
|
|
260
|
+
*
|
|
261
|
+
* **It used to replace the root config with a references-only stub, and that
|
|
262
|
+
* silently broke every `.tsx` page in the app.** The reasoning was tidy — the
|
|
263
|
+
* generated projects carry the JSX options, so the root does not need them — and
|
|
264
|
+
* it was wrong about *who reads the root*. `tsc` follows `references`; **Bun's
|
|
265
|
+
* runtime does not.** Bun reads `compilerOptions.jsx*` from the root
|
|
266
|
+
* `tsconfig.json` and nothing else, so a references-only root means pages get
|
|
267
|
+
* transpiled against the automatic JSX runtime instead of Bakery's
|
|
268
|
+
* `createElement`.
|
|
269
|
+
*
|
|
270
|
+
* The symptom is worse than the 500 that mistake usually produces. Measured on a
|
|
271
|
+
* scratch app: `GET /` answered **200** with
|
|
272
|
+
* `{"type":"html","props":{…},"_owner":null,"_store":{}}` — a React element tree,
|
|
273
|
+
* JSON-encoded, because the handler received an object where it expects a
|
|
274
|
+
* `SafeHtml` string. Nothing logs, nothing throws, the status is fine.
|
|
275
|
+
*
|
|
276
|
+
* So the root is now *merged*, not replaced: every key the developer wrote stays,
|
|
277
|
+
* and only `references` is ours. That also settles the contradiction with the
|
|
278
|
+
* scaffolder, which writes those JSX options under a comment saying to keep them
|
|
279
|
+
* — and with the three documentation pages that repeat it.
|
|
280
|
+
*/
|
|
281
|
+
export async function syncTSConfigProjects(): Promise<void> {
|
|
282
|
+
try {
|
|
283
|
+
const written = await writeProjects(buildPaths())
|
|
284
|
+
|
|
285
|
+
const references = written.map(name => ({
|
|
286
|
+
path: `./.cache/tsconfig/${name}.json`,
|
|
287
|
+
}))
|
|
288
|
+
|
|
289
|
+
const current = fs.exists(APP_CONFIG_PATH)
|
|
290
|
+
? parseJSONC(await Bun.file(APP_CONFIG_PATH).text())
|
|
291
|
+
: null
|
|
292
|
+
|
|
293
|
+
// Only rewrite when the reference set actually changed. The root config is
|
|
294
|
+
// committed, and a dev boot that dirties git every time trains people to
|
|
295
|
+
// ignore the diff.
|
|
296
|
+
if (current && Bun.deepEquals(current.references, references)) return
|
|
297
|
+
|
|
298
|
+
const root = mergeRootConfig(current, references)
|
|
299
|
+
|
|
300
|
+
await Bun.write(APP_CONFIG_PATH, `${JSON.stringify(root, null, 2)}\n`)
|
|
301
|
+
serveLog.TSCONFIG_PROJECTS_WRITTEN({ count: String(written.length) })
|
|
302
|
+
} catch (err: any) {
|
|
303
|
+
serveLog.UNHANDLED_ERR({
|
|
304
|
+
error: `TSConfig project sync error: ${errorMsg(err)}`,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
48
309
|
export async function syncTSConfigPaths(): Promise<void> {
|
|
49
310
|
try {
|
|
50
311
|
const newPaths = buildPaths()
|
package/src/core/config.ts
CHANGED
|
@@ -21,6 +21,13 @@ const defaultConfig: Required<AppConfig> = {
|
|
|
21
21
|
middleware: [],
|
|
22
22
|
backups: DEFAULT_DB_BACKUPS,
|
|
23
23
|
blocked: [],
|
|
24
|
+
// No CORS by default. Not even permissive in development: a framework
|
|
25
|
+
|
|
26
|
+
// that quietly allows every origin teaches people it works, then
|
|
27
|
+
|
|
28
|
+
// surprises them in production.
|
|
29
|
+
|
|
30
|
+
cors: null,
|
|
24
31
|
head: '',
|
|
25
32
|
body: '',
|
|
26
33
|
plugins: [],
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RouteHandler } from '../types'
|
|
2
|
+
import { response } from '../utils/http'
|
|
3
|
+
import { type Validator, validate } from '../utils/http/validate'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `defineRoute`, in its own module rather than in `core/index.ts`.
|
|
7
|
+
*
|
|
8
|
+
* `core/index.ts` is a barrel — it pulls in the logger, plugins, jsx and utils —
|
|
9
|
+
* so anything importing it from *inside* core closes a cycle and dies with
|
|
10
|
+
* `ReferenceError: Cannot access 'Logger' before initialization`. That is
|
|
11
|
+
* recorded in CLAUDE.md as costing 67 tests once; it cost this file's own tests
|
|
12
|
+
* a second time, which is what moved it here. Consumers still reach it through
|
|
13
|
+
* the barrel; core's modules and tests import this file directly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Identity at runtime in its one-argument form; exists so a route module can
|
|
18
|
+
* declare its body shape once and have the whole signature inferred:
|
|
19
|
+
*
|
|
20
|
+
* export default defineRoute<{ id: string }>((req, body) => …)
|
|
21
|
+
*
|
|
22
|
+
* With a validator, it also *enforces* that shape — see the overload below.
|
|
23
|
+
*
|
|
24
|
+
* `defineRoute`, not `defineHandler` — "handler" already means a registered
|
|
25
|
+
* `Handler` subclass in this framework, and this defines a route module.
|
|
26
|
+
*/
|
|
27
|
+
export function defineRoute<P = {}>(fn: RouteHandler<P>): RouteHandler<P>
|
|
28
|
+
/**
|
|
29
|
+
* Validate the body before the handler runs.
|
|
30
|
+
*
|
|
31
|
+
* export default defineRoute({ body: schema }, (req, body) => …)
|
|
32
|
+
*
|
|
33
|
+
* `body` is a Standard Schema (zod, valibot, arktype — Bakery imports none of
|
|
34
|
+
* them) or a plain function returning the parsed value. A rejection answers
|
|
35
|
+
* `400` through the framework's JSON envelope and the handler never runs.
|
|
36
|
+
*/
|
|
37
|
+
export function defineRoute<T>(
|
|
38
|
+
options: { body: Validator<T> },
|
|
39
|
+
fn: RouteHandler<T>,
|
|
40
|
+
): RouteHandler<T>
|
|
41
|
+
export function defineRoute(
|
|
42
|
+
a: RouteHandler<any> | { body: Validator<any> },
|
|
43
|
+
b?: RouteHandler<any>,
|
|
44
|
+
): RouteHandler<any> {
|
|
45
|
+
// One argument: identity, unchanged. Types only, no runtime cost — every
|
|
46
|
+
// existing route keeps working byte for byte.
|
|
47
|
+
if (typeof a === 'function') return a
|
|
48
|
+
|
|
49
|
+
const { body: validator } = a
|
|
50
|
+
const fn = b as RouteHandler<any>
|
|
51
|
+
|
|
52
|
+
return async function validatedRoute(req, body, server) {
|
|
53
|
+
const result = await validate(validator, body)
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
// The framework's one JSON envelope, so a validation failure looks like
|
|
56
|
+
// every other error a client receives rather than a special case.
|
|
57
|
+
return response.json.error(400, 'Invalid request body', {
|
|
58
|
+
issues: result.issues,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
// The *parsed* value, not the raw body: a schema that coerces or strips
|
|
62
|
+
// unknown keys is doing so precisely to be used, and handing the handler
|
|
63
|
+
// the original would make the coercion a lie.
|
|
64
|
+
return fn(req, result.value, server)
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Logger, log } from '../logger'
|
|
2
2
|
import { definePlugin as _definePlugin } from '../plugins/types'
|
|
3
|
-
import type { RouteHandler } from '../types'
|
|
4
3
|
import { Case, is, Math2, match, Try } from '../utils/common'
|
|
5
|
-
import { response } from '../utils/http'
|
|
4
|
+
import { encodeSSE, response, sse } from '../utils/http'
|
|
6
5
|
import Bakery, { getHostname, hostKey, hostStore } from './bakery'
|
|
7
6
|
import { getConfig, NOOP } from './config'
|
|
8
7
|
import { createElement, Fragment, html } from './jsx'
|
|
@@ -10,17 +9,6 @@ import { createElement, Fragment, html } from './jsx'
|
|
|
10
9
|
export const defineConfig = <T extends AppConfig>(config: T): T => config
|
|
11
10
|
export const definePlugin = _definePlugin
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Identity at runtime, like `defineConfig`; exists so a route module can
|
|
15
|
-
* declare its body shape once and have the whole signature inferred:
|
|
16
|
-
*
|
|
17
|
-
* export default defineRoute<{ id: string }>((req, body) => …)
|
|
18
|
-
*
|
|
19
|
-
* `defineRoute`, not `defineHandler` — "handler" already means a registered
|
|
20
|
-
* `Handler` subclass in this framework, and this defines a route module.
|
|
21
|
-
*/
|
|
22
|
-
export const defineRoute = <P = {}>(fn: RouteHandler<P>): RouteHandler<P> => fn
|
|
23
|
-
|
|
24
12
|
/**
|
|
25
13
|
* Helper types, previously ambient globals. Importable so an app that declares
|
|
26
14
|
* its own `MapOf` is not met with a redeclaration error it cannot opt out of.
|
|
@@ -34,11 +22,44 @@ export type {
|
|
|
34
22
|
RouteResponse,
|
|
35
23
|
Wrapped,
|
|
36
24
|
} from '../types'
|
|
25
|
+
/** Configured in `server.config.ts`; the type is exported so an app can build one. */
|
|
26
|
+
export type { CorsOptions } from '../utils/http/cors'
|
|
27
|
+
export type { SSEMessage, SSEOptions, SSEStream } from '../utils/http/sse'
|
|
28
|
+
// The validation surface, so an app can type its own schemas and validators.
|
|
29
|
+
export type {
|
|
30
|
+
FunctionValidator,
|
|
31
|
+
StandardSchemaLike,
|
|
32
|
+
ValidationIssue,
|
|
33
|
+
Validator,
|
|
34
|
+
} from '../utils/http/validate'
|
|
35
|
+
/**
|
|
36
|
+
* Identity at runtime, like `defineConfig`; exists so a route module can
|
|
37
|
+
* declare its body shape once and have the whole signature inferred:
|
|
38
|
+
*
|
|
39
|
+
* export default defineRoute<{ id: string }>((req, body) => …)
|
|
40
|
+
*
|
|
41
|
+
* `defineRoute`, not `defineHandler` — "handler" already means a registered
|
|
42
|
+
* `Handler` subclass in this framework, and this defines a route module.
|
|
43
|
+
*/
|
|
44
|
+
export { defineRoute } from './define-route'
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Note the shape of the two lines below: `sse` and `encodeSSE` are *imported*
|
|
48
|
+
* at the top of this file and re-exported here, not re-exported directly from
|
|
49
|
+
* `../utils/http/sse`.
|
|
50
|
+
*
|
|
51
|
+
* That is not style. `export … from '../utils/http/sse'` adds a second edge
|
|
52
|
+
* into the module graph and reorders evaluation enough to close the cycle this
|
|
53
|
+
* barrel is always one step away from: it typechecks, and then 47 tests fail
|
|
54
|
+
* with `ReferenceError: Cannot access 'Logger' before initialization`. Going
|
|
55
|
+
* through the `utils/http` index — which line 4 already imports for `response`
|
|
56
|
+
* — adds no edge at all. Every other value here follows the same rule.
|
|
57
|
+
*/
|
|
38
58
|
export {
|
|
39
59
|
Bakery,
|
|
40
60
|
Case,
|
|
41
61
|
createElement,
|
|
62
|
+
encodeSSE,
|
|
42
63
|
Fragment,
|
|
43
64
|
getConfig,
|
|
44
65
|
// Multi-host helpers. Documented in docs/configuration/multi-host.md, and
|
|
@@ -55,6 +76,7 @@ export {
|
|
|
55
76
|
match,
|
|
56
77
|
NOOP,
|
|
57
78
|
response,
|
|
79
|
+
sse,
|
|
58
80
|
Try,
|
|
59
81
|
}
|
|
60
82
|
|
package/src/core/port.ts
CHANGED
|
@@ -71,3 +71,58 @@ export function resolvePort(configPort?: number | null): number {
|
|
|
71
71
|
// Changing that is a separate decision from unifying the three call sites.
|
|
72
72
|
return configPort || DEFAULT_PORT
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `--port 8080`, `--port=8080`, `-p 8080`, `-p=8080`.
|
|
77
|
+
*
|
|
78
|
+
* Returns the raw string so the caller can validate it through the one rule
|
|
79
|
+
* above rather than a second one — a flag that accepted `0x1f` where `PORT`
|
|
80
|
+
* rejects it would be exactly the drift this module exists to end.
|
|
81
|
+
*/
|
|
82
|
+
function portFlagValue(argv: string[]): string | null {
|
|
83
|
+
for (let i = 0; i < argv.length; i++) {
|
|
84
|
+
const arg = argv[i]
|
|
85
|
+
if (arg === '--port' || arg === '-p') return argv[i + 1] ?? ''
|
|
86
|
+
if (arg.startsWith('--port=')) return arg.slice('--port='.length)
|
|
87
|
+
if (arg.startsWith('-p=')) return arg.slice('-p='.length)
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fold a `--port` flag into `process.env.PORT`, before anything reads it.
|
|
94
|
+
*
|
|
95
|
+
* **Why the env rather than a parameter.** The port is read in three places
|
|
96
|
+
* (see the note at the top of this file) across up to three *processes*: the
|
|
97
|
+
* dev master, the dev worker it spawns, and N cluster workers. The spawn sites
|
|
98
|
+
* pass `env: {...process.env}` and build their argv explicitly — `dev-service`
|
|
99
|
+
* forwards `--dev`, `--dev-worker` and `--sync`, and nothing else — so a flag
|
|
100
|
+
* would have to be threaded through each of them and kept in step forever,
|
|
101
|
+
* while an environment variable already propagates to all of them. Normalising
|
|
102
|
+
* once, in the entry, means every existing reader is already correct.
|
|
103
|
+
*
|
|
104
|
+
* **Precedence: flag beats `PORT` beats config.** That is what `--port` means
|
|
105
|
+
* everywhere else a developer has met it (Vite, Next, Astro, Nuxt), and an
|
|
106
|
+
* explicit argument losing to an inherited environment variable would be the
|
|
107
|
+
* surprising order. It is also the recoverable one: a shell with a stale
|
|
108
|
+
* exported `PORT` is fixed by typing the flag, whereas the reverse needs the
|
|
109
|
+
* developer to work out which variable is winning.
|
|
110
|
+
*
|
|
111
|
+
* @throws if the flag is present but its value is not an integer in `0..65535`,
|
|
112
|
+
* including when it is missing entirely (`bakery --port` with nothing after
|
|
113
|
+
* it). A flag typed and then ignored is worse than one that complains.
|
|
114
|
+
*/
|
|
115
|
+
export function applyPortFlag(argv: string[] = process.argv.slice(2)): void {
|
|
116
|
+
const raw = portFlagValue(argv)
|
|
117
|
+
if (raw === null) return
|
|
118
|
+
|
|
119
|
+
const trimmed = raw.trim()
|
|
120
|
+
const parsed = RE_DECIMAL.test(trimmed) ? Number(trimmed) : Number.NaN
|
|
121
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_PORT) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Invalid --port: ${JSON.stringify(raw)} is not an integer between 0 and ${MAX_PORT}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
process.env.PORT = String(parsed)
|
|
128
|
+
}
|