@bakery-framework/plugin-vue 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0",
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.0"
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
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,99 @@ 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
+ if (!isRootScript) {
355
+ for (const style of compiled.styles) {
356
+ if (style.code) {
357
+ 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)}})()`
358
+ }
359
+ }
360
+ }
361
+
362
+ if (served) {
363
+ const sourceURL =
364
+ routePath.replace(/^.*[\\/]/, '').replace(/\?.*$/, '') ||
365
+ (isRootScript ? 'root.vue' : 'module.vue')
366
+ code += `\n//# sourceURL=${sourceURL}`
367
+ }
368
+
369
+ return code
370
+ }
371
+
372
+ /** A component's stylesheet, scoped, or '' when it has no styles. */
373
+ export async function componentCss(
374
+ id: string,
375
+ parsed: ParsedCacheEntry,
376
+ ): Promise<string> {
377
+ const compiled = await Promise.all(
378
+ parsed.styles.map(style =>
379
+ compileStyleBlock({ style, id: parsed.scopeId || id }),
380
+ ),
381
+ )
382
+ return compiled
383
+ .map(s => s.code)
384
+ .filter(Boolean)
385
+ .join('\n\n')
386
+ }
387
+
288
388
  export class VueHandler extends DynamicHandler {
289
389
  static get config() {
290
390
  return {
@@ -306,6 +406,16 @@ export class VueHandler extends DynamicHandler {
306
406
 
307
407
  static handle = sharedHandler
308
408
 
409
+ /**
410
+ * The runtime chunks, stylesheets, root scripts, and module scripts of
411
+ * components with no server block: what `sharedHandler` marked while
412
+ * serving this request. Never a page, an action, or a module whose server
413
+ * block ran for it, since those run app code on every request.
414
+ */
415
+ static isAsset(_path: string, req: Request): boolean {
416
+ return servedAsAsset.has(req)
417
+ }
418
+
309
419
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SFC parser
310
420
  static async parseVueFile(
311
421
  id: string,
@@ -414,61 +524,21 @@ export class VueHandler extends DynamicHandler {
414
524
  return parsed
415
525
  }
416
526
 
417
- private static async buildScriptCode(
527
+ private static buildScriptCode(
418
528
  id: string,
419
529
  routePath: string,
420
530
  isRootScript: boolean,
421
531
  parsed: ParsedCacheEntry,
422
532
  serverDataReplacement?: string,
423
533
  ) {
424
- const { cleanContent, scopeId } = parsed
425
- const compiled = await compileVueFile({
426
- content: cleanContent,
427
- filename: routePath,
428
- id: scopeId || id,
534
+ return componentScript({
535
+ id,
536
+ routePath,
429
537
  isRootScript,
430
- layoutRoute: isRootScript ? parsed.layoutRoute : null,
538
+ parsed,
539
+ serverData: serverDataReplacement,
540
+ served: true,
431
541
  })
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
542
  }
473
543
 
474
544
  static async handleScript(
@@ -501,9 +571,7 @@ export class VueHandler extends DynamicHandler {
501
571
  ? `${id}.root.${vueBuildVariant()}${layoutTag}.js`
502
572
  : `${id}.js`
503
573
  const replacement =
504
- isRootScript && hasServerScript
505
- ? '(globalThis.__vue_server || {})'
506
- : undefined
574
+ isRootScript && hasServerScript ? ROOT_SERVER_DATA : undefined
507
575
 
508
576
  return await fs.getOrCreateCachedFile(dir, fileName, lastMod, () =>
509
577
  this.buildScriptCode(id, routePath, isRootScript, parsed, replacement),
@@ -529,23 +597,15 @@ export class VueHandler extends DynamicHandler {
529
597
  }
530
598
 
531
599
  static async handleCss(id: string, parsed: ParsedCacheEntry) {
532
- const { lastMod, scopeId, styles } = parsed
533
-
534
600
  const dir = fs.resolve(cacheDir, 'css')
535
601
  const fileName = `${id}.css`
536
602
 
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
- })
603
+ return await fs.getOrCreateCachedFile(
604
+ dir,
605
+ fileName,
606
+ parsed.lastMod,
607
+ async () => (await componentCss(id, parsed)) || null,
608
+ )
549
609
  }
550
610
 
551
611
  /**
@@ -602,6 +662,11 @@ export class VueHandler extends DynamicHandler {
602
662
  * server block (see `needsServerData`), so the URL only picks the file.
603
663
  */
604
664
  assetPath: string = routePath,
665
+ /**
666
+ * The page as `bakery --build` made it, with the exports of the server
667
+ * blocks it reaches. Absent, the page is served unbundled.
668
+ */
669
+ built?: { page: BuiltPage; moduleData?: Record<string, unknown> },
605
670
  ) {
606
671
  const { hasCss, serverScript } = parsed
607
672
  const assets = escapeHtml(assetPath)
@@ -628,9 +693,14 @@ export class VueHandler extends DynamicHandler {
628
693
  ? `<script>globalThis.__vue_route = ${escapeScriptJson(route)};</script>`
629
694
  : ''
630
695
 
696
+ const moduleDecl =
697
+ built?.moduleData && Object.keys(built.moduleData).length
698
+ ? `<script>globalThis.${SERVER_MODULES_GLOBAL} = ${escapeScriptJson(built.moduleData)};</script>`
699
+ : ''
700
+
631
701
  let hydrated = VUE_HTML_SHELL.replace(
632
702
  '/*__SERVER_VARIABLES__*/',
633
- () => serverDecl + routeDecl,
703
+ () => serverDecl + moduleDecl + routeDecl,
634
704
  )
635
705
 
636
706
  // Static markup, injected verbatim. See parseSkeleton for why it is
@@ -651,12 +721,26 @@ export class VueHandler extends DynamicHandler {
651
721
  )
652
722
  }
653
723
 
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>`
724
+ // Built: the entry chunk, the chunks it imports announced beside it so
725
+ // the browser fetches them at once rather than after parsing the entry,
726
+ // and the build's stylesheets. The import map still comes first in the
727
+ // head (`injectIfHtml`): a module preload before it would settle module
728
+ // resolution without it.
729
+ const prio = built
730
+ ? built.page.css
731
+ .map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">\n`)
732
+ .join('') +
733
+ built.page.preload
734
+ .map(
735
+ href => `<link rel="modulepreload" href="${escapeHtml(href)}">\n`,
736
+ )
737
+ .join('') +
738
+ `<script type="module" src="${escapeHtml(built.page.script)}"></script>`
739
+ : (await VueHandler.layoutCssLink(parsed)) +
740
+ (hasCss
741
+ ? `<link rel="stylesheet" id="__vu_css_${id}" href="${assets}?__vue_css=true">\n`
742
+ : '') +
743
+ `<script type="module" src="${assets}?__vue_script=root"></script>`
660
744
 
661
745
  const htmlRes = await injectIfHtml(hydrated, params, { prio })
662
746
  return htmlRes || response.error('Failed to build HTML', 500)
@@ -713,6 +797,14 @@ async function serveIfDirect(value: any, req: Request) {
713
797
  }
714
798
 
715
799
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request handler, one branch per SFC render path
800
+ /**
801
+ * Requests `sharedHandler` answered with an asset, for `VueHandler.isAsset`.
802
+ * Weak, so an entry lives exactly as long as its request. Recorded by the
803
+ * branch that serves the asset rather than worked out again from the query
804
+ * afterwards: the two would be one more pair of copies to keep in step.
805
+ */
806
+ const servedAsAsset = new WeakSet<Request>()
807
+
716
808
  async function sharedHandler(
717
809
  this: typeof DynamicHandler | typeof DynamicErrorHandler,
718
810
  path: string,
@@ -720,6 +812,7 @@ async function sharedHandler(
720
812
  errors?: Handler.Error.Data,
721
813
  ) {
722
814
  if (path.startsWith(VUE_CHUNK_PREFIX)) {
815
+ servedAsAsset.add(req)
723
816
  return serveVueChunk(path, req)
724
817
  }
725
818
 
@@ -854,6 +947,7 @@ async function sharedHandler(
854
947
  const needsServerData = !isCss && vueScriptParam !== 'root'
855
948
 
856
949
  if (!needsServerData) {
950
+ servedAsAsset.add(req)
857
951
  if (isCss) return VueHandler.handleCss(id, parsed)
858
952
  return VueHandler.handleScript(id, routePath, true, parsed)
859
953
  }
@@ -872,10 +966,17 @@ async function sharedHandler(
872
966
  if (direct) return direct
873
967
 
874
968
  if (isScript) {
875
- const serverValues = serverScript.trim() ? serverParams : undefined
969
+ const hasServerBlock = Boolean(serverScript.trim())
970
+ if (!hasServerBlock) servedAsAsset.add(req)
971
+ const serverValues = hasServerBlock ? serverParams : undefined
876
972
  return VueHandler.handleScript(id, routePath, false, parsed, serverValues)
877
973
  }
878
974
 
975
+ const page = await builtPage(Bakery.serveRoot, diskFile.name ?? '')
976
+ const built = page
977
+ ? { page, moduleData: await builtModuleData(page.serverModules, req) }
978
+ : undefined
979
+
879
980
  // `base` is the URL prefix the page owns: the file's directory. For
880
981
  // `wiki/[...page!].vue` that is `/wiki`; for a root-level catch-all it is
881
982
  // the empty string, which `defineLayout` treats as "everything".
@@ -899,5 +1000,55 @@ async function sharedHandler(
899
1000
  // error render is the exception: its URL is the one that failed, and it
900
1001
  // would route to the error, not to the error page's component.
901
1002
  errorData ? routePath : url.pathname,
1003
+ built,
1004
+ )
1005
+ }
1006
+
1007
+ /**
1008
+ * The exports of the server blocks of the components a built page reaches,
1009
+ * keyed by route path, for the page's HTML.
1010
+ *
1011
+ * Served unbundled, such a component's block runs when the browser fetches
1012
+ * the component, with that request. Bundled, there is no such request, so
1013
+ * the blocks run here, with the page's, side by side. What they see differs
1014
+ * in one respect: `getRequest()` is the page request, with the page's URL.
1015
+ * The body is empty, as a module request's always was.
1016
+ *
1017
+ * A block answering with a response rather than data is recorded with its
1018
+ * status for the component to throw on (`moduleServerData`).
1019
+ */
1020
+ async function builtModuleData(
1021
+ routePaths: string[],
1022
+ req: Request,
1023
+ ): Promise<Record<string, unknown>> {
1024
+ const entries = await Promise.all(
1025
+ routePaths.map(async routePath => {
1026
+ const file = fs.resolve(Bakery.serveRoot, `.${routePath}`)
1027
+ const disk = Bun.file(file)
1028
+ const relPath = routePath.slice(1)
1029
+ const id = toHash(hostKey(relPath))
1030
+ const parsed = await VueHandler.parseVueFile(
1031
+ id,
1032
+ disk,
1033
+ file,
1034
+ disk.lastModified,
1035
+ )
1036
+ const result = await getServerResponse({
1037
+ script: parsed.serverScript,
1038
+ id,
1039
+ lastMod: disk.lastModified,
1040
+ req,
1041
+ body: {},
1042
+ filePath: file,
1043
+ })
1044
+ const direct = asDirectResponse(result)
1045
+ if (!direct) return [routePath, result] as const
1046
+ const status =
1047
+ direct instanceof Response || direct instanceof JsonResponseData
1048
+ ? direct.status
1049
+ : 200
1050
+ return [routePath, { __bakeryResponse: status }] as const
1051
+ }),
902
1052
  )
1053
+ return Object.fromEntries(entries)
903
1054
  }
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 (