@bakery-framework/plugin-vue 2.1.1 → 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 +2 -2
- package/src/build.ts +500 -0
- package/src/built.ts +212 -0
- package/src/chunks.ts +19 -1
- package/src/compile.ts +15 -0
- package/src/handler.ts +223 -72
- package/src/index.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-vue",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
filename: routePath,
|
|
428
|
-
id: scopeId || id,
|
|
534
|
+
return componentScript({
|
|
535
|
+
id,
|
|
536
|
+
routePath,
|
|
429
537
|
isRootScript,
|
|
430
|
-
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
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 (
|