@bakery-framework/core 1.2.3 → 2.0.0-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "1.2.3",
3
+ "version": "2.0.0-alpha.1",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -41,15 +41,13 @@ import { fs } from '../utils'
41
41
  * entries also survive; they are still rebuildable, just no longer discarded on
42
42
  * a schedule nobody chose.
43
43
  *
44
- * **That was aspirational until 2026-08-09, and the `await` below is what makes
45
- * it true.** The check ran from `initConfig()`, while this module opens the
46
- * database at *import* timeso the process already held a handle inside the
47
- * directory the wipe was about to delete. On Windows the delete failed with
48
- * `EBUSY`, node's recursive walk stopped at the locked entry, and whatever it
49
- * had not reached yet survived: sessions *did* outlive upgrades, and so did
50
- * `html/`, the compiled-page cache. Awaiting the check here orders the two by
51
- * construction — the cache cannot be opened before it has been validated —
52
- * rather than by hoping one import happens after another.
44
+ * **The `await` below is what makes that true, and it must stay here.** This
45
+ * module opens the database at *import* time, so running the check anywhere
46
+ * else it used to run from `initConfig()` leaves the process holding a
47
+ * handle inside the directory the wipe is about to delete. On Windows that
48
+ * delete fails with `EBUSY`, node's recursive walk stops at the locked entry,
49
+ * and whatever it had not reached yet survives. Awaiting here orders the two by
50
+ * construction rather than by hoping one import happens after another.
53
51
  */
54
52
  await checkCacheVersion()
55
53
 
