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