@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,136 @@
|
|
|
1
|
+
import { Bakery } from '../../core'
|
|
2
|
+
import { hostKey } from '../../core/bakery'
|
|
3
|
+
import { errorMsg } from '../../logger'
|
|
4
|
+
import { Math2 } from '../../utils/common'
|
|
5
|
+
import { FileSystem as fs } from '../../utils/fs'
|
|
6
|
+
import { response } from '../../utils/http'
|
|
7
|
+
import { Handler, type Route } from '../core/$base'
|
|
8
|
+
import { getStatic } from '../core/$static'
|
|
9
|
+
|
|
10
|
+
const IS_IMAGE_REGEX = /(.*)\/(.*)(;(\d+))?\.(png|jpg|jpeg|webp|gif|bmp)$/i
|
|
11
|
+
const IMAGE_CAPTURE = /^(.+?)([^/;.]+)(?:;(\d+))?\.([a-zA-Z0-9]+)$/i
|
|
12
|
+
|
|
13
|
+
export class ImageHandler extends Handler {
|
|
14
|
+
static maxImageSize = 4096
|
|
15
|
+
|
|
16
|
+
static canHandle(path: string): boolean {
|
|
17
|
+
return IS_IMAGE_REGEX.test(path)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private static async lookUpImage(path: string) {
|
|
21
|
+
const key = hostKey(path)
|
|
22
|
+
if (!this.cache.has(key)) return
|
|
23
|
+
|
|
24
|
+
const cached = this.cache.get(key) as Route.Info
|
|
25
|
+
const parsed = await this.path(path)
|
|
26
|
+
|
|
27
|
+
if (parsed) {
|
|
28
|
+
const sourceFile = Bun.file(parsed.source)
|
|
29
|
+
const sourceMtime = sourceFile.lastModified
|
|
30
|
+
const cachedMtime = cached.file.lastModified
|
|
31
|
+
|
|
32
|
+
if (sourceMtime && cachedMtime && sourceMtime > cachedMtime) {
|
|
33
|
+
this.cache.delete(key)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (fs.exists(cached.file)) return cached
|
|
39
|
+
|
|
40
|
+
this.cache.delete(key)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static clampSize(size: number) {
|
|
44
|
+
return Math2.clamp(Math2.step(size, 32), 16, this.maxImageSize)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static async path(path: string) {
|
|
48
|
+
const match = path.match(IMAGE_CAPTURE)
|
|
49
|
+
if (!match) return
|
|
50
|
+
|
|
51
|
+
const [_, dir, name, sizeStr, ext] = match
|
|
52
|
+
const size = sizeStr ? parseInt(sizeStr, 10) : null
|
|
53
|
+
const rel = `${dir.slice(1)}/${name}.${ext}`
|
|
54
|
+
|
|
55
|
+
// This handler outranks PublicHandler, so the containment and `.forbidden`
|
|
56
|
+
// checks must still apply — otherwise a protected image under public/ is
|
|
57
|
+
// served anyway, despite PublicHandler correctly refusing it. getStatic
|
|
58
|
+
// carries those checks so all the file-serving handlers share one spelling.
|
|
59
|
+
const resolved = await getStatic(`/${rel}`, [
|
|
60
|
+
Bakery.serveRoot,
|
|
61
|
+
Bakery.publicRoot,
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
if (!resolved) return
|
|
65
|
+
|
|
66
|
+
const source = resolved.file
|
|
67
|
+
|
|
68
|
+
return { dir, name, size, ext, source }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async handle(path: string) {
|
|
72
|
+
try {
|
|
73
|
+
const routeInfo = await this.lookUpImage(path)
|
|
74
|
+
if (routeInfo) return routeInfo.file
|
|
75
|
+
|
|
76
|
+
const parsed = await this.path(path)
|
|
77
|
+
if (!parsed) return response.error('Not Found')
|
|
78
|
+
|
|
79
|
+
const { size: sizeStr, source } = parsed
|
|
80
|
+
const size = sizeStr ? this.clampSize(sizeStr) : null
|
|
81
|
+
|
|
82
|
+
const sourceFile = Bun.file(source)
|
|
83
|
+
const sourceTime = sourceFile.lastModified
|
|
84
|
+
|
|
85
|
+
if (!sourceTime) return response.error('Not Found')
|
|
86
|
+
|
|
87
|
+
const cacheDir = fs.resolve(Bakery.cacheDir, 'images')
|
|
88
|
+
// Key on the resolved source, not the request path. The path carries the
|
|
89
|
+
// caller's raw `;NNN` size suffix *before* clamping, so `;16`, `;17`, `;18`…
|
|
90
|
+
// each produced a distinct id and re-encoded the whole source to WebP —
|
|
91
|
+
// an unbounded cache and unbounded CPU from one image URL.
|
|
92
|
+
const imageCacheId = Bun.hash(hostKey(source)).toString(36)
|
|
93
|
+
|
|
94
|
+
await fs.mkdir(cacheDir)
|
|
95
|
+
|
|
96
|
+
const masterPath = fs.resolve(cacheDir, `${imageCacheId}-main.webp`)
|
|
97
|
+
|
|
98
|
+
let masterFile = Bun.file(masterPath)
|
|
99
|
+
const masterMtime = masterFile.lastModified
|
|
100
|
+
|
|
101
|
+
if (!fs.exists(masterFile) || masterMtime < sourceTime) {
|
|
102
|
+
const imgObj = sourceFile.image()
|
|
103
|
+
await imgObj.webp({ quality: 80 }).write(masterPath)
|
|
104
|
+
masterFile = Bun.file(masterPath)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!size) return masterFile
|
|
108
|
+
|
|
109
|
+
const cachePath = fs.resolve(cacheDir, `${imageCacheId}-${size}.webp`)
|
|
110
|
+
let cacheFile = Bun.file(cachePath)
|
|
111
|
+
const cacheMtime = cacheFile.lastModified
|
|
112
|
+
|
|
113
|
+
if (!fs.exists(cacheFile) || cacheMtime < masterMtime) {
|
|
114
|
+
const imgObj = masterFile.image()
|
|
115
|
+
const meta = await imgObj.metadata()
|
|
116
|
+
|
|
117
|
+
const w = meta.width
|
|
118
|
+
const h = meta.height
|
|
119
|
+
|
|
120
|
+
const targetSize = Math.min(size, this.maxImageSize)
|
|
121
|
+
const shortest = Math.min(w, h)
|
|
122
|
+
|
|
123
|
+
const scale = Math.min(targetSize / shortest, 1)
|
|
124
|
+
|
|
125
|
+
const targetW = Math.round(w * scale)
|
|
126
|
+
const targetH = Math.round(h * scale)
|
|
127
|
+
|
|
128
|
+
await imgObj.resize(targetW, targetH).write(cachePath)
|
|
129
|
+
cacheFile = Bun.file(cachePath)
|
|
130
|
+
}
|
|
131
|
+
return cacheFile
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return response.error(`Unexpected error: ${errorMsg(e)}`, 500)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { bundleModule } from '../../compiler'
|
|
2
|
+
import { Bakery } from '../../core/bakery'
|
|
3
|
+
import { toHash } from '../../utils/common'
|
|
4
|
+
import { fs } from '../../utils/fs'
|
|
5
|
+
import { Handler } from '../core/$base'
|
|
6
|
+
|
|
7
|
+
export class NMHandler extends Handler {
|
|
8
|
+
static get cacheDir() {
|
|
9
|
+
return fs.resolve(Bakery.cacheDir, 'nm_cache')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static canHandle(path: string) {
|
|
13
|
+
return path.startsWith('/_nm/')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Serve a browser-ready bundle of a file inside `node_modules`.
|
|
18
|
+
*
|
|
19
|
+
* **Why this does not call `getStatic`.** `getStatic` is the single writer of
|
|
20
|
+
* the containment + `.forbidden` pair, and three of the four handlers its doc
|
|
21
|
+
* comment names now go through it. This one cannot, and the reason is not
|
|
22
|
+
* the root (it takes a `roots` argument) — it is that `getStatic` answers
|
|
23
|
+
* "is there a plain file literally at this path", and `/_nm/` deliberately
|
|
24
|
+
* asks a different question. The entry here goes to `Bun.build`, which does
|
|
25
|
+
* Node resolution: `/_nm/pkg/sub` resolves to `pkg/sub/index.js`, and that is
|
|
26
|
+
* precisely what the import map's `"<pkg>/": "/_nm/<pkg>/"` prefix entry
|
|
27
|
+
* (`utils/http/dom.ts`) produces for an extensionless subpath import.
|
|
28
|
+
* `getStatic` returns `null` for that path — it is a directory — so routing
|
|
29
|
+
* through it would turn every extensionless subpath import into a 204.
|
|
30
|
+
* `nm.test.ts` pins both halves of that divergence.
|
|
31
|
+
*
|
|
32
|
+
* What *was* missing is the second half of the pair. Containment was spelled
|
|
33
|
+
* out here and was correct, but `.forbidden` was never checked, so `/_nm/*`
|
|
34
|
+
* was the one file-serving surface an operator could not pull offline with a
|
|
35
|
+
* marker file. Both checks are below, in `getStatic`'s order and with its
|
|
36
|
+
* semantics, against the `node_modules` root.
|
|
37
|
+
*
|
|
38
|
+
* The `.forbidden` walk has to happen *before* the cache lookup, not after:
|
|
39
|
+
* `getOrCreateCachedFile` answers from `.cache/nm_cache` without touching the
|
|
40
|
+
* source tree, so a marker dropped on an already-served package would
|
|
41
|
+
* otherwise be invisible until the cache was cleared.
|
|
42
|
+
*/
|
|
43
|
+
static async handle(path: string) {
|
|
44
|
+
const nmPath = path.replace(/^\/_nm\//, 'node_modules/')
|
|
45
|
+
const nmRoot = fs.resolve(Bakery.root, 'node_modules')
|
|
46
|
+
const nodeModulesPath = fs.resolve(Bakery.root, nmPath)
|
|
47
|
+
|
|
48
|
+
// Strictly below the root, so `node_modules` itself is not an entry point.
|
|
49
|
+
// `getStatic` admits `file === root` and then rejects it as a directory;
|
|
50
|
+
// here the same answer has to come from the containment test, because
|
|
51
|
+
// nothing downstream stats the path.
|
|
52
|
+
if (!nodeModulesPath.startsWith(`${nmRoot}/`)) return undefined
|
|
53
|
+
if (fs.isForbidden(nodeModulesPath, nmRoot)) return undefined
|
|
54
|
+
|
|
55
|
+
const nmFile = Bun.file(nodeModulesPath)
|
|
56
|
+
const sourceMtime = fs.exists(nmFile) ? nmFile.lastModified : null
|
|
57
|
+
|
|
58
|
+
const cacheId = toHash(nmPath)
|
|
59
|
+
const cacheName = `${cacheId}.js`
|
|
60
|
+
|
|
61
|
+
const cached = await fs.getOrCreateCachedFile(
|
|
62
|
+
this.cacheDir,
|
|
63
|
+
cacheName,
|
|
64
|
+
sourceMtime,
|
|
65
|
+
async () => {
|
|
66
|
+
const module = await bundleModule(nodeModulesPath)
|
|
67
|
+
return module.success && module.content ? module.content : null
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return cached || undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
import { response } from '../../utils/http'
|
|
3
|
+
import { Handler } from '../core/$base'
|
|
4
|
+
import { getStatic } from '../core/$static'
|
|
5
|
+
|
|
6
|
+
export class PublicHandler extends Handler {
|
|
7
|
+
static canHandle(path: string) {
|
|
8
|
+
return path.startsWith('/uploads/')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static async handle(path: string) {
|
|
12
|
+
const resolved = await getStatic(path, Bakery.publicRoot)
|
|
13
|
+
if (!resolved) return response.error('Not Found')
|
|
14
|
+
|
|
15
|
+
return Bun.file(resolved.file)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Bakery, hostKey } from '../../core/bakery'
|
|
2
|
+
import { matchBlockedCached } from '../../core/context'
|
|
3
|
+
import { toHash } from '../../utils'
|
|
4
|
+
import { fs } from '../../utils/fs'
|
|
5
|
+
import { response } from '../../utils/http'
|
|
6
|
+
import { Handler } from '../core/$base'
|
|
7
|
+
import { ErrorHandler } from '../core/$error'
|
|
8
|
+
import { getStatic } from '../core/$static'
|
|
9
|
+
|
|
10
|
+
export class StaticHandler extends Handler {
|
|
11
|
+
static canHandle(): boolean {
|
|
12
|
+
return true
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static get cacheDir() {
|
|
16
|
+
return fs.resolve(Bakery.cacheDir, 'static')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async handle(path: string) {
|
|
20
|
+
// The router already ran this for every file-serving handler, but this
|
|
21
|
+
// check must stay: direct callers (tests, embedders) reach `handle`
|
|
22
|
+
// without the router gate. `matchBlockedCached` makes the overlap cost a
|
|
23
|
+
// per-request map hit rather than a second pair of glob matches.
|
|
24
|
+
// One read of the config getter: `blocked` and the serve root come from
|
|
25
|
+
// the same snapshot — getStatic's default `roots` would re-read it.
|
|
26
|
+
const cfg = Bakery.config
|
|
27
|
+
if (matchBlockedCached(cfg.blocked, path)) {
|
|
28
|
+
return response.error('Forbidden', 403)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolved = await getStatic(path, cfg.root)
|
|
32
|
+
if (!resolved) return response.error('Not Found')
|
|
33
|
+
|
|
34
|
+
const file = Bun.file(resolved.file)
|
|
35
|
+
const ext = fs.parse(path).ext
|
|
36
|
+
|
|
37
|
+
if (fs.isCompressible(ext)) {
|
|
38
|
+
const cacheName = `${toHash(hostKey(path))}${ext}`
|
|
39
|
+
const cached = await fs.getOrCreateCachedFile(
|
|
40
|
+
this.cacheDir,
|
|
41
|
+
cacheName,
|
|
42
|
+
file.lastModified,
|
|
43
|
+
() => file.arrayBuffer(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (cached) return cached
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return file
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class DefaultErrorHandler extends ErrorHandler {
|
|
54
|
+
/**
|
|
55
|
+
* This is the fallback for every path with no more specific error handler,
|
|
56
|
+
* so it renders through the inherited `publicBody` like the rest of the
|
|
57
|
+
* error surface — `errorBody` carries the thrown error's stack (that is
|
|
58
|
+
* what the log wants), and handing it to the client verbatim gave any
|
|
59
|
+
* anonymous request source paths and query text in PROD.
|
|
60
|
+
*/
|
|
61
|
+
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
|
+
error ||= this.DEFAULT_ERROR
|
|
66
|
+
|
|
67
|
+
const errorPage = `
|
|
68
|
+
<!DOCTYPE html>
|
|
69
|
+
<html lang="en">
|
|
70
|
+
<head>
|
|
71
|
+
<meta charset="UTF-8" />
|
|
72
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
73
|
+
<title>Error ${error.errorCode} | Bakery 🚀</title>
|
|
74
|
+
</head>
|
|
75
|
+
<body style="margin: 2rem; font-family: sans-serif;">
|
|
76
|
+
<h1>${Bun.escapeHTML(`${error.errorCode} - ${error.errorText}`)}</h1>
|
|
77
|
+
<pre>${Bun.escapeHTML(this.publicBody(error))}</pre>
|
|
78
|
+
<hr />
|
|
79
|
+
<small>${Bun.escapeHTML(date)} - ${Bun.escapeHTML(ip)}</small>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
`
|
|
83
|
+
|
|
84
|
+
return response.html(errorPage, error.errorCode)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { compile } from '../../compiler'
|
|
2
|
+
import { Bakery, hostKey } from '../../core/bakery'
|
|
3
|
+
import { toHash } from '../../utils/common'
|
|
4
|
+
import { fs } from '../../utils/fs'
|
|
5
|
+
import { response } from '../../utils/http'
|
|
6
|
+
import { DynamicHandler } from '../core/$dynamic'
|
|
7
|
+
|
|
8
|
+
const normalizePath = (path: string) =>
|
|
9
|
+
path.endsWith('.js') ? path.slice(0, -3) : path
|
|
10
|
+
|
|
11
|
+
export class TSHandler extends DynamicHandler {
|
|
12
|
+
static get config() {
|
|
13
|
+
return {
|
|
14
|
+
ext: ['ts'],
|
|
15
|
+
dir: Bakery.serveRoot,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static get cacheDir() {
|
|
20
|
+
return fs.resolve(Bakery.cacheDir, 'ts_cache')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static async canHandle(path: string, req: Request) {
|
|
24
|
+
if (path.endsWith('.ts')) return true
|
|
25
|
+
return await super.canHandle(normalizePath(path), req)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static async handle(path: string) {
|
|
29
|
+
const info = await this.resolveRoute(normalizePath(path))
|
|
30
|
+
if (!info) return response.error('Not Found')
|
|
31
|
+
|
|
32
|
+
const file = info.file
|
|
33
|
+
const id = toHash(hostKey(info.path))
|
|
34
|
+
const cacheName = `${id}.js`
|
|
35
|
+
const fileOrig = fs.resolve(this.config.dir, info.path)
|
|
36
|
+
|
|
37
|
+
const cached = await fs.getOrCreateCachedFile(
|
|
38
|
+
this.cacheDir,
|
|
39
|
+
cacheName,
|
|
40
|
+
file.lastModified,
|
|
41
|
+
() => this.compileRoute(fileOrig),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// 500, not the default 404: the route exists, the server failed to build
|
|
45
|
+
// it, and a 404 sends the developer hunting for a missing file.
|
|
46
|
+
//
|
|
47
|
+
// The route is named because this branch was unreachable until
|
|
48
|
+
// `compileText` stopped throwing past it, and a nameless "Compilation
|
|
49
|
+
// Failed" is only half an answer: the diagnostic — file, line, column,
|
|
50
|
+
// source line — goes to the log as `compLog.COMPILE_FAIL`, and the two
|
|
51
|
+
// have to be joinable. Naming it discloses nothing a client did not
|
|
52
|
+
// already send: `info.path` is what it asked for. In production
|
|
53
|
+
// `publicBody` replaces the whole 5xx body anyway.
|
|
54
|
+
return cached || response.error(`Compilation Failed: ${info.path}`, 500)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Compile seam — ts.test.ts substitutes it to exercise the failure branch. */
|
|
58
|
+
static compileRoute(file: fs.AbsolutePath): Promise<string | null> {
|
|
59
|
+
return compile(file)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Bakery } from '../../core/bakery'
|
|
2
|
+
import { isSafeHtml } from '../../core/jsx'
|
|
3
|
+
import { is, jsonResponse } from '../../utils/common'
|
|
4
|
+
import { fs } from '../../utils/fs'
|
|
5
|
+
import { injectIfHtml, response } from '../../utils/http'
|
|
6
|
+
import type { Handler } from '../core/$base'
|
|
7
|
+
import { bustInDev, DynamicHandler } from '../core/$dynamic'
|
|
8
|
+
import {
|
|
9
|
+
beginPageRoute,
|
|
10
|
+
DynamicErrorHandler,
|
|
11
|
+
markDevFile,
|
|
12
|
+
publicErrorData,
|
|
13
|
+
} from '../core/$error'
|
|
14
|
+
|
|
15
|
+
export class TSXHandler extends DynamicHandler {
|
|
16
|
+
static get config() {
|
|
17
|
+
return {
|
|
18
|
+
ext: ['tsx'],
|
|
19
|
+
dir: Bakery.serveRoot,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static handle = sharedHandler
|
|
24
|
+
|
|
25
|
+
static executeModule(file: fs.AbsolutePath, req: Request, body: any) {
|
|
26
|
+
return super.executeModule(bustInDev(file), req, body)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TSXErrorHandler extends DynamicErrorHandler {
|
|
31
|
+
static get config() {
|
|
32
|
+
return {
|
|
33
|
+
ext: ['tsx'],
|
|
34
|
+
dir: Bakery.serveRoot,
|
|
35
|
+
include: ['**/error.tsx', '**/error-*.tsx'],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static handle = sharedHandler
|
|
40
|
+
|
|
41
|
+
static executeModule(file: fs.AbsolutePath, req: Request, body: any) {
|
|
42
|
+
return super.executeModule(bustInDev(file), req, body)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//
|
|
47
|
+
//
|
|
48
|
+
async function sharedHandler(
|
|
49
|
+
this: typeof DynamicHandler | typeof DynamicErrorHandler,
|
|
50
|
+
path: string,
|
|
51
|
+
req: Request,
|
|
52
|
+
errors?: Handler.Error.Data,
|
|
53
|
+
) {
|
|
54
|
+
const begun = await beginPageRoute(this, path, errors)
|
|
55
|
+
if (begun instanceof Response) return begun
|
|
56
|
+
const { errorData, info } = begun
|
|
57
|
+
const filePath = info.path
|
|
58
|
+
|
|
59
|
+
const modulePath = fs.resolve(Bakery.serveRoot, filePath)
|
|
60
|
+
const params = info.getParams(path) || {}
|
|
61
|
+
markDevFile(params, filePath)
|
|
62
|
+
// Redacted before it becomes page data — see `publicErrorData`. The params
|
|
63
|
+
// are both substituted into the render and injected as `__PAGE_PARAMS__`.
|
|
64
|
+
// `errorData` is undefined for an ordinary page; Object.assign ignores that.
|
|
65
|
+
const finalParams = Object.assign(
|
|
66
|
+
{},
|
|
67
|
+
params,
|
|
68
|
+
errorData && publicErrorData(errorData),
|
|
69
|
+
)
|
|
70
|
+
const body = await this.params(req, finalParams)
|
|
71
|
+
|
|
72
|
+
const returned = await this.executeModule(modulePath, req, body)
|
|
73
|
+
if (returned === null) return response.error('Not Found')
|
|
74
|
+
|
|
75
|
+
// `createElement` returns `raw()` → a SafeHtml, which is a String
|
|
76
|
+
// *subclass*, so `is.object` (typeof === 'object') is true for it and an
|
|
77
|
+
// unwrapped JSX page got JSON-encoded instead of served as HTML. Unbox it
|
|
78
|
+
// here rather than widening `is.object`: `is.object([]) === true` is a
|
|
79
|
+
// deliberate decision that router.ts depends on.
|
|
80
|
+
const resData = isSafeHtml(returned) ? String(returned) : returned
|
|
81
|
+
|
|
82
|
+
const code = errorData?.errorCode || 200
|
|
83
|
+
if (is.object(resData)) {
|
|
84
|
+
return resData instanceof Response
|
|
85
|
+
? resData
|
|
86
|
+
: jsonResponse(code, 'Success', resData)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [hasTs, hasCss] = await Promise.all([
|
|
90
|
+
fs.exists(modulePath.replace(/\.tsx$/, '.ts')),
|
|
91
|
+
fs.exists(modulePath.replace(/\.tsx$/, '.css')),
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
const style = filePath.replace(/\.tsx$/, '.css')
|
|
95
|
+
const tsUrl = filePath.replace(/\.tsx$/, '.js')
|
|
96
|
+
|
|
97
|
+
const html = await injectIfHtml(resData, params, {
|
|
98
|
+
body: hasTs ? `<script src="/${tsUrl}" type="module"></script>` : '',
|
|
99
|
+
head: hasCss ? `<link rel="stylesheet" href="/${style}">` : '',
|
|
100
|
+
})
|
|
101
|
+
if (html) return html
|
|
102
|
+
|
|
103
|
+
return is.string(resData)
|
|
104
|
+
? response.text(resData)
|
|
105
|
+
: response.error('Not Found')
|
|
106
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Strings } from '../../cache/string'
|
|
2
|
+
import { bundleModule } from '../../compiler'
|
|
3
|
+
import { Bakery } from '../../core/bakery'
|
|
4
|
+
import { frameworkPath } from '../../core/paths'
|
|
5
|
+
import type { MapOf } from '../../types'
|
|
6
|
+
import { toHash } from '../../utils'
|
|
7
|
+
import { FileSystem as fs } from '../../utils/fs'
|
|
8
|
+
import { Handler } from '../core/$base'
|
|
9
|
+
|
|
10
|
+
export class VirtualAssetHandler extends Handler {
|
|
11
|
+
static canHandle(path: string) {
|
|
12
|
+
if (!import.meta.env.DEV && path === '/_client/livereload.js') return false
|
|
13
|
+
return path.startsWith('/_client/') || path.startsWith('/_virtual/')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static get cacheDir() {
|
|
17
|
+
return fs.resolve(Bakery.cacheDir, 'virtual')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static get clientAssets(): MapOf<string> {
|
|
21
|
+
// frameworkPath, not Bakery.root: these ship with the framework, so they
|
|
22
|
+
// must resolve relative to it rather than to the application's cwd.
|
|
23
|
+
const assets: MapOf<string> = {
|
|
24
|
+
'/_client/utils.js': frameworkPath('client/utils.ts'),
|
|
25
|
+
}
|
|
26
|
+
if (import.meta.env.DEV) {
|
|
27
|
+
assets['/_client/livereload.js'] = frameworkPath('client/livereload.ts')
|
|
28
|
+
}
|
|
29
|
+
return assets
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async handleClientAsset(path: string) {
|
|
33
|
+
const masterPath = this.clientAssets[path]
|
|
34
|
+
if (!masterPath) return null
|
|
35
|
+
|
|
36
|
+
const masterFile = Bun.file(masterPath)
|
|
37
|
+
if (!fs.exists(masterFile)) return null
|
|
38
|
+
|
|
39
|
+
const parsed = fs.parse(masterPath)
|
|
40
|
+
const fileId = `client-${parsed.name}.js`
|
|
41
|
+
|
|
42
|
+
const cachedFile = await fs.getOrCreateCachedFile(
|
|
43
|
+
this.cacheDir,
|
|
44
|
+
fileId,
|
|
45
|
+
masterFile.lastModified,
|
|
46
|
+
async function bundleClient() {
|
|
47
|
+
// Bundled, not merely transpiled: these are browser entry points, and
|
|
48
|
+
// the browser cannot resolve the relative imports a transpile leaves
|
|
49
|
+
// behind — they resolve against the page URL, return the HTML
|
|
50
|
+
// fallback, and fail strict MIME checking for module scripts.
|
|
51
|
+
const built = await bundleModule(masterPath as fs.AbsolutePath)
|
|
52
|
+
return built.success && built.content ? built.content : null
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if (!cachedFile) return null
|
|
57
|
+
|
|
58
|
+
const routeInfo = new Handler.Route.Info(cachedFile.name!, path.slice(1))
|
|
59
|
+
|
|
60
|
+
this.cache.set(path, routeInfo)
|
|
61
|
+
return routeInfo
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static async handleVirtualAsset(path: string) {
|
|
65
|
+
const id = path.slice('/_virtual/'.length)
|
|
66
|
+
const resolvedPath = Strings.getValue(id)
|
|
67
|
+
if (!resolvedPath) return null
|
|
68
|
+
|
|
69
|
+
const assetFile = Bun.file(resolvedPath)
|
|
70
|
+
if (!fs.exists(assetFile)) return null
|
|
71
|
+
|
|
72
|
+
const ext = fs.parse(resolvedPath).ext
|
|
73
|
+
const cacheName = `${toHash(resolvedPath)}${ext}`
|
|
74
|
+
|
|
75
|
+
const cachedFile = await fs.getOrCreateCachedFile(
|
|
76
|
+
this.cacheDir,
|
|
77
|
+
cacheName,
|
|
78
|
+
assetFile.lastModified,
|
|
79
|
+
async function getVirtualAsset() {
|
|
80
|
+
return assetFile.arrayBuffer()
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (!cachedFile) return null
|
|
85
|
+
|
|
86
|
+
const routeInfo = new Handler.Route.Info(cachedFile.name!, path.slice(1))
|
|
87
|
+
|
|
88
|
+
this.cache.set(path, routeInfo)
|
|
89
|
+
return routeInfo
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static async getRouteInfo(path: string) {
|
|
93
|
+
if (path.startsWith('/_client/')) return await this.handleClientAsset(path)
|
|
94
|
+
if (path.startsWith('/_virtual/'))
|
|
95
|
+
return await this.handleVirtualAsset(path)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static async handle(path: string) {
|
|
99
|
+
const route = this.cache.get(path)
|
|
100
|
+
if (route?.valid) return route.file
|
|
101
|
+
|
|
102
|
+
return (await this.getRouteInfo(path))?.file
|
|
103
|
+
}
|
|
104
|
+
}
|