@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/utils.ts
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readdir as nodeReaddir,
|
|
3
|
+
rename as nodeRename,
|
|
4
|
+
unlink as nodeUnlink,
|
|
5
|
+
} from 'node:fs/promises'
|
|
6
|
+
import { LRUCache } from '@bakery-framework/core/cache/lru'
|
|
7
|
+
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
8
|
+
import { Logger } from '@bakery-framework/core/logger'
|
|
9
|
+
import { fs, is, Try } from '@bakery-framework/core/utils'
|
|
10
|
+
import type { ParsedCacheEntry, ServerResponseOptions, VueMeta } from './types'
|
|
11
|
+
|
|
12
|
+
const logger = new Logger('vue')
|
|
13
|
+
|
|
14
|
+
export let VUE_VERSION = 'vue'
|
|
15
|
+
export function initVueVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const pkgPath = Bun.resolveSync('vue/package.json', process.cwd())
|
|
18
|
+
const content = fs.readFileSync(pkgPath)
|
|
19
|
+
if (content) {
|
|
20
|
+
const pkg = JSON.parse(content)
|
|
21
|
+
if (pkg?.version) VUE_VERSION = pkg.version
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Vue is resolved from the app's cwd and may not be installed there at
|
|
25
|
+
// all. The pinned default is the fallback, and the caller renders either way.
|
|
26
|
+
}
|
|
27
|
+
return VUE_VERSION
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const RX_IMPORT_VUE_FILE =
|
|
31
|
+
/(?:from\s+|import\s+|import\()(['"][^'"]*\.vue['"])/g
|
|
32
|
+
export const RX_EXPORT_BRACE = /\bexport\s*\{([\s\S]*?)\}/g
|
|
33
|
+
export const RX_EXPORT_HANGING = /\bexport\s+(const|let|var)\s+(\w+)\s*=\s*/g
|
|
34
|
+
export const RX_EXPORT_FUNCTION = /\bexport\s+(?:async\s+)?function\s+(\w+)/g
|
|
35
|
+
/** `export const fn = async (a) => {}` / `= function () {}` — callable, not data. */
|
|
36
|
+
export const RX_EXPORT_CALLABLE_CONST =
|
|
37
|
+
/\bexport\s+(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|\w+)\s*=>)/g
|
|
38
|
+
export const RX_TOP_LEVEL_IMPORT =
|
|
39
|
+
/^\s*import\s*(?:(?:[\w_$*{},\s\n]+from\s*)?['"`][^'"`\n]+['"`]|['"`][^'"`\n]+['"`]);?/
|
|
40
|
+
|
|
41
|
+
export const cacheDir = fs.resolve(Bakery.cacheDir, 'vue')
|
|
42
|
+
export const VUE_SERVER_DATA_TOKEN = '__BAKERY_VUE_SERVER_DATA__'
|
|
43
|
+
|
|
44
|
+
/** Prefix for names the generated wrapper reserves, so user code can't collide. */
|
|
45
|
+
const INTERNAL_PREFIX = '__bkry_'
|
|
46
|
+
const SERVER_SCRIPT_TIMEOUT_MS = 5000
|
|
47
|
+
|
|
48
|
+
export const parsedCache = new LRUCache<string, ParsedCacheEntry>(1000)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Escaping is owned by the framework, not by this plugin \u2014 core and other
|
|
52
|
+
* plugins were importing it from here, which would make `@bakery-framework/core` depend
|
|
53
|
+
* on `@bakery-framework/vue` once these ship as separate packages. Re-exported so this
|
|
54
|
+
* plugin's own modules and tests keep their existing import path.
|
|
55
|
+
*/
|
|
56
|
+
export { escapeHtml, escapeScriptJson } from '@bakery-framework/core/utils/http'
|
|
57
|
+
|
|
58
|
+
const RX_SERVER_SCRIPT_OPEN = /<script\b(?=[^>]*\bserver(?=[\s/>=]))[^>]*>/gi
|
|
59
|
+
const CLOSING_TAG = '</script'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find the `</script>` that actually closes a server block, skipping ones that
|
|
63
|
+
* appear inside string literals, template literals, or comments. A plain
|
|
64
|
+
* non-greedy regex stops at the first match — so server code containing
|
|
65
|
+
* `"</script>"` would be cut short and its remainder left in the client bundle.
|
|
66
|
+
*/
|
|
67
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner — locates a block end past nested quotes
|
|
68
|
+
function findServerScriptEnd(raw: string, start: number): number {
|
|
69
|
+
let index = start
|
|
70
|
+
|
|
71
|
+
while (index < raw.length) {
|
|
72
|
+
const char = raw[index]
|
|
73
|
+
const next = raw[index + 1]
|
|
74
|
+
|
|
75
|
+
if (char === '/' && next === '/') {
|
|
76
|
+
const nl = raw.indexOf('\n', index)
|
|
77
|
+
index = nl === -1 ? raw.length : nl
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (char === '/' && next === '*') {
|
|
82
|
+
const end = raw.indexOf('*/', index + 2)
|
|
83
|
+
index = end === -1 ? raw.length : end + 2
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
88
|
+
index += 1
|
|
89
|
+
while (index < raw.length) {
|
|
90
|
+
if (raw[index] === '\\') {
|
|
91
|
+
index += 2
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (raw[index] === char) break
|
|
95
|
+
index += 1
|
|
96
|
+
}
|
|
97
|
+
index += 1
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
char === '<' &&
|
|
103
|
+
raw.slice(index, index + CLOSING_TAG.length).toLowerCase() === CLOSING_TAG
|
|
104
|
+
) {
|
|
105
|
+
return index
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
index += 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return raw.length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Collect every `<script server>` block; a file may declare more than one. */
|
|
115
|
+
export function extractServerScripts(raw: string): {
|
|
116
|
+
script: string
|
|
117
|
+
clean: string
|
|
118
|
+
} {
|
|
119
|
+
const blocks: string[] = []
|
|
120
|
+
let clean = ''
|
|
121
|
+
let cursor = 0
|
|
122
|
+
|
|
123
|
+
RX_SERVER_SCRIPT_OPEN.lastIndex = 0
|
|
124
|
+
let open: RegExpExecArray | null
|
|
125
|
+
|
|
126
|
+
while ((open = RX_SERVER_SCRIPT_OPEN.exec(raw))) {
|
|
127
|
+
const bodyStart = open.index + open[0].length
|
|
128
|
+
const bodyEnd = findServerScriptEnd(raw, bodyStart)
|
|
129
|
+
|
|
130
|
+
blocks.push(raw.slice(bodyStart, bodyEnd))
|
|
131
|
+
clean += raw.slice(cursor, open.index)
|
|
132
|
+
|
|
133
|
+
const tagEnd = raw.indexOf('>', bodyEnd)
|
|
134
|
+
cursor = bodyEnd >= raw.length || tagEnd === -1 ? raw.length : tagEnd + 1
|
|
135
|
+
RX_SERVER_SCRIPT_OPEN.lastIndex = cursor
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clean += raw.slice(cursor)
|
|
139
|
+
return { script: blocks.join('\n'), clean }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function extractImportsAndBody(code: string) {
|
|
143
|
+
const imports: string[] = []
|
|
144
|
+
|
|
145
|
+
const codeLength = code.length
|
|
146
|
+
let index = 0
|
|
147
|
+
let lastImportEnd = 0
|
|
148
|
+
|
|
149
|
+
while (index < codeLength) {
|
|
150
|
+
const remaining = code.slice(index)
|
|
151
|
+
const wsMatch = remaining.match(/^\s+|^\/\/.*|^\/\*[\s\S]*?\*\//)
|
|
152
|
+
if (wsMatch) {
|
|
153
|
+
index += wsMatch[0].length
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const importMatch = remaining.match(RX_TOP_LEVEL_IMPORT)
|
|
158
|
+
if (importMatch && importMatch.index === 0) {
|
|
159
|
+
imports.push(importMatch[0])
|
|
160
|
+
index += importMatch[0].length
|
|
161
|
+
lastImportEnd = index
|
|
162
|
+
} else {
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { imports: imports.join('\n'), body: code.slice(lastImportEnd) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The compiled server block runs from the cache directory, so relative imports
|
|
172
|
+
* must be made absolute. `import(` is included because dynamic imports are the
|
|
173
|
+
* normal way a server action pulls in an API handler.
|
|
174
|
+
*/
|
|
175
|
+
export function rewriteRelativeImports(
|
|
176
|
+
code: string,
|
|
177
|
+
filePath?: string,
|
|
178
|
+
): string {
|
|
179
|
+
if (!filePath) return code
|
|
180
|
+
const dir = fs.dirname(filePath)
|
|
181
|
+
return code.replace(
|
|
182
|
+
/(?:from\s+|import\s+|import\()(['"]\.[^'"]+['"])/g,
|
|
183
|
+
(match, quotePath: string) => {
|
|
184
|
+
const rel = quotePath.slice(1, -1)
|
|
185
|
+
const abs = fs.resolve(dir, rel).replace(/\\/g, '/')
|
|
186
|
+
return match.replace(quotePath, `'${abs}'`)
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find where the expression starting at `start` ends. Tracks bracket depth and
|
|
193
|
+
* skips strings/comments, so a multi-line object, function, or arrow body
|
|
194
|
+
* survives intact — stopping at the first newline breaks all three.
|
|
195
|
+
*/
|
|
196
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner — brace and quote state machine
|
|
197
|
+
function findExpressionEnd(code: string, start: number): number {
|
|
198
|
+
let depth = 0
|
|
199
|
+
let index = start
|
|
200
|
+
let sawBlock = false
|
|
201
|
+
let lastMeaningful = ''
|
|
202
|
+
const startsWithFunction = /^\s*(?:async\s+)?function\b/.test(
|
|
203
|
+
code.slice(start),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
while (index < code.length) {
|
|
207
|
+
const char = code[index]
|
|
208
|
+
const next = code[index + 1]
|
|
209
|
+
|
|
210
|
+
if (char === '/' && next === '/') {
|
|
211
|
+
const nl = code.indexOf('\n', index)
|
|
212
|
+
index = nl === -1 ? code.length : nl
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (char === '/' && next === '*') {
|
|
217
|
+
const end = code.indexOf('*/', index + 2)
|
|
218
|
+
index = end === -1 ? code.length : end + 2
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
223
|
+
index += 1
|
|
224
|
+
while (index < code.length) {
|
|
225
|
+
if (code[index] === '\\') {
|
|
226
|
+
index += 2
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (code[index] === char) break
|
|
230
|
+
index += 1
|
|
231
|
+
}
|
|
232
|
+
index += 1
|
|
233
|
+
lastMeaningful = char
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (char === '(' || char === '[' || char === '{') {
|
|
238
|
+
depth += 1
|
|
239
|
+
index += 1
|
|
240
|
+
lastMeaningful = char
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (char === ')' || char === ']' || char === '}') {
|
|
245
|
+
depth -= 1
|
|
246
|
+
if (depth <= 0 && char === '}') sawBlock = true
|
|
247
|
+
index += 1
|
|
248
|
+
lastMeaningful = char
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (char === ';' && depth <= 0) return index
|
|
253
|
+
|
|
254
|
+
if (char === '\n' && depth <= 0) {
|
|
255
|
+
// ASI: only end the expression here if it already looks complete.
|
|
256
|
+
const incomplete =
|
|
257
|
+
lastMeaningful === '' ||
|
|
258
|
+
'+-*/%=&|^<>,?:.({['.includes(lastMeaningful) ||
|
|
259
|
+
(startsWithFunction && !sawBlock)
|
|
260
|
+
if (!incomplete) return index
|
|
261
|
+
index += 1
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!/\s/.test(char)) lastMeaningful = char
|
|
266
|
+
index += 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return code.length
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Rewrite `export default <expr>` into a plain const. Evaluating it at the end
|
|
274
|
+
* of the wrapper rather than inline puts value- and function-form defaults on
|
|
275
|
+
* one code path and lets middleware gate both.
|
|
276
|
+
*/
|
|
277
|
+
function rewriteDefaultExport(body: string): {
|
|
278
|
+
code: string
|
|
279
|
+
hasDefault: boolean
|
|
280
|
+
} {
|
|
281
|
+
const match = body.match(/\bexport\s+default\s+/)
|
|
282
|
+
if (!match || match.index === undefined) {
|
|
283
|
+
return { code: body, hasDefault: false }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const exprStart = match.index + match[0].length
|
|
287
|
+
const exprEnd = findExpressionEnd(body, exprStart)
|
|
288
|
+
const expr = body.slice(exprStart, exprEnd)
|
|
289
|
+
|
|
290
|
+
const code =
|
|
291
|
+
body.slice(0, match.index) +
|
|
292
|
+
`const ${INTERNAL_PREFIX}defaultVal = ${expr};` +
|
|
293
|
+
body.slice(exprEnd)
|
|
294
|
+
|
|
295
|
+
return { code, hasDefault: true }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function collectExportedFunctionNames(script: string): string[] {
|
|
299
|
+
const names: string[] = []
|
|
300
|
+
for (const m of script.matchAll(RX_EXPORT_FUNCTION)) names.push(m[1])
|
|
301
|
+
for (const m of script.matchAll(RX_EXPORT_CALLABLE_CONST)) names.push(m[1])
|
|
302
|
+
return [...new Set(names)]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function compileServerBlock(
|
|
306
|
+
serverScript: string,
|
|
307
|
+
filePath?: string,
|
|
308
|
+
): string {
|
|
309
|
+
let script = rewriteRelativeImports(serverScript, filePath)
|
|
310
|
+
|
|
311
|
+
script = script
|
|
312
|
+
.replace(/\bexport\s+type\s+\w+\s*=[\s\S]*?;/g, '')
|
|
313
|
+
.replace(/\bexport\s+interface\s+\w+[\s\S]*?\}/g, '')
|
|
314
|
+
|
|
315
|
+
const { imports, body } = extractImportsAndBody(script)
|
|
316
|
+
|
|
317
|
+
const exportedFnNames = collectExportedFunctionNames(body)
|
|
318
|
+
const { code: withDefault, hasDefault } = rewriteDefaultExport(body)
|
|
319
|
+
let compiledBody = withDefault
|
|
320
|
+
|
|
321
|
+
compiledBody = compiledBody.replace(
|
|
322
|
+
/\bexport\s+(?=(?:async\s+)?function\b)/g,
|
|
323
|
+
'',
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
compiledBody = compiledBody.replace(
|
|
327
|
+
/\bexport\s+(const|let|var)\s+\{([^}]+)\}\s*=\s*([\s\S]+?)(?:;|\n|$)/g,
|
|
328
|
+
(_, kind, dest, expr) => {
|
|
329
|
+
const keys = dest
|
|
330
|
+
.split(',')
|
|
331
|
+
.map((k: string) => k.trim())
|
|
332
|
+
.filter(Boolean)
|
|
333
|
+
const assignments = keys
|
|
334
|
+
.map((k: string) => `${INTERNAL_PREFIX}result['${k}'] = ${k};`)
|
|
335
|
+
.join(' ')
|
|
336
|
+
return `${kind} { ${dest} } = ${expr};\n${assignments}`
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
compiledBody = compiledBody
|
|
341
|
+
.replace(RX_EXPORT_HANGING, `$1 $2 = ${INTERNAL_PREFIX}result['$2'] = `)
|
|
342
|
+
.replace(RX_EXPORT_BRACE, (_, exportsContent) => {
|
|
343
|
+
const properties = exportsContent
|
|
344
|
+
.split(',')
|
|
345
|
+
.map((part: string) => {
|
|
346
|
+
const trimmed = part.trim()
|
|
347
|
+
if (!trimmed) return ''
|
|
348
|
+
if (trimmed.includes(' as ')) {
|
|
349
|
+
const [local, alias] = trimmed.split(/\s+as\s+/)
|
|
350
|
+
return `${alias}: ${local}`
|
|
351
|
+
}
|
|
352
|
+
return `${trimmed}: ${trimmed}`
|
|
353
|
+
})
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join(', ')
|
|
356
|
+
return `Object.assign(${INTERNAL_PREFIX}result, { ${properties} })`
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const fnAssignments = exportedFnNames
|
|
360
|
+
.map(fn => `try { ${INTERNAL_PREFIX}result['${fn}'] = ${fn}; } catch {}`)
|
|
361
|
+
.join('\n')
|
|
362
|
+
|
|
363
|
+
// Only these names are reachable via `?__vue_action=`; anything else the
|
|
364
|
+
// script exports stays server-side data.
|
|
365
|
+
const actionAllowList = JSON.stringify(
|
|
366
|
+
exportedFnNames.filter(fn => fn !== 'middleware' && fn !== 'default'),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const p = INTERNAL_PREFIX
|
|
370
|
+
|
|
371
|
+
return `
|
|
372
|
+
${imports}
|
|
373
|
+
|
|
374
|
+
const ${p}isResponseLike = (v: any) =>
|
|
375
|
+
v instanceof Response ||
|
|
376
|
+
(v && typeof v === 'object' && 'arrayBuffer' in v && 'size' in v)
|
|
377
|
+
|
|
378
|
+
const ${p}actions = new Set(${actionAllowList})
|
|
379
|
+
|
|
380
|
+
// NOTE: the top-level statements below run before middleware. Middleware can
|
|
381
|
+
// stop the response but cannot stop top-level code from executing — put
|
|
382
|
+
// auth-gated work inside an exported function, not at the top level.
|
|
383
|
+
export default async function ${p}server(req: any, body: any, actionName?: string, actionArgs?: any[]) {
|
|
384
|
+
const ${p}result: any = {}
|
|
385
|
+
|
|
386
|
+
// The allow-list is static — it is this script's own exports — so it can
|
|
387
|
+
// be answered before a line of the component runs. It used to be checked
|
|
388
|
+
// after the body, which meant naming an action that does not exist still
|
|
389
|
+
// executed every top-level statement in the file; with \`__vue_file\`
|
|
390
|
+
// pointing at another component, that was a way to run one page's server
|
|
391
|
+
// block from another page's route.
|
|
392
|
+
//
|
|
393
|
+
// It does sit ahead of middleware, so an unauthenticated caller can now
|
|
394
|
+
// tell an unknown action name from a known one. That is the smaller leak:
|
|
395
|
+
// the alternative is running the file to find out.
|
|
396
|
+
if (actionName && !${p}actions.has(actionName)) {
|
|
397
|
+
return { error: \`Action '\${actionName}' not found\` }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
${compiledBody}
|
|
401
|
+
|
|
402
|
+
${fnAssignments}
|
|
403
|
+
|
|
404
|
+
if (typeof ${p}result['middleware'] === 'function') {
|
|
405
|
+
const ${p}mwRes = await ${p}result['middleware'](req, body);
|
|
406
|
+
if (${p}isResponseLike(${p}mwRes)) return ${p}mwRes;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (actionName) {
|
|
410
|
+
if (!${p}actions.has(actionName) || !Object.hasOwn(${p}result, actionName)) {
|
|
411
|
+
return { error: \`Action '\${actionName}' not found\` }
|
|
412
|
+
}
|
|
413
|
+
const ${p}args = Array.isArray(actionArgs) ? actionArgs : []
|
|
414
|
+
return await ${p}result[actionName](...${p}args)
|
|
415
|
+
}
|
|
416
|
+
${
|
|
417
|
+
hasDefault
|
|
418
|
+
? `
|
|
419
|
+
let ${p}defaultRes: any = ${p}defaultVal;
|
|
420
|
+
if (typeof ${p}defaultRes === 'function') {
|
|
421
|
+
${p}defaultRes = await ${p}defaultRes(req, body);
|
|
422
|
+
}
|
|
423
|
+
if (${p}isResponseLike(${p}defaultRes)) return ${p}defaultRes;
|
|
424
|
+
if (typeof ${p}defaultRes === 'object' && ${p}defaultRes !== null) {
|
|
425
|
+
Object.assign(${p}result, ${p}defaultRes);
|
|
426
|
+
} else if (${p}defaultRes !== undefined) {
|
|
427
|
+
${p}result['default'] = ${p}defaultRes;
|
|
428
|
+
}
|
|
429
|
+
`
|
|
430
|
+
: ''
|
|
431
|
+
}
|
|
432
|
+
return ${p}result
|
|
433
|
+
}
|
|
434
|
+
`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Drop stale compiled versions of the same source so the cache dir stays bounded. */
|
|
438
|
+
async function pruneServerCache(dir: string, id: string, keepName: string) {
|
|
439
|
+
const entries = await Try(nodeReaddir(dir))
|
|
440
|
+
if (!entries) return
|
|
441
|
+
await Promise.all(
|
|
442
|
+
entries
|
|
443
|
+
.filter(
|
|
444
|
+
name =>
|
|
445
|
+
name.startsWith(`${id}_`) &&
|
|
446
|
+
name.endsWith('.ts') &&
|
|
447
|
+
name !== keepName,
|
|
448
|
+
)
|
|
449
|
+
.map(name => Try(nodeUnlink(fs.resolve(dir, name)))),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const inFlightCompiles = new Map<string, Promise<void>>()
|
|
454
|
+
let tempCounter = 0
|
|
455
|
+
|
|
456
|
+
function writeServerModule(
|
|
457
|
+
dir: string,
|
|
458
|
+
cacheName: string,
|
|
459
|
+
id: string,
|
|
460
|
+
script: string,
|
|
461
|
+
filePath?: string,
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
const cacheKey = fs.resolve(dir, cacheName)
|
|
464
|
+
const pending = inFlightCompiles.get(cacheKey)
|
|
465
|
+
if (pending) return pending
|
|
466
|
+
|
|
467
|
+
const task = (async () => {
|
|
468
|
+
await fs.mkdir(dir)
|
|
469
|
+
// Write to a temp path then rename, so a concurrent request can never
|
|
470
|
+
// import a half-written module.
|
|
471
|
+
const tempPath = `${cacheKey}.${process.pid}.${tempCounter++}.tmp`
|
|
472
|
+
await Bun.write(tempPath, compileServerBlock(script, filePath))
|
|
473
|
+
const [renameError] = await Try.catch(nodeRename(tempPath, cacheKey))
|
|
474
|
+
if (renameError) {
|
|
475
|
+
await Bun.write(cacheKey, Bun.file(tempPath))
|
|
476
|
+
await Try(nodeUnlink(tempPath))
|
|
477
|
+
}
|
|
478
|
+
await pruneServerCache(dir, id, cacheName)
|
|
479
|
+
})().finally(() => inFlightCompiles.delete(cacheKey))
|
|
480
|
+
|
|
481
|
+
inFlightCompiles.set(cacheKey, task)
|
|
482
|
+
return task
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function getServerResponse(options: ServerResponseOptions) {
|
|
486
|
+
const { script, id, lastMod, req, body, filePath, actionName, actionArgs } =
|
|
487
|
+
options
|
|
488
|
+
if (!script.trim()) return {}
|
|
489
|
+
|
|
490
|
+
const dir = fs.resolve(cacheDir, 'server')
|
|
491
|
+
const cacheName = `${id}_${lastMod}.ts`
|
|
492
|
+
const cacheKey = fs.resolve(dir, cacheName)
|
|
493
|
+
|
|
494
|
+
if (!fs.exists(cacheKey)) {
|
|
495
|
+
await writeServerModule(dir, cacheName, id, script, filePath)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const importUrl = Bun.pathToFileURL(cacheKey).href
|
|
499
|
+
const [importError, mod] = await Try.catch(import(importUrl))
|
|
500
|
+
const exported = mod?.default
|
|
501
|
+
|
|
502
|
+
if (!exported) {
|
|
503
|
+
logger.log(
|
|
504
|
+
`Failed to load server script for ${id}: ${
|
|
505
|
+
importError?.message || 'no default export'
|
|
506
|
+
}`,
|
|
507
|
+
'error',
|
|
508
|
+
)
|
|
509
|
+
return {}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
513
|
+
try {
|
|
514
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
515
|
+
timer = setTimeout(
|
|
516
|
+
() =>
|
|
517
|
+
reject(
|
|
518
|
+
new Error(
|
|
519
|
+
`Server script execution timed out (${SERVER_SCRIPT_TIMEOUT_MS}ms limit)`,
|
|
520
|
+
),
|
|
521
|
+
),
|
|
522
|
+
SERVER_SCRIPT_TIMEOUT_MS,
|
|
523
|
+
)
|
|
524
|
+
})
|
|
525
|
+
const execPromise = Promise.resolve(
|
|
526
|
+
is.function(exported)
|
|
527
|
+
? exported(req, body, actionName, actionArgs)
|
|
528
|
+
: exported,
|
|
529
|
+
)
|
|
530
|
+
const params = await Promise.race([execPromise, timeoutPromise])
|
|
531
|
+
|
|
532
|
+
if (
|
|
533
|
+
params instanceof Response ||
|
|
534
|
+
(params &&
|
|
535
|
+
typeof params === 'object' &&
|
|
536
|
+
'arrayBuffer' in params &&
|
|
537
|
+
'size' in params)
|
|
538
|
+
) {
|
|
539
|
+
return params
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (actionName !== undefined) {
|
|
543
|
+
return params
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return is.object(params) ? params : {}
|
|
547
|
+
} catch (err: any) {
|
|
548
|
+
logger.log(
|
|
549
|
+
`Server script error in ${id}: ${err?.message || err} at ${req.url}`,
|
|
550
|
+
'error',
|
|
551
|
+
)
|
|
552
|
+
return {}
|
|
553
|
+
} finally {
|
|
554
|
+
clearTimeout(timer)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function rewriteVueImports(code: string): string {
|
|
559
|
+
return code.replace(
|
|
560
|
+
RX_IMPORT_VUE_FILE,
|
|
561
|
+
(match, quote: string) =>
|
|
562
|
+
`${match.slice(0, -quote.length)}${quote.slice(0, -1)}?__vue_script=module${quote[0]}`,
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A leading `<meta … />` directive. Quoted attribute values may contain `>`,
|
|
568
|
+
* so plain `[^>]` would truncate the tag. Anchored: only the prologue counts.
|
|
569
|
+
*/
|
|
570
|
+
export const RX_VUE_META = /^<meta(?![\w-])((?:"[^"]*"|'[^']*'|[^>])*?)\/>/i
|
|
571
|
+
const RX_META_SKIPPABLE = /^\s+|^<!--[\s\S]*?-->/
|
|
572
|
+
|
|
573
|
+
export function parseVueMeta(raw: string): { meta: VueMeta; clean: string } {
|
|
574
|
+
const meta: VueMeta = { moduleOnly: false, pageOnly: false, title: null }
|
|
575
|
+
|
|
576
|
+
// Directives live in the file prologue only. Walking forward from the start
|
|
577
|
+
// (rather than scanning the whole file) keeps a `<meta />` inside a template
|
|
578
|
+
// or a script string from being treated as a directive.
|
|
579
|
+
let index = 0
|
|
580
|
+
let prologue = ''
|
|
581
|
+
|
|
582
|
+
while (index < raw.length) {
|
|
583
|
+
const rest = raw.slice(index)
|
|
584
|
+
|
|
585
|
+
const skippable = rest.match(RX_META_SKIPPABLE)
|
|
586
|
+
if (skippable) {
|
|
587
|
+
prologue += skippable[0]
|
|
588
|
+
index += skippable[0].length
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const tag = rest.match(RX_VUE_META)
|
|
593
|
+
if (!tag) break
|
|
594
|
+
|
|
595
|
+
const attrs = tag[1]
|
|
596
|
+
if (/\bmodule-only\b/i.test(attrs)) meta.moduleOnly = true
|
|
597
|
+
if (/\bpage-only\b/i.test(attrs)) meta.pageOnly = true
|
|
598
|
+
|
|
599
|
+
const titleMatch = attrs.match(
|
|
600
|
+
/\btitle\s*=\s*"([^"]*)"|\btitle\s*=\s*'([^']*)'/i,
|
|
601
|
+
)
|
|
602
|
+
if (titleMatch) meta.title = titleMatch[1] ?? titleMatch[2]
|
|
603
|
+
|
|
604
|
+
index += tag[0].length
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return { meta, clean: prologue + raw.slice(index) }
|
|
608
|
+
}
|