@@ -48,7 +48,7 @@ declare global {
48
48
  var escapeHTML: typeof import('../utils/isomorphic/escape').escapeHtml
49
49
 
50
50
  var request: typeof import('./utils').request
51
- var randomId: typeof import('./utils').randomId
51
+ var RequestError: typeof import('./utils').RequestError
52
52
 
53
53
  // Not derivable: written inline in client/utils.ts's Object.assign call
54
54
  // rather than exported, so there is no module member to take `typeof` of.
@@ -46,16 +46,43 @@ function processGetBody(
46
46
  // declare the globals as `typeof import('./utils').randomId` / `.request`
47
47
  // instead of restating their signatures — which is how both had drifted from
48
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
- }
49
+ //
50
+ // `randomId` moved to `utils/isomorphic/misc.ts` it was never
51
+ // browser-specific, and a server block calling the "browser global" got a
52
+ // ReferenceError. Imported (not re-exported directly) because the globals
53
+ // object below needs the local binding too.
54
+ import { randomId } from '../utils/isomorphic/misc'
55
+
56
+ export { randomId }
56
57
 
57
58
  type RequestJson = RequestInit & { body?: any }
58
59
 
60
+ /**
61
+ * What `request()` throws for a non-2xx envelope.
62
+ *
63
+ * It used to throw a bare `Error` carrying only the envelope's `message` —
64
+ * which made every structured failure unusable: a 409 whose `data` lists the
65
+ * conflicting rows, a 400 carrying per-field validation issues. Callers had to
66
+ * drop to raw `fetch` precisely for the requests where the framework's
67
+ * envelope was doing its job.
68
+ *
69
+ * `status` is the envelope's status, or the HTTP status when the body was not
70
+ * JSON at all. `data` is the envelope's `data`, `undefined` when there was
71
+ * none. Bound as a browser global like `request` itself, so
72
+ * `err instanceof RequestError` works in app code without an import.
73
+ */
74
+ export class RequestError extends Error {
75
+ readonly status: number
76
+ readonly data: unknown
77
+
78
+ constructor(message: string, status: number, data?: unknown) {
79
+ super(message)
80
+ this.name = 'RequestError'
81
+ this.status = status
82
+ this.data = data
83
+ }
84
+ }
85
+
59
86
  export async function request(
60
87
  url: string,
61
88
  init: RequestJson | string = {},
@@ -94,7 +121,10 @@ export async function request(
94
121
  const [err, data] = await tryCatch(response.json.bind(response))
95
122
 
96
123
  if (err) {
97
- throws(`Request failed: ${err.message || 'Unknown error'}`)
124
+ throw new RequestError(
125
+ `Request failed: ${err.message || 'Unknown error'}`,
126
+ response.status,
127
+ )
98
128
  }
99
129
 
100
130
  if (
@@ -107,7 +137,8 @@ export async function request(
107
137
  if (status >= 200 && status < 300) {
108
138
  return data as JsonResponse
109
139
  }
110
- throws((data as any).message)
140
+ // The envelope's `data` rides along: it is the part a caller can act on.
141
+ throw new RequestError((data as any).message, status, (data as any).data)
111
142
  }
112
143
 
113
144
  return data
@@ -159,6 +190,7 @@ Object.assign(globalThis, {
159
190
  escapeHTML,
160
191
  repeat,
161
192
  request,
193
+ RequestError,
162
194
  randomId,
163
195
  Bakery: {
164
196
  // **Cast rather than `import.meta.env` directly.** `ImportMeta.env` is
@@ -8,8 +8,9 @@ import {
8
8
  handlerLog,
9
9
  } from '../logger/serve-log'
10
10
  import type { MapOf } from '../types'
11
- import { is, Try } from '../utils/common'
11
+ import { is, Try, toHash } from '../utils/common'
12
12
  import { FileSystem as fs } from '../utils/fs'
13
+ import { installedPackages } from '../utils/http/dom'
13
14
 
14
15
  const RX_IMPORT =
15
16
  /import\s+(?:(?:\*\s+as\s+)?([a-zA-Z_$\d\s{},/*]+?)\s+from\s+)?['"]([^'"]+?\.([a-zA-Z0-9]+))['"](?:\s+(?:with|assert)\s*\{[^}]+\})?\s*;?/gm
@@ -156,43 +157,16 @@ export async function compileText(source: string, path?: fs.AbsolutePath) {
156
157
  return null
157
158
  }
158
159
 
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)
160
+ // **No import rewriting, of any kind.** Two generations of it lived here and
161
+ // both corrupted user data the same way: a regular expression over
162
+ // transpiled JavaScript cannot tell code from a string literal that merely
163
+ // looks like an import. The bare-specifier -> `/_nm/` rewrite went first
164
+ // (the import map covers every installed package); the `/index` append for
165
+ // relative directory imports went second, once `ts.test.ts` pinned that the
166
+ // handler already resolves `/lib`, `/lib.js` and their nested forms to the
167
+ // directory's index server-side. Resolution belongs to the import map and
168
+ // the handlers — the compiler only transpiles.
169
+ return await PluginHooks.onCompile(transformed!, path)
196
170
  }
197
171
 
198
172
  export async function compile(
@@ -211,6 +185,412 @@ type CompileResult = {
211
185
  errors?: string[]
212
186
  }
213
187
 
188
+ /**
189
+ * A CommonJS package that assigns `module.exports` wholesale bundles to
190
+ * `export default …` and nothing else.
191
+ *
192
+ * Not all CJS: `exports.greet = …` is statically analysable and Bun emits a real
193
+ * named export for it. It is the whole-object form — `module.exports = { … }` —
194
+ * whose members cannot be known without running the module, and that is the one
195
+ * that breaks.
196
+ *
197
+ * That is correct output and a silent trap. The import map points a bare
198
+ * specifier at `/_nm/<pkg>`, so `import { greet } from 'pkg'` in browser code
199
+ * compiles happily, the bundle is served with a **200**, and the only sign of
200
+ * trouble is a browser-side `SyntaxError: The requested module 'pkg' does not
201
+ * provide an export named 'greet'`. Nothing reaches the server log.
202
+ *
203
+ * Detecting the shape is what lets `bundleCjsWithNamedExports` repair it. The
204
+ * check is deliberately conservative: it fires only when the output has a
205
+ * default export and no named one, *and* the bundle carries Bun's CJS wrapper.
206
+ * An ESM package with only a default export is normal and says nothing.
207
+ */
208
+ /**
209
+ * Bun's CJS wrapper, in a spelling minification cannot destroy.
210
+ *
211
+ * The obvious check — `content.includes('__commonJS')` — held in dev and
212
+ * silently never matched in PROD, where minification renames the helper to a
213
+ * single letter. The consequence was the worst kind of split: named imports
214
+ * of CJS packages worked all through development and broke only in the
215
+ * deployed app. What survives minification is the helper's *body*: it always
216
+ * constructs `{ exports: {} }`, spaces or not.
217
+ */
218
+ const RX_CJS_WRAPPER = /\{\s*exports\s*:\s*\{\s*\}\s*\}/
219
+
220
+ /**
221
+ * A `module.exports = { … }` assignment, whatever the module variable is
222
+ * called after minification (`n.exports={…}`). The helper's own
223
+ * `mod.exports);` never matches — no `= {` follows it.
224
+ */
225
+ const RX_MODULE_EXPORTS = /[A-Za-z_$][\w$]*\.exports\s*=\s*\{/g
226
+
227
+ export function isCjsDefaultOnly(content: string): boolean {
228
+ if (!RX_CJS_WRAPPER.test(content)) return false
229
+
230
+ // `export {` covers the named-export block Bun emits; `export default` alone
231
+ // is the shape that breaks a named import.
232
+ const hasNamed = /\bexport\s*\{[^}]*\b(?!default\b)\w+/.test(content)
233
+ if (hasNamed) return false
234
+
235
+ return /\bexport\s+default\b|\bexport\s*\{\s*\w+\s+as\s+default\s*\}/.test(
236
+ content,
237
+ )
238
+ }
239
+
240
+ /**
241
+ * A bundle consisting of nothing but a non-empty `export { … }` list.
242
+ *
243
+ * Such a file names bindings that were never declared, so every one of them is
244
+ * a `ReferenceError` the moment the browser evaluates it — and `Bun.build`
245
+ * reports it as **`success: true` with zero diagnostics**.
246
+ *
247
+ * The cause is `sideEffects`, and it is not specific to one package. When the
248
+ * bundle *entry* is a file inside a package whose manifest declares
249
+ * `sideEffects: false` (or `[]`, or a list not covering that file), Bun's
250
+ * tree-shaker drops the entry's own imports while keeping its export list.
251
+ * Reduced to a two-file fixture:
252
+ *
253
+ * no `sideEffects` field -> body 67 bytes
254
+ * `sideEffects: false` -> body 0
255
+ * `sideEffects: []` -> body 0
256
+ * `sideEffects: ["./x.js"]`-> body 0
257
+ * `sideEffects: true` -> body 65 bytes
258
+ *
259
+ * `@vue-material/core@1.0.0-alpha.28` declares
260
+ * `["./dist/attach-styles.js", "./dist/assets/*.css.js"]`, so its barrel
261
+ * bundles to 3,549 bytes of pure export list and throws `AggregateError: 189
262
+ * errors` on import. Most modern libraries set `sideEffects: false`, so any
263
+ * re-export barrel among them is a candidate — this is a wide class, not a
264
+ * single broken package.
265
+ *
266
+ * `bundleReExportShim` repairs it; this only recognises it. `export {}` on its
267
+ * own is a legal empty module and is not flagged — the list has to name
268
+ * something for the file to be self-contradictory.
269
+ */
270
+ export function isEmptyExportList(content: string): boolean {
271
+ const match = content.match(/export\s*\{([\s\S]*?)\}\s*;?\s*$/)
272
+ if (!match) return false
273
+ if (!match[1].trim()) return false
274
+
275
+ return !content.slice(0, match.index).trim()
276
+ }
277
+
278
+ /**
279
+ * Re-bundle a tree-shaken-to-nothing module through a shim outside the package.
280
+ *
281
+ * The `sideEffects` drop described on {@link isEmptyExportList} keys on the
282
+ * *entry* being inside the offending package. Re-exporting the very same file
283
+ * from a module that is not — the shim lands in `.cache/`, which is never
284
+ * inside `node_modules` — leaves the tree-shaker with an entry it has no
285
+ * manifest for, and the imports survive. Measured on `@vue-material/core`:
286
+ * 3,549 bytes of husk becomes a 428,470-byte bundle with all 189 exports.
287
+ *
288
+ * Two attempts, because `export *` deliberately does not carry `default`, and
289
+ * naming a `default` the package does not have is a hard build error rather
290
+ * than a no-op. So: try with it, fall back to without.
291
+ *
292
+ * **Every installed package is `external`, and that is load-bearing rather than
293
+ * an optimisation.** Left to itself the shim inlines the whole reachable tree —
294
+ * `@vue-material/core` came out at 428KB with `vue` baked in, and `vue` is a
295
+ * *peer* dependency the app resolves for itself. Two Vue runtimes in one page
296
+ * is not a size problem, it is broken reactivity and a duplicated component
297
+ * registry. Externalised, each dependency stays a bare specifier that the
298
+ * import map sends to its own `/_nm/<dep>`, so there is exactly one copy of
299
+ * each; the same bundle drops to 260KB.
300
+ *
301
+ * Externalising *installed packages* specifically, rather than Bun's
302
+ * `packages: 'external'`, because that switch also externalises `node:*` —
303
+ * turning a builtin Bun would otherwise polyfill for the browser into a bare
304
+ * import nothing can resolve.
305
+ */
306
+ async function bundleReExportShim(
307
+ path: string,
308
+ defines: MapOf<string>,
309
+ ): Promise<string | null> {
310
+ const spec = JSON.stringify(path)
311
+ const shimPath = fs.resolve(
312
+ Bakery.cacheDir,
313
+ 'nm_cache',
314
+ `${toHash(path)}.reexport.mjs`,
315
+ )
316
+ const external = await installedPackages()
317
+
318
+ for (const withDefault of [true, false]) {
319
+ const shim =
320
+ `export * from ${spec}\n` +
321
+ (withDefault ? `export { default } from ${spec}\n` : '')
322
+
323
+ const [writeErr] = await Try.catch(() => Bun.write(shimPath, shim))
324
+ if (writeErr) return null
325
+
326
+ // Wrapped, because naming a `default` the package does not export is a
327
+ // *throw* from `Bun.build`, not a `success: false` — and that throw is the
328
+ // expected outcome of the first attempt for any package without one.
329
+ const [buildErr, build] = await Try.catch(() =>
330
+ Bun.build({
331
+ entrypoints: [shimPath],
332
+ target: 'browser',
333
+ format: 'esm',
334
+ minify: Boolean(import.meta.env.PROD),
335
+ define: defines,
336
+ external,
337
+ }),
338
+ )
339
+
340
+ if (buildErr || !build?.success || !build.outputs.length) continue
341
+
342
+ const content = await build.outputs[0].text()
343
+ // The shim is only worth serving if it actually carries the code the
344
+ // original was missing; otherwise this is the same husk with extra steps.
345
+ if (!isEmptyExportList(content)) return content
346
+ }
347
+
348
+ return null
349
+ }
350
+
351
+ /** A key that can be written as `export const <name> =`. */
352
+ const RE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
353
+ const RESERVED_EXPORT_NAMES = new Set(['default', '__esModule'])
354
+
355
+ /**
356
+ * How long the export probe may take before it is killed.
357
+ *
358
+ * Generous, because it pays a process start and a module evaluation, and it runs
359
+ * once per package — the result is cached with the bundle. Short enough that a
360
+ * package which hangs on import costs a pause rather than a wedged server.
361
+ */
362
+ const CJS_PROBE_TIMEOUT_MS = 5_000
363
+
364
+ const OPENERS = '{[('
365
+ const CLOSERS = '}])'
366
+
367
+ /** Index of the separator ending the value that starts at `from`. */
368
+ function endOfValue(src: string, from: number): number {
369
+ let depth = 0
370
+
371
+ for (let i = from; i < src.length; i++) {
372
+ const c = src[i]
373
+ if (OPENERS.includes(c)) depth++
374
+ else if (CLOSERS.includes(c)) {
375
+ if (depth === 0) return i
376
+ depth--
377
+ } else if (c === ',' && depth === 0) return i
378
+ }
379
+
380
+ return src.length
381
+ }
382
+
383
+ /**
384
+ * Top-level keys of the object literal whose `{` sits at `open`.
385
+ *
386
+ * Depth-counted rather than regex-matched, so a nested object or array in a
387
+ * value does not end the scan early. Returns `null` if the literal never closes,
388
+ * which is the signal to distrust the whole reading rather than guess.
389
+ */
390
+ function objectLiteralKeys(src: string, open: number): string[] | null {
391
+ const keys: string[] = []
392
+ let depth = 0
393
+ let keyStart = -1
394
+
395
+ const take = (end: number) => {
396
+ const piece = src.slice(keyStart, end).trim()
397
+ if (piece) keys.push(piece)
398
+ }
399
+
400
+ for (let i = open; i < src.length; i++) {
401
+ const c = src[i]
402
+
403
+ if (OPENERS.includes(c)) {
404
+ if (++depth === 1) keyStart = i + 1
405
+ continue
406
+ }
407
+
408
+ if (CLOSERS.includes(c)) {
409
+ if (--depth > 0) continue
410
+ take(i)
411
+ return keys
412
+ }
413
+
414
+ if (depth !== 1) continue
415
+
416
+ if (c === ',') {
417
+ take(i)
418
+ keyStart = i + 1
419
+ continue
420
+ }
421
+
422
+ if (c === ':') {
423
+ take(i)
424
+ // `{ a: expr, b }` — the value is skipped wholesale so a comma inside it
425
+ // cannot be mistaken for the next key.
426
+ i = endOfValue(src, i + 1)
427
+ if (src[i] !== ',') return keys
428
+ keyStart = i + 1
429
+ }
430
+ }
431
+
432
+ return null
433
+ }
434
+
435
+ /**
436
+ * Export names read out of the **bundled** output, without running anything.
437
+ *
438
+ * This is the job `cjs-module-lexer` does for Node and Vite: recognise a small
439
+ * set of known-safe `module.exports` shapes and answer nothing for the rest. It
440
+ * is deliberately one shape here — `module.exports = { … }`, the object literal
441
+ * — because that is the only form that reaches this code path at all. Bun
442
+ * already emits real named exports for `exports.name = …`, so a package written
443
+ * that way never gets here.
444
+ *
445
+ * Reading the *bundle* rather than the source matters. Bun has already
446
+ * normalised the module into a `__commonJS((exports, module) => { … })` wrapper,
447
+ * so there is a single known shape to look inside. The string-literal hazard
448
+ * that made the old import rewriter corrupt user code is reduced, not
449
+ * eliminated — a string containing `module.exports = {` would still fool this —
450
+ * which is why a failed or empty reading falls through to the probe instead of
451
+ * being trusted as "no exports".
452
+ */
453
+ export function staticCjsExportNames(bundled: string): string[] {
454
+ // `RX_MODULE_EXPORTS`, not the literal `module.exports = {`: minification
455
+ // renames the module variable and drops the spaces (`n.exports={`), and the
456
+ // literal spelling made this reader dev-only — the probe silently took over
457
+ // every PROD bundle.
458
+ const matches = [...bundled.matchAll(RX_MODULE_EXPORTS)]
459
+ if (matches.length === 0) return []
460
+ // More than one assignment and the last one wins at runtime; rather than
461
+ // model that, decline and let the probe answer.
462
+ if (matches.length > 1) return []
463
+
464
+ const match = matches[0]
465
+ const keys = objectLiteralKeys(
466
+ bundled,
467
+ match.index + match[0].length - 1, // the `{` closing the match
468
+ )
469
+ if (!keys) return []
470
+
471
+ return [
472
+ ...new Set(
473
+ keys
474
+ .map(key => key.replace(/^["']|["']$/g, '').trim())
475
+ .filter(key => RE_IDENTIFIER.test(key)),
476
+ ),
477
+ ]
478
+ }
479
+
480
+ /**
481
+ * The export names of a CommonJS module, read **in a throwaway subprocess**.
482
+ *
483
+ * The fallback for whatever `staticCjsExportNames` cannot see. Something has to
484
+ * run the module, and the question is only *where*: it used to be here, which
485
+ * meant an arbitrary `node_modules` package executing inside the server process
486
+ * — in production as well as dev — free to start a timer, open a socket, or
487
+ * mutate a global that then outlives the request that caused it.
488
+ *
489
+ * A child process does not make execution safe, and nothing can: a module-scope
490
+ * write to the filesystem still happens. What it buys is containment of
491
+ * everything *in-process* — crashes, hangs, globals, listeners — and a package
492
+ * that hangs on import is killed by the timeout rather than wedging a bundle.
493
+ */
494
+ async function cjsExportNames(path: string): Promise<string[]> {
495
+ // `process.stdout.write`, not `console.log`: a machine-read value on a pipe,
496
+ // not a log line.
497
+ const code =
498
+ 'const m = await import(process.argv[1]); ' +
499
+ 'process.stdout.write(JSON.stringify(Object.keys(m)))'
500
+
501
+ const [spawnErr, names] = await Try.catch(async () => {
502
+ const proc = Bun.spawn(['bun', '-e', code, path], {
503
+ stdout: 'pipe',
504
+ stderr: 'ignore',
505
+ })
506
+
507
+ const timer = setTimeout(() => proc.kill(), CJS_PROBE_TIMEOUT_MS)
508
+ const out = await new Response(proc.stdout).text()
509
+ clearTimeout(timer)
510
+ // Killed or not, it must not outlive this call.
511
+ proc.kill()
512
+
513
+ // **The exit code is deliberately not consulted.** A package that leaves a
514
+ // timer or a listener running — which plenty do at module scope — has
515
+ // already printed its answer and then simply fails to exit, so the probe
516
+ // kills it and the exit code reports the kill. Requiring a clean exit threw
517
+ // away a correct result and fell back to default-only, which is the very
518
+ // failure this exists to prevent. Valid JSON on stdout is the signal;
519
+ // anything else falls back.
520
+ const [, parsed] = await Try.catch(() => JSON.parse(out.trim()))
521
+ return Array.isArray(parsed) ? (parsed as string[]) : []
522
+ })
523
+
524
+ if (spawnErr || !names) return []
525
+
526
+ return names.filter(
527
+ key => !RESERVED_EXPORT_NAMES.has(key) && RE_IDENTIFIER.test(key),
528
+ )
529
+ }
530
+
531
+ /**
532
+ * Re-bundle a `module.exports = { … }` package with real named exports.
533
+ *
534
+ * The names are read from the bundle, or failing that by importing the module,
535
+ * and a shim generated that states them statically:
536
+ *
537
+ * import __cjs from '<abs>'
538
+ * export default __cjs
539
+ * export const greet = __cjs.greet
540
+ *
541
+ * That shim is what gets bundled, so the browser gets a module that really does
542
+ * provide `greet`. A package that throws on import — one touching `window` at
543
+ * module scope, say — yields no names, and the caller keeps the plain bundle it
544
+ * already has.
545
+ *
546
+ * Non-identifier keys are skipped rather than mangled: `module.exports['a-b']`
547
+ * is legal CJS and is not a legal export name, and inventing one would be worse
548
+ * than omitting it.
549
+ */
550
+ async function bundleCjsWithNamedExports(
551
+ path: string,
552
+ defines: MapOf<string>,
553
+ bundled: string,
554
+ ): Promise<string | null> {
555
+ // Static first, so the usual outcome is that nothing is executed at all. The
556
+ // probe covers shapes a reader cannot see: a computed key, an assignment built
557
+ // at runtime, a re-export.
558
+ const statik = staticCjsExportNames(bundled)
559
+ const names = statik.length ? statik : await cjsExportNames(path)
560
+ if (!names.length) return null
561
+
562
+ if (!statik.length) handlerLog.BUNDLE_CJS_PROBED({ file: path })
563
+
564
+ const spec = JSON.stringify(path)
565
+ const shim = [
566
+ `import __cjs from ${spec}`,
567
+ 'export default __cjs',
568
+ ...names.map(
569
+ name => `export const ${name} = __cjs[${JSON.stringify(name)}]`,
570
+ ),
571
+ '',
572
+ ].join('\n')
573
+
574
+ const shimPath = fs.resolve(
575
+ Bakery.cacheDir,
576
+ 'nm_cache',
577
+ `${toHash(path)}.interop.mjs`,
578
+ )
579
+ const [writeErr] = await Try.catch(() => Bun.write(shimPath, shim))
580
+ if (writeErr) return null
581
+
582
+ const build = await Bun.build({
583
+ entrypoints: [shimPath],
584
+ target: 'browser',
585
+ format: 'esm',
586
+ minify: Boolean(import.meta.env.PROD),
587
+ define: defines,
588
+ })
589
+
590
+ if (!build.success || !build.outputs.length) return null
591
+ return await build.outputs[0].text()
592
+ }
593
+
214
594
  export async function bundleModule(
215
595
  path: fs.AbsolutePath,
216
596
  ): Promise<CompileResult> {
@@ -231,10 +611,49 @@ export async function bundleModule(
231
611
  // writes we do not own.
232
612
  minify: Boolean(import.meta.env.PROD),
233
613
  define: await getDefines(),
614
+ // Installed packages stay bare imports and resolve through the import map
615
+ // — which covers every one of them by construction (`initImportMap`). The
616
+ // alternative was measured on `@vue-material/core`: each `/_nm/` bundle
617
+ // inlined its own copy of `vue`, and two Vue instances in one page is
618
+ // broken reactivity, not a size problem. A ref created by one Vue is
619
+ // invisible to another Vue's render effect. Node builtins are not in the
620
+ // list, so Bun still polyfills them.
621
+ external: await installedPackages(),
234
622
  })
235
623
 
236
624
  if (build.success && build.outputs.length > 0) {
237
- return { success: true, content: await build.outputs[0].text() }
625
+ const content = await build.outputs[0].text()
626
+
627
+ // A bundle that cannot possibly work is not a success, whatever `build`
628
+ // says — see `isEmptyExportList`. Repairable in the common case, so try
629
+ // that before refusing.
630
+ if (isEmptyExportList(content)) {
631
+ const repaired = await bundleReExportShim(path, await getDefines())
632
+ if (repaired) {
633
+ handlerLog.BUNDLE_SIDE_EFFECTS_REPAIRED({ file: path })
634
+ return { success: true, content: repaired }
635
+ }
636
+
637
+ handlerLog.BUNDLE_EMPTY_EXPORTS({ file: path })
638
+ return { success: false, errors: ['bundle body is empty'] }
639
+ }
640
+
641
+ // Only for the shape that breaks, and only after the cheap build has proved
642
+ // it is that shape — so nothing is imported speculatively.
643
+ if (isCjsDefaultOnly(content)) {
644
+ const interop = await bundleCjsWithNamedExports(
645
+ path,
646
+ await getDefines(),
647
+ content,
648
+ )
649
+ if (interop) {
650
+ handlerLog.BUNDLE_CJS_INTEROP({ file: path })
651
+ return { success: true, content: interop }
652
+ }
653
+ handlerLog.BUNDLE_CJS_DEFAULT_ONLY({ file: path })
654
+ }
655
+
656
+ return { success: true, content }
238
657
  }
239
658
 
240
659
  const errors = build.logs
@@ -1,13 +1,30 @@
1
1
  import { unlinkSync } from 'node:fs'
2
- import { Bakery } from '../core/bakery'
2
+ import { cacheDir } from '../core/context'
3
3
  import { Try } from '../utils/common/try'
4
4
 
5
+ /**
6
+ * **`cacheDir` comes from `core/context`, not from `Bakery` — do not change it
7
+ * back.** `logger.ts` imports this module, so importing `core/bakery` here
8
+ * completed a cycle:
9
+ *
10
+ * logger.ts -> prompt-tracker.ts -> core/bakery.ts -> core/config.ts
11
+ * -> logger/serve-log.ts -> logger.ts
12
+ *
13
+ * `serve-log.ts` runs `new Logger('serve')` at module scope, so whichever
14
+ * import arrived first found `Logger` still in its temporal dead zone. That
15
+ * shipped in 1.2.3 and made `import '@bakery-framework/core'` throw
16
+ * `ReferenceError: Cannot access 'Logger' before initialization` from a clean
17
+ * install — see `tests/module-cycle.test.ts`.
18
+ *
19
+ * `core/context` holds the same single definition of the path and imports
20
+ * nothing that reaches the logger.
21
+ */
5
22
  export const PromptTracker = {
6
23
  getFilePath(pid: number): string {
7
24
  // Derived, not written out: this lands in the cache directory, which the
8
25
  // framework wipes wholesale, and a stale literal here would leave marker
9
26
  // files behind in a directory nothing sweeps.
10
- return `${Bakery.cacheDir}/.prompt-active-${pid}`
27
+ return `${cacheDir()}/.prompt-active-${pid}`
11
28
  },
12
29
 
13
30
  async isActive(pid: number): Promise<boolean> {