@bakery-framework/core 1.1.1 → 1.2.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/package.json +1 -1
- package/src/cache/shared-db.ts +69 -14
- 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;')
|
|
@@ -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
|
+
}
|
package/src/global.d.ts
CHANGED
|
@@ -44,15 +44,30 @@ declare global {
|
|
|
44
44
|
inBody?: boolean
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What a middleware may return to stop the chain: a `Response`, or a
|
|
49
|
+
* `response.json.*` envelope. Anything else — including a bare object or a
|
|
50
|
+
* string — is ignored and the next middleware runs. See `$middleware.ts`.
|
|
51
|
+
*/
|
|
52
|
+
type MiddlewareResponse =
|
|
53
|
+
| Response
|
|
54
|
+
| import('./utils/common/json').JsonResponseData
|
|
55
|
+
|
|
47
56
|
type HostEntry = {
|
|
48
57
|
root?: string
|
|
49
58
|
importMap?: Record<string, string>
|
|
50
59
|
middleware?: ((
|
|
51
60
|
req: Request,
|
|
52
61
|
server: Bun.Server<any>,
|
|
53
|
-
) => MixedPromise<
|
|
62
|
+
) => MixedPromise<MiddlewareResponse | void>)[]
|
|
54
63
|
onRequest?(req: Request): MixedPromise<any>
|
|
55
64
|
onError?(error: Handler.Error.Data): MixedPromise<any>
|
|
65
|
+
/**
|
|
66
|
+
* Cross-origin resource sharing. Absent means no CORS headers at all,
|
|
67
|
+
* which is the browser default and the safe one — there is deliberately no
|
|
68
|
+
* permissive default, not even in development.
|
|
69
|
+
*/
|
|
70
|
+
cors?: import('./utils/http/cors').CorsOptions | null
|
|
56
71
|
head?: string
|
|
57
72
|
body?: string
|
|
58
73
|
proxy?: Record<string, string>
|
|
@@ -79,6 +94,12 @@ declare global {
|
|
|
79
94
|
|
|
80
95
|
proxy?: Record<string, string>
|
|
81
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Cross-origin resource sharing. Absent means no CORS headers at all,
|
|
99
|
+
* which is the browser default and the safe one — there is deliberately no
|
|
100
|
+
* permissive default, not even in development.
|
|
101
|
+
*/
|
|
102
|
+
cors?: import('./utils/http/cors').CorsOptions | null
|
|
82
103
|
head?: string
|
|
83
104
|
|
|
84
105
|
body?: string
|
|
@@ -94,7 +115,7 @@ declare global {
|
|
|
94
115
|
middleware?: ((
|
|
95
116
|
req: Request,
|
|
96
117
|
server: Bun.Server<any>,
|
|
97
|
-
) => MixedPromise<
|
|
118
|
+
) => MixedPromise<MiddlewareResponse | void>)[]
|
|
98
119
|
|
|
99
120
|
plugins?: ServerPlugin[]
|
|
100
121
|
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { Bakery } from '../../core/bakery'
|
|
2
2
|
import { errorMsg, handlerLog } from '../../logger/serve-log'
|
|
3
|
+
import { JsonResponseData } from '../../utils/common/json'
|
|
3
4
|
import { injectIfHtml, response } from '../../utils/http'
|
|
4
5
|
import { Handler } from './$base'
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* What a middleware may return to stop the chain.
|
|
9
|
+
*
|
|
10
|
+
* A `Response` and a `response.json.*` envelope, and deliberately nothing
|
|
11
|
+
* else. `JsonResponseData` is the framework's one-envelope idiom (convention
|
|
12
|
+
* 7) and `processResponse` already renders it with its own `status`, so an
|
|
13
|
+
* `ApiHandler` route returning `response.json.error(401, …)` answered 401
|
|
14
|
+
* while the identical line in a middleware did not — the value was not a
|
|
15
|
+
* `Response`, so the chain ignored it and the request carried on.
|
|
16
|
+
*
|
|
17
|
+
* Widening further, to "anything truthy", is the tempting version and is
|
|
18
|
+
* wrong: a middleware that returns a stray string or object would then halt
|
|
19
|
+
* the request by accident, and returning a value is how middleware signals
|
|
20
|
+
* *nothing* in plenty of code (`arr.map`, an assignment expression, an
|
|
21
|
+
* implicit arrow return). Two named shapes, both of which mean "I am the
|
|
22
|
+
* response".
|
|
23
|
+
*/
|
|
24
|
+
type MiddlewareResult = Response | JsonResponseData
|
|
25
|
+
|
|
26
|
+
function isMiddlewareResult(value: unknown): value is MiddlewareResult {
|
|
27
|
+
return value instanceof Response || value instanceof JsonResponseData
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
/**
|
|
7
31
|
* Per-request slot for the response produced during `canHandle`, so `handle`
|
|
8
32
|
* can return it without re-running the chain. This was previously a static
|
|
9
33
|
* field, which meant two concurrent requests could swap responses — including
|
|
10
34
|
* each other's `Set-Cookie` headers — at any `await` boundary.
|
|
11
35
|
*/
|
|
12
|
-
const pending = new WeakMap<Request,
|
|
36
|
+
const pending = new WeakMap<Request, MiddlewareResult>()
|
|
13
37
|
|
|
14
38
|
export class MiddlewareHandler extends Handler {
|
|
15
39
|
/** Answers from app code, not from disk. See `Handler.servesFiles`. */
|
|
@@ -48,12 +72,12 @@ export class MiddlewareHandler extends Handler {
|
|
|
48
72
|
return (await injectIfHtml(intercepted)) || intercepted
|
|
49
73
|
}
|
|
50
74
|
|
|
51
|
-
let data:
|
|
75
|
+
let data: MiddlewareResult | undefined
|
|
52
76
|
|
|
53
77
|
for (const middleware of config.middleware) {
|
|
54
78
|
try {
|
|
55
79
|
const result = await middleware(req, Bakery.server!)
|
|
56
|
-
if (result
|
|
80
|
+
if (isMiddlewareResult(result)) {
|
|
57
81
|
data = result
|
|
58
82
|
break
|
|
59
83
|
}
|
|
@@ -65,7 +89,12 @@ export class MiddlewareHandler extends Handler {
|
|
|
65
89
|
}
|
|
66
90
|
}
|
|
67
91
|
|
|
68
|
-
|
|
92
|
+
// An envelope is JSON by construction, so it skips injection rather than
|
|
93
|
+
// paying `DOMTools.isHTML` to be told so. `processResponse` reads its
|
|
94
|
+
// `status` and serialises it.
|
|
95
|
+
if (data instanceof JsonResponseData) return data
|
|
96
|
+
|
|
97
|
+
const injectedRes = await injectIfHtml(data!)
|
|
69
98
|
return injectedRes || data
|
|
70
99
|
}
|
|
71
100
|
}
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -25,6 +25,17 @@ const serveMsgs = {
|
|
|
25
25
|
WATCHER_ERR: 'E Watcher error: %r{error}%*',
|
|
26
26
|
TSCONFIG_SYNCED:
|
|
27
27
|
'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
|
|
28
|
+
TSCONFIG_PROJECTS_WRITTEN:
|
|
29
|
+
'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
|
|
30
|
+
// A plugin asking for a project name that is taken. Named rather than
|
|
31
|
+
// silent: the symptom otherwise is one plugin's types quietly not applying,
|
|
32
|
+
// discovered much later and blamed on the wrong thing.
|
|
33
|
+
TSCONFIG_PROJECT_CLASH:
|
|
34
|
+
'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists — %rskipped%*',
|
|
35
|
+
// Degrades to "no types" rather than failing the boot: a missing declaration
|
|
36
|
+
// is a worse editor experience, not a broken server.
|
|
37
|
+
TSCONFIG_FILE_UNRESOLVED:
|
|
38
|
+
'W Could not resolve tsconfig files entry %y{entry}%* — %rskipped%*',
|
|
28
39
|
MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
|
|
29
40
|
CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
|
|
30
41
|
// Multi-line on purpose: a present-but-broken config booting on defaults is
|
package/src/plugins/types.ts
CHANGED
|
@@ -3,9 +3,68 @@ import type { MixedPromise } from '../types'
|
|
|
3
3
|
|
|
4
4
|
export type ValidResponses = Handler.Response
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A TypeScript project a plugin contributes to the app.
|
|
8
|
+
*
|
|
9
|
+
* Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
|
|
10
|
+
* from the app's root `tsconfig.json`, so the editor typechecks each file under
|
|
11
|
+
* the project that owns it. Regenerated rather than committed: it is derived
|
|
12
|
+
* from the plugin list and the app's config, and `.cache/` is the disposable
|
|
13
|
+
* half of the two runtime directories.
|
|
14
|
+
*/
|
|
15
|
+
export interface PluginTsProject {
|
|
16
|
+
/** File name under `.cache/tsconfig/`, and the project's identity. */
|
|
17
|
+
name: string
|
|
18
|
+
/** Usually one of core's three bases. Written through as-is. */
|
|
19
|
+
extends: string
|
|
20
|
+
/** Globs, relative to the **app root** — the generator rewrites them. */
|
|
21
|
+
include?: string[]
|
|
22
|
+
exclude?: string[]
|
|
23
|
+
/**
|
|
24
|
+
* Ambient declarations the plugin owns.
|
|
25
|
+
*
|
|
26
|
+
* Package specifiers are allowed here and resolved to real paths before
|
|
27
|
+
* writing, because TypeScript resolves `files` as paths and would treat
|
|
28
|
+
* `@scope/pkg/x.d.ts` as a missing file rather than a module.
|
|
29
|
+
*/
|
|
30
|
+
files?: string[]
|
|
31
|
+
compilerOptions?: Record<string, unknown>
|
|
32
|
+
/**
|
|
33
|
+
* Whether this project should receive `paths` derived from `importMap`.
|
|
34
|
+
*
|
|
35
|
+
* Off by default, and that default is the correction: the generator used to
|
|
36
|
+
* write those `paths` into *every* project on the reasoning that an alias is
|
|
37
|
+
* app-wide. It is not. `importMap` is a **browser** import map — the framework
|
|
38
|
+
* serves it as `<script type="importmap">` and the browser is what resolves
|
|
39
|
+
* its specifiers. An alias in the server project therefore typechecks an
|
|
40
|
+
* import that only the browser can satisfy, which is the same class of bug the
|
|
41
|
+
* server/client split was introduced to end.
|
|
42
|
+
*
|
|
43
|
+
* A plugin whose project compiles browser code should set it.
|
|
44
|
+
*/
|
|
45
|
+
importMapPaths?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a plugin contributes to the generated tsconfig projects. */
|
|
49
|
+
export interface PluginTsConfig {
|
|
50
|
+
/** A project this plugin owns outright — Vue SFCs, for instance. */
|
|
51
|
+
project?: PluginTsProject
|
|
52
|
+
}
|
|
53
|
+
|
|
6
54
|
export interface ServerPlugin {
|
|
7
55
|
name: string
|
|
8
56
|
setup?(config: ProcessedAppConfig): MixedPromise<void>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Declarative, unlike every hook below it: read by the tsconfig generator at
|
|
60
|
+
* dev boot, never by the running server.
|
|
61
|
+
*
|
|
62
|
+
* A plugin that brings its own file type or its own ambient globals needs a
|
|
63
|
+
* project to typecheck them under. `@bakery-framework/plugin-vue` is the
|
|
64
|
+
* worked example — it owns `.vue` and declares `req`/`body` for SFC scope,
|
|
65
|
+
* globals core deliberately does not provide.
|
|
66
|
+
*/
|
|
67
|
+
tsconfig?: PluginTsConfig
|
|
9
68
|
onStart?(server: Bun.Server<any>): MixedPromise<void>
|
|
10
69
|
onRequest?(req: Request): ValidResponses
|
|
11
70
|
onRoute?(req: Request): MixedPromise<void>
|
package/src/router.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
response,
|
|
19
19
|
withStatus,
|
|
20
20
|
} from './utils/http'
|
|
21
|
+
import { applyCors, preflightResponse } from './utils/http/cors'
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
|
|
@@ -97,6 +98,19 @@ export async function handleRequest(req: Request) {
|
|
|
97
98
|
// itself, so that was a second full path resolution of the same string on
|
|
98
99
|
// every request.
|
|
99
100
|
const config = Bakery.config
|
|
101
|
+
|
|
102
|
+
// Before anything else, including the forbidden-path check: a preflight names
|
|
103
|
+
// the route it is asking about in a *header*, not the path, so running it
|
|
104
|
+
// through routing would answer a question nobody asked. The browser sends no
|
|
105
|
+
// credentials with it either, which is why it cannot be authorised.
|
|
106
|
+
//
|
|
107
|
+
// Only when `cors` is configured. Absent, this is not reached at all and the
|
|
108
|
+
// browser's own default applies.
|
|
109
|
+
if (config.cors) {
|
|
110
|
+
const preflight = preflightResponse(config.cors, req)
|
|
111
|
+
if (preflight) return preflight
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
const serveRoot = config.root
|
|
101
115
|
if (fs.isForbidden(serveRoot + path, serveRoot)) {
|
|
102
116
|
return new Response('Forbidden', { status: 403 })
|
|
@@ -338,6 +352,14 @@ export async function processResponse(
|
|
|
338
352
|
// append, not set: a handler may already have issued its own Set-Cookie
|
|
339
353
|
// (e.g. an auth cookie from a login route) that must not be overwritten.
|
|
340
354
|
sess && resp.headers.append('Set-Cookie', sess)
|
|
355
|
+
|
|
356
|
+
// Every response funnels through here — pages, API JSON, static files, error
|
|
357
|
+
// pages — so this is the one place that cannot miss one. Applied before ETag
|
|
358
|
+
// so the negotiated `Vary` sees the `Origin` entry and merges with it rather
|
|
359
|
+
// than either overwriting the other.
|
|
360
|
+
const cors = Bakery.config.cors
|
|
361
|
+
if (cors) applyCors(cors, req, resp)
|
|
362
|
+
|
|
341
363
|
const final = ETag.sendResponse(req, resp)
|
|
342
364
|
if (!(final instanceof Response)) {
|
|
343
365
|
log({
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-origin resource sharing.
|
|
3
|
+
*
|
|
4
|
+
* Bakery already ships the pieces of an API server — `ApiHandler`, sessions,
|
|
5
|
+
* CSRF, rate limiting — and had no way to serve one to a browser on another
|
|
6
|
+
* origin. This is that.
|
|
7
|
+
*
|
|
8
|
+
* Two halves, and both are needed: a preflight `OPTIONS` has to be answered
|
|
9
|
+
* before routing (there is no route to run, and the browser will not send the
|
|
10
|
+
* real request until it is answered), and every *other* response needs the
|
|
11
|
+
* headers appended on the way out.
|
|
12
|
+
*
|
|
13
|
+
* **Nothing happens unless `cors` is configured.** No default origin, not even
|
|
14
|
+
* a permissive one in development: a framework that quietly allows every origin
|
|
15
|
+
* teaches people it works and then surprises them in production. An absent
|
|
16
|
+
* config means the headers are never written, which is the browser's own
|
|
17
|
+
* default and the safe one.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** What the app writes in `server.config.ts`. */
|
|
21
|
+
export interface CorsOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Origins allowed to read responses.
|
|
24
|
+
*
|
|
25
|
+
* `'*'` is honoured literally, and is refused in combination with
|
|
26
|
+
* `credentials` — see `resolveOrigin`. A function receives the request's
|
|
27
|
+
* `Origin` and returns the value to echo, or `null` to deny.
|
|
28
|
+
*/
|
|
29
|
+
origin: string | string[] | ((origin: string) => string | null)
|
|
30
|
+
/** Defaults to the methods a browser will preflight for. */
|
|
31
|
+
methods?: string[]
|
|
32
|
+
/** Request headers the browser may send. Defaults to echoing what it asks. */
|
|
33
|
+
allowHeaders?: string[]
|
|
34
|
+
/** Response headers JavaScript may read. Nothing is exposed by default. */
|
|
35
|
+
exposeHeaders?: string[]
|
|
36
|
+
/** Send `Access-Control-Allow-Credentials`. Incompatible with `origin: '*'`. */
|
|
37
|
+
credentials?: boolean
|
|
38
|
+
/** Preflight cache lifetime in seconds. */
|
|
39
|
+
maxAge?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The value for `Access-Control-Allow-Origin`, or `null` to send nothing.
|
|
46
|
+
*
|
|
47
|
+
* **`'*'` with credentials is refused rather than silently downgraded.** The
|
|
48
|
+
* browser rejects that pairing anyway, so honouring it would produce a request
|
|
49
|
+
* that fails in the client with a CORS error and a server that believes it
|
|
50
|
+
* allowed the call. Echoing the origin instead would be a *quiet widening* of
|
|
51
|
+
* what the app asked for. Returning null makes the misconfiguration visible as
|
|
52
|
+
* a denied request, which is the direction a security control should fail in.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOrigin(
|
|
55
|
+
options: CorsOptions,
|
|
56
|
+
requestOrigin: string | null,
|
|
57
|
+
): string | null {
|
|
58
|
+
const { origin, credentials } = options
|
|
59
|
+
|
|
60
|
+
if (origin === '*') return credentials ? null : '*'
|
|
61
|
+
if (!requestOrigin) return null
|
|
62
|
+
|
|
63
|
+
if (typeof origin === 'function') return origin(requestOrigin)
|
|
64
|
+
if (Array.isArray(origin)) {
|
|
65
|
+
return origin.includes(requestOrigin) ? requestOrigin : null
|
|
66
|
+
}
|
|
67
|
+
return origin === requestOrigin ? requestOrigin : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Headers for a non-preflight response, or `null` when the origin is denied.
|
|
72
|
+
*
|
|
73
|
+
* `Vary: Origin` is always set when the allowed origin is anything but `'*'`,
|
|
74
|
+
* because the response now differs per origin and a shared cache that ignored
|
|
75
|
+
* that would serve one origin's response to another.
|
|
76
|
+
*/
|
|
77
|
+
export function corsHeaders(
|
|
78
|
+
options: CorsOptions,
|
|
79
|
+
requestOrigin: string | null,
|
|
80
|
+
): Record<string, string> | null {
|
|
81
|
+
const allowed = resolveOrigin(options, requestOrigin)
|
|
82
|
+
if (allowed === null) return null
|
|
83
|
+
|
|
84
|
+
const headers: Record<string, string> = {
|
|
85
|
+
'Access-Control-Allow-Origin': allowed,
|
|
86
|
+
}
|
|
87
|
+
if (allowed !== '*') headers.Vary = 'Origin'
|
|
88
|
+
if (options.credentials) {
|
|
89
|
+
headers['Access-Control-Allow-Credentials'] = 'true'
|
|
90
|
+
}
|
|
91
|
+
if (options.exposeHeaders?.length) {
|
|
92
|
+
headers['Access-Control-Expose-Headers'] = options.exposeHeaders.join(', ')
|
|
93
|
+
}
|
|
94
|
+
return headers
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The response to a preflight, or `null` if this is not one.
|
|
99
|
+
*
|
|
100
|
+
* A preflight is `OPTIONS` *with* `Access-Control-Request-Method` — plain
|
|
101
|
+
* `OPTIONS` is a normal request and must fall through to routing, or an app
|
|
102
|
+
* with its own OPTIONS route would find it shadowed.
|
|
103
|
+
*
|
|
104
|
+
* 204 rather than 200: there is no body, and some proxies treat a 200 with no
|
|
105
|
+
* content-length as needing one.
|
|
106
|
+
*/
|
|
107
|
+
export function preflightResponse(
|
|
108
|
+
options: CorsOptions,
|
|
109
|
+
req: Request,
|
|
110
|
+
): Response | null {
|
|
111
|
+
if (req.method !== 'OPTIONS') return null
|
|
112
|
+
if (!req.headers.get('Access-Control-Request-Method')) return null
|
|
113
|
+
|
|
114
|
+
const base = corsHeaders(options, req.headers.get('Origin'))
|
|
115
|
+
// A denied origin still gets an answer, just without the headers that would
|
|
116
|
+
// permit the call. Returning 403 here would be a worse signal: the browser
|
|
117
|
+
// reports a CORS failure either way, and a 403 invites debugging the route.
|
|
118
|
+
if (!base) return new Response(null, { status: 204 })
|
|
119
|
+
|
|
120
|
+
const headers: Record<string, string> = {
|
|
121
|
+
...base,
|
|
122
|
+
'Access-Control-Allow-Methods': (options.methods ?? DEFAULT_METHODS).join(
|
|
123
|
+
', ',
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Echoing the requested headers is what makes a default config usable:
|
|
128
|
+
// enumerating every header a client might send is a list nobody maintains,
|
|
129
|
+
// and getting it wrong fails at request time in the browser only.
|
|
130
|
+
const requested = req.headers.get('Access-Control-Request-Headers')
|
|
131
|
+
const allow = options.allowHeaders?.length
|
|
132
|
+
? options.allowHeaders.join(', ')
|
|
133
|
+
: requested
|
|
134
|
+
if (allow) headers['Access-Control-Allow-Headers'] = allow
|
|
135
|
+
|
|
136
|
+
if (options.maxAge !== undefined) {
|
|
137
|
+
headers['Access-Control-Max-Age'] = String(options.maxAge)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Vary on the negotiated request headers too, for the same caching reason.
|
|
141
|
+
headers.Vary = [headers.Vary, 'Access-Control-Request-Headers']
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join(', ')
|
|
144
|
+
|
|
145
|
+
return new Response(null, { status: 204, headers })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Append the headers to a response that already exists. */
|
|
149
|
+
export function applyCors(
|
|
150
|
+
options: CorsOptions,
|
|
151
|
+
req: Request,
|
|
152
|
+
res: Response,
|
|
153
|
+
): Response {
|
|
154
|
+
const headers = corsHeaders(options, req.headers.get('Origin'))
|
|
155
|
+
if (!headers) return res
|
|
156
|
+
|
|
157
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
158
|
+
// `Vary` may already carry a value from ETag negotiation; appending keeps
|
|
159
|
+
// both rather than dropping whichever ran second.
|
|
160
|
+
if (key === 'Vary' && res.headers.has('Vary')) {
|
|
161
|
+
const existing = res.headers.get('Vary') ?? ''
|
|
162
|
+
if (!existing.split(',').some(v => v.trim() === value)) {
|
|
163
|
+
res.headers.set('Vary', `${existing}, ${value}`)
|
|
164
|
+
}
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
res.headers.set(key, value)
|
|
168
|
+
}
|
|
169
|
+
return res
|
|
170
|
+
}
|
package/src/utils/http/index.ts
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-sent events.
|
|
3
|
+
*
|
|
4
|
+
* Bakery had WebSockets and nothing for one-way streaming, which is the cheaper
|
|
5
|
+
* half of that pair and the right shape for progress, notifications and tailing
|
|
6
|
+
* — no upgrade, no protocol, reconnects handled by the browser.
|
|
7
|
+
*
|
|
8
|
+
* A route could always return a `Response` wrapping a `ReadableStream` and it
|
|
9
|
+
* would reach the client intact: `ETag.sendResponse` returns early without an
|
|
10
|
+
* `ETag` header, and `injectIfHtml` ignores anything that is not HTML. What was
|
|
11
|
+
* missing is the framing, and framing is where this goes wrong — a payload
|
|
12
|
+
* containing a newline silently truncates unless every line is prefixed, and a
|
|
13
|
+
* write after the client has gone throws where nobody is catching.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One event. Every field is optional except the payload. */
|
|
17
|
+
export interface SSEMessage {
|
|
18
|
+
/** Serialised with `JSON.stringify` unless it is already a string. */
|
|
19
|
+
data: unknown
|
|
20
|
+
/** `event:` — the client listens for this name instead of `message`. */
|
|
21
|
+
event?: string
|
|
22
|
+
/** `id:` — echoed back as `Last-Event-ID` when the browser reconnects. */
|
|
23
|
+
id?: string
|
|
24
|
+
/** `retry:` — how long the browser waits before reconnecting, in ms. */
|
|
25
|
+
retry?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The handle a producer writes to. */
|
|
29
|
+
export interface SSEStream {
|
|
30
|
+
/** Send one event. A no-op once the stream is closed. */
|
|
31
|
+
send(message: SSEMessage | unknown): void
|
|
32
|
+
/** Send a `:` comment. Useful as a keep-alive through idle proxies. */
|
|
33
|
+
comment(text?: string): void
|
|
34
|
+
/** Close the stream. Idempotent. */
|
|
35
|
+
close(): void
|
|
36
|
+
/** True once the client has gone or `close()` has run. */
|
|
37
|
+
readonly closed: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SSEOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Milliseconds between automatic keep-alive comments. `0` disables them.
|
|
43
|
+
*
|
|
44
|
+
* Defaults to 15s because idle proxies and load balancers commonly cut a
|
|
45
|
+
* connection at 30–60s, and a dead SSE stream is invisible: the browser
|
|
46
|
+
* reconnects silently, so the symptom is duplicated work on the server rather
|
|
47
|
+
* than an error anyone sees.
|
|
48
|
+
*/
|
|
49
|
+
keepAlive?: number
|
|
50
|
+
/** Initial `retry:` hint sent once, before any event. */
|
|
51
|
+
retry?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Encode one message as an SSE frame.
|
|
56
|
+
*
|
|
57
|
+
* **Every line of `data` is prefixed.** A payload containing a newline is
|
|
58
|
+
* otherwise cut short at that newline — the rest is read as a new field, and
|
|
59
|
+
* the client sees a truncated message rather than an error. Pretty-printed JSON
|
|
60
|
+
* and stack traces both hit this.
|
|
61
|
+
*/
|
|
62
|
+
export function encodeSSE(message: SSEMessage): string {
|
|
63
|
+
const lines: string[] = []
|
|
64
|
+
|
|
65
|
+
if (message.event) lines.push(`event: ${message.event}`)
|
|
66
|
+
if (message.id !== undefined) lines.push(`id: ${message.id}`)
|
|
67
|
+
if (message.retry !== undefined) lines.push(`retry: ${message.retry}`)
|
|
68
|
+
|
|
69
|
+
const payload =
|
|
70
|
+
typeof message.data === 'string'
|
|
71
|
+
? message.data
|
|
72
|
+
: JSON.stringify(message.data)
|
|
73
|
+
|
|
74
|
+
// `?? ''` rather than skipping: `JSON.stringify(undefined)` is undefined, and
|
|
75
|
+
// a frame with no `data:` line at all is a comment to the client — the event
|
|
76
|
+
// would vanish rather than arrive empty.
|
|
77
|
+
for (const line of (payload ?? '').split('\n')) lines.push(`data: ${line}`)
|
|
78
|
+
|
|
79
|
+
// The blank line terminates the frame. Without it the client buffers forever.
|
|
80
|
+
return `${lines.join('\n')}\n\n`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build an SSE response and hand the producer a stream to write to.
|
|
85
|
+
*
|
|
86
|
+
* export default defineRoute(req =>
|
|
87
|
+
* sse(req, stream => {
|
|
88
|
+
* const timer = setInterval(() => stream.send({ now: Date.now() }), 1000)
|
|
89
|
+
* return () => clearInterval(timer)
|
|
90
|
+
* }),
|
|
91
|
+
* )
|
|
92
|
+
*
|
|
93
|
+
* The producer may return a cleanup function, which runs exactly once when the
|
|
94
|
+
* client disconnects or `close()` is called. That is the only reliable place to
|
|
95
|
+
* stop a timer or unsubscribe: without it, a closed connection leaves the
|
|
96
|
+
* interval running for the life of the process, and the leak is silent.
|
|
97
|
+
*/
|
|
98
|
+
export function sse(
|
|
99
|
+
req: Request,
|
|
100
|
+
producer: (
|
|
101
|
+
stream: SSEStream,
|
|
102
|
+
) => void | (() => void) | Promise<void | (() => void)>,
|
|
103
|
+
options: SSEOptions = {},
|
|
104
|
+
): Response {
|
|
105
|
+
const encoder = new TextEncoder()
|
|
106
|
+
const { keepAlive = 15_000, retry } = options
|
|
107
|
+
|
|
108
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
109
|
+
let closed = false
|
|
110
|
+
let cleanup: (() => void) | void
|
|
111
|
+
let keepAliveTimer: ReturnType<typeof setInterval> | undefined
|
|
112
|
+
|
|
113
|
+
const write = (chunk: string) => {
|
|
114
|
+
if (closed || !controller) return
|
|
115
|
+
try {
|
|
116
|
+
controller.enqueue(encoder.encode(chunk))
|
|
117
|
+
} catch {
|
|
118
|
+
// The client went away between the `closed` check and the enqueue. That
|
|
119
|
+
// is a normal race on every disconnect, not an error worth surfacing —
|
|
120
|
+
// and throwing here would reject inside a timer callback, where nothing
|
|
121
|
+
// is catching.
|
|
122
|
+
finish()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function finish() {
|
|
127
|
+
if (closed) return
|
|
128
|
+
closed = true
|
|
129
|
+
if (keepAliveTimer) clearInterval(keepAliveTimer)
|
|
130
|
+
try {
|
|
131
|
+
cleanup?.()
|
|
132
|
+
} catch {
|
|
133
|
+
// A producer's cleanup that throws must not prevent the stream closing;
|
|
134
|
+
// the connection is already going away either way.
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
controller?.close()
|
|
138
|
+
} catch {
|
|
139
|
+
// Already closed by the runtime when the client disconnected.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stream: SSEStream = {
|
|
144
|
+
send(message) {
|
|
145
|
+
const normalised: SSEMessage =
|
|
146
|
+
message && typeof message === 'object' && 'data' in (message as object)
|
|
147
|
+
? (message as SSEMessage)
|
|
148
|
+
: { data: message }
|
|
149
|
+
write(encodeSSE(normalised))
|
|
150
|
+
},
|
|
151
|
+
comment(text = '') {
|
|
152
|
+
write(`: ${text}\n\n`)
|
|
153
|
+
},
|
|
154
|
+
close: finish,
|
|
155
|
+
get closed() {
|
|
156
|
+
return closed
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const body = new ReadableStream<Uint8Array>({
|
|
161
|
+
async start(c) {
|
|
162
|
+
controller = c
|
|
163
|
+
|
|
164
|
+
// The client aborting is the common ending, not an exception. Without
|
|
165
|
+
// this the producer keeps writing into a dead socket.
|
|
166
|
+
req.signal?.addEventListener('abort', finish, { once: true })
|
|
167
|
+
if (req.signal?.aborted) return finish()
|
|
168
|
+
|
|
169
|
+
if (retry !== undefined) write(`retry: ${retry}\n\n`)
|
|
170
|
+
if (keepAlive > 0) {
|
|
171
|
+
keepAliveTimer = setInterval(
|
|
172
|
+
() => stream.comment('keep-alive'),
|
|
173
|
+
keepAlive,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
cleanup = await producer(stream)
|
|
179
|
+
} catch {
|
|
180
|
+
// A producer that throws ends the stream rather than leaving the client
|
|
181
|
+
// hanging on a connection nobody will write to again.
|
|
182
|
+
finish()
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
cancel: finish,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return new Response(body, {
|
|
189
|
+
headers: {
|
|
190
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
191
|
+
// A cached event stream is a stream that never arrives.
|
|
192
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
193
|
+
Connection: 'keep-alive',
|
|
194
|
+
// nginx buffers proxied responses by default, which holds every event
|
|
195
|
+
// until the buffer fills — the stream appears to work in development and
|
|
196
|
+
// to hang in production. This is the documented opt-out.
|
|
197
|
+
'X-Accel-Buffering': 'no',
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-body validation for route modules.
|
|
3
|
+
*
|
|
4
|
+
* `defineRoute<{ title: string }>` declares a body shape and enforces nothing —
|
|
5
|
+
* the scaffolder's own template says so twice: *"declares the contract — it
|
|
6
|
+
* does not validate it. The body is still client input."* Every route was left
|
|
7
|
+
* to hand-check or not, and most did not.
|
|
8
|
+
*
|
|
9
|
+
* **No schema library is bundled, and none is depended on.** `@bakery-framework/
|
|
10
|
+
* core` has zero runtime dependencies and that is worth keeping, so validation
|
|
11
|
+
* accepts two shapes it can consume without knowing who produced them:
|
|
12
|
+
*
|
|
13
|
+
* - **Standard Schema** (`~standard`) — the shared interface zod, valibot and
|
|
14
|
+
* arktype all implement. Bring your own library; Bakery never imports it.
|
|
15
|
+
* - **A plain function** that returns the parsed value or throws.
|
|
16
|
+
*
|
|
17
|
+
* The second exists because the first is overkill for `body => { if (!body.id)
|
|
18
|
+
* throw new Error('id required'); return body }`, and a framework that forces a
|
|
19
|
+
* dependency for that has made the common case worse.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The subset of Standard Schema v1 that validation needs. */
|
|
23
|
+
export interface StandardSchemaLike<T> {
|
|
24
|
+
'~standard': {
|
|
25
|
+
version: 1
|
|
26
|
+
vendor: string
|
|
27
|
+
validate(value: unknown): StandardResult<T> | Promise<StandardResult<T>>
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type StandardResult<T> =
|
|
32
|
+
| { value: T; issues?: undefined }
|
|
33
|
+
| { issues: readonly StandardIssue[] }
|
|
34
|
+
|
|
35
|
+
interface StandardIssue {
|
|
36
|
+
message: string
|
|
37
|
+
path?: readonly (PropertyKey | { key: PropertyKey })[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A function validator: return the parsed value, or throw to reject. */
|
|
41
|
+
export type FunctionValidator<T> = (value: unknown) => T | Promise<T>
|
|
42
|
+
|
|
43
|
+
export type Validator<T> = StandardSchemaLike<T> | FunctionValidator<T>
|
|
44
|
+
|
|
45
|
+
/** One human-readable problem, with the field path when the schema gave one. */
|
|
46
|
+
export interface ValidationIssue {
|
|
47
|
+
path: string
|
|
48
|
+
message: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ValidationResult<T> =
|
|
52
|
+
| { ok: true; value: T }
|
|
53
|
+
| { ok: false; issues: ValidationIssue[] }
|
|
54
|
+
|
|
55
|
+
function isStandardSchema<T>(v: Validator<T>): v is StandardSchemaLike<T> {
|
|
56
|
+
return typeof v === 'object' && v !== null && '~standard' in v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render a Standard Schema path as dotted notation.
|
|
61
|
+
*
|
|
62
|
+
* Segments may be plain keys or `{ key }` objects — the spec allows both, and a
|
|
63
|
+
* library that uses the object form would otherwise render as `[object Object]`
|
|
64
|
+
* in the very message meant to tell someone which field is wrong.
|
|
65
|
+
*/
|
|
66
|
+
function renderPath(path: StandardIssue['path']): string {
|
|
67
|
+
if (!path?.length) return ''
|
|
68
|
+
return path
|
|
69
|
+
.map(seg => (typeof seg === 'object' && seg !== null ? seg.key : seg))
|
|
70
|
+
.join('.')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run a validator, never throwing.
|
|
75
|
+
*
|
|
76
|
+
* A function validator that throws is a *rejection*, not a crash: that is the
|
|
77
|
+
* whole idiom for the plain-function form. Its message becomes the issue, so
|
|
78
|
+
* `throw new Error('id must be a number')` reaches the client as written.
|
|
79
|
+
*/
|
|
80
|
+
export async function validate<T>(
|
|
81
|
+
validator: Validator<T>,
|
|
82
|
+
value: unknown,
|
|
83
|
+
): Promise<ValidationResult<T>> {
|
|
84
|
+
if (isStandardSchema(validator)) {
|
|
85
|
+
const result = await validator['~standard'].validate(value)
|
|
86
|
+
if (result.issues) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
issues: result.issues.map(issue => ({
|
|
90
|
+
path: renderPath(issue.path),
|
|
91
|
+
message: issue.message,
|
|
92
|
+
})),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, value: result.value }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
return { ok: true, value: await validator(value) }
|
|
100
|
+
} catch (error: any) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
issues: [{ path: '', message: error?.message ?? 'Invalid request body' }],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|