@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,205 @@
|
|
|
1
|
+
import Bakery from '../../core'
|
|
2
|
+
import { fs } from '../../utils'
|
|
3
|
+
import { type Handler, RouteData } from './$base'
|
|
4
|
+
|
|
5
|
+
const GETFILE = (dir: fs.AbsolutePath) => ({
|
|
6
|
+
absolute: true,
|
|
7
|
+
cwd: dir,
|
|
8
|
+
dot: true,
|
|
9
|
+
onlyFiles: true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export type RouteScanOptions = {
|
|
13
|
+
staticOnly?: boolean
|
|
14
|
+
dynamicOnly?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `[!.]` keeps catch-alls (`[...name]`) out of the single-param globs: they
|
|
18
|
+
// are matched separately, and last — a catch-all is the weakest route form,
|
|
19
|
+
// consulted only after specific files, single-param siblings and child-index
|
|
20
|
+
// descent have all missed.
|
|
21
|
+
const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
|
|
22
|
+
|
|
23
|
+
// The single-param route forms, in the order they are tried: `[name].ext`
|
|
24
|
+
// first, then the escaped-literal `*.ext`. Built here rather than twice inside
|
|
25
|
+
// `routeGlobs` — the `dynamicOnly` branch and the combined branch returned
|
|
26
|
+
// character-identical pairs, and the two must stay in step or a route form
|
|
27
|
+
// resolves under one caller and not the other. A function, not a hoisted
|
|
28
|
+
// constant: `ext` varies per handler, and `staticOnly` returns before it needs
|
|
29
|
+
// them at all.
|
|
30
|
+
const dynamicGlobs = (ext: string) => [
|
|
31
|
+
new Bun.Glob(`[[][!.]*${ext || '.*'}`),
|
|
32
|
+
new Bun.Glob(`\\*${ext || '.*'}`),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const routeGlobs = (
|
|
36
|
+
first: string,
|
|
37
|
+
ext: string,
|
|
38
|
+
exts: string[],
|
|
39
|
+
options: RouteScanOptions = {},
|
|
40
|
+
) => {
|
|
41
|
+
if (options.dynamicOnly) {
|
|
42
|
+
return dynamicGlobs(ext)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hasExt = Boolean(fs.parse(first).ext)
|
|
46
|
+
const valid =
|
|
47
|
+
!hasExt || exts.length === 0 || exts.some(e => first.endsWith(`.${e}`))
|
|
48
|
+
const stem = fs.parse(first).name
|
|
49
|
+
|
|
50
|
+
const staticGlobs: Bun.Glob[] = []
|
|
51
|
+
if (valid) {
|
|
52
|
+
staticGlobs.push(new Bun.Glob(hasExt ? first : first + ext))
|
|
53
|
+
}
|
|
54
|
+
if (hasExt && ext && !valid) {
|
|
55
|
+
staticGlobs.push(new Bun.Glob(stem + ext))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (options.staticOnly) {
|
|
59
|
+
return staticGlobs
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...staticGlobs, ...dynamicGlobs(ext)]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The catch-all fallback for one directory level: `dir/[...name].ext`,
|
|
67
|
+
* containment-checked like every other candidate. Skipped for `staticOnly`
|
|
68
|
+
* (the caller wants a literal file), and it *yields to any real file*: when
|
|
69
|
+
* the request's remaining segments name an existing file under `dir` —
|
|
70
|
+
* whatever its extension — the catch-all declines, so a lower-priority
|
|
71
|
+
* handler (TSHandler, StaticHandler) can serve the file itself. A directory
|
|
72
|
+
* is not a file and does not trigger the yield. `findDynamicRoute` applies
|
|
73
|
+
* the same rule on the cached path; the two must agree.
|
|
74
|
+
*/
|
|
75
|
+
async function getCatchAllRoute(
|
|
76
|
+
ext: string,
|
|
77
|
+
dir: fs.AbsolutePath,
|
|
78
|
+
root: fs.AbsolutePath,
|
|
79
|
+
restSegments: string[],
|
|
80
|
+
): Promise<Handler.Route.Info | null> {
|
|
81
|
+
const found = await catchAllGlob(ext)
|
|
82
|
+
.scan(GETFILE(dir))
|
|
83
|
+
.next()
|
|
84
|
+
.catch(() => null)
|
|
85
|
+
if (!found || found.done || !found.value) return null
|
|
86
|
+
|
|
87
|
+
if (restSegments.length) {
|
|
88
|
+
const target = fs.resolve(dir, restSegments.join('/'))
|
|
89
|
+
// Stat only inside `dir`. A rest containing `..` resolves outside it, and
|
|
90
|
+
// statting there would make this yield a boolean existence probe for any
|
|
91
|
+
// path on the filesystem — the 404-vs-render difference is observable.
|
|
92
|
+
// URL parsing normalises `..` and `%2e%2e` away, so no HTTP request
|
|
93
|
+
// reaches here with one; this closes the door for any caller that skips
|
|
94
|
+
// that normalisation. An escape skips the yield rather than refusing, so
|
|
95
|
+
// the catch-all answers exactly as it did before the yield rule existed.
|
|
96
|
+
// `dir` comes from `fs.resolve`, so the separator-suffixed prefix test is
|
|
97
|
+
// exact — plain `startsWith(dir)` would also accept a sibling directory
|
|
98
|
+
// whose name merely begins with it.
|
|
99
|
+
if (
|
|
100
|
+
(target === dir || target.startsWith(`${dir}/`)) &&
|
|
101
|
+
fs.isFileSync(target)
|
|
102
|
+
) {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const file = fs.resolve(found.value)
|
|
108
|
+
if (fs.isForbidden(file, root)) return null
|
|
109
|
+
return new RouteData.Info(file, fs.relative(root, file))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request-to-route dispatcher
|
|
113
|
+
export async function getRoute(
|
|
114
|
+
pathOrArr: string | string[],
|
|
115
|
+
exts: string[] = [],
|
|
116
|
+
dir?: fs.AbsolutePath,
|
|
117
|
+
root?: fs.AbsolutePath,
|
|
118
|
+
options: RouteScanOptions = {},
|
|
119
|
+
): Promise<Handler.Route.Info | null> {
|
|
120
|
+
// Both used to default to `Bakery.serveRoot` independently — two reads of
|
|
121
|
+
// the config getter (an AsyncLocalStorage getStore each) when a caller
|
|
122
|
+
// omits both. One read now covers whichever is missing; the values are
|
|
123
|
+
// unchanged.
|
|
124
|
+
if (dir === undefined || root === undefined) {
|
|
125
|
+
const serveRoot = Bakery.serveRoot
|
|
126
|
+
dir ??= serveRoot
|
|
127
|
+
root ??= serveRoot
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (fs.isForbidden(dir, root)) return null
|
|
131
|
+
|
|
132
|
+
let pathArr = Array.isArray(pathOrArr)
|
|
133
|
+
? [...pathOrArr]
|
|
134
|
+
: pathOrArr.split('/').filter(Boolean)
|
|
135
|
+
exts = exts.filter(Boolean).map(e => e.replace(/^\./, ''))
|
|
136
|
+
|
|
137
|
+
if (pathArr.length === 0) {
|
|
138
|
+
pathArr = ['index']
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const count = pathArr.length
|
|
142
|
+
const first = pathArr.shift() ?? ''
|
|
143
|
+
const ext = exts.length ? `.{${exts.join(',')}}` : ''
|
|
144
|
+
const scanOptions = GETFILE(dir)
|
|
145
|
+
const globs = routeGlobs(first, ext, exts, options)
|
|
146
|
+
|
|
147
|
+
if (count === 1) {
|
|
148
|
+
let file: string | null = null
|
|
149
|
+
|
|
150
|
+
for (const glob of globs) {
|
|
151
|
+
const globFile = await glob
|
|
152
|
+
.scan(scanOptions)
|
|
153
|
+
.next()
|
|
154
|
+
.catch(() => null)
|
|
155
|
+
if (!globFile || globFile.done || !globFile.value) continue
|
|
156
|
+
|
|
157
|
+
file = fs.resolve(globFile.value)
|
|
158
|
+
if (fs.isForbidden(file, root)) {
|
|
159
|
+
file = null
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (file) return new RouteData.Info(file, fs.relative(root, file))
|
|
166
|
+
|
|
167
|
+
// No containment check before either recursion: the callee's first
|
|
168
|
+
// statement is `isForbidden(dir, root)` with these exact arguments, and its
|
|
169
|
+
// `null` reaches the same `return null` this function ends on. Checking
|
|
170
|
+
// here as well made every level of a nested route pay two identical
|
|
171
|
+
// directory tree-walks — and `isForbidden` walks from the file to the root
|
|
172
|
+
// doing an existsSync at each level, so that is the most expensive thing
|
|
173
|
+
// this function does. `src/tests/forbidden.test.ts` pins both branches.
|
|
174
|
+
if (first !== 'index') {
|
|
175
|
+
const targetDir = fs.resolve(dir, first)
|
|
176
|
+
const route = await getRoute('', exts, targetDir, root, options)
|
|
177
|
+
if (route) return route
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// `first === 'index'` covers the bare-directory request (`/docs` arrives
|
|
181
|
+
// here as an injected 'index' segment): the catch-all pattern requires at
|
|
182
|
+
// least one rest segment, so it cannot match that request — returning the
|
|
183
|
+
// Info anyway would claim the route with null params.
|
|
184
|
+
if (!options.staticOnly && first !== 'index') {
|
|
185
|
+
return await getCatchAllRoute(ext, dir, root, [first])
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (count > 1) {
|
|
190
|
+
const targetDir = fs.resolve(dir, first)
|
|
191
|
+
const route = await getRoute(pathArr, exts, targetDir, root, options)
|
|
192
|
+
if (route) return route
|
|
193
|
+
|
|
194
|
+
// The walk above descends one real segment at a time and dead-ends when
|
|
195
|
+
// the next directory does not exist — which for a catch-all request
|
|
196
|
+
// (`/docs/a/b/c` against `docs/[...slug].tsx`) is the common case. Each
|
|
197
|
+
// level unwinds through here, so the deepest existing directory gets the
|
|
198
|
+
// first chance to claim the rest.
|
|
199
|
+
if (!options.staticOnly) {
|
|
200
|
+
return await getCatchAllRoute(ext, dir, root, [first, ...pathArr])
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
import { Try } from '../../utils/common'
|
|
3
|
+
import { fs } from '../../utils/fs'
|
|
4
|
+
import { resolveMount } from './$mounts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Literal file resolution — the static counterpart to `getRoute`.
|
|
8
|
+
*
|
|
9
|
+
* `getRoute` answers "which file backs this *route*", with extensionless URLs,
|
|
10
|
+
* dynamic `[id]` segments and glob scanning. This answers the simpler question
|
|
11
|
+
* "which file is at this path, and am I allowed to serve it".
|
|
12
|
+
*
|
|
13
|
+
* It exists because four handlers were each spelling that out themselves —
|
|
14
|
+
* StaticHandler, PublicHandler, ImageHandler and NMHandler — in four slightly
|
|
15
|
+
* different ways, and only some of them checked everything. Once route mounts
|
|
16
|
+
* arrived the drift got worse: a mount-aware StaticHandler sitting next to a
|
|
17
|
+
* mount-blind PublicHandler is the kind of inconsistency that turns into a bug
|
|
18
|
+
* the first time someone mounts a directory containing an upload.
|
|
19
|
+
*
|
|
20
|
+
* **Three of those four go through this; NMHandler does not, on purpose.** Its
|
|
21
|
+
* entry point is resolved by `Bun.build`, not by the filesystem, so
|
|
22
|
+
* `/_nm/pkg/sub` legitimately means `pkg/sub/index.js` — a path this function
|
|
23
|
+
* answers `null` for, because it is a directory. It therefore repeats the
|
|
24
|
+
* containment test and calls `fs.isForbidden` itself; the reasoning, and the
|
|
25
|
+
* test that pins the divergence, are in `handlers/assets/nm.ts`. It is the one
|
|
26
|
+
* documented exception, and it stayed a *silent* one long enough for `/_nm/*`
|
|
27
|
+
* to be the only file-serving surface that ignored `.forbidden`.
|
|
28
|
+
*/
|
|
29
|
+
export interface StaticTarget {
|
|
30
|
+
/** Absolute path to the file on disk. */
|
|
31
|
+
file: fs.AbsolutePath
|
|
32
|
+
/** The root it was resolved against; containment is relative to this. */
|
|
33
|
+
root: fs.AbsolutePath
|
|
34
|
+
/** True when a route mount supplied the root rather than app config. */
|
|
35
|
+
mounted: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve `path` to a servable file, or null.
|
|
40
|
+
*
|
|
41
|
+
* A registered mount wins over `roots`: the prefix is stripped and the mount
|
|
42
|
+
* directory becomes both the search root and the containment boundary, so a
|
|
43
|
+
* mounted plugin cannot be traversed out of.
|
|
44
|
+
*
|
|
45
|
+
* Roots are tried in order, and a root that fails containment is skipped
|
|
46
|
+
* rather than aborting — mirroring ImageHandler, which legitimately looks in
|
|
47
|
+
* both the serve root and the public root.
|
|
48
|
+
*/
|
|
49
|
+
export async function getStatic(
|
|
50
|
+
path: string,
|
|
51
|
+
roots: string | string[] = Bakery.serveRoot,
|
|
52
|
+
): Promise<StaticTarget | null> {
|
|
53
|
+
const mounted = resolveMount(path)
|
|
54
|
+
|
|
55
|
+
const candidates: { root: string; relative: string }[] = mounted
|
|
56
|
+
? [{ root: mounted.mount.dir, relative: mounted.rest }]
|
|
57
|
+
: (Array.isArray(roots) ? roots : [roots]).map(root => ({
|
|
58
|
+
root,
|
|
59
|
+
relative: path.replace(/^\/+/, ''),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const root = fs.resolve(candidate.root)
|
|
64
|
+
const file = fs.resolve(root, candidate.relative)
|
|
65
|
+
|
|
66
|
+
// Two checks, not one: the prefix test catches a resolved path that
|
|
67
|
+
// escaped the root, and isForbidden additionally honours `.forbidden`
|
|
68
|
+
// markers. It does *not* apply the blocked globs — `Bakery.config.blocked`
|
|
69
|
+
// is matched against the request path in `router.ts` and again in
|
|
70
|
+
// `handlers/assets/static.ts`, never against a resolved file path — so a
|
|
71
|
+
// caller reaching for `getStatic` as a one-stop authorisation check is
|
|
72
|
+
// getting containment and `.forbidden` only.
|
|
73
|
+
//
|
|
74
|
+
// Containment stays first — nothing stats a path that escaped the root.
|
|
75
|
+
// After that the order is by cost: `isForbidden` walks every directory
|
|
76
|
+
// between the file and the root, so running it before the existence check
|
|
77
|
+
// paid for a whole tree-walk on every candidate that simply is not there —
|
|
78
|
+
// which is most of them, since a miss here is how each root in the list,
|
|
79
|
+
// and every extensionless probe, gets ruled out. All checks are pure and
|
|
80
|
+
// all `continue`, so the candidate chosen is unchanged.
|
|
81
|
+
//
|
|
82
|
+
// One stat answers both questions. This used to be `fs.exists` (a stat)
|
|
83
|
+
// then `fs.isDir` (which is `fs.exists` plus a second stat of its own) —
|
|
84
|
+
// up to three stats per candidate at ~28us each on Bun/Windows. A failed
|
|
85
|
+
// stat means "nothing servable here" and falls through to the next
|
|
86
|
+
// candidate, exactly as the exists check did.
|
|
87
|
+
if (file !== root && !file.startsWith(`${root}/`)) continue
|
|
88
|
+
const stat = await Try(() => Bun.file(file).stat())
|
|
89
|
+
if (!stat || stat.isDirectory()) continue
|
|
90
|
+
if (fs.isForbidden(file, root)) continue
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
file: file as fs.AbsolutePath,
|
|
94
|
+
root: root as fs.AbsolutePath,
|
|
95
|
+
mounted: Boolean(mounted),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Bakery, hostStore } from '../../core/bakery'
|
|
2
|
+
import type { MixedPromise } from '../../types'
|
|
3
|
+
import { Handler } from './$base'
|
|
4
|
+
|
|
5
|
+
export class WebSocketHandler extends Handler {
|
|
6
|
+
static WS_UPGRADE = Symbol('WS_UPGRADE')
|
|
7
|
+
|
|
8
|
+
static canHandle(path: string, req: Request): MixedPromise<boolean>
|
|
9
|
+
static canHandle() {
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static handle(path: string, req: Request) {
|
|
14
|
+
let data = this.upgrade(req, path) || ({} as any)
|
|
15
|
+
data = {
|
|
16
|
+
this: this,
|
|
17
|
+
type: 'websocket',
|
|
18
|
+
orig: this.name,
|
|
19
|
+
path,
|
|
20
|
+
hostname: req.__hostname || '',
|
|
21
|
+
config: hostStore.getStore()?.config ?? Bakery.config,
|
|
22
|
+
data,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const upgraded = Bakery.server?.upgrade(req, { data })
|
|
26
|
+
return upgraded ? (WebSocketHandler.WS_UPGRADE as any) : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static upgrade(req: Request, path: string): MixedPromise<UpgradeData>
|
|
30
|
+
static upgrade() {}
|
|
31
|
+
|
|
32
|
+
static open(ws: ServerWebSocket<any>, data: any): MixedPromise<void>
|
|
33
|
+
static open() {}
|
|
34
|
+
|
|
35
|
+
static message(
|
|
36
|
+
ws: ServerWebSocket<any>,
|
|
37
|
+
msg: any,
|
|
38
|
+
data: any,
|
|
39
|
+
): MixedPromise<void>
|
|
40
|
+
static message() {}
|
|
41
|
+
|
|
42
|
+
static close(
|
|
43
|
+
ws: ServerWebSocket<any>,
|
|
44
|
+
code: number,
|
|
45
|
+
reason: string,
|
|
46
|
+
data: any,
|
|
47
|
+
): MixedPromise<void>
|
|
48
|
+
static close() {}
|
|
49
|
+
|
|
50
|
+
static drain(ws: ServerWebSocket<any>, data: any): MixedPromise<void>
|
|
51
|
+
static drain() {}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from './assets/google-font'
|
|
2
|
+
export * from './assets/image'
|
|
3
|
+
export * from './assets/nm'
|
|
4
|
+
export * from './assets/public'
|
|
5
|
+
export * from './assets/static'
|
|
6
|
+
export * from './assets/ts'
|
|
7
|
+
export * from './assets/tsx'
|
|
8
|
+
export * from './assets/virtual-asset'
|
|
9
|
+
export * from './core/$base'
|
|
10
|
+
export * from './core/$dynamic'
|
|
11
|
+
export * from './core/$error'
|
|
12
|
+
export * from './core/$middleware'
|
|
13
|
+
export * from './core/$mounts'
|
|
14
|
+
export * from './core/$registry'
|
|
15
|
+
export * from './core/$routing'
|
|
16
|
+
export * from './core/$static'
|
|
17
|
+
export * from './core/$websocket'
|
|
18
|
+
export * from './routes/api'
|
|
19
|
+
export * from './routes/html'
|
|
20
|
+
export * from './routes/livereload'
|
|
21
|
+
export * from './routes/proxy'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
import { handlerLog } from '../../logger/serve-log'
|
|
3
|
+
import { FileSystem } from '../../utils/fs'
|
|
4
|
+
import { checkCsrf, response } from '../../utils/http'
|
|
5
|
+
import type { Handler } from '../core/$base'
|
|
6
|
+
import { bustInDev, DynamicHandler } from '../core/$dynamic'
|
|
7
|
+
import { ErrorHandler } from '../core/$error'
|
|
8
|
+
|
|
9
|
+
export class ApiHandler extends DynamicHandler {
|
|
10
|
+
/** Executes a module and returns its value; never file bytes. See `Handler.servesFiles`. */
|
|
11
|
+
static servesFiles = false
|
|
12
|
+
|
|
13
|
+
static canHandle(path: string) {
|
|
14
|
+
return path.startsWith('/api/')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static get config() {
|
|
18
|
+
return {
|
|
19
|
+
ext: ['ts', 'js'],
|
|
20
|
+
dir: Bakery.apiRoot,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static resolveRoute(path: string) {
|
|
25
|
+
path = path.slice(4) // remove api prefix
|
|
26
|
+
return super.resolveRoute(path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static async handle(path: string, req: Request) {
|
|
30
|
+
// State-changing methods must be same-origin. SameSite=Lax alone does not
|
|
31
|
+
// cover this: a cross-site form POST is a CORS-simple request.
|
|
32
|
+
const url: URL = (req as any).__parsedUrl || new URL(req.url)
|
|
33
|
+
const csrf = checkCsrf(req, url)
|
|
34
|
+
if (csrf) return response.json.error(403, csrf) as unknown as Response
|
|
35
|
+
|
|
36
|
+
const info = await this.resolveRoute(path)
|
|
37
|
+
if (!info) return response.error('No API handler found')
|
|
38
|
+
|
|
39
|
+
const cleanPath = path.slice(4)
|
|
40
|
+
const params = info.getParams(cleanPath) || {}
|
|
41
|
+
const body = await this.params(req, params)
|
|
42
|
+
|
|
43
|
+
const filePath = FileSystem.resolve(this.config.dir, info.path)
|
|
44
|
+
const result = await this.executeModule(filePath, req, body)
|
|
45
|
+
|
|
46
|
+
if (result !== null && result !== undefined) return result
|
|
47
|
+
|
|
48
|
+
// Two different faults used to share one bare 404, and neither channel
|
|
49
|
+
// named the file — so "No response from handler" sent the developer
|
|
50
|
+
// hunting for a route that was sitting on disk the whole time.
|
|
51
|
+
//
|
|
52
|
+
// `null` and `undefined` are what tell them apart, and the split is exact
|
|
53
|
+
// rather than incidental: `DynamicHandler.executeModule` *throws* when the
|
|
54
|
+
// import fails (that is the 500 path), returns a literal `null` for
|
|
55
|
+
// "module loaded, no `default`", and otherwise returns whatever the
|
|
56
|
+
// handler produced — `undefined` when it produced nothing.
|
|
57
|
+
if (result === null) {
|
|
58
|
+
// A 500, not a 404: the route resolved, the file exists, and the server
|
|
59
|
+
// could not answer with it. That is a server fault, and the message says
|
|
60
|
+
// what to add. PROD still redacts it — `publicBody` replaces any 5xx
|
|
61
|
+
// body — so naming the file here discloses nothing to a client.
|
|
62
|
+
handlerLog.API_NO_DEFAULT({ file: filePath })
|
|
63
|
+
return response.error(
|
|
64
|
+
`API route has no export default: ${info.path}`,
|
|
65
|
+
500,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handlerLog.API_NO_RESPONSE({ file: filePath })
|
|
70
|
+
return response.error('No response from handler')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static async executeModule(
|
|
74
|
+
file: FileSystem.AbsolutePath,
|
|
75
|
+
req: Request,
|
|
76
|
+
body: any,
|
|
77
|
+
): Promise<any> {
|
|
78
|
+
// See `bustInDev` for why the suffix exists and why `!TEST` is part of the
|
|
79
|
+
// gate. `TSXHandler` applies the same rule through the same helper.
|
|
80
|
+
return super.executeModule(bustInDev(file), req, body)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class ApiErrorHandler extends ErrorHandler {
|
|
85
|
+
static canHandle(path: string) {
|
|
86
|
+
return path.startsWith('/api/')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static handle(_p: string, _r: Request, error: Handler.Error.Data) {
|
|
90
|
+
// `publicBody`, not `errorBody`: in production the latter is the stack of
|
|
91
|
+
// whatever threw. The full trace still reaches the log — `handleRequestError`
|
|
92
|
+
// passes the unredacted data to `config.onError` before this runs.
|
|
93
|
+
return response.json.error(error.errorCode, ErrorHandler.publicBody(error))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Bakery, hostKey } from '../../core/bakery'
|
|
2
|
+
import type { MapOf } from '../../types'
|
|
3
|
+
import { assembleHtml, fs, toHash } from '../../utils'
|
|
4
|
+
import { injectIfHtml, response } from '../../utils/http'
|
|
5
|
+
import type { Handler } from '../core/$base'
|
|
6
|
+
import { DynamicHandler } from '../core/$dynamic'
|
|
7
|
+
import {
|
|
8
|
+
beginPageRoute,
|
|
9
|
+
DynamicErrorHandler,
|
|
10
|
+
markDevFile,
|
|
11
|
+
publicErrorData,
|
|
12
|
+
} from '../core/$error'
|
|
13
|
+
|
|
14
|
+
export class HTMLHandler extends DynamicHandler {
|
|
15
|
+
static get config() {
|
|
16
|
+
return {
|
|
17
|
+
ext: ['html'],
|
|
18
|
+
dir: Bakery.serveRoot,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static canHandle(path: string, req: Request) {
|
|
23
|
+
return path.endsWith('.html') || super.canHandle(path, req)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static handle = sharedHandler
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class HTMLErrorHandler extends DynamicErrorHandler {
|
|
30
|
+
static get config() {
|
|
31
|
+
return {
|
|
32
|
+
ext: ['html'],
|
|
33
|
+
dir: Bakery.serveRoot,
|
|
34
|
+
include: ['**/error.html', '**/error-*.html'],
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static handle = sharedHandler
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCacheDir() {
|
|
42
|
+
return fs.resolve(Bakery.cacheDir, 'html')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function sharedHandler(
|
|
46
|
+
this: typeof DynamicHandler | typeof DynamicErrorHandler,
|
|
47
|
+
path: string,
|
|
48
|
+
req: Request,
|
|
49
|
+
errors?: Handler.Error.Data,
|
|
50
|
+
) {
|
|
51
|
+
const begun = await beginPageRoute(this, path, errors)
|
|
52
|
+
if (begun instanceof Response) return begun
|
|
53
|
+
const { errorData, info } = begun
|
|
54
|
+
|
|
55
|
+
const file = info.file
|
|
56
|
+
|
|
57
|
+
if (!info.isDynamic && !errorData) {
|
|
58
|
+
const cacheHash = toHash(hostKey(info.path))
|
|
59
|
+
const cacheName = `${cacheHash}.html`
|
|
60
|
+
|
|
61
|
+
const cached = await fs.getOrCreateCachedFile(
|
|
62
|
+
getCacheDir(),
|
|
63
|
+
cacheName,
|
|
64
|
+
file.lastModified,
|
|
65
|
+
async () => {
|
|
66
|
+
const content = await file.text()
|
|
67
|
+
return assembleHtml(content)
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (cached) return cached
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const params = await this.params(req, info.getParams(path) || {})
|
|
75
|
+
markDevFile(params, info.path)
|
|
76
|
+
const content = await info.file.text()
|
|
77
|
+
// `publicErrorData`, not `errorData`: these params reach the document
|
|
78
|
+
// through `{{...}}` *and* through the `__PAGE_PARAMS__` script injected
|
|
79
|
+
// into every page, so the raw stack was published in PROD even by a
|
|
80
|
+
// template that never mentioned it. Guarded because `errorData` is
|
|
81
|
+
// `undefined` for an ordinary page — `DEFAULT_ERROR` only exists on the
|
|
82
|
+
// error handler — and the spread below tolerates that where the helper,
|
|
83
|
+
// deliberately strict about the shape it redacts, does not.
|
|
84
|
+
// Annotated because `beginPageRoute` hands back real `Handler.Error.Data`
|
|
85
|
+
// where this used to read an untyped `(this as any).DEFAULT_ERROR`: the
|
|
86
|
+
// merged record now has a numeric `errorCode` in it, and `injectIfHtml`
|
|
87
|
+
// substitutes stringly. Same object as before, same values.
|
|
88
|
+
const data: MapOf<any> = {
|
|
89
|
+
...params,
|
|
90
|
+
...(errorData && publicErrorData(errorData)),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const html = await injectIfHtml(content, data)
|
|
94
|
+
return html || response.error('Not Found')
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { connectedLoggers, log, serveLog } from '../../logger'
|
|
2
|
+
import { WebSocketHandler } from '../core/$websocket'
|
|
3
|
+
|
|
4
|
+
export class LiveReloadHandler extends WebSocketHandler {
|
|
5
|
+
static connectedLoggers = connectedLoggers
|
|
6
|
+
|
|
7
|
+
static init() {}
|
|
8
|
+
|
|
9
|
+
static canHandle(path: string) {
|
|
10
|
+
if (!import.meta.env.DEV || !import.meta.env.DEV_WORKER) return false
|
|
11
|
+
return path === '/_livereload'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static upgrade() {}
|
|
15
|
+
|
|
16
|
+
static open(ws: ServerWebSocket) {
|
|
17
|
+
if (!import.meta.env.DEV) return
|
|
18
|
+
ws.subscribe('livereload')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static message(ws: ServerWebSocket, message: any) {
|
|
22
|
+
if (!import.meta.env.DEV) return
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(String(message))
|
|
25
|
+
const { type: msgType, level, payload } = parsed
|
|
26
|
+
switch (msgType) {
|
|
27
|
+
case 'subscribe_logger':
|
|
28
|
+
LiveReloadHandler.connectedLoggers.add(ws)
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
case 'force_reload':
|
|
32
|
+
serveLog.MANUAL_RELOAD()
|
|
33
|
+
ws.publish('livereload', 'force_reload')
|
|
34
|
+
|
|
35
|
+
break
|
|
36
|
+
case 'client_log': {
|
|
37
|
+
const ipAddr = ws.remoteAddress
|
|
38
|
+
const clientLogMsg = JSON.stringify({ ...parsed, by: ipAddr })
|
|
39
|
+
LiveReloadHandler.connectedLoggers.forEach(
|
|
40
|
+
loggerWs => void loggerWs.send(clientLogMsg),
|
|
41
|
+
)
|
|
42
|
+
log({ by: ipAddr, msg: payload, level })
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
serveLog.WEBSOCKET_ERR({ ip: ws.remoteAddress, error: String(err) })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static close(ws: ServerWebSocket) {
|
|
52
|
+
LiveReloadHandler.connectedLoggers.delete(ws)
|
|
53
|
+
}
|
|
54
|
+
}
|