@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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. 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
+ }