@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,315 @@
1
+ /** biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: '*/
2
+
3
+ /**
4
+ * Browser runtime: binds the shared utilities onto `globalThis` and adds the
5
+ * DOM-dependent pieces ($fmt, speculation rules, live navigation) on top.
6
+ *
7
+ * The utilities themselves live in `utils/isomorphic/` and are imported rather
8
+ * than redefined here. This file used to carry its own copy of each one, which
9
+ * is how the $fmt replacer-arity bug and the match() prototype-chain bug each
10
+ * had to be fixed twice.
11
+ */
12
+ import type { MapOf } from '../types'
13
+ import { Case } from '../utils/isomorphic/case'
14
+ import { escapeHtml as escapeHTML } from '../utils/isomorphic/escape'
15
+ import { is } from '../utils/isomorphic/is'
16
+ import { match, matchDefault } from '../utils/isomorphic/match'
17
+ import { Math2 } from '../utils/isomorphic/math'
18
+ import { any, assert, repeat, throws } from '../utils/isomorphic/misc'
19
+ import { circularReplacer } from '../utils/isomorphic/stringify'
20
+ import { Try, tryCatch } from '../utils/isomorphic/try'
21
+
22
+ export { escapeHTML }
23
+
24
+ function processGetBody(
25
+ body: FormData | MapOf<any> | URLSearchParams | string,
26
+ ) {
27
+ if (body instanceof URLSearchParams) {
28
+ return body.toString()
29
+ }
30
+
31
+ if (body instanceof FormData || (is.object(body) && body !== null)) {
32
+ const urlSearchParams = new URLSearchParams()
33
+ const entries =
34
+ body instanceof FormData ? body.entries() : Object.entries(body)
35
+
36
+ for (const [key, value] of entries) {
37
+ urlSearchParams.append(key, (value as any).toString())
38
+ }
39
+ return urlSearchParams.toString()
40
+ }
41
+
42
+ return String(body)
43
+ }
44
+
45
+ // `randomId` and `request` are exported purely so `client/globals.d.ts` can
46
+ // declare the globals as `typeof import('./utils').randomId` / `.request`
47
+ // instead of restating their signatures — which is how both had drifted from
48
+ // this file. Nothing imports them; the bundle entry's exports are inert.
49
+ export function randomId(length = 8) {
50
+ const arr = new Uint8Array(Math.ceil(length / 2))
51
+ crypto.getRandomValues(arr)
52
+ return Array.from(arr, dec => dec.toString(16).padStart(2, '0'))
53
+ .join('')
54
+ .slice(0, length)
55
+ }
56
+
57
+ type RequestJson = RequestInit & { body?: any }
58
+
59
+ export async function request(
60
+ url: string,
61
+ init: RequestJson | string = {},
62
+ bodyData?: any,
63
+ ): Promise<JsonResponse> {
64
+ let options: RequestJson = {}
65
+ if (typeof init === 'string') {
66
+ options = { method: init, body: bodyData }
67
+ } else {
68
+ options = init || {}
69
+ }
70
+
71
+ const method = (options.method || 'GET').toUpperCase()
72
+ const body = options.body || {}
73
+
74
+ if (method === 'GET' && body && Object.keys(body).length > 0) {
75
+ const query = processGetBody(body)
76
+ if (query) {
77
+ url = `${url}${url.includes('?') ? '&' : '?'}${query}`
78
+ }
79
+ }
80
+
81
+ const isFormData = body instanceof FormData
82
+
83
+ const response = await fetch(url, {
84
+ ...options,
85
+ method,
86
+ body:
87
+ method === 'GET' ? undefined : isFormData ? body : JSON.stringify(body),
88
+ headers: {
89
+ ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
90
+ ...(options.headers || {}),
91
+ },
92
+ })
93
+
94
+ const [err, data] = await tryCatch(response.json.bind(response))
95
+
96
+ if (err) {
97
+ throws(`Request failed: ${err.message || 'Unknown error'}`)
98
+ }
99
+
100
+ if (
101
+ data &&
102
+ typeof data === 'object' &&
103
+ 'status' in data &&
104
+ 'message' in data
105
+ ) {
106
+ const status = (data as any).status
107
+ if (status >= 200 && status < 300) {
108
+ return data as JsonResponse
109
+ }
110
+ throws((data as any).message)
111
+ }
112
+
113
+ return data
114
+ }
115
+
116
+ export function formatHTML(html: string, indentWidth: number = 2): string {
117
+ if (!html) return ''
118
+
119
+ const cleanHtml = html
120
+ .replace(/\n/g, '')
121
+ .replace(/[\s]{2,}/g, ' ')
122
+ .replace(/>\s*</g, '><')
123
+ .trim()
124
+
125
+ const tokens = cleanHtml.match(/<[^>]+>|[^<]+/g) || []
126
+ let indentLevel = 0
127
+
128
+ return tokens.reduce((formattedString, token) => {
129
+ const isClosing = /^<\//.test(token)
130
+ const isSelfClosing =
131
+ /^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)[^>]*>/i.test(
132
+ token,
133
+ ) || /<[^>]+\/>/.test(token)
134
+ const isOpening = /^<[^/!?]/.test(token) && !isSelfClosing
135
+
136
+ indentLevel = isClosing ? Math.max(0, indentLevel - 1) : indentLevel
137
+
138
+ const indent = ' '.repeat(indentLevel * indentWidth)
139
+ const appendedToken =
140
+ formattedString === '' ? token : `\n${indent}${token.trim()}`
141
+
142
+ indentLevel = isOpening ? indentLevel + 1 : indentLevel
143
+
144
+ return formattedString + appendedToken
145
+ }, '')
146
+ }
147
+
148
+ Object.assign(globalThis, {
149
+ match,
150
+ matchDefault,
151
+ Try,
152
+ tryCatch,
153
+ is,
154
+ Case,
155
+ Math2,
156
+ throws,
157
+ assert,
158
+ any,
159
+ escapeHTML,
160
+ repeat,
161
+ request,
162
+ randomId,
163
+ Bakery: {
164
+ version: import.meta.env.BAKERY_VERSION,
165
+ async virtual(path: string) {
166
+ const response = await fetch(path)
167
+ if (!response.ok) {
168
+ return null
169
+ }
170
+
171
+ const contentType = response.headers.get('Content-Type') || ''
172
+ if (contentType.includes('application/json')) {
173
+ return response.json()
174
+ } else {
175
+ const text = await response.text()
176
+
177
+ if (path.endsWith('.css')) {
178
+ const style = document.createElement('style')
179
+ style.textContent = text
180
+ document.head.appendChild(style)
181
+ return null
182
+ }
183
+
184
+ // The body was already consumed above; re-reading it throws.
185
+ return text
186
+ }
187
+ },
188
+
189
+ params<T = MapOf<any>>(): T {
190
+ return any(window).__PAGE_PARAMS__ as T
191
+ },
192
+ },
193
+ $fmt(data: any): string {
194
+ if (data == null) return ''
195
+ if (is.string(data)) return data
196
+ if (is.bigint(data)) return `${data}n`
197
+ if (is.symbol(data)) return data.toString()
198
+
199
+ if (data instanceof Element) return `\n${formatHTML(data.outerHTML)}`
200
+ if (data instanceof Error) return data.stack || data.message || String(data)
201
+ if (data instanceof Date) return data.toISOString()
202
+ if (data instanceof RegExp) return data.toString()
203
+
204
+ if (is.function(data)) {
205
+ return data.name ? `[Function: ${data.name}]` : '[Function]'
206
+ }
207
+
208
+ if (data instanceof Set) {
209
+ const fmt = (globalThis as any).$fmt
210
+ return `Set(${data.size}) { ${Array.from(data)
211
+ .map(v => fmt(v))
212
+ .join(', ')} }`
213
+ }
214
+ if (data instanceof Map) {
215
+ const fmt = (globalThis as any).$fmt
216
+ return `Map(${data.size}) { ${Array.from(data.entries())
217
+ .map(([k, v]) => `${fmt(k)} => ${fmt(v)}`)
218
+ .join(', ')} }`
219
+ }
220
+
221
+ if (
222
+ Array.isArray(data) ||
223
+ typeof data?.toJSON === 'function' ||
224
+ (typeof data === 'object' &&
225
+ Object.prototype.toString.call(data) === '[object Object]')
226
+ ) {
227
+ try {
228
+ // Cycle handling comes from circularReplacer; this adds only the
229
+ // display rules for values JSON has no representation for.
230
+ return JSON.stringify(
231
+ data,
232
+ circularReplacer((_key, value) => {
233
+ if (is.bigint(value)) return `${value}n`
234
+ if (is.function(value))
235
+ return value.name ? `[Function: ${value.name}]` : '[Function]'
236
+ if (value instanceof RegExp) return String(value)
237
+ return value
238
+ }),
239
+ 2,
240
+ )
241
+ } catch {
242
+ return String(data)
243
+ }
244
+ }
245
+
246
+ return String(data)
247
+ },
248
+ })
249
+
250
+ if (typeof document !== 'undefined') {
251
+ let debounceTimer: any
252
+
253
+ const updateSpeculationRules = () => {
254
+ const urls = new Set<string>()
255
+ const elements = document.querySelectorAll(
256
+ '[href]:not(link, base, use, image)',
257
+ )
258
+
259
+ const ignorePattern = /([?&](utm_|fbclid)|\.(pdf|zip)$)/i
260
+ for (const el of elements) {
261
+ const prefetchAttr = el.getAttribute('prefetch')
262
+ if (prefetchAttr === 'false') continue
263
+
264
+ const url = el.getAttribute('href')?.trim()
265
+
266
+ if (
267
+ !url ||
268
+ url.startsWith('#') ||
269
+ url.includes(':') ||
270
+ ignorePattern.test(url) ||
271
+ url.toLowerCase().includes('logout')
272
+ )
273
+ continue
274
+
275
+ urls.add(url)
276
+ }
277
+
278
+ if (urls.size === 0) return
279
+
280
+ document.querySelector('script[type="speculationrules"]')?.remove()
281
+
282
+ const specScript = document.createElement('script')
283
+ specScript.type = 'speculationrules'
284
+
285
+ // Prerender the same filtered list as prefetch, not every link on the page.
286
+ // A `source: 'document'` rule with href_matches '/*' and eager eagerness
287
+ // ignored the exclusions above and fully loaded — and ran the JS of — every
288
+ // same-origin link, including things like /logout or a destructive GET.
289
+ const urlList = Array.from(urls)
290
+ specScript.textContent = JSON.stringify({
291
+ prefetch: [{ source: 'list', urls: urlList }],
292
+ prerender: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
293
+ })
294
+
295
+ document.head.appendChild(specScript)
296
+ }
297
+
298
+ const observer = new MutationObserver(() => {
299
+ clearTimeout(debounceTimer)
300
+ debounceTimer = setTimeout(updateSpeculationRules, 1000)
301
+ })
302
+
303
+ const initObserver = () => {
304
+ updateSpeculationRules()
305
+ if (document.body) {
306
+ observer.observe(document.body, { childList: true, subtree: true })
307
+ }
308
+ }
309
+
310
+ if (document.readyState === 'loading') {
311
+ document.addEventListener('DOMContentLoaded', initObserver)
312
+ } else {
313
+ initObserver()
314
+ }
315
+ }
@@ -0,0 +1,263 @@
1
+ import { Strings } from '../cache/string'
2
+ import { Bakery } from '../core/bakery'
3
+ import { PluginHooks } from '../core/plugins'
4
+ import {
5
+ compLog,
6
+ errorMsg,
7
+ errorWithPosition,
8
+ handlerLog,
9
+ } from '../logger/serve-log'
10
+ import type { MapOf } from '../types'
11
+ import { is, Try } from '../utils/common'
12
+ import { FileSystem as fs } from '../utils/fs'
13
+
14
+ const RX_IMPORT =
15
+ /import\s+(?:(?:\*\s+as\s+)?([a-zA-Z_$\d\s{},/*]+?)\s+from\s+)?['"]([^'"]+?\.([a-zA-Z0-9]+))['"](?:\s+(?:with|assert)\s*\{[^}]+\})?\s*;?/gm
16
+
17
+ /**
18
+ * The build-time `define` table, resolved on first use rather than at module
19
+ * evaluation.
20
+ *
21
+ * This used to be a top-level `await` reading `<cwd>/package.json`, which put a
22
+ * filesystem read on the boot path of every process that loads this module —
23
+ * and `handlers/assets/ts.ts` imports it, so that is every worker and every dev
24
+ * restart. The value it produces is needed only when something actually
25
+ * compiles; a production process serving out of a warm compile cache never
26
+ * needs it at all.
27
+ *
28
+ * Memoised as a promise, not a value, so two concurrent first compiles share
29
+ * one read instead of racing to do it twice.
30
+ */
31
+ let definesPromise: Promise<MapOf<string>> | null = null
32
+
33
+ async function buildDefines(): Promise<MapOf<string>> {
34
+ let bakeryVersion = '1.0.0'
35
+ try {
36
+ const file = await Bun.file(`${process.cwd()}/package.json`).json()
37
+ if (file?.version) bakeryVersion = file.version
38
+ } catch {
39
+ // No package.json in cwd, or it is unreadable. The version is cosmetic —
40
+ // it lands in a banner and a build define — so the default above stands.
41
+ }
42
+
43
+ return {
44
+ 'import.meta.env.DEV': JSON.stringify(!!import.meta.env.DEV),
45
+ 'import.meta.env.PROD': JSON.stringify(!import.meta.env.DEV),
46
+ 'import.meta.env.WORKER': JSON.stringify(!!import.meta.env.WORKER),
47
+ 'import.meta.env.MODE': JSON.stringify(
48
+ import.meta.env.MODE || 'production',
49
+ ),
50
+ 'import.meta.env.BAKERY_VERSION': JSON.stringify(bakeryVersion),
51
+ }
52
+ }
53
+
54
+ function getDefines(): Promise<MapOf<string>> {
55
+ definesPromise ??= buildDefines()
56
+ return definesPromise
57
+ }
58
+
59
+ let transpilerPromise: Promise<Bun.Transpiler> | null = null
60
+ function getTranspiler(): Promise<Bun.Transpiler> {
61
+ transpilerPromise ??= getDefines().then(
62
+ define =>
63
+ new Bun.Transpiler({
64
+ loader: 'ts',
65
+ inline: true,
66
+ trimUnusedImports: false,
67
+ minifyWhitespace: true,
68
+ target: 'browser',
69
+ deadCodeElimination: false,
70
+ define,
71
+ }),
72
+ )
73
+ return transpilerPromise
74
+ }
75
+
76
+ let virtualIdCounter = 0
77
+ function nextVirtualId(): string {
78
+ return (++virtualIdCounter).toString(36)
79
+ }
80
+
81
+ function preprocessImports(source: string, filePath: fs.AbsolutePath): string {
82
+ const fileDir = fs.resolve(filePath)
83
+
84
+ const matches = [...source.matchAll(RX_IMPORT)]
85
+
86
+ for (const [string, varName, importPath, ext] of matches) {
87
+ if (ext !== 'css' && ext !== 'json') continue
88
+
89
+ const assetPath = fs.resolve(fileDir, importPath)
90
+
91
+ // The key is the bare id, never the `/_virtual/` URL. Storing the full URL
92
+ // meant getKey() returned an already-prefixed value that got prefixed again
93
+ // on the next compile — which both threw a StringCache collision and left
94
+ // VirtualAssetHandler (which looks up by bare id) unable to resolve it.
95
+ const id =
96
+ Strings.getKey(assetPath) || `${Date.now()}_${nextVirtualId()}.${ext}`
97
+
98
+ const quotedUrl = JSON.stringify(`/_virtual/${id}`)
99
+
100
+ const replacement = varName
101
+ ? `const ${varName} = await Bakery.virtual(${quotedUrl});`
102
+ : `await Bakery.virtual(${quotedUrl});`
103
+
104
+ source = source.replace(string, () => replacement)
105
+ Strings.set(id, assetPath)
106
+ }
107
+
108
+ return source
109
+ }
110
+
111
+ /**
112
+ * Compile `source`, naming `path` so a failure can say where it happened.
113
+ *
114
+ * The two branches are not symmetric on purpose. Without a path there is no
115
+ * file to name and no caller that can render a 500 — the Vue plugin compiles
116
+ * fragments of an SFC it has already parsed — so a failure logs and hands the
117
+ * source back. With a path the failure is a served request, and the answer is
118
+ * `null`: `TSHandler` turns that into the 500 it has always had a branch for.
119
+ *
120
+ * It used to be neither. The transpiler was wrapped only on the pathless
121
+ * branch, so a syntax error in a `.ts` asset threw straight past `compile()`,
122
+ * past `TSHandler` — leaving its `'Compilation Failed'` arm permanently
123
+ * unreachable — and into the worker's catch-all, which printed
124
+ * `Unhandled Server Error: <message>` with no file and no line while the client
125
+ * got `An unexpected error occurred.` A total blackout for a one-character typo.
126
+ */
127
+ export function compileText(source: string): Promise<string>
128
+ export function compileText(
129
+ source: string,
130
+ path: fs.AbsolutePath,
131
+ ): Promise<string | null>
132
+ export async function compileText(source: string, path?: fs.AbsolutePath) {
133
+ if (!path) {
134
+ try {
135
+ const content = await (await getTranspiler()).transform(source)
136
+ return content
137
+ } catch (err) {
138
+ compLog.COMPILE_SOURCE_FAIL({ error: errorMsg(err) })
139
+ return source
140
+ }
141
+ }
142
+
143
+ source = preprocessImports(source, path)
144
+
145
+ const [failed, transformed] = await Try.catch(
146
+ (await getTranspiler()).transform(source),
147
+ )
148
+
149
+ if (failed) {
150
+ // `errorWithPosition`, not `errorMsg`: the thrown `BuildMessage` has no
151
+ // stack, so `errorMsg` degrades to the bare message and the line number —
152
+ // the only thing that makes this actionable — is dropped. The file comes
153
+ // from `{file}`; the diagnostic's own `position.file` is `input.ts`,
154
+ // because `transform()` was handed a string.
155
+ compLog.COMPILE_FAIL({ file: path, error: errorWithPosition(failed) })
156
+ return null
157
+ }
158
+
159
+ const content = transformed!
160
+ const importRegex = /\b(from|import)(\s*\(?\s*)(["'])([^"']+)\3(\)?)/g
161
+ const matches = [...content.matchAll(importRegex)]
162
+
163
+ if (!matches.length) return content
164
+
165
+ const { dir } = fs.parse(path)
166
+ const serveRoot = Bakery.serveRoot
167
+ const importMap = Bakery.config.importMap
168
+ const mapKeys = Object.keys(importMap)
169
+
170
+ const replacements = await Promise.all(
171
+ matches.map(
172
+ async ([fullMatch, keyword, spacing, quote, importPath, closing]) => {
173
+ const hasExtension = (importPath.split('/').pop() || '').includes('.')
174
+ if (hasExtension) return fullMatch
175
+
176
+ const prefix = mapKeys.find(k => importPath.startsWith(k))
177
+
178
+ if (!prefix && !importPath.startsWith('.')) return fullMatch
179
+
180
+ const targetPath = prefix
181
+ ? fs.resolve(
182
+ serveRoot,
183
+ importMap[prefix],
184
+ importPath.slice(prefix.length),
185
+ )
186
+ : fs.resolve(dir, importPath)
187
+
188
+ const isDir = await fs.isDir(targetPath)
189
+
190
+ return `${keyword}${spacing}${quote}${importPath}${isDir ? '/index' : ''}${quote}${closing}`
191
+ },
192
+ ),
193
+ )
194
+ const result = content.replace(importRegex, () => replacements.shift()!)
195
+ return await PluginHooks.onCompile(result, path)
196
+ }
197
+
198
+ export async function compile(
199
+ path: fs.AbsolutePath | Bun.BunFile,
200
+ ): Promise<string | null> {
201
+ if (!fs.exists(path))
202
+ throw new Error(`File not found: ${is.string(path) ? path : path.name}`)
203
+
204
+ path = typeof path === 'string' ? Bun.file(path) : path
205
+ return compileText(await path.text(), path.name! as fs.AbsolutePath)
206
+ }
207
+
208
+ type CompileResult = {
209
+ success: boolean
210
+ content?: string
211
+ errors?: string[]
212
+ }
213
+
214
+ export async function bundleModule(
215
+ path: fs.AbsolutePath,
216
+ ): Promise<CompileResult> {
217
+ const build = await Bun.build({
218
+ entrypoints: [path],
219
+ target: 'browser',
220
+ format: 'esm',
221
+ // Coerced, and not defensively: `Bun.build` *rejects* a non-boolean
222
+ // `minify` with "Expected minify to be a boolean or an object" rather than
223
+ // treating it as truthy, so anything that reaches this line as a string
224
+ // takes the whole bundle down.
225
+ //
226
+ // That is reachable from outside this repo. `PROD` is an accessor on
227
+ // `process.env`, and Bun's `process.env` proxy stringifies on write, so an
228
+ // embedder that assigns the flag at all — even `process.env.PROD = true` —
229
+ // leaves a string behind. The suite's own instance of this is fixed at the
230
+ // source (see `setModeFlag` in `src/tests/fixtures.ts`); this guards the
231
+ // writes we do not own.
232
+ minify: Boolean(import.meta.env.PROD),
233
+ define: await getDefines(),
234
+ })
235
+
236
+ if (build.success && build.outputs.length > 0) {
237
+ return { success: true, content: await build.outputs[0].text() }
238
+ }
239
+
240
+ const errors = build.logs
241
+ .map(log => {
242
+ if (!log) return ''
243
+ if (is.string(log)) return log
244
+ const msg = log.message || ''
245
+ const pos = log.position
246
+ if (!pos) return msg
247
+
248
+ return `${pos.file || ''}:${pos.line || 0}:${pos.column || 0} - ${msg}`
249
+ })
250
+ .filter(Boolean)
251
+
252
+ // `handlerLog.BUNDLE_ERR` was declared and never called, so a bundle that
253
+ // failed was silent here: every caller either swallowed the result
254
+ // (`nm.ts`, `virtual-asset.ts` fall back to serving nothing) or logged its
255
+ // own line, and none of them printed the diagnostics this function had
256
+ // already collected. The 500 the caller returns is not a description.
257
+ handlerLog.BUNDLE_ERR({
258
+ file: path,
259
+ error: errors.join('\n ') || 'no diagnostics reported',
260
+ })
261
+
262
+ return { success: false, errors }
263
+ }