@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,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
+ }