@bakery-framework/core 1.2.3 → 2.0.0-alpha.2
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 +1 -1
- package/src/cache/shared-db.ts +7 -9
- package/src/client/globals.d.ts +1 -1
- package/src/client/utils.ts +41 -9
- package/src/compiler/compiler.ts +458 -39
- package/src/compiler/prompt-tracker.ts +19 -2
- package/src/core/bakery.ts +11 -12
- package/src/core/context.ts +42 -8
- package/src/core/index.ts +1 -0
- package/src/core/init.ts +6 -0
- package/src/handlers/assets/nm.ts +74 -15
- package/src/handlers/core/$base.ts +54 -8
- package/src/handlers/core/$dynamic.ts +21 -10
- package/src/handlers/core/$routing.ts +57 -8
- package/src/logger/serve-log.ts +15 -0
- package/src/session.ts +0 -3
- package/src/shared.d.ts +7 -0
- package/src/types.d.ts +11 -0
- package/src/utils/http/credential.ts +70 -0
- package/src/utils/http/dom.ts +73 -64
- package/src/utils/http/html.ts +4 -4
- package/src/utils/http/index.ts +1 -0
- package/src/utils/isomorphic/misc.ts +16 -0
package/package.json
CHANGED
package/src/cache/shared-db.ts
CHANGED
|
@@ -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
|
-
* **
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* directory the wipe
|
|
48
|
-
* `EBUSY`, node's recursive walk
|
|
49
|
-
* had not reached yet
|
|
50
|
-
*
|
|
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
|
|
package/src/client/globals.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/client/utils.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/compiler/compiler.ts
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 {
|
|
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 `${
|
|
27
|
+
return `${cacheDir()}/.prompt-active-${pid}`
|
|
11
28
|
},
|
|
12
29
|
|
|
13
30
|
async isActive(pid: number): Promise<boolean> {
|