@bakery-framework/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- package/tsconfig.app.json +34 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { matchBlocked } from '../utils/constants'
|
|
3
|
+
import { fs } from '../utils/fs'
|
|
4
|
+
|
|
5
|
+
export type HostContext = {
|
|
6
|
+
config: Readonly<ProcessedAppConfig>
|
|
7
|
+
hostname: string
|
|
8
|
+
/**
|
|
9
|
+
* Per-request memo of raw `.forbidden` marker probes, keyed by the marker
|
|
10
|
+
* path (`<dir>/.forbidden`). Lazily created by `fs.isForbidden` and never
|
|
11
|
+
* read anywhere else. Scoped to one request by construction: every
|
|
12
|
+
* `hostStore.run` in the tree wraps a single request, WebSocket event or
|
|
13
|
+
* error dispatch, so the map dies with the store. That is what keeps this
|
|
14
|
+
* distinct from the cross-request cache `fs.isForbidden`'s regression test
|
|
15
|
+
* forbids — see the block comment there. Bounded (convention 6) by request
|
|
16
|
+
* lifetime: one request touches a handful of paths, each of bounded depth.
|
|
17
|
+
*/
|
|
18
|
+
forbiddenProbes?: Map<string, boolean>
|
|
19
|
+
/**
|
|
20
|
+
* Per-request memo of `matchBlocked` verdicts keyed by request path — the
|
|
21
|
+
* router runs the check before dispatch and `StaticHandler.handle` must
|
|
22
|
+
* keep its own (direct callers bypass the router gate), so without this the
|
|
23
|
+
* same globs matched the same path twice per static request. Sound to key
|
|
24
|
+
* by path alone because both call sites pass the ambient host config's
|
|
25
|
+
* globs, which are frozen for the life of the store.
|
|
26
|
+
*/
|
|
27
|
+
blockedPaths?: Map<string, boolean>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const hostStore = new AsyncLocalStorage<HostContext>()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `matchBlocked`, deduplicated within the current request.
|
|
34
|
+
*
|
|
35
|
+
* Outside a request store (tests, direct handler calls) this is exactly
|
|
36
|
+
* `matchBlocked` — no caching, fail closed on nothing, because nothing is
|
|
37
|
+
* skipped. See `HostContext.blockedPaths` for the scoping argument.
|
|
38
|
+
*/
|
|
39
|
+
export function matchBlockedCached(
|
|
40
|
+
blocked: { match(path: string): boolean } | undefined,
|
|
41
|
+
path: string,
|
|
42
|
+
): boolean {
|
|
43
|
+
const store = hostStore.getStore()
|
|
44
|
+
const seen = store ? (store.blockedPaths ??= new Map()) : null
|
|
45
|
+
|
|
46
|
+
let verdict = seen?.get(path)
|
|
47
|
+
if (verdict === undefined) {
|
|
48
|
+
verdict = matchBlocked(blocked, path)
|
|
49
|
+
seen?.set(path, verdict)
|
|
50
|
+
}
|
|
51
|
+
return verdict
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The **application's** version, from `<cwd>/package.json`.
|
|
56
|
+
*
|
|
57
|
+
* Named `getBakeryVersion` until 2026-08-09, which is exactly the wrong name:
|
|
58
|
+
* it reads the package.json of whatever is being served, not the framework's.
|
|
59
|
+
* That misnomer hid a real bug for as long as it existed — see
|
|
60
|
+
* {@link getFrameworkVersion}.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* What each version reader returns when it cannot read its manifest.
|
|
64
|
+
*
|
|
65
|
+
* They must be **distinct from each other and impossible as a real version**,
|
|
66
|
+
* and both halves are load-bearing. Distinct, because the two readers exist to
|
|
67
|
+
* be different files and a shared fallback would let one silently stand in for
|
|
68
|
+
* the other. Impossible, because they used to be plain `'1.0.0'` and `'0.0.0'`
|
|
69
|
+
* — and when the framework was renumbered to 1.0.0 for its first publish, the
|
|
70
|
+
* app fallback became a legitimate framework version. Nothing broke at runtime,
|
|
71
|
+
* but `cache-version.test.ts` could no longer tell "read the manifest" from
|
|
72
|
+
* "fell back to the other one", so the guard was disarmed by a version bump.
|
|
73
|
+
*
|
|
74
|
+
* A prerelease suffix keeps them valid semver while making the collision
|
|
75
|
+
* unrepeatable: no published version can equal these.
|
|
76
|
+
*/
|
|
77
|
+
const UNKNOWN_APP = '0.0.0-unknown-app'
|
|
78
|
+
const UNKNOWN_FW = '0.0.0-unknown-framework'
|
|
79
|
+
|
|
80
|
+
let _appVersion: string | null = null
|
|
81
|
+
export function getAppVersion() {
|
|
82
|
+
if (_appVersion) return _appVersion
|
|
83
|
+
try {
|
|
84
|
+
const content = fs.readFileSync(fs.resolve(fs.cwd, 'package.json'))
|
|
85
|
+
if (content) _appVersion = JSON.parse(content).version || UNKNOWN_APP
|
|
86
|
+
} catch {
|
|
87
|
+
// Missing or malformed package.json. The version is cosmetic and the
|
|
88
|
+
// fallback below is the answer either way.
|
|
89
|
+
}
|
|
90
|
+
return _appVersion || UNKNOWN_APP
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The **framework's** version, from `@bakery-framework/core`'s own package.json.
|
|
95
|
+
*
|
|
96
|
+
* Resolved from this module's location rather than by package specifier: a
|
|
97
|
+
* package self-referencing by name works only via the `exports` field and is
|
|
98
|
+
* one config edit away from breaking, while `../../package.json` is the same
|
|
99
|
+
* relative path in the repo and in the published tarball (`files` keeps both
|
|
100
|
+
* `src/` and the manifest).
|
|
101
|
+
*
|
|
102
|
+
* This exists because `.cache/` invalidation was keyed on the *app's* version
|
|
103
|
+
* alone, so upgrading `@bakery-framework/*` left a cache compiled by the previous
|
|
104
|
+
* framework version in place. In-repo that was invisible: `apps/example`'s
|
|
105
|
+
* version tracks the framework's, so bumping both wiped it anyway.
|
|
106
|
+
*/
|
|
107
|
+
let _frameworkVersion: string | null = null
|
|
108
|
+
export function getFrameworkVersion() {
|
|
109
|
+
if (_frameworkVersion) return _frameworkVersion
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(
|
|
112
|
+
fs.resolve(import.meta.dir, '../../package.json'),
|
|
113
|
+
)
|
|
114
|
+
if (content) _frameworkVersion = JSON.parse(content).version || UNKNOWN_FW
|
|
115
|
+
} catch {
|
|
116
|
+
// Same reasoning as above, with one addition: a fallback that never
|
|
117
|
+
// changes would silently stop invalidating the cache, so it is a distinct
|
|
118
|
+
// value from the app fallback.
|
|
119
|
+
}
|
|
120
|
+
return _frameworkVersion || UNKNOWN_FW
|
|
121
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Logger, log } from '../logger'
|
|
2
|
+
import { definePlugin as _definePlugin } from '../plugins/types'
|
|
3
|
+
import type { RouteHandler } from '../types'
|
|
4
|
+
import { Case, is, Math2, match, Try } from '../utils/common'
|
|
5
|
+
import { response } from '../utils/http'
|
|
6
|
+
import Bakery, { getHostname, hostKey, hostStore } from './bakery'
|
|
7
|
+
import { getConfig, NOOP } from './config'
|
|
8
|
+
import { createElement, Fragment, html } from './jsx'
|
|
9
|
+
|
|
10
|
+
export const defineConfig = <T extends AppConfig>(config: T): T => config
|
|
11
|
+
export const definePlugin = _definePlugin
|
|
12
|
+
|
|
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
|
+
/**
|
|
25
|
+
* Helper types, previously ambient globals. Importable so an app that declares
|
|
26
|
+
* its own `MapOf` is not met with a redeclaration error it cannot opt out of.
|
|
27
|
+
* The Route* types are the app-facing typing surface for route modules.
|
|
28
|
+
*/
|
|
29
|
+
export type {
|
|
30
|
+
MapOf,
|
|
31
|
+
MixedPromise,
|
|
32
|
+
RouteBody,
|
|
33
|
+
RouteHandler,
|
|
34
|
+
RouteResponse,
|
|
35
|
+
Wrapped,
|
|
36
|
+
} from '../types'
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
Bakery,
|
|
40
|
+
Case,
|
|
41
|
+
createElement,
|
|
42
|
+
Fragment,
|
|
43
|
+
getConfig,
|
|
44
|
+
// Multi-host helpers. Documented in docs/configuration/multi-host.md, and
|
|
45
|
+
// the only reason `./core/bakery` had to be a subpath of its own.
|
|
46
|
+
getHostname,
|
|
47
|
+
hostKey,
|
|
48
|
+
hostStore,
|
|
49
|
+
html,
|
|
50
|
+
html as HTMLBody,
|
|
51
|
+
is,
|
|
52
|
+
Logger,
|
|
53
|
+
log,
|
|
54
|
+
Math2,
|
|
55
|
+
match,
|
|
56
|
+
NOOP,
|
|
57
|
+
response,
|
|
58
|
+
Try,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default Bakery
|
package/src/core/init.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createElement, Fragment, html } from './jsx'
|
|
2
|
+
|
|
3
|
+
const hasDevWorkerArg = process.argv.includes('--dev-worker')
|
|
4
|
+
const isThreadWorker =
|
|
5
|
+
process.argv.includes('--thread-worker') || process.env.THREAD_WORKER === '1'
|
|
6
|
+
const isDev = process.argv.includes('--dev') || hasDevWorkerArg
|
|
7
|
+
const isTest = process.env.NODE_ENV === 'test' || Bun.env.NODE_ENV === 'test'
|
|
8
|
+
const mode = hasDevWorkerArg
|
|
9
|
+
? 'dev-worker'
|
|
10
|
+
: isThreadWorker
|
|
11
|
+
? 'thread-worker'
|
|
12
|
+
: isDev
|
|
13
|
+
? 'development'
|
|
14
|
+
: 'production'
|
|
15
|
+
|
|
16
|
+
const getArgValue = (name: string) => {
|
|
17
|
+
const prefix = `${name}=`
|
|
18
|
+
const found = process.argv.find(a => a.startsWith(prefix))
|
|
19
|
+
if (found) return found.slice(prefix.length)
|
|
20
|
+
const idx = process.argv.indexOf(name)
|
|
21
|
+
return idx !== -1 && idx + 1 < process.argv.length
|
|
22
|
+
? process.argv[idx + 1]
|
|
23
|
+
: null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const threadId = process.env.THREAD_ID ?? getArgValue('--thread-id') ?? '0'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An accessor pair, not a bare getter.
|
|
30
|
+
*
|
|
31
|
+
* These are `process.env` properties, and a getter with no setter is readonly:
|
|
32
|
+
* in strict-mode ESM an assignment to one throws
|
|
33
|
+
* `TypeError: Attempted to assign to readonly property`. `threads.ts` assigns
|
|
34
|
+
* `THREAD_ID = '0'` on the single-worker/clamped path (deliberately not
|
|
35
|
+
* `THREAD_WORKER` — a cluster of one must keep full-size caches). When the
|
|
36
|
+
* assignment was getter-only it was wrapped in `Try(...)`, so the throw was
|
|
37
|
+
* swallowed and the flags never moved — `reusePort`, the per-worker cache
|
|
38
|
+
* scaling and the startup banner all silently read the master's values. The
|
|
39
|
+
* `Try(...)` was what made a dead code path look deliberate.
|
|
40
|
+
*/
|
|
41
|
+
const accessor = (initial: any) => {
|
|
42
|
+
let value = initial
|
|
43
|
+
return {
|
|
44
|
+
get: () => value,
|
|
45
|
+
set: (next: any) => {
|
|
46
|
+
value = next
|
|
47
|
+
},
|
|
48
|
+
enumerable: true,
|
|
49
|
+
configurable: true,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Object.defineProperties(process.env, {
|
|
54
|
+
DEV: accessor(isDev),
|
|
55
|
+
TEST: accessor(isTest),
|
|
56
|
+
PROD: accessor(!isDev && !hasDevWorkerArg),
|
|
57
|
+
WORKER: accessor(hasDevWorkerArg || isThreadWorker),
|
|
58
|
+
DEV_WORKER: accessor(hasDevWorkerArg),
|
|
59
|
+
THREAD_WORKER: accessor(isThreadWorker),
|
|
60
|
+
THREAD_ID: accessor(threadId),
|
|
61
|
+
MODE: accessor(mode),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* "This process is the worker of a *development* server."
|
|
66
|
+
*
|
|
67
|
+
* `DEV_WORKER` alone would answer the same — `isDev` above is
|
|
68
|
+
* `--dev || --dev-worker`, so a dev worker always carries `DEV` too — but the
|
|
69
|
+
* conjunction is the condition the call sites were written against, and it
|
|
70
|
+
* says what it means. Exported from here rather than recomputed per module
|
|
71
|
+
* because three of them branch on it (`cli/worker.ts`,
|
|
72
|
+
* `compiler/dev-service.ts`, the dashboard plugin's `setup.ts`) and a
|
|
73
|
+
* byte-identical expression in three files is three chances to drift.
|
|
74
|
+
*
|
|
75
|
+
* Read once, at the moment the accessors above are installed: the flags do not
|
|
76
|
+
* move afterwards, so this is the same value each copy computed at its own
|
|
77
|
+
* load time.
|
|
78
|
+
*/
|
|
79
|
+
export const isDevWorker = Boolean(
|
|
80
|
+
import.meta.env.DEV_WORKER && import.meta.env.DEV,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
Object.assign(globalThis, {
|
|
84
|
+
createElement,
|
|
85
|
+
Fragment,
|
|
86
|
+
html,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
process.on('SIGHUP', () => {})
|
|
90
|
+
process.on('SIGBREAK', () => {})
|
package/src/core/jsx.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { MapOf, RouteBody } from '../types'
|
|
2
|
+
import { is } from '../utils/common/misc'
|
|
3
|
+
import { Bakery } from './bakery'
|
|
4
|
+
|
|
5
|
+
type Server = Bun.Server<any>
|
|
6
|
+
|
|
7
|
+
const VOID_ELEMENTS = new Set([
|
|
8
|
+
'area',
|
|
9
|
+
'base',
|
|
10
|
+
'br',
|
|
11
|
+
'col',
|
|
12
|
+
'embed',
|
|
13
|
+
'hr',
|
|
14
|
+
'img',
|
|
15
|
+
'input',
|
|
16
|
+
'link',
|
|
17
|
+
'meta',
|
|
18
|
+
'param',
|
|
19
|
+
'source',
|
|
20
|
+
'track',
|
|
21
|
+
'wbr',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export const Fragment = ({ children }: { children?: any }) =>
|
|
25
|
+
raw(renderChildren([children]))
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Markup that is already HTML and must not be escaped again.
|
|
29
|
+
*
|
|
30
|
+
* A String subclass rather than a plain string, because `createElement`
|
|
31
|
+
* composes by returning strings: without a marker there is no way to tell
|
|
32
|
+
* "HTML produced by a child component" from "text a user typed". It behaves
|
|
33
|
+
* like a string everywhere (`.trim()`, concatenation, `String()`), so callers
|
|
34
|
+
* are unaffected.
|
|
35
|
+
*/
|
|
36
|
+
export class SafeHtml extends String {}
|
|
37
|
+
|
|
38
|
+
/** Mark a string as trusted HTML, exempting it from escaping. */
|
|
39
|
+
export function raw(value: unknown): string {
|
|
40
|
+
return new SafeHtml(value ?? '') as unknown as string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for markup this module produced, or that a caller vouched for. */
|
|
44
|
+
export function isSafeHtml(value: unknown): boolean {
|
|
45
|
+
return value instanceof SafeHtml
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TEXT_ESCAPES: Record<string, string> = {
|
|
49
|
+
'&': '&',
|
|
50
|
+
'<': '<',
|
|
51
|
+
'>': '>',
|
|
52
|
+
'"': '"',
|
|
53
|
+
"'": ''',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeText(value: unknown): string {
|
|
57
|
+
return String(value).replace(/[&<>"']/g, ch => TEXT_ESCAPES[ch] || ch)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Children are escaped unless they are SafeHtml. Nested `createElement` calls
|
|
62
|
+
* return SafeHtml, so composition still works; a bare string interpolated from
|
|
63
|
+
* data is treated as text, which is the safe default.
|
|
64
|
+
*/
|
|
65
|
+
function renderChildren(children: any[]): string {
|
|
66
|
+
return children
|
|
67
|
+
.flat(10)
|
|
68
|
+
.map(c => {
|
|
69
|
+
if (c === null || c === undefined || is.boolean(c)) return ''
|
|
70
|
+
return isSafeHtml(c) ? String(c) : escapeText(c)
|
|
71
|
+
})
|
|
72
|
+
.join('')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const createElement = (
|
|
76
|
+
tag: any,
|
|
77
|
+
props: MapOf<any> | null,
|
|
78
|
+
...children: any[]
|
|
79
|
+
): string => {
|
|
80
|
+
if (is.function(tag)) return tag({ ...props, children })
|
|
81
|
+
|
|
82
|
+
const childStr = renderChildren(children)
|
|
83
|
+
|
|
84
|
+
let attrStr = ''
|
|
85
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
86
|
+
if (key === 'children') continue
|
|
87
|
+
if (value === false || value === null || value === undefined) continue
|
|
88
|
+
|
|
89
|
+
if (value === true) {
|
|
90
|
+
attrStr += ` ${key}`
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let attrKey = key
|
|
95
|
+
|
|
96
|
+
if (key === 'className') attrKey = 'class'
|
|
97
|
+
else if (key === 'htmlFor') attrKey = 'for'
|
|
98
|
+
|
|
99
|
+
const safeValue = escapeText(value)
|
|
100
|
+
attrStr += ` ${attrKey}="${safeValue}"`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isVoid = VOID_ELEMENTS.has(tag)
|
|
104
|
+
|
|
105
|
+
return raw(
|
|
106
|
+
isVoid ? `<${tag}${attrStr}>` : `<${tag}${attrStr}>${childStr}</${tag}>`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type RenderFn<P = {}> = (
|
|
111
|
+
req: Request,
|
|
112
|
+
body: RouteBody<P>,
|
|
113
|
+
server: Server,
|
|
114
|
+
) => string | Promise<string> | Promise<Response> | Response
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `<P>` is type-level only — declare the route's params once and `body` is
|
|
118
|
+
* typed inside the render function: `html<{ id: string }>((req, body) => …)`.
|
|
119
|
+
* `RouteBody<{}>` is `MapOf<any>`, so an unparameterised call is unchanged.
|
|
120
|
+
*/
|
|
121
|
+
export function html<P = {}>(render: RenderFn<P>) {
|
|
122
|
+
return async (req: Request, body: RouteBody<P>) => {
|
|
123
|
+
const rendered = await render(req, body, Bakery.server!)
|
|
124
|
+
const rawDom = rendered instanceof Response ? rendered : String(rendered)
|
|
125
|
+
|
|
126
|
+
if (rawDom instanceof Response) {
|
|
127
|
+
return rawDom
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (rawDom.trim().toLowerCase().startsWith('<html'))
|
|
131
|
+
return `<!DOCTYPE html>\n${rawDom}`
|
|
132
|
+
if (rawDom.trim().toLowerCase().startsWith('<!doctype')) return rawDom
|
|
133
|
+
|
|
134
|
+
let title = 'Document'
|
|
135
|
+
const dom = rawDom.replace(/<title>(.*?)<\/title>/i, (_, t) => {
|
|
136
|
+
title = t
|
|
137
|
+
return ''
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return `
|
|
141
|
+
<!DOCTYPE html>
|
|
142
|
+
<html>
|
|
143
|
+
<head>
|
|
144
|
+
<title>${title}</title>
|
|
145
|
+
</head>
|
|
146
|
+
<body>
|
|
147
|
+
${dom}
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
150
|
+
`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FileSystem as fs } from '../utils/fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the framework's own files live, as opposed to where the application
|
|
5
|
+
* lives.
|
|
6
|
+
*
|
|
7
|
+
* Two roots were previously conflated on `process.cwd()`: the app root (its
|
|
8
|
+
* `src/`, `public/`, `api/`, `schema.ts`) and the framework root (the client
|
|
9
|
+
* runtime it serves at `/_client/*`, the dashboard's assets, the tsconfig
|
|
10
|
+
* templates). Framework assets were addressed as `<cwd>/.server/...`, which
|
|
11
|
+
* only holds while the framework is a directory inside the app.
|
|
12
|
+
*
|
|
13
|
+
* Derived from this module's own location, so it keeps working once the
|
|
14
|
+
* framework is a workspace package or resolved out of `node_modules`.
|
|
15
|
+
*
|
|
16
|
+
* Use `frameworkPath()` for files the framework ships. Use `Bakery.root` (cwd)
|
|
17
|
+
* for anything the application owns.
|
|
18
|
+
*/
|
|
19
|
+
export const frameworkRoot: string = fs.resolve(import.meta.dir, '..')
|
|
20
|
+
|
|
21
|
+
/** Resolve a path against the framework's own root. */
|
|
22
|
+
export function frameworkPath(...segments: string[]): string {
|
|
23
|
+
return fs.resolve(frameworkRoot, ...segments)
|
|
24
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Handler } from '../handlers'
|
|
2
|
+
import { errorMsg, pluginLog, serveLog } from '../logger'
|
|
3
|
+
import { Try } from '../utils'
|
|
4
|
+
import { is } from '../utils/common'
|
|
5
|
+
import { injectIfHtml, response } from '../utils/http'
|
|
6
|
+
import { Bakery } from './bakery'
|
|
7
|
+
|
|
8
|
+
function getPlugins() {
|
|
9
|
+
return Bakery.config.plugins
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function normalizePluginResult(result: Handler.Response) {
|
|
13
|
+
if (result instanceof Response) {
|
|
14
|
+
const injectedRes = await injectIfHtml(result)
|
|
15
|
+
return injectedRes || result
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (result !== undefined && result !== null) {
|
|
19
|
+
return is.object(result)
|
|
20
|
+
? response.json(200, 'OK', result)
|
|
21
|
+
: response.text(String(result))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export namespace PluginHooks {
|
|
28
|
+
export async function setup() {
|
|
29
|
+
for (const plugin of getPlugins()) {
|
|
30
|
+
const [err] = await Try.catch(() => plugin.setup?.(Bakery.config))
|
|
31
|
+
if (err) {
|
|
32
|
+
serveLog.UNHANDLED_ERR({
|
|
33
|
+
error: `Plugin setup error (${plugin.name}): ${errorMsg(err)}`,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function onRequest(req: Request) {
|
|
40
|
+
for (const plugin of getPlugins()) {
|
|
41
|
+
const [err, result] = await Try.catch(plugin.onRequest?.(req))
|
|
42
|
+
if (err) {
|
|
43
|
+
serveLog.UNHANDLED_ERR({
|
|
44
|
+
error: `Plugin request error (${plugin.name}): ${errorMsg(err)}`,
|
|
45
|
+
})
|
|
46
|
+
return response.json.error(
|
|
47
|
+
500,
|
|
48
|
+
'Internal Server Error',
|
|
49
|
+
) as unknown as Response
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const normalized = await normalizePluginResult(result)
|
|
53
|
+
if (normalized) return normalized
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function onRoute(req: Request) {
|
|
59
|
+
for (const plugin of getPlugins()) {
|
|
60
|
+
const [err] = await Try.catch(() => plugin.onRoute?.(req))
|
|
61
|
+
|
|
62
|
+
if (err) {
|
|
63
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function onStart(server: any) {
|
|
69
|
+
for (const plugin of getPlugins()) {
|
|
70
|
+
const [err] = await Try.catch(() => plugin.onStart?.(server))
|
|
71
|
+
|
|
72
|
+
if (err) {
|
|
73
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function onError(error: Handler.Error.Data, req?: Request) {
|
|
79
|
+
for (const plugin of getPlugins()) {
|
|
80
|
+
if (!plugin.onError) continue
|
|
81
|
+
const [err, result] = await Try.catch(() => plugin.onError!(error, req))
|
|
82
|
+
|
|
83
|
+
if (err) {
|
|
84
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const normalized = await normalizePluginResult(result)
|
|
89
|
+
if (normalized) return normalized
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function onShutdown() {
|
|
95
|
+
for (const plugin of getPlugins()) {
|
|
96
|
+
const [err] = await Try.catch(() => plugin.onShutdown?.())
|
|
97
|
+
if (err) {
|
|
98
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function onCompile(
|
|
104
|
+
content: string,
|
|
105
|
+
path: string,
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
for (const plugin of getPlugins()) {
|
|
108
|
+
if (!plugin.onCompile) continue
|
|
109
|
+
const [err, res] = await Try.catch(() => plugin.onCompile!(content, path))
|
|
110
|
+
if (err) {
|
|
111
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (typeof res === 'string') {
|
|
115
|
+
content = res
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return content
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/core/port.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one answer to "what port are we on".
|
|
3
|
+
*
|
|
4
|
+
* This was written three times, with three different rules, in three files that
|
|
5
|
+
* all have to agree or the framework lies to the developer:
|
|
6
|
+
*
|
|
7
|
+
* - `cli/worker.ts` decides the port `Bun.serve` actually binds.
|
|
8
|
+
* - `startup.ts` decides the port the startup banner prints.
|
|
9
|
+
* - `compiler/dev-service.ts` decides the URL the dev master advertises.
|
|
10
|
+
*
|
|
11
|
+
* They drifted on two axes. `parseInt` (startup, dev-service) reads `3000x` as
|
|
12
|
+
* `3000`; `Number` (worker) reads it as `NaN`, and `Bun.serve({port: NaN})`
|
|
13
|
+
* quietly binds an **ephemeral** port — so `PORT=3000x` printed
|
|
14
|
+
* `http://localhost:3000/` while the server was listening on 51570. And the
|
|
15
|
+
* final fallbacks differed (`Bakery.server?.port || 0` against a literal
|
|
16
|
+
* `3000`), so the two survivors of a bad parse disagreed again.
|
|
17
|
+
*
|
|
18
|
+
* The rule here is `Number` plus an explicit range check, and a malformed
|
|
19
|
+
* `PORT` is an **error** rather than a guess. Convention 2 is about guards, but
|
|
20
|
+
* the same instinct applies: a value the operator plainly meant as a port and
|
|
21
|
+
* which is not one has no safe default. Silently binding 3000 hides a typo in a
|
|
22
|
+
* deploy script until something else is already on 3000; silently binding a
|
|
23
|
+
* random port hides it until a health check times out. Failing at boot names
|
|
24
|
+
* the variable and its value while the operator is still looking at the
|
|
25
|
+
* terminal.
|
|
26
|
+
*
|
|
27
|
+
* `0` is deliberately *not* an error — it is the documented "let the OS pick
|
|
28
|
+
* one" port, and `startup.ts` prefers `Bakery.server.port` precisely so the
|
|
29
|
+
* banner prints the port that was picked rather than the `0` that was asked
|
|
30
|
+
* for.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Where `PORT` and a portless `server.config.ts` both land. */
|
|
34
|
+
export const DEFAULT_PORT = 3000
|
|
35
|
+
|
|
36
|
+
/** Highest port number a TCP socket can bind. */
|
|
37
|
+
const MAX_PORT = 65535
|
|
38
|
+
|
|
39
|
+
// 🚀 Hoisted Regex
|
|
40
|
+
const RE_DECIMAL = /^\d+$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve `PORT` → `configPort` → {@link DEFAULT_PORT}.
|
|
44
|
+
*
|
|
45
|
+
* An unset *or empty* `PORT` counts as absent: `PORT=` is how a shell and a
|
|
46
|
+
* process manager both spell "no value", and all three call sites already
|
|
47
|
+
* treated it that way.
|
|
48
|
+
*
|
|
49
|
+
* @throws if `PORT` is set to anything that is not an integer in `0..65535`.
|
|
50
|
+
*/
|
|
51
|
+
export function resolvePort(configPort?: number | null): number {
|
|
52
|
+
const raw = process.env.PORT
|
|
53
|
+
|
|
54
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
55
|
+
const trimmed = raw.trim()
|
|
56
|
+
// Decimal digits only, deliberately narrower than `Number`: `Number` also
|
|
57
|
+
// accepts `0x1f` (31), `1e3` (1000) and `+80`, none of which anyone types
|
|
58
|
+
// into a `PORT` on purpose, and all of which would resolve to a port the
|
|
59
|
+
// operator did not write down.
|
|
60
|
+
const parsed = RE_DECIMAL.test(trimmed) ? Number(trimmed) : Number.NaN
|
|
61
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > MAX_PORT) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid PORT: ${JSON.stringify(raw)} is not an integer between 0 and ${MAX_PORT}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return parsed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `||`, not `??`: a config that says `port: 0` has always meant "unset" here
|
|
70
|
+
// rather than "ephemeral", and only `PORT=0` asks for an ephemeral port.
|
|
71
|
+
// Changing that is a separate decision from unifying the three call sites.
|
|
72
|
+
return configPort || DEFAULT_PORT
|
|
73
|
+
}
|