@bakery-framework/plugin-vue 1.0.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/LICENSE +19 -0
- package/README.md +38 -0
- package/package.json +49 -0
- package/src/actions.ts +118 -0
- package/src/chunks.ts +66 -0
- package/src/compile.ts +247 -0
- package/src/handler.ts +538 -0
- package/src/index.ts +39 -0
- package/src/setup.ts +23 -0
- package/src/shell.ts +15 -0
- package/src/types.d.ts +92 -0
- package/src/utils.ts +608 -0
- package/src/vue.d.ts +6 -0
package/src/handler.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { LRUCache } from '@bakery-framework/core/cache/lru'
|
|
2
|
+
import { Bakery, hostKey } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import type { Handler } from '@bakery-framework/core/handlers'
|
|
4
|
+
import {
|
|
5
|
+
beginPageRoute,
|
|
6
|
+
DynamicErrorHandler,
|
|
7
|
+
DynamicHandler,
|
|
8
|
+
} from '@bakery-framework/core/handlers'
|
|
9
|
+
import { Logger } from '@bakery-framework/core/logger'
|
|
10
|
+
import {
|
|
11
|
+
fs,
|
|
12
|
+
JsonResponseData,
|
|
13
|
+
response,
|
|
14
|
+
toHash,
|
|
15
|
+
} from '@bakery-framework/core/utils'
|
|
16
|
+
import { ETag, injectIfHtml } from '@bakery-framework/core/utils/http'
|
|
17
|
+
|
|
18
|
+
const logger = new Logger('vue')
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
resolveActionTarget,
|
|
22
|
+
validateActionRequest,
|
|
23
|
+
validateActionTarget,
|
|
24
|
+
} from './actions'
|
|
25
|
+
import { serveVueChunk, VUE_CHUNK_PREFIX } from './chunks'
|
|
26
|
+
import { compileStyleBlock, compileVueFile, parseVue } from './compile'
|
|
27
|
+
import { VUE_HTML_SHELL } from './shell'
|
|
28
|
+
import type { ParsedCacheEntry } from './types'
|
|
29
|
+
import {
|
|
30
|
+
cacheDir,
|
|
31
|
+
collectExportedFunctionNames,
|
|
32
|
+
escapeHtml,
|
|
33
|
+
escapeScriptJson,
|
|
34
|
+
extractServerScripts,
|
|
35
|
+
getServerResponse,
|
|
36
|
+
parsedCache,
|
|
37
|
+
parseVueMeta,
|
|
38
|
+
RX_EXPORT_BRACE,
|
|
39
|
+
RX_EXPORT_HANGING,
|
|
40
|
+
rewriteVueImports,
|
|
41
|
+
VUE_SERVER_DATA_TOKEN,
|
|
42
|
+
} from './utils'
|
|
43
|
+
|
|
44
|
+
function normalizePath(path: string) {
|
|
45
|
+
return path.endsWith('.js') ? path.slice(0, -3) : path
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const RX_SERVER_DATA_TOKEN = new RegExp(`\\b${VUE_SERVER_DATA_TOKEN}\\b`, 'g')
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compiled module code with the server-data token still in place. Components
|
|
52
|
+
* with a `<script server>` block get per-request data, so the code cannot be
|
|
53
|
+
* cached on disk with the data baked in — but the expensive compile can be.
|
|
54
|
+
*/
|
|
55
|
+
const tokenizedModuleCache = new LRUCache<
|
|
56
|
+
string,
|
|
57
|
+
{ lastMod: number; code: string }
|
|
58
|
+
>(500)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Client-side stub for a server action: calls back into this route over HTTP.
|
|
62
|
+
* Must be POST + JSON to satisfy the CSRF checks in `validateActionRequest`.
|
|
63
|
+
*/
|
|
64
|
+
function buildActionStub(fn: string, relPath: string) {
|
|
65
|
+
const query = `?__vue_action=${encodeURIComponent(fn)}&__vue_file=${encodeURIComponent(relPath)}`
|
|
66
|
+
return (
|
|
67
|
+
`const ${fn} = async (...args) => { ` +
|
|
68
|
+
`const res = await fetch(window.location.pathname + '${query}', { ` +
|
|
69
|
+
`method: 'POST', headers: { 'Content-Type': 'application/json' }, ` +
|
|
70
|
+
`body: JSON.stringify({ args }) }); ` +
|
|
71
|
+
`const ct = res.headers.get('content-type') || ''; ` +
|
|
72
|
+
`if (ct.includes('application/json')) { const json = await res.json(); ` +
|
|
73
|
+
`return json.data !== undefined ? json.data : json; } ` +
|
|
74
|
+
`return res; };`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class VueHandler extends DynamicHandler {
|
|
79
|
+
static get config() {
|
|
80
|
+
return {
|
|
81
|
+
ext: ['vue'],
|
|
82
|
+
dir: Bakery.serveRoot,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static get cacheDir() {
|
|
87
|
+
return cacheDir
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static async canHandle(path: string, req: Request) {
|
|
91
|
+
if (path.startsWith(VUE_CHUNK_PREFIX)) return true
|
|
92
|
+
if (path.endsWith('.vue')) return true
|
|
93
|
+
if (path.endsWith('.js')) path = path.slice(0, -3)
|
|
94
|
+
return await super.canHandle(path, req)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static handle = sharedHandler
|
|
98
|
+
|
|
99
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SFC parser
|
|
100
|
+
static async parseVueFile(
|
|
101
|
+
id: string,
|
|
102
|
+
diskFile: Bun.BunFile,
|
|
103
|
+
filePath: string,
|
|
104
|
+
lastMod: number,
|
|
105
|
+
) {
|
|
106
|
+
const cached = parsedCache.get(id)
|
|
107
|
+
if (cached && cached.lastMod === lastMod) return cached
|
|
108
|
+
|
|
109
|
+
const rawText = await diskFile.text()
|
|
110
|
+
const { meta, clean: metaCleaned } = parseVueMeta(rawText)
|
|
111
|
+
const { script: serverScript, clean: withoutServer } =
|
|
112
|
+
extractServerScripts(metaCleaned)
|
|
113
|
+
let cleanContent = withoutServer
|
|
114
|
+
|
|
115
|
+
if (serverScript.trim()) {
|
|
116
|
+
const functionNames = collectExportedFunctionNames(serverScript)
|
|
117
|
+
const dataNames: string[] = []
|
|
118
|
+
|
|
119
|
+
for (const m of serverScript.matchAll(RX_EXPORT_HANGING)) {
|
|
120
|
+
if (!functionNames.includes(m[2])) dataNames.push(m[2])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const m of serverScript.matchAll(RX_EXPORT_BRACE)) {
|
|
124
|
+
for (const part of m[1].split(',')) {
|
|
125
|
+
const t = part.trim()
|
|
126
|
+
if (!t) continue
|
|
127
|
+
const alias = t.includes(' as ') ? t.split(/\s+as\s+/)[1] : t
|
|
128
|
+
if (!functionNames.includes(alias)) dataNames.push(alias)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const scriptInjections: string[] = []
|
|
133
|
+
if (dataNames.length) {
|
|
134
|
+
scriptInjections.push(
|
|
135
|
+
`const { ${dataNames.join(', ')} } = ${VUE_SERVER_DATA_TOKEN};`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (functionNames.length) {
|
|
140
|
+
const relPath = fs.relative(Bakery.serveRoot, filePath)
|
|
141
|
+
for (const fn of functionNames) {
|
|
142
|
+
scriptInjections.push(buildActionStub(fn, relPath))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (scriptInjections.length) {
|
|
147
|
+
const langMatch = cleanContent.match(
|
|
148
|
+
/<script\s+[^>]*\blang=["']([^"']+)["']/i,
|
|
149
|
+
)
|
|
150
|
+
const langAttr = langMatch ? ` lang="${langMatch[1]}"` : ''
|
|
151
|
+
cleanContent = `<script${langAttr}>\n${scriptInjections.join(
|
|
152
|
+
'\n',
|
|
153
|
+
)}\n</script>\n${cleanContent}`
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const rxEventAttr =
|
|
158
|
+
/(@[\w.-]+|v-on:[\w.-]+)=("([^"\n]*\n[^"]*)"|'([^'\n]*\n[^']*)')/g
|
|
159
|
+
cleanContent = cleanContent.replace(
|
|
160
|
+
rxEventAttr,
|
|
161
|
+
(match, attr, doubleMatch, innerDouble, innerSingle) => {
|
|
162
|
+
const quote = doubleMatch.startsWith('"') ? '"' : "'"
|
|
163
|
+
const val = innerDouble !== undefined ? innerDouble : innerSingle
|
|
164
|
+
if (!val.includes(';')) {
|
|
165
|
+
return `${attr}=${quote}${val};${quote}`
|
|
166
|
+
}
|
|
167
|
+
return match
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const { descriptor } = await parseVue({
|
|
172
|
+
content: cleanContent,
|
|
173
|
+
filename: filePath,
|
|
174
|
+
})
|
|
175
|
+
const scopeId = `data-v-${id}`
|
|
176
|
+
|
|
177
|
+
const parsed: ParsedCacheEntry = {
|
|
178
|
+
lastMod,
|
|
179
|
+
serverScript,
|
|
180
|
+
cleanContent,
|
|
181
|
+
scopeId,
|
|
182
|
+
styles: descriptor.styles,
|
|
183
|
+
hasCss: descriptor.styles.length > 0,
|
|
184
|
+
meta,
|
|
185
|
+
}
|
|
186
|
+
parsedCache.set(id, parsed)
|
|
187
|
+
return parsed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private static async buildScriptCode(
|
|
191
|
+
id: string,
|
|
192
|
+
routePath: string,
|
|
193
|
+
isRootScript: boolean,
|
|
194
|
+
parsed: ParsedCacheEntry,
|
|
195
|
+
serverDataReplacement?: string,
|
|
196
|
+
) {
|
|
197
|
+
const { cleanContent, scopeId } = parsed
|
|
198
|
+
const compiled = await compileVueFile({
|
|
199
|
+
content: cleanContent,
|
|
200
|
+
filename: routePath,
|
|
201
|
+
id: scopeId || id,
|
|
202
|
+
isRootScript,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if (compiled.errors.length) {
|
|
206
|
+
logger.log(`Compile errors: ${compiled.errors.join(', ')}`, 'error')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let code = compiled.code
|
|
210
|
+
|
|
211
|
+
if (code.includes('.vue"') || code.includes(".vue'")) {
|
|
212
|
+
code = rewriteVueImports(code)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (serverDataReplacement !== undefined) {
|
|
216
|
+
code = code.replace(RX_SERVER_DATA_TOKEN, () => serverDataReplacement)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!isRootScript) {
|
|
220
|
+
for (const style of compiled.styles) {
|
|
221
|
+
if (style.code) {
|
|
222
|
+
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)}})()`
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const sourceURL =
|
|
228
|
+
routePath.replace(/^.*[\\/]/, '').replace(/\?.*$/, '') ||
|
|
229
|
+
(isRootScript ? 'root.vue' : 'module.vue')
|
|
230
|
+
code += `\n//# sourceURL=${sourceURL}`
|
|
231
|
+
|
|
232
|
+
return code
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
static async handleScript(
|
|
236
|
+
id: string,
|
|
237
|
+
routePath: string,
|
|
238
|
+
isRootScript: boolean,
|
|
239
|
+
parsed: ParsedCacheEntry,
|
|
240
|
+
serverValues?: Record<string, any>,
|
|
241
|
+
) {
|
|
242
|
+
const { lastMod, serverScript } = parsed
|
|
243
|
+
const hasServerScript = Boolean(serverScript?.trim())
|
|
244
|
+
|
|
245
|
+
// Static subcomponent (no server script) or root script: no per-request
|
|
246
|
+
// data, so the built file can live on disk.
|
|
247
|
+
if (!hasServerScript || isRootScript) {
|
|
248
|
+
const dir = fs.resolve(cacheDir, 'js')
|
|
249
|
+
const fileName = `${id}${isRootScript ? '.root' : ''}.js`
|
|
250
|
+
const replacement =
|
|
251
|
+
isRootScript && hasServerScript
|
|
252
|
+
? '(globalThis.__vue_server || {})'
|
|
253
|
+
: undefined
|
|
254
|
+
|
|
255
|
+
return await fs.getOrCreateCachedFile(dir, fileName, lastMod, () =>
|
|
256
|
+
this.buildScriptCode(id, routePath, isRootScript, parsed, replacement),
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Dynamic subcomponent with <script server>: compile once, then splice in
|
|
261
|
+
// this request's data. The response is per-user, so it must not be stored.
|
|
262
|
+
const cached = tokenizedModuleCache.get(id)
|
|
263
|
+
let template = cached?.lastMod === lastMod ? cached.code : null
|
|
264
|
+
|
|
265
|
+
if (template === null) {
|
|
266
|
+
template = await this.buildScriptCode(id, routePath, false, parsed)
|
|
267
|
+
tokenizedModuleCache.set(id, { lastMod, code: template })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const replacement = escapeScriptJson(serverValues || {})
|
|
271
|
+
const code = template.replace(RX_SERVER_DATA_TOKEN, () => replacement)
|
|
272
|
+
|
|
273
|
+
return response.type(code, 'text/javascript; charset=utf-8', {
|
|
274
|
+
headers: { 'Cache-Control': 'private, no-store' },
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
static async handleCss(id: string, parsed: ParsedCacheEntry) {
|
|
279
|
+
const { lastMod, scopeId, styles } = parsed
|
|
280
|
+
|
|
281
|
+
const dir = fs.resolve(cacheDir, 'css')
|
|
282
|
+
const fileName = `${id}.css`
|
|
283
|
+
|
|
284
|
+
return await fs.getOrCreateCachedFile(dir, fileName, lastMod, async () => {
|
|
285
|
+
const compiled = await Promise.all(
|
|
286
|
+
styles.map(style => compileStyleBlock({ style, id: scopeId || id })),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const css = compiled
|
|
290
|
+
.map(s => s.code)
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.join('\n\n')
|
|
293
|
+
|
|
294
|
+
return css || null
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
static async handleHtml(
|
|
299
|
+
id: string,
|
|
300
|
+
params: any,
|
|
301
|
+
routePath: string,
|
|
302
|
+
serverParams: any,
|
|
303
|
+
parsed: ParsedCacheEntry,
|
|
304
|
+
) {
|
|
305
|
+
const { hasCss, serverScript } = parsed
|
|
306
|
+
const hasServerData =
|
|
307
|
+
Boolean(serverScript) || params?.errorCode !== undefined
|
|
308
|
+
|
|
309
|
+
const payload =
|
|
310
|
+
typeof serverParams === 'object' && serverParams
|
|
311
|
+
? { ...params, ...serverParams }
|
|
312
|
+
: params?.errorCode !== undefined
|
|
313
|
+
? params
|
|
314
|
+
: serverParams
|
|
315
|
+
|
|
316
|
+
const serverDecl = hasServerData
|
|
317
|
+
? `<script>globalThis.__vue_server = ${escapeScriptJson(payload)};</script>`
|
|
318
|
+
: ''
|
|
319
|
+
|
|
320
|
+
let hydrated = VUE_HTML_SHELL.replace(
|
|
321
|
+
'/*__SERVER_VARIABLES__*/',
|
|
322
|
+
() => serverDecl,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if (parsed.meta.title) {
|
|
326
|
+
const title = escapeHtml(parsed.meta.title)
|
|
327
|
+
hydrated = hydrated.replace(
|
|
328
|
+
'<title>Vue App</title>',
|
|
329
|
+
() => `<title>${title}</title>`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const prio =
|
|
334
|
+
(hasCss
|
|
335
|
+
? `<link rel="stylesheet" id="__vu_css_${id}" href="${routePath}?__vue_css=true">\n`
|
|
336
|
+
: '') +
|
|
337
|
+
`<script type="module" src="${routePath}?__vue_script=root"></script>`
|
|
338
|
+
|
|
339
|
+
const htmlRes = await injectIfHtml(hydrated, params, { prio })
|
|
340
|
+
return htmlRes || response.error('Failed to build HTML', 500)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export class VueErrorHandler extends DynamicErrorHandler {
|
|
345
|
+
static get config() {
|
|
346
|
+
return {
|
|
347
|
+
ext: ['vue'],
|
|
348
|
+
dir: Bakery.serveRoot,
|
|
349
|
+
include: ['**/error.vue', '**/error-*.vue'],
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
static get cacheDir() {
|
|
354
|
+
return cacheDir
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
static handle = sharedHandler
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** A server-script result that is already a complete response, not page data. */
|
|
361
|
+
function asDirectResponse(value: any) {
|
|
362
|
+
if (value instanceof Response) return value
|
|
363
|
+
if (value instanceof JsonResponseData) return value
|
|
364
|
+
if (
|
|
365
|
+
value &&
|
|
366
|
+
typeof value === 'object' &&
|
|
367
|
+
'arrayBuffer' in value &&
|
|
368
|
+
'size' in value
|
|
369
|
+
) {
|
|
370
|
+
return value as Bun.BunFile
|
|
371
|
+
}
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Serve `value` if the server script already produced a complete response,
|
|
377
|
+
* otherwise `null` so the caller treats it as page data.
|
|
378
|
+
*
|
|
379
|
+
* Both paths through `sharedHandler` — the `__vue_action` call and the ordinary
|
|
380
|
+
* page render — need exactly this, and each carried its own verbatim copy. A
|
|
381
|
+
* `BunFile` goes through `ETag.sendFile` so a conditional request can 304; the
|
|
382
|
+
* `new Response` is the fallback for the un-cacheable case.
|
|
383
|
+
*/
|
|
384
|
+
async function serveIfDirect(value: any, req: Request) {
|
|
385
|
+
const direct = asDirectResponse(value)
|
|
386
|
+
if (!direct) return null
|
|
387
|
+
if (direct instanceof Response || direct instanceof JsonResponseData) {
|
|
388
|
+
return direct
|
|
389
|
+
}
|
|
390
|
+
return (await ETag.sendFile(direct, req)) || new Response(direct as any)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request handler — one branch per SFC render path
|
|
394
|
+
async function sharedHandler(
|
|
395
|
+
this: typeof DynamicHandler | typeof DynamicErrorHandler,
|
|
396
|
+
path: string,
|
|
397
|
+
req: Request,
|
|
398
|
+
errors?: Handler.Error.Data,
|
|
399
|
+
) {
|
|
400
|
+
if (path.startsWith(VUE_CHUNK_PREFIX)) {
|
|
401
|
+
return serveVueChunk(path, req)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
path = normalizePath(path)
|
|
405
|
+
const begun = await beginPageRoute(this, path, errors)
|
|
406
|
+
if (begun instanceof Response) return begun
|
|
407
|
+
const { errorData, info } = begun
|
|
408
|
+
const routePath = `/${info.path}`
|
|
409
|
+
|
|
410
|
+
const diskFile = info.file
|
|
411
|
+
const lastMod = diskFile.lastModified
|
|
412
|
+
const id = toHash(hostKey(info.path))
|
|
413
|
+
|
|
414
|
+
const url = new URL(req.url)
|
|
415
|
+
const vueScriptParam = url.searchParams.get('__vue_script')
|
|
416
|
+
const isCss = url.searchParams.has('__vue_css')
|
|
417
|
+
const accept = req.headers.get('accept') || ''
|
|
418
|
+
const isScript =
|
|
419
|
+
vueScriptParam !== null ||
|
|
420
|
+
accept.includes('text/javascript') ||
|
|
421
|
+
req.headers.get('sec-fetch-dest') === 'script'
|
|
422
|
+
|
|
423
|
+
const parsed = await VueHandler.parseVueFile(
|
|
424
|
+
id,
|
|
425
|
+
diskFile,
|
|
426
|
+
info.file.name!,
|
|
427
|
+
lastMod,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
// module-only: block page requests (allow script/css imports)
|
|
431
|
+
if (parsed.meta.moduleOnly && !isScript && !isCss) {
|
|
432
|
+
return response.error('Not Found', 404)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// page-only: block module imports (allow root scripts, page, and css)
|
|
436
|
+
if (parsed.meta.pageOnly && isScript && vueScriptParam === 'module') {
|
|
437
|
+
return response.error('Not Found', 404)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { serverScript } = parsed
|
|
441
|
+
const params = info.getParams(path) || {}
|
|
442
|
+
const finalParams = errorData ? Object.assign({}, params, errorData) : params
|
|
443
|
+
|
|
444
|
+
const actionName =
|
|
445
|
+
url.searchParams.get('__vue_action') || req.headers.get('x-vue-action')
|
|
446
|
+
|
|
447
|
+
if (actionName) {
|
|
448
|
+
const rejected = validateActionRequest(req, url)
|
|
449
|
+
if (rejected) return rejected
|
|
450
|
+
|
|
451
|
+
const actionFile = url.searchParams.get('__vue_file')
|
|
452
|
+
|
|
453
|
+
let target = { id, lastMod, filePath: diskFile.name, script: serverScript }
|
|
454
|
+
let targetMeta = parsed.meta
|
|
455
|
+
|
|
456
|
+
if (actionFile) {
|
|
457
|
+
const resolved = await resolveActionTarget(actionFile)
|
|
458
|
+
if (!resolved) return response.error('Not Found', 404)
|
|
459
|
+
|
|
460
|
+
const targetParsed = await VueHandler.parseVueFile(
|
|
461
|
+
resolved.id,
|
|
462
|
+
resolved.diskFile,
|
|
463
|
+
resolved.filePath,
|
|
464
|
+
resolved.lastMod,
|
|
465
|
+
)
|
|
466
|
+
target = {
|
|
467
|
+
id: resolved.id,
|
|
468
|
+
lastMod: resolved.lastMod,
|
|
469
|
+
filePath: resolved.filePath,
|
|
470
|
+
script: targetParsed.serverScript,
|
|
471
|
+
}
|
|
472
|
+
targetMeta = targetParsed.meta
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The gates above ran against the route. This one runs against whatever
|
|
476
|
+
// `__vue_file` chose, which is the thing that is about to execute — and it
|
|
477
|
+
// runs before the body is read, so a rejected request costs a parse it
|
|
478
|
+
// never needed.
|
|
479
|
+
const denied = validateActionTarget(
|
|
480
|
+
{ id: target.id, meta: targetMeta, serverScript: target.script },
|
|
481
|
+
id,
|
|
482
|
+
actionName,
|
|
483
|
+
)
|
|
484
|
+
if (denied) return denied
|
|
485
|
+
|
|
486
|
+
const body = await this.params(req, finalParams)
|
|
487
|
+
|
|
488
|
+
const actionArgs = Array.isArray(body?.args)
|
|
489
|
+
? body.args
|
|
490
|
+
: Array.isArray(body)
|
|
491
|
+
? body
|
|
492
|
+
: []
|
|
493
|
+
|
|
494
|
+
const actionResult = await getServerResponse({
|
|
495
|
+
script: target.script,
|
|
496
|
+
id: target.id,
|
|
497
|
+
lastMod: target.lastMod,
|
|
498
|
+
req,
|
|
499
|
+
body,
|
|
500
|
+
filePath: target.filePath,
|
|
501
|
+
actionName,
|
|
502
|
+
actionArgs,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const direct = await serveIfDirect(actionResult, req)
|
|
506
|
+
if (direct) return direct
|
|
507
|
+
return response.json.success('OK', actionResult)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Root scripts and stylesheets carry no per-request data — running the server
|
|
511
|
+
// block for them would re-execute every top-level query on each sub-request.
|
|
512
|
+
const needsServerData = !isCss && vueScriptParam !== 'root'
|
|
513
|
+
|
|
514
|
+
if (!needsServerData) {
|
|
515
|
+
if (isCss) return VueHandler.handleCss(id, parsed)
|
|
516
|
+
return VueHandler.handleScript(id, routePath, true, parsed)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const body = await this.params(req, finalParams)
|
|
520
|
+
const serverParams = await getServerResponse({
|
|
521
|
+
script: serverScript,
|
|
522
|
+
id,
|
|
523
|
+
lastMod,
|
|
524
|
+
req,
|
|
525
|
+
body,
|
|
526
|
+
filePath: diskFile.name,
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
const direct = await serveIfDirect(serverParams, req)
|
|
530
|
+
if (direct) return direct
|
|
531
|
+
|
|
532
|
+
if (isScript) {
|
|
533
|
+
const serverValues = serverScript.trim() ? serverParams : undefined
|
|
534
|
+
return VueHandler.handleScript(id, routePath, false, parsed, serverValues)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return VueHandler.handleHtml(id, finalParams, routePath, serverParams, parsed)
|
|
538
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
+
import { setVuePluginOptions } from './compile'
|
|
3
|
+
import type { VuePluginOptions } from './types'
|
|
4
|
+
import { rewriteVueImports } from './utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `vuePlugin` is this package's only export, and its parameter type lived in a
|
|
8
|
+
* `.d.ts` reachable through no working specifier — so a consumer could pass
|
|
9
|
+
* options but never name their type. Re-exported here, where the function that
|
|
10
|
+
* takes them is.
|
|
11
|
+
*/
|
|
12
|
+
export type { CustomElementsOption, VuePluginOptions } from './types'
|
|
13
|
+
|
|
14
|
+
export default function vuePlugin(options?: VuePluginOptions) {
|
|
15
|
+
if (options) setVuePluginOptions(options)
|
|
16
|
+
return definePlugin({
|
|
17
|
+
name: 'vue',
|
|
18
|
+
async setup() {
|
|
19
|
+
const { setupVue } = await import('./setup')
|
|
20
|
+
await setupVue()
|
|
21
|
+
},
|
|
22
|
+
onCompile(content, path) {
|
|
23
|
+
if (
|
|
24
|
+
!path.endsWith('.ts') &&
|
|
25
|
+
!path.endsWith('.js') &&
|
|
26
|
+
!path.endsWith('.vue')
|
|
27
|
+
)
|
|
28
|
+
return content
|
|
29
|
+
|
|
30
|
+
let modified = content
|
|
31
|
+
|
|
32
|
+
if (modified.includes('.vue"') || modified.includes(".vue'")) {
|
|
33
|
+
modified = rewriteVueImports(modified)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return modified
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
2
|
+
import { Logger } from '@bakery-framework/core/logger'
|
|
3
|
+
import { VueErrorHandler, VueHandler } from './handler'
|
|
4
|
+
import { initVueVersion, VUE_VERSION } from './utils'
|
|
5
|
+
|
|
6
|
+
const logger = new Logger('vue')
|
|
7
|
+
|
|
8
|
+
function checkDeps() {
|
|
9
|
+
try {
|
|
10
|
+
Bun.resolveSync('vue/package.json', import.meta.dir)
|
|
11
|
+
} catch {
|
|
12
|
+
logger.log('"vue" is not installed. Run `bun add vue`.', 'error')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setupVue() {
|
|
18
|
+
checkDeps()
|
|
19
|
+
initVueVersion()
|
|
20
|
+
Bakery.handlers.fetch.set(VueHandler, 58)
|
|
21
|
+
Bakery.handlers.error.set(VueErrorHandler, 18)
|
|
22
|
+
Bakery.config.importMap.vue = `/_vue/${VUE_VERSION}.js`
|
|
23
|
+
}
|
package/src/shell.ts
ADDED
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SFCDescriptor,
|
|
3
|
+
SFCStyleBlock,
|
|
4
|
+
SFCStyleCompileResults,
|
|
5
|
+
} from '@vue/compiler-sfc'
|
|
6
|
+
|
|
7
|
+
export interface ParseVueOptions {
|
|
8
|
+
content: string
|
|
9
|
+
filename: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParseVueResult {
|
|
13
|
+
descriptor: SFCDescriptor
|
|
14
|
+
errors: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CompileScriptOptions {
|
|
18
|
+
descriptor: SFCDescriptor
|
|
19
|
+
id: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CompileScriptResult {
|
|
23
|
+
code: string
|
|
24
|
+
bindings: Record<string, any>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CompileTemplateOptions {
|
|
28
|
+
descriptor: SFCDescriptor
|
|
29
|
+
id: string
|
|
30
|
+
filename: string
|
|
31
|
+
bindings?: Record<string, any>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CompileStyleOptions {
|
|
35
|
+
style: SFCStyleBlock
|
|
36
|
+
id: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AssembleComponentOptions {
|
|
40
|
+
scriptCode: string
|
|
41
|
+
renderCode: string | null
|
|
42
|
+
isRoot: boolean
|
|
43
|
+
scopeId?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CompileVueFileOptions {
|
|
47
|
+
content: string
|
|
48
|
+
filename: string
|
|
49
|
+
id: string
|
|
50
|
+
isRootScript: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CompileVueFileResult {
|
|
54
|
+
code: string
|
|
55
|
+
styles: SFCStyleCompileResults[]
|
|
56
|
+
errors: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VueMeta {
|
|
60
|
+
moduleOnly: boolean
|
|
61
|
+
pageOnly: boolean
|
|
62
|
+
title: string | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ParsedCacheEntry {
|
|
66
|
+
lastMod: number
|
|
67
|
+
serverScript: string
|
|
68
|
+
cleanContent: string
|
|
69
|
+
scopeId: string
|
|
70
|
+
/** Style blocks from the parsed descriptor, reused when building the CSS. */
|
|
71
|
+
styles: SFCStyleBlock[]
|
|
72
|
+
hasCss: boolean
|
|
73
|
+
meta: VueMeta
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ServerResponseOptions {
|
|
77
|
+
script: string
|
|
78
|
+
id: string
|
|
79
|
+
lastMod: number
|
|
80
|
+
req: Request
|
|
81
|
+
body: any
|
|
82
|
+
filePath?: string
|
|
83
|
+
actionName?: string
|
|
84
|
+
actionArgs?: any[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type CustomElementsOption = string[] | ((tag: string) => boolean)
|
|
88
|
+
|
|
89
|
+
export interface VuePluginOptions {
|
|
90
|
+
customElements?: CustomElementsOption
|
|
91
|
+
compilerOptions?: Record<string, any>
|
|
92
|
+
}
|