@bakery-framework/plugin-vue 2.1.2 → 2.2.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/plugin-vue",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "Bakery vue plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -43,7 +43,7 @@
43
43
  "vue": "^3.5.38"
44
44
  },
45
45
  "dependencies": {
46
- "@bakery-framework/core": "^2.1.2"
46
+ "@bakery-framework/core": "^2.2.1"
47
47
  },
48
48
  "engines": {
49
49
  "bun": ">=1.4.0"
package/src/build.ts ADDED
@@ -0,0 +1,500 @@
1
+ import { mkdir, readdir, rename, rm } from 'node:fs/promises'
2
+ import { clientDefines } from '@bakery-framework/core/compiler'
3
+ import { Bakery, hostKey, hostStore } from '@bakery-framework/core/core/bakery'
4
+ import { resolveHostConfig } from '@bakery-framework/core/core/config'
5
+ import { errorMsg, Logger } from '@bakery-framework/core/logger'
6
+ import { fs, toHash } from '@bakery-framework/core/utils'
7
+ import {
8
+ BUILD_DIR,
9
+ BUILD_FORMAT,
10
+ BUILD_URL,
11
+ type BuildManifest,
12
+ type BuiltPage,
13
+ buildFingerprint,
14
+ layoutsUnder,
15
+ moduleServerData,
16
+ } from './built'
17
+ import {
18
+ componentCss,
19
+ componentScript,
20
+ ROOT_SERVER_DATA,
21
+ VueHandler,
22
+ } from './handler'
23
+ import { cacheDir, parseVueMeta, VUE_VERSION } from './utils'
24
+
25
+ /**
26
+ * `bakery --build` for Vue pages: every page under every serve root bundled
27
+ * with `Bun.build`, into content-hashed chunks a production server hands out
28
+ * with immutable caching. Served unbundled, a page costs a request per
29
+ * component, per imported module and per package (48 to 51 for a cold admin
30
+ * page of a real app with 40 components); built, it costs the shell, the Vue
31
+ * runtime, its entry chunk with the chunks it shares with other pages, and
32
+ * its stylesheets.
33
+ *
34
+ * Components are compiled by the same function that serves them
35
+ * (`componentScript`), with their imports left for the bundler to resolve
36
+ * against the files on disk. Import-map keys stay out of the bundle, for the
37
+ * browser's import map to resolve: `vue` above all, since two copies of Vue
38
+ * in one page is broken reactivity, and the runtime at `/_vue/` is the copy
39
+ * every unbundled module already shares.
40
+ *
41
+ * A component's `<script server>` block never reaches the bundle, as it never
42
+ * reaches the browser. What reaches it is the expression the component reads
43
+ * its exports from: the page's HTML carries them, because a built page runs
44
+ * the blocks of every component it can reach (see `builtModuleData`).
45
+ */
46
+
47
+ const logger = new Logger('vue')
48
+
49
+ /**
50
+ * A stand-in name for a module, in the directory of the file it stands for.
51
+ *
52
+ * Two things need one. A page's entry compiles as a root script (mounted, not
53
+ * imported), so it is a different module from the same file imported as a
54
+ * component, and needs a name of its own. And Bun 1.4.2 refuses any path a
55
+ * plugin resolves to that contains `...` ("must be absolute when the
56
+ * namespace is file"), measured on `[...slug].vue` and `a...b.vue` alike,
57
+ * with `[id].vue` and `[x]y.vue` accepted: so every catch-all page needs one.
58
+ *
59
+ * In the real file's directory, so relative imports and package lookups
60
+ * resolve from where the file is.
61
+ */
62
+ const RX_ALIAS = /__bakery_(page|file)_[a-z0-9]+\.[a-z]+$/
63
+
64
+ function aliasFor(ctx: RootContext, file: string, page: boolean): string {
65
+ const ext = file.slice(file.lastIndexOf('.'))
66
+ const alias = `${fs.dirname(file)}/__bakery_${page ? 'page' : 'file'}_${toHash(file)}${ext}`
67
+ ctx.aliases.set(alias, { file, page })
68
+ return alias
69
+ }
70
+
71
+ /** A resolved path Bun can take: the file's own, or a stand-in when it needs one. */
72
+ function resolved(ctx: RootContext, file: string): string {
73
+ return file.includes('...') ? aliasFor(ctx, file, false) : file
74
+ }
75
+
76
+ /** The serve roots to build: the base one, then each host's own. */
77
+ function serveRoots(): { root: string; hostname: string }[] {
78
+ const base = fs.resolve(Bakery.config.root)
79
+ const roots = [{ root: base, hostname: '' }]
80
+ const seen = new Set([base])
81
+ // A host without a root of its own serves the base root's files, and the
82
+ // base build serves it: the bundle's scope ids and data keys depend on the
83
+ // files alone.
84
+ for (const [hostname, entry] of Object.entries(Bakery.config.hosts ?? {})) {
85
+ if (!entry.root) continue
86
+ const root = fs.resolve(entry.root)
87
+ if (seen.has(root)) continue
88
+ seen.add(root)
89
+ roots.push({ root, hostname: hostname.toLowerCase() })
90
+ }
91
+ return roots
92
+ }
93
+
94
+ /**
95
+ * Every `.vue` file under `root` a request could render as a page: not a
96
+ * layout, not `module-only`, and not a file the server refuses to serve, whose
97
+ * code would otherwise sit in a chunk anybody could fetch by name.
98
+ */
99
+ async function discoverPages(root: string): Promise<string[]> {
100
+ const blocked = Bakery.config.blocked
101
+ const pages: string[] = []
102
+ for await (const found of new Bun.Glob('**/*.vue').scan({
103
+ cwd: root,
104
+ absolute: true,
105
+ onlyFiles: true,
106
+ })) {
107
+ const file = fs.resolve(found)
108
+ if (file.endsWith('/layout.vue')) continue
109
+ if (file.includes('/node_modules/')) continue
110
+ if (fs.isForbidden(file, root) || blocked?.match(file)) continue
111
+ const { meta } = parseVueMeta(await Bun.file(file).text())
112
+ if (meta.moduleOnly) continue
113
+ pages.push(file)
114
+ }
115
+ return pages.sort()
116
+ }
117
+
118
+ function isExternal(specifier: string, externals: string[]): boolean {
119
+ return externals.some(key =>
120
+ key.endsWith('/') ? specifier.startsWith(key) : specifier === key,
121
+ )
122
+ }
123
+
124
+ /**
125
+ * A specifier written the way served code writes it: from the serve root, and
126
+ * perhaps with a query. Resolved the way the handlers resolve the URL: the
127
+ * file, a `.js` name for a `.ts` file, or an extensionless name with its
128
+ * extension or its directory's index.
129
+ */
130
+ function resolveRooted(root: string, specifier: string, importer: string) {
131
+ const clean = decodeURIComponent(specifier.replace(/[?#].*$/, ''))
132
+ const base = fs.resolve(root, `.${clean}`)
133
+ if (base !== root && !base.startsWith(`${root}/`)) {
134
+ throw new Error(
135
+ `${specifier}, imported by ${importer}, leaves the serve root`,
136
+ )
137
+ }
138
+ const candidates = [
139
+ base,
140
+ base.endsWith('.js') ? `${base.slice(0, -3)}.ts` : '',
141
+ `${base}.ts`,
142
+ `${base}.js`,
143
+ `${base}/index.ts`,
144
+ `${base}/index.js`,
145
+ ]
146
+ for (const candidate of candidates) {
147
+ if (candidate && fs.isFileSync(candidate)) return candidate
148
+ }
149
+ throw new Error(
150
+ `${specifier}, imported by ${importer}, names no file under ${root}`,
151
+ )
152
+ }
153
+
154
+ /** A served module injects an imported stylesheet; so does a bundled one. */
155
+ function stylesheetModule(css: string): string {
156
+ return (
157
+ `(function(){var s=document.createElement('style');` +
158
+ `s.textContent=${JSON.stringify(css)};document.head.appendChild(s)})();` +
159
+ 'export default null;'
160
+ )
161
+ }
162
+
163
+ interface RootContext {
164
+ root: string
165
+ externals: string[]
166
+ /** Components with a server block, as they are loaded: file to route path. */
167
+ serverModules: Map<string, string>
168
+ /** Stand-in names (`aliasFor`), to the file and whether it is a page. */
169
+ aliases: Map<string, { file: string; page: boolean }>
170
+ }
171
+
172
+ /** The real file a loaded path is, and whether it is a page's entry. */
173
+ function realFile(ctx: RootContext, path: string) {
174
+ const normalized = fs.resolve(path)
175
+ return ctx.aliases.get(normalized) ?? { file: normalized, page: false }
176
+ }
177
+
178
+ /** One component, compiled for the bundle. */
179
+ async function compileForBundle(ctx: RootContext, path: string) {
180
+ const { file, page: isPage } = realFile(ctx, path)
181
+ const relPath = fs.relative(ctx.root, file)
182
+ if (relPath === '..' || relPath.startsWith('../')) {
183
+ throw new Error(
184
+ `${file} is outside the serve root ${ctx.root}, and only components under it compile`,
185
+ )
186
+ }
187
+ const routePath = `/${relPath}`
188
+ const id = toHash(hostKey(relPath))
189
+ const disk = Bun.file(file)
190
+ const parsed = await VueHandler.parseVueFile(
191
+ id,
192
+ disk,
193
+ file,
194
+ disk.lastModified,
195
+ )
196
+ const hasServerBlock = Boolean(parsed.serverScript.trim())
197
+
198
+ if (!isPage && parsed.meta.pageOnly) {
199
+ throw new Error(
200
+ `${routePath} is page-only, and a component imports it: served unbundled, that import is refused`,
201
+ )
202
+ }
203
+ if (!isPage && hasServerBlock) ctx.serverModules.set(file, routePath)
204
+
205
+ return componentScript({
206
+ id,
207
+ routePath,
208
+ isRootScript: isPage,
209
+ parsed,
210
+ served: false,
211
+ serverData: hasServerBlock
212
+ ? isPage
213
+ ? ROOT_SERVER_DATA
214
+ : moduleServerData(routePath)
215
+ : undefined,
216
+ })
217
+ }
218
+
219
+ function bundlePlugin(ctx: RootContext): Bun.BunPlugin {
220
+ return {
221
+ name: 'bakery-vue-build',
222
+ setup(build) {
223
+ build.onResolve({ filter: RX_ALIAS }, args => ({ path: args.path }))
224
+
225
+ build.onResolve({ filter: /.*/ }, args => {
226
+ const specifier = args.path
227
+ if (isExternal(specifier, ctx.externals)) {
228
+ return { path: specifier, external: true }
229
+ }
230
+ if (specifier.startsWith('/') && !specifier.startsWith('//')) {
231
+ const file = resolveRooted(ctx.root, specifier, args.importer)
232
+ return { path: resolved(ctx, file) }
233
+ }
234
+ // A relative `.vue` import carrying the query served code adds.
235
+ const query = specifier.indexOf('?')
236
+ if (
237
+ query !== -1 &&
238
+ specifier.startsWith('.') &&
239
+ specifier.slice(0, query).endsWith('.vue')
240
+ ) {
241
+ const importer = realFile(ctx, args.importer).file
242
+ const file = fs.resolve(
243
+ fs.dirname(importer),
244
+ specifier.slice(0, query),
245
+ )
246
+ return { path: resolved(ctx, file) }
247
+ }
248
+ return undefined
249
+ })
250
+
251
+ build.onLoad({ filter: /\.vue$/ }, async args => ({
252
+ contents: await compileForBundle(ctx, args.path),
253
+ loader: 'js',
254
+ }))
255
+
256
+ // A stand-in for a script with `...` in its name: the real file's text.
257
+ build.onLoad(
258
+ { filter: /__bakery_file_[a-z0-9]+\.(ts|tsx|js|jsx|mjs)$/ },
259
+ async args => {
260
+ const { file } = realFile(ctx, args.path)
261
+ const ext = file.slice(file.lastIndexOf('.') + 1)
262
+ return {
263
+ contents: await Bun.file(file).text(),
264
+ loader: (ext === 'mjs' ? 'js' : ext) as 'ts' | 'tsx' | 'js' | 'jsx',
265
+ }
266
+ },
267
+ )
268
+
269
+ build.onLoad({ filter: /\.css$/ }, async args => ({
270
+ contents: stylesheetModule(await Bun.file(args.path).text()),
271
+ loader: 'js',
272
+ }))
273
+ },
274
+ }
275
+ }
276
+
277
+ /** A build message with the file and line it names, when it names one. */
278
+ function describeBuildError(error: unknown): string {
279
+ const message = (error as { message?: string })?.message ?? errorMsg(error)
280
+ const position = (error as { position?: { file?: string; line?: number } })
281
+ ?.position
282
+ return position?.file
283
+ ? `${position.file}:${position.line ?? 0}: ${message}`
284
+ : message
285
+ }
286
+
287
+ type Metafile = NonNullable<Bun.BuildOutput['metafile']>
288
+
289
+ /** Every output reached from `start`, through the import kinds given. */
290
+ function reachable(
291
+ outputs: Metafile['outputs'],
292
+ start: string,
293
+ kinds: string[],
294
+ ): string[] {
295
+ const seen = new Set([start])
296
+ const queue = [start]
297
+ while (queue.length) {
298
+ for (const edge of outputs[queue.pop()!]?.imports ?? []) {
299
+ if (!kinds.includes(edge.kind) || !(edge.path in outputs)) continue
300
+ if (seen.has(edge.path)) continue
301
+ seen.add(edge.path)
302
+ queue.push(edge.path)
303
+ }
304
+ }
305
+ return [...seen]
306
+ }
307
+
308
+ /** A metafile output name as the URL it is served under. */
309
+ const servedAs = (output: string) =>
310
+ `${BUILD_URL}${output.replace(/^\.\//, '')}`
311
+
312
+ /** A stylesheet, written once under a name its content decides. */
313
+ async function writeStylesheet(outdir: string, css: string): Promise<string> {
314
+ const name = `s-${toHash(css)}.css`
315
+ await Bun.write(`${outdir}/${name}`, css)
316
+ return `${BUILD_URL}${name}`
317
+ }
318
+
319
+ /** The page's stylesheets, as `handleHtml` links them unbundled: layout first. */
320
+ async function pageStylesheets(
321
+ root: string,
322
+ page: string,
323
+ outdir: string,
324
+ ): Promise<string[]> {
325
+ const relPath = fs.relative(root, page)
326
+ const id = toHash(hostKey(relPath))
327
+ const disk = Bun.file(page)
328
+ const parsed = await VueHandler.parseVueFile(
329
+ id,
330
+ disk,
331
+ page,
332
+ disk.lastModified,
333
+ )
334
+
335
+ const urls: string[] = []
336
+ if (parsed.layoutRoute) {
337
+ const layoutFile = fs.resolve(root, `.${parsed.layoutRoute}`)
338
+ const layoutId = toHash(hostKey(parsed.layoutRoute.slice(1)))
339
+ const layoutDisk = Bun.file(layoutFile)
340
+ const layout = await VueHandler.parseVueFile(
341
+ layoutId,
342
+ layoutDisk,
343
+ layoutFile,
344
+ layoutDisk.lastModified,
345
+ )
346
+ const css = await componentCss(layoutId, layout)
347
+ if (css) urls.push(await writeStylesheet(outdir, css))
348
+ }
349
+ const css = await componentCss(id, parsed)
350
+ if (css) urls.push(await writeStylesheet(outdir, css))
351
+ return urls
352
+ }
353
+
354
+ async function buildRoot(
355
+ root: string,
356
+ outdir: string,
357
+ manifest: BuildManifest,
358
+ ): Promise<number> {
359
+ manifest.layouts[root] = await layoutsUnder(root)
360
+ manifest.roots[root] = {}
361
+ const pages = await discoverPages(root)
362
+ if (!pages.length) return 0
363
+
364
+ const ctx: RootContext = {
365
+ root,
366
+ externals: Object.keys(Bakery.config.importMap ?? {}),
367
+ serverModules: new Map(),
368
+ aliases: new Map(),
369
+ }
370
+
371
+ let result: Bun.BuildOutput
372
+ try {
373
+ result = await Bun.build({
374
+ entrypoints: pages.map(page => aliasFor(ctx, page, true)),
375
+ outdir,
376
+ target: 'browser',
377
+ format: 'esm',
378
+ splitting: true,
379
+ minify: true,
380
+ metafile: true,
381
+ define: await clientDefines(),
382
+ // Names from content alone: a file's name changes exactly when its
383
+ // bytes do, which is what makes immutable caching true. No source
384
+ // names either, since `[...slug]` is not a name a URL carries well.
385
+ naming: {
386
+ entry: 'p-[hash].[ext]',
387
+ chunk: 'c-[hash].[ext]',
388
+ asset: 'a-[hash].[ext]',
389
+ },
390
+ plugins: [bundlePlugin(ctx)],
391
+ })
392
+ } catch (error) {
393
+ // Bun throws an AggregateError whose message is only "Bundle failed";
394
+ // what failed, and where, is in its `errors`.
395
+ const causes = (error as { errors?: unknown[] }).errors ?? [error]
396
+ const detail = causes.map(describeBuildError).join('\n ')
397
+ throw new Error(`bundling the pages under ${root} failed:\n ${detail}`)
398
+ }
399
+ if (!result.success || !result.metafile) {
400
+ const logs = result.logs.map(log => log.message).join('\n ')
401
+ throw new Error(`bundling the pages under ${root} failed:\n ${logs}`)
402
+ }
403
+
404
+ const { inputs, outputs } = result.metafile
405
+ const cwd = process.cwd()
406
+ const absolute = (path: string) => realFile(ctx, fs.resolve(cwd, path)).file
407
+
408
+ for (const input of Object.keys(inputs)) {
409
+ const file = absolute(input)
410
+ manifest.inputs[file] = Bun.file(file).lastModified
411
+ }
412
+
413
+ for (const [output, meta] of Object.entries(outputs)) {
414
+ if (!meta.entryPoint) continue
415
+ const page = absolute(meta.entryPoint)
416
+
417
+ const preload = reachable(outputs, output, ['import-statement'])
418
+ .filter(name => name !== output)
419
+ .map(servedAs)
420
+
421
+ const reached = new Set<string>()
422
+ for (const name of reachable(outputs, output, [
423
+ 'import-statement',
424
+ 'dynamic-import',
425
+ ])) {
426
+ for (const input of Object.keys(outputs[name]?.inputs ?? {})) {
427
+ reached.add(absolute(input))
428
+ }
429
+ }
430
+ const serverModules = [...reached]
431
+ .flatMap(file => ctx.serverModules.get(file) ?? [])
432
+ .sort()
433
+
434
+ const built: BuiltPage = {
435
+ script: servedAs(output),
436
+ preload,
437
+ css: await pageStylesheets(root, page, outdir),
438
+ serverModules,
439
+ }
440
+ manifest.roots[root][page] = built
441
+ }
442
+ return pages.length
443
+ }
444
+
445
+ /** `bakery --build`'s work for Vue: returns the manifest it wrote. */
446
+ export async function buildVuePages(): Promise<BuildManifest> {
447
+ const started = performance.now()
448
+
449
+ // What a build killed part way left behind: it never reached the swap, so
450
+ // nothing reads it, and nothing else would ever remove it.
451
+ for (const name of await readdir(cacheDir).catch(() => [] as string[])) {
452
+ if (/^build-\d+-\d+$/.test(name)) {
453
+ await rm(fs.resolve(cacheDir, name), { recursive: true, force: true })
454
+ }
455
+ }
456
+
457
+ const temp = fs.resolve(cacheDir, `build-${process.pid}-${Date.now()}`)
458
+ await mkdir(temp, { recursive: true })
459
+
460
+ const manifest: BuildManifest = {
461
+ format: BUILD_FORMAT,
462
+ vue: VUE_VERSION,
463
+ fingerprint: buildFingerprint(),
464
+ inputs: {},
465
+ layouts: {},
466
+ files: [],
467
+ roots: {},
468
+ }
469
+
470
+ let pages = 0
471
+ try {
472
+ for (const { root, hostname } of serveRoots()) {
473
+ const run = () => buildRoot(root, temp, manifest)
474
+ pages += hostname
475
+ ? await hostStore.run(
476
+ { hostname, config: resolveHostConfig(hostname) },
477
+ run,
478
+ )
479
+ : await run()
480
+ }
481
+
482
+ manifest.files = (await readdir(temp)).sort()
483
+ await Bun.write(`${temp}/manifest.json`, JSON.stringify(manifest))
484
+
485
+ // Swapped in whole, so a server never reads half of one build and half
486
+ // of another.
487
+ await rm(BUILD_DIR, { recursive: true, force: true })
488
+ await rename(temp, BUILD_DIR)
489
+ } catch (error) {
490
+ await rm(temp, { recursive: true, force: true })
491
+ throw error
492
+ }
493
+
494
+ const ms = Math.round(performance.now() - started)
495
+ logger.log(
496
+ `Built ${pages} page${pages === 1 ? '' : 's'} into ${manifest.files.length} files in ${ms} ms (.cache/vue/build)`,
497
+ 'info',
498
+ )
499
+ return manifest
500
+ }
package/src/built.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { Logger } from '@bakery-framework/core/logger'
3
+ import { fs, toHash } from '@bakery-framework/core/utils'
4
+ import { vueBuildVariant, vuePluginOptionsFingerprint } from './compile'
5
+ import { cacheDir, VUE_VERSION } from './utils'
6
+
7
+ /**
8
+ * What `bakery --build` leaves behind, and what a production server reads of
9
+ * it. The bundler itself is `build.ts`, which the server never loads.
10
+ */
11
+
12
+ const logger = new Logger('vue')
13
+
14
+ /** Where the build lives: disposable, like the rest of `.cache/`. */
15
+ export const BUILD_DIR = fs.resolve(cacheDir, 'build')
16
+
17
+ /** The URL prefix the build's files are served under. */
18
+ export const BUILD_URL = '/_vue/build/'
19
+
20
+ /**
21
+ * Bumped when the manifest or the bundled code changes shape, so a server
22
+ * never reads a build an older plugin made.
23
+ */
24
+ export const BUILD_FORMAT = 1
25
+
26
+ /**
27
+ * Where a built page's HTML puts the exports of the server blocks of the
28
+ * components it reaches, keyed by route path.
29
+ */
30
+ export const SERVER_MODULES_GLOBAL = '__vue_server_modules'
31
+
32
+ /**
33
+ * What a bundled component with a `<script server>` block reads its exports
34
+ * from, in place of the data a served one has spliced in.
35
+ *
36
+ * A block that answered with a response instead of data (a middleware's 401,
37
+ * a redirect) is recorded as `{ __bakeryResponse: status }`, and the
38
+ * component throws as it loads. Served, the same answer fails the component's
39
+ * module request, which fails the import: the page breaks either way, and the
40
+ * error says why.
41
+ */
42
+ export function moduleServerData(routePath: string): string {
43
+ const key = JSON.stringify(routePath)
44
+ const message = JSON.stringify(
45
+ `${routePath}: its <script server> block answered with a response, not data: HTTP `,
46
+ )
47
+ return (
48
+ `((d) => { if (d && d.__bakeryResponse) throw new Error(${message} + d.__bakeryResponse); return d || {} })` +
49
+ `((globalThis.${SERVER_MODULES_GLOBAL} || {})[${key}])`
50
+ )
51
+ }
52
+
53
+ /** One page, as the build made it. */
54
+ export interface BuiltPage {
55
+ /** The entry chunk's URL. */
56
+ script: string
57
+ /** Chunks the entry imports statically, preloaded beside it. */
58
+ preload: string[]
59
+ /** Stylesheet URLs: the layout's first, then the page's. */
60
+ css: string[]
61
+ /**
62
+ * Route paths of the components the page can reach that carry a
63
+ * `<script server>` block. Their blocks run with the page request, and
64
+ * their exports arrive in the HTML.
65
+ */
66
+ serverModules: string[]
67
+ }
68
+
69
+ export interface BuildManifest {
70
+ format: number
71
+ /** The Vue the render functions were compiled for. */
72
+ vue: string
73
+ /** The plugin options and import map the build was made under. */
74
+ fingerprint: string
75
+ /** Every file the bundle read, with the mtime it had then. */
76
+ inputs: Record<string, number>
77
+ /** Each serve root's `layout.vue` files at build time. */
78
+ layouts: Record<string, string[]>
79
+ /** Every file under the build directory the server may hand out. */
80
+ files: string[]
81
+ /** Serve root, then page file, both absolute. */
82
+ roots: Record<string, Record<string, BuiltPage>>
83
+ }
84
+
85
+ /**
86
+ * The options and the import map the build depends on, as one value.
87
+ *
88
+ * Every import-map key is left out of the bundle for the browser to resolve,
89
+ * `vue` among them, so adding or removing one changes what the bundle should
90
+ * contain without touching a source file. The plugin's options change the
91
+ * compiled templates the same way.
92
+ */
93
+ export function buildFingerprint(): string {
94
+ const keys = new Set(Object.keys(Bakery.config.importMap ?? {}))
95
+ for (const host of Object.values(Bakery.config.hosts ?? {})) {
96
+ for (const key of Object.keys(host.importMap ?? {})) keys.add(key)
97
+ }
98
+ return toHash(
99
+ JSON.stringify([
100
+ vueBuildVariant(),
101
+ vuePluginOptionsFingerprint(),
102
+ [...keys].sort(),
103
+ ]),
104
+ )
105
+ }
106
+
107
+ /** Each root's `layout.vue` files, sorted, for the manifest and the check. */
108
+ export async function layoutsUnder(root: string): Promise<string[]> {
109
+ const found: string[] = []
110
+ for await (const file of new Bun.Glob('**/layout.vue').scan({
111
+ cwd: root,
112
+ absolute: true,
113
+ onlyFiles: true,
114
+ })) {
115
+ found.push(fs.resolve(file))
116
+ }
117
+ return found.sort()
118
+ }
119
+
120
+ /**
121
+ * Why the manifest no longer describes the app, or null when it still does.
122
+ *
123
+ * Inputs are compared by mtime, the same test every compile cache here uses.
124
+ * Layouts are listed separately because adding one changes what wraps a page
125
+ * without touching any file the build read.
126
+ */
127
+ export async function staleReason(
128
+ manifest: BuildManifest,
129
+ ): Promise<string | null> {
130
+ if (manifest.format !== BUILD_FORMAT) {
131
+ return 'it was made by another version of the plugin'
132
+ }
133
+ if (manifest.vue !== VUE_VERSION) {
134
+ return `it was compiled for Vue ${manifest.vue}, and ${VUE_VERSION} is installed`
135
+ }
136
+ if (manifest.fingerprint !== buildFingerprint()) {
137
+ return 'the Vue plugin options or the import map changed'
138
+ }
139
+ for (const [path, mtime] of Object.entries(manifest.inputs)) {
140
+ const file = Bun.file(path)
141
+ if (!fs.exists(file)) return `${path} is gone`
142
+ if (file.lastModified !== mtime) return `${path} changed`
143
+ }
144
+ for (const [root, layouts] of Object.entries(manifest.layouts)) {
145
+ const now = await layoutsUnder(root)
146
+ if (now.join('\n') !== layouts.join('\n')) {
147
+ return `a layout.vue was added or removed under ${root}`
148
+ }
149
+ }
150
+ return null
151
+ }
152
+
153
+ let loaded: Promise<{
154
+ manifest: BuildManifest
155
+ files: Set<string>
156
+ } | null> | null = null
157
+
158
+ /**
159
+ * The build, once per process, or null: in development, when there is none,
160
+ * and when it no longer matches the app. Production has no watcher and the
161
+ * page tree only changes with a restart (see `claimedBeside`), so one check
162
+ * is the whole of it, and a server started before a build keeps serving what
163
+ * it found.
164
+ */
165
+ export function loadBuild(): Promise<{
166
+ manifest: BuildManifest
167
+ files: Set<string>
168
+ } | null> {
169
+ if (import.meta.env.PROD !== '1') return Promise.resolve(null)
170
+ loaded ??= readBuild()
171
+ return loaded
172
+ }
173
+
174
+ async function readBuild() {
175
+ const file = Bun.file(`${BUILD_DIR}/manifest.json`)
176
+ if (!(await file.exists())) return null
177
+
178
+ let manifest: BuildManifest
179
+ try {
180
+ manifest = (await file.json()) as BuildManifest
181
+ } catch (error) {
182
+ logger.log(
183
+ `The Vue build's manifest is unreadable (${String(error)}): pages are served unbundled until \`bakery --build\` runs again.`,
184
+ 'warn',
185
+ )
186
+ return null
187
+ }
188
+
189
+ const reason = await staleReason(manifest)
190
+ if (reason) {
191
+ logger.log(
192
+ `The Vue build is out of date (${reason}): pages are served unbundled until \`bakery --build\` runs again.`,
193
+ 'warn',
194
+ )
195
+ return null
196
+ }
197
+ return { manifest, files: new Set(manifest.files) }
198
+ }
199
+
200
+ /** The built page for a file under a root, or null to serve it unbundled. */
201
+ export async function builtPage(
202
+ root: string,
203
+ file: string,
204
+ ): Promise<BuiltPage | null> {
205
+ const build = await loadBuild()
206
+ return build?.manifest.roots[fs.resolve(root)]?.[fs.resolve(file)] ?? null
207
+ }
208
+
209
+ /** Test seam (convention 9): forget the memoized build. */
210
+ export function __resetBuild(): void {
211
+ loaded = null
212
+ }
package/src/chunks.ts CHANGED
@@ -2,6 +2,7 @@ import { Bakery } from '@bakery-framework/core/core/bakery'
2
2
  import { Logger } from '@bakery-framework/core/logger'
3
3
  import { fs, response } from '@bakery-framework/core/utils'
4
4
  import { ETag } from '@bakery-framework/core/utils/http'
5
+ import { BUILD_DIR, BUILD_URL, loadBuild } from './built'
5
6
  import { vueBuildVariant } from './compile'
6
7
  import { VUE_VERSION } from './utils'
7
8
 
@@ -34,11 +35,28 @@ export function vueChunkPath(): string {
34
35
  return `${VUE_CHUNK_PREFIX}${VUE_VERSION}.${vueBuildVariant()}.js`
35
36
  }
36
37
 
38
+ /**
39
+ * A file `bakery --build` made. Only a name the manifest lists is served, so
40
+ * nothing else under the build directory, and nothing a crafted path could
41
+ * reach outside it, is ever handed out. Every name carries its content's
42
+ * hash, which is what makes caching it for a year correct.
43
+ */
44
+ async function serveBuiltFile(path: string, req: Request): Promise<Response> {
45
+ const build = await loadBuild()
46
+ const name = path.slice(BUILD_URL.length)
47
+ if (!build?.files.has(name)) return response.error('Not Found', 404)
48
+
49
+ const res = await ETag.sendFile(Bun.file(`${BUILD_DIR}/${name}`), req)
50
+ res.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
51
+ return res
52
+ }
53
+
37
54
  /** Serves the self-hosted Vue runtime that `Bakery.config.importMap` points at. */
38
55
  export async function serveVueChunk(
39
56
  path: string,
40
57
  req: Request,
41
58
  ): Promise<Response> {
59
+ if (path.startsWith(BUILD_URL)) return serveBuiltFile(path, req)
42
60
  if (path !== vueChunkPath()) {
43
61
  return response.error('Not Found', 404)
44
62
  }
@@ -66,7 +84,7 @@ export async function serveVueChunk(
66
84
  entrypoints: [sourcePath],
67
85
  target: 'browser',
68
86
  format: 'esm',
69
- // `Boolean(...)`, not the flag directly: the mode flags are `'1'`/`''`
87
+ // `Boolean(...)`, not the flag directly: the mode flags are `'1'`/`''`
70
88
  // strings (`core/init.ts`), and `Bun.build` **rejects** a non-boolean
71
89
  // `minify` rather than coercing it. Core's `compiler.ts` already wrapped
72
90
  // its three; this one did not, and the build would have thrown on every
package/src/compile.ts CHANGED
@@ -80,6 +80,21 @@ export function vueBuildVariant(): 'runtime' | 'full' {
80
80
  return vuePluginOptions.build === 'full' ? 'full' : 'runtime'
81
81
  }
82
82
 
83
+ /**
84
+ * The options that change what a template compiles to, as one string, for a
85
+ * build to record and a server to compare. Functions count by their source,
86
+ * which is as close as a running process can get to knowing they changed.
87
+ */
88
+ export function vuePluginOptionsFingerprint(): string {
89
+ return JSON.stringify(
90
+ {
91
+ customElements: vuePluginOptions.customElements,
92
+ compilerOptions: vuePluginOptions.compilerOptions,
93
+ },
94
+ (_key, value) => (typeof value === 'function' ? String(value) : value),
95
+ )
96
+ }
97
+
83
98
  export function resolveIsCustomElement(tag: string): boolean {
84
99
  const ce = vuePluginOptions?.customElements
85
100
  const userFn = vuePluginOptions?.compilerOptions?.isCustomElement
@@ -123,15 +138,14 @@ export async function compileScriptBlock(
123
138
  }
124
139
  }
125
140
 
126
- if (descriptor.script && descriptor.scriptSetup) {
127
- const scriptLang = descriptor.script.lang || descriptor.script.attrs?.lang
128
- if (scriptLang === 'ts') {
129
- // If the script block is TypeScript, we need to compile it first before passing it to the Vue compiler
130
- const compiledScript = await compileText(descriptor.script.content)
131
- descriptor.script.content = compiledScript
132
- }
133
- }
134
-
141
+ // The blocks go to Vue as written, TypeScript included: `compileScript`
142
+ // reads TypeScript, and `compileText` below takes the types out of what it
143
+ // returns. A plain `<script lang="ts">` beside `<script setup>` used to be
144
+ // transpiled first and written back into `descriptor.script.content`, but
145
+ // Vue edits the whole file by the block's original offsets, so a rewritten
146
+ // block put its edits in the wrong places: an `export default` of options
147
+ // came out as `returnconst __default__ = MIT)` over the lines around it,
148
+ // and the module failed to parse.
135
149
  const { compileScript } = await loadCompiler()
136
150
  const result = compileScript(descriptor, {
137
151
  id,
package/src/handler.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  validateActionRequest,
25
25
  validateActionTarget,
26
26
  } from './actions'
27
+ import { type BuiltPage, builtPage, SERVER_MODULES_GLOBAL } from './built'
27
28
  import { serveVueChunk, VUE_CHUNK_PREFIX } from './chunks'
28
29
  import {
29
30
  compileStyleBlock,
@@ -66,6 +67,12 @@ function requestNamesFile(path: string, routePath: string): boolean {
66
67
 
67
68
  const RX_SERVER_DATA_TOKEN = new RegExp(`\\b${VUE_SERVER_DATA_TOKEN}\\b`, 'g')
68
69
 
70
+ /**
71
+ * What a root script reads its page's server data from: the global the shell
72
+ * assigns, so the cached or bundled script itself carries no user's data.
73
+ */
74
+ export const ROOT_SERVER_DATA = '(globalThis.__vue_server || {})'
75
+
69
76
  /**
70
77
  * Compiled module code with the server-data token still in place. Components
71
78
  * with a `<script server>` block get per-request data, so the code cannot be
@@ -285,6 +292,105 @@ export function claimedBeside(catchAllFile: string): {
285
292
  return result
286
293
  }
287
294
 
295
+ /**
296
+ * A component's browser code: what `?__vue_script` serves, and what
297
+ * `bakery --build` bundles, from one compile so the two cannot drift apart.
298
+ *
299
+ * `served` is the difference between them. A served module is fetched by
300
+ * URL, so its `.vue` imports gain `?__vue_script=module` and its relative
301
+ * imports become absolute (see `absolutizeRelativeImports`), and it is named
302
+ * for the browser's debugger. A bundled one keeps its specifiers as written
303
+ * for the bundler to resolve against the file on disk.
304
+ *
305
+ * `serverData` replaces the server-data token, which only a component with a
306
+ * `<script server>` block carries: the request's data spliced in, or the
307
+ * expression a root script or a bundled component reads it from.
308
+ */
309
+ export async function componentScript(options: {
310
+ id: string
311
+ routePath: string
312
+ isRootScript: boolean
313
+ parsed: ParsedCacheEntry
314
+ serverData?: string
315
+ served: boolean
316
+ }): Promise<string> {
317
+ const { id, routePath, isRootScript, parsed, serverData, served } = options
318
+ const compiled = await compileVueFile({
319
+ content: parsed.cleanContent,
320
+ filename: routePath,
321
+ id: parsed.scopeId || id,
322
+ isRootScript,
323
+ layoutRoute: isRootScript ? parsed.layoutRoute : null,
324
+ })
325
+
326
+ if (compiled.errors.length) {
327
+ // Thrown, not logged-and-served: a template Vue could not compile has
328
+ // the raw unparseable expression in its render function, so serving it
329
+ // is a browser-side SyntaxError behind a 200 and an empty page, the
330
+ // report that surfaced this described exactly that. The throw lands in
331
+ // the error registry as a 500 that names the file and the error, or
332
+ // fails `bakery --build` naming the same.
333
+ throw new Error(
334
+ `Vue compile failed (${routePath}): ${compiled.errors.join('; ')}`,
335
+ )
336
+ }
337
+
338
+ let code = compiled.code
339
+
340
+ if (served) {
341
+ if (code.includes('.vue"') || code.includes(".vue'")) {
342
+ code = rewriteVueImports(code)
343
+ }
344
+ // After the `.vue` rewrite, which only appends a query and leaves the
345
+ // specifier relative. See `absolutizeRelativeImports` for why relative
346
+ // imports cannot survive into the browser.
347
+ code = absolutizeRelativeImports(code, routePath)
348
+ }
349
+
350
+ if (serverData !== undefined) {
351
+ code = code.replace(RX_SERVER_DATA_TOKEN, () => serverData)
352
+ }
353
+
354
+ // One element per component, holding every block, joined as `componentCss`
355
+ // joins them for a page. The element is per component so a module evaluated
356
+ // again replaces its styles rather than stacking a second copy; writing each
357
+ // block into it in turn, as this did, kept only the last block.
358
+ if (!isRootScript) {
359
+ const css = compiled.styles
360
+ .map(style => style.code)
361
+ .filter(Boolean)
362
+ .join('\n\n')
363
+ if (css) {
364
+ code += `;\n(function(){var k='__vu_css_${id}';var s=document.getElementById(k)||(function(){var el=document.createElement('style');el.id=k;document.head.appendChild(el);return el})();s.textContent=${JSON.stringify(css)}})()`
365
+ }
366
+ }
367
+
368
+ if (served) {
369
+ const sourceURL =
370
+ routePath.replace(/^.*[\\/]/, '').replace(/\?.*$/, '') ||
371
+ (isRootScript ? 'root.vue' : 'module.vue')
372
+ code += `\n//# sourceURL=${sourceURL}`
373
+ }
374
+
375
+ return code
376
+ }
377
+
378
+ /** A component's stylesheet, scoped, or '' when it has no styles. */
379
+ export async function componentCss(
380
+ id: string,
381
+ parsed: ParsedCacheEntry,
382
+ ): Promise<string> {
383
+ const compiled = await Promise.all(
384
+ parsed.styles.map(style =>
385
+ compileStyleBlock({ style, id: parsed.scopeId || id }),
386
+ ),
387
+ )
388
+ return compiled
389
+ .map(s => s.code)
390
+ .filter(Boolean)
391
+ .join('\n\n')
392
+ }
393
+
288
394
  export class VueHandler extends DynamicHandler {
289
395
  static get config() {
290
396
  return {
@@ -306,6 +412,16 @@ export class VueHandler extends DynamicHandler {
306
412
 
307
413
  static handle = sharedHandler
308
414
 
415
+ /**
416
+ * The runtime chunks, stylesheets, root scripts, and module scripts of
417
+ * components with no server block: what `sharedHandler` marked while
418
+ * serving this request. Never a page, an action, or a module whose server
419
+ * block ran for it, since those run app code on every request.
420
+ */
421
+ static isAsset(_path: string, req: Request): boolean {
422
+ return servedAsAsset.has(req)
423
+ }
424
+
309
425
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SFC parser
310
426
  static async parseVueFile(
311
427
  id: string,
@@ -414,61 +530,21 @@ export class VueHandler extends DynamicHandler {
414
530
  return parsed
415
531
  }
416
532
 
417
- private static async buildScriptCode(
533
+ private static buildScriptCode(
418
534
  id: string,
419
535
  routePath: string,
420
536
  isRootScript: boolean,
421
537
  parsed: ParsedCacheEntry,
422
538
  serverDataReplacement?: string,
423
539
  ) {
424
- const { cleanContent, scopeId } = parsed
425
- const compiled = await compileVueFile({
426
- content: cleanContent,
427
- filename: routePath,
428
- id: scopeId || id,
540
+ return componentScript({
541
+ id,
542
+ routePath,
429
543
  isRootScript,
430
- layoutRoute: isRootScript ? parsed.layoutRoute : null,
544
+ parsed,
545
+ serverData: serverDataReplacement,
546
+ served: true,
431
547
  })
432
-
433
- if (compiled.errors.length) {
434
- // Thrown, not logged-and-served: a template Vue could not compile has
435
- // the raw unparseable expression in its render function, so serving it
436
- // is a browser-side SyntaxError behind a 200 and an empty page, the
437
- // report that surfaced this described exactly that. The throw lands in
438
- // the error registry as a 500 that names the file and the error.
439
- throw new Error(
440
- `Vue compile failed (${routePath}): ${compiled.errors.join('; ')}`,
441
- )
442
- }
443
-
444
- let code = compiled.code
445
-
446
- if (code.includes('.vue"') || code.includes(".vue'")) {
447
- code = rewriteVueImports(code)
448
- }
449
- // After the `.vue` rewrite, which only appends a query and leaves the
450
- // specifier relative. See `absolutizeRelativeImports` for why relative
451
- // imports cannot survive into the browser.
452
- code = absolutizeRelativeImports(code, routePath)
453
-
454
- if (serverDataReplacement !== undefined) {
455
- code = code.replace(RX_SERVER_DATA_TOKEN, () => serverDataReplacement)
456
- }
457
-
458
- if (!isRootScript) {
459
- for (const style of compiled.styles) {
460
- if (style.code) {
461
- code += `;\n(function(){var k='__vu_css_${id}';var s=document.getElementById(k)||(function(){var el=document.createElement('style');el.id=k;document.head.appendChild(el);return el})();s.textContent=${JSON.stringify(style.code)}})()`
462
- }
463
- }
464
- }
465
-
466
- const sourceURL =
467
- routePath.replace(/^.*[\\/]/, '').replace(/\?.*$/, '') ||
468
- (isRootScript ? 'root.vue' : 'module.vue')
469
- code += `\n//# sourceURL=${sourceURL}`
470
-
471
- return code
472
548
  }
473
549
 
474
550
  static async handleScript(
@@ -501,9 +577,7 @@ export class VueHandler extends DynamicHandler {
501
577
  ? `${id}.root.${vueBuildVariant()}${layoutTag}.js`
502
578
  : `${id}.js`
503
579
  const replacement =
504
- isRootScript && hasServerScript
505
- ? '(globalThis.__vue_server || {})'
506
- : undefined
580
+ isRootScript && hasServerScript ? ROOT_SERVER_DATA : undefined
507
581
 
508
582
  return await fs.getOrCreateCachedFile(dir, fileName, lastMod, () =>
509
583
  this.buildScriptCode(id, routePath, isRootScript, parsed, replacement),
@@ -529,23 +603,15 @@ export class VueHandler extends DynamicHandler {
529
603
  }
530
604
 
531
605
  static async handleCss(id: string, parsed: ParsedCacheEntry) {
532
- const { lastMod, scopeId, styles } = parsed
533
-
534
606
  const dir = fs.resolve(cacheDir, 'css')
535
607
  const fileName = `${id}.css`
536
608
 
537
- return await fs.getOrCreateCachedFile(dir, fileName, lastMod, async () => {
538
- const compiled = await Promise.all(
539
- styles.map(style => compileStyleBlock({ style, id: scopeId || id })),
540
- )
541
-
542
- const css = compiled
543
- .map(s => s.code)
544
- .filter(Boolean)
545
- .join('\n\n')
546
-
547
- return css || null
548
- })
609
+ return await fs.getOrCreateCachedFile(
610
+ dir,
611
+ fileName,
612
+ parsed.lastMod,
613
+ async () => (await componentCss(id, parsed)) || null,
614
+ )
549
615
  }
550
616
 
551
617
  /**
@@ -602,6 +668,11 @@ export class VueHandler extends DynamicHandler {
602
668
  * server block (see `needsServerData`), so the URL only picks the file.
603
669
  */
604
670
  assetPath: string = routePath,
671
+ /**
672
+ * The page as `bakery --build` made it, with the exports of the server
673
+ * blocks it reaches. Absent, the page is served unbundled.
674
+ */
675
+ built?: { page: BuiltPage; moduleData?: Record<string, unknown> },
605
676
  ) {
606
677
  const { hasCss, serverScript } = parsed
607
678
  const assets = escapeHtml(assetPath)
@@ -628,9 +699,14 @@ export class VueHandler extends DynamicHandler {
628
699
  ? `<script>globalThis.__vue_route = ${escapeScriptJson(route)};</script>`
629
700
  : ''
630
701
 
702
+ const moduleDecl =
703
+ built?.moduleData && Object.keys(built.moduleData).length
704
+ ? `<script>globalThis.${SERVER_MODULES_GLOBAL} = ${escapeScriptJson(built.moduleData)};</script>`
705
+ : ''
706
+
631
707
  let hydrated = VUE_HTML_SHELL.replace(
632
708
  '/*__SERVER_VARIABLES__*/',
633
- () => serverDecl + routeDecl,
709
+ () => serverDecl + moduleDecl + routeDecl,
634
710
  )
635
711
 
636
712
  // Static markup, injected verbatim. See parseSkeleton for why it is
@@ -651,12 +727,26 @@ export class VueHandler extends DynamicHandler {
651
727
  )
652
728
  }
653
729
 
654
- const prio =
655
- (await VueHandler.layoutCssLink(parsed)) +
656
- (hasCss
657
- ? `<link rel="stylesheet" id="__vu_css_${id}" href="${assets}?__vue_css=true">\n`
658
- : '') +
659
- `<script type="module" src="${assets}?__vue_script=root"></script>`
730
+ // Built: the entry chunk, the chunks it imports announced beside it so
731
+ // the browser fetches them at once rather than after parsing the entry,
732
+ // and the build's stylesheets. The import map still comes first in the
733
+ // head (`injectIfHtml`): a module preload before it would settle module
734
+ // resolution without it.
735
+ const prio = built
736
+ ? built.page.css
737
+ .map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">\n`)
738
+ .join('') +
739
+ built.page.preload
740
+ .map(
741
+ href => `<link rel="modulepreload" href="${escapeHtml(href)}">\n`,
742
+ )
743
+ .join('') +
744
+ `<script type="module" src="${escapeHtml(built.page.script)}"></script>`
745
+ : (await VueHandler.layoutCssLink(parsed)) +
746
+ (hasCss
747
+ ? `<link rel="stylesheet" id="__vu_css_${id}" href="${assets}?__vue_css=true">\n`
748
+ : '') +
749
+ `<script type="module" src="${assets}?__vue_script=root"></script>`
660
750
 
661
751
  const htmlRes = await injectIfHtml(hydrated, params, { prio })
662
752
  return htmlRes || response.error('Failed to build HTML', 500)
@@ -713,6 +803,14 @@ async function serveIfDirect(value: any, req: Request) {
713
803
  }
714
804
 
715
805
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request handler, one branch per SFC render path
806
+ /**
807
+ * Requests `sharedHandler` answered with an asset, for `VueHandler.isAsset`.
808
+ * Weak, so an entry lives exactly as long as its request. Recorded by the
809
+ * branch that serves the asset rather than worked out again from the query
810
+ * afterwards: the two would be one more pair of copies to keep in step.
811
+ */
812
+ const servedAsAsset = new WeakSet<Request>()
813
+
716
814
  async function sharedHandler(
717
815
  this: typeof DynamicHandler | typeof DynamicErrorHandler,
718
816
  path: string,
@@ -720,6 +818,7 @@ async function sharedHandler(
720
818
  errors?: Handler.Error.Data,
721
819
  ) {
722
820
  if (path.startsWith(VUE_CHUNK_PREFIX)) {
821
+ servedAsAsset.add(req)
723
822
  return serveVueChunk(path, req)
724
823
  }
725
824
 
@@ -854,6 +953,7 @@ async function sharedHandler(
854
953
  const needsServerData = !isCss && vueScriptParam !== 'root'
855
954
 
856
955
  if (!needsServerData) {
956
+ servedAsAsset.add(req)
857
957
  if (isCss) return VueHandler.handleCss(id, parsed)
858
958
  return VueHandler.handleScript(id, routePath, true, parsed)
859
959
  }
@@ -872,10 +972,17 @@ async function sharedHandler(
872
972
  if (direct) return direct
873
973
 
874
974
  if (isScript) {
875
- const serverValues = serverScript.trim() ? serverParams : undefined
975
+ const hasServerBlock = Boolean(serverScript.trim())
976
+ if (!hasServerBlock) servedAsAsset.add(req)
977
+ const serverValues = hasServerBlock ? serverParams : undefined
876
978
  return VueHandler.handleScript(id, routePath, false, parsed, serverValues)
877
979
  }
878
980
 
981
+ const page = await builtPage(Bakery.serveRoot, diskFile.name ?? '')
982
+ const built = page
983
+ ? { page, moduleData: await builtModuleData(page.serverModules, req) }
984
+ : undefined
985
+
879
986
  // `base` is the URL prefix the page owns: the file's directory. For
880
987
  // `wiki/[...page!].vue` that is `/wiki`; for a root-level catch-all it is
881
988
  // the empty string, which `defineLayout` treats as "everything".
@@ -899,5 +1006,55 @@ async function sharedHandler(
899
1006
  // error render is the exception: its URL is the one that failed, and it
900
1007
  // would route to the error, not to the error page's component.
901
1008
  errorData ? routePath : url.pathname,
1009
+ built,
1010
+ )
1011
+ }
1012
+
1013
+ /**
1014
+ * The exports of the server blocks of the components a built page reaches,
1015
+ * keyed by route path, for the page's HTML.
1016
+ *
1017
+ * Served unbundled, such a component's block runs when the browser fetches
1018
+ * the component, with that request. Bundled, there is no such request, so
1019
+ * the blocks run here, with the page's, side by side. What they see differs
1020
+ * in one respect: `getRequest()` is the page request, with the page's URL.
1021
+ * The body is empty, as a module request's always was.
1022
+ *
1023
+ * A block answering with a response rather than data is recorded with its
1024
+ * status for the component to throw on (`moduleServerData`).
1025
+ */
1026
+ async function builtModuleData(
1027
+ routePaths: string[],
1028
+ req: Request,
1029
+ ): Promise<Record<string, unknown>> {
1030
+ const entries = await Promise.all(
1031
+ routePaths.map(async routePath => {
1032
+ const file = fs.resolve(Bakery.serveRoot, `.${routePath}`)
1033
+ const disk = Bun.file(file)
1034
+ const relPath = routePath.slice(1)
1035
+ const id = toHash(hostKey(relPath))
1036
+ const parsed = await VueHandler.parseVueFile(
1037
+ id,
1038
+ disk,
1039
+ file,
1040
+ disk.lastModified,
1041
+ )
1042
+ const result = await getServerResponse({
1043
+ script: parsed.serverScript,
1044
+ id,
1045
+ lastMod: disk.lastModified,
1046
+ req,
1047
+ body: {},
1048
+ filePath: file,
1049
+ })
1050
+ const direct = asDirectResponse(result)
1051
+ if (!direct) return [routePath, result] as const
1052
+ const status =
1053
+ direct instanceof Response || direct instanceof JsonResponseData
1054
+ ? direct.status
1055
+ : 200
1056
+ return [routePath, { __bakeryResponse: status }] as const
1057
+ }),
902
1058
  )
1059
+ return Object.fromEntries(entries)
903
1060
  }
package/src/index.ts CHANGED
@@ -72,6 +72,18 @@ export default function vuePlugin(options?: VuePluginOptions) {
72
72
  */
73
73
  onStart() {
74
74
  preloadCompiler()
75
+ // In production, read and check the build while the rest of boot runs,
76
+ // for the same reason; the first page awaits the same promise.
77
+ void import('./built').then(({ loadBuild }) => loadBuild())
78
+ },
79
+
80
+ /**
81
+ * `bakery --build`: every page bundled into content-hashed chunks, which
82
+ * a production server then serves in place of a request per module.
83
+ */
84
+ async build() {
85
+ const { buildVuePages } = await import('./build')
86
+ await buildVuePages()
75
87
  },
76
88
  onCompile(content, path) {
77
89
  if (