@bakery-framework/plugin-vue 2.0.0-alpha.9 → 2.0.0-rc.2
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/README.md +6 -6
- package/package.json +3 -3
- package/src/actions.ts +3 -3
- package/src/chunks.ts +7 -2
- package/src/client.ts +19 -17
- package/src/compile.ts +77 -16
- package/src/handler.ts +142 -31
- package/src/index.ts +29 -4
- package/src/server-context.ts +37 -0
- package/src/setup.ts +1 -1
- package/src/types.d.ts +3 -3
- package/src/utils.ts +15 -10
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Vue single-file components for
|
|
|
7
7
|
bun add @bakery-framework/plugin-vue vue
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
`vue` and `@vue/compiler-sfc` are peer dependencies
|
|
10
|
+
`vue` and `@vue/compiler-sfc` are peer dependencies: installing `vue` brings
|
|
11
11
|
the compiler with it.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -28,11 +28,11 @@ Options are typed as `VuePluginOptions`.
|
|
|
28
28
|
|
|
29
29
|
## License
|
|
30
30
|
|
|
31
|
-
MIT with the Commons Clause v1.0
|
|
31
|
+
MIT with the Commons Clause v1.0. See [LICENSE](./LICENSE).
|
|
32
32
|
|
|
33
|
-
**Not an OSI-approved
|
|
34
|
-
the software
|
|
33
|
+
**Not an OSI-approved license.** The Commons Clause removes the right to *sell*
|
|
34
|
+
the software: meaning to charge for a product or service whose value derives
|
|
35
35
|
substantially from it, hosting and support included. Everything else the MIT
|
|
36
|
-
|
|
37
|
-
If your
|
|
36
|
+
license grants is unchanged: use it, modify it, ship it inside your own product.
|
|
37
|
+
If your organization only permits OSI-approved dependencies, this will not pass
|
|
38
38
|
that check.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-vue",
|
|
3
|
-
"version": "2.0.0-
|
|
3
|
+
"version": "2.0.0-rc.2",
|
|
4
4
|
"description": "Bakery vue plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"vue": "^3.5.38"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@bakery-framework/core": "^2.0.0-
|
|
46
|
+
"@bakery-framework/core": "^2.0.0-rc.2"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
|
-
"bun": ">=1.
|
|
49
|
+
"bun": ">=1.4.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/actions.ts
CHANGED
|
@@ -49,8 +49,8 @@ export function validateActionRequest(req: Request, url: URL): Response | null {
|
|
|
49
49
|
/**
|
|
50
50
|
* Gate the component a `__vue_action` request will actually execute.
|
|
51
51
|
*
|
|
52
|
-
* `__vue_file` replaces the whole execution target
|
|
53
|
-
* script
|
|
52
|
+
* `__vue_file` replaces the whole execution target (id, path and server
|
|
53
|
+
* script), so every check that ran against the *route's* descriptor stopped
|
|
54
54
|
* describing the code that runs. The route was gated; the target was not, and
|
|
55
55
|
* `POST /any-public-page?__vue_action=x&__vue_file=admin/Panel.vue` ran
|
|
56
56
|
* `admin/Panel.vue`'s top-level server code with nothing to justify it.
|
|
@@ -65,7 +65,7 @@ export function validateActionTarget(
|
|
|
65
65
|
): Response | null {
|
|
66
66
|
// A `page-only` component is a page: reachable at its own route and nowhere
|
|
67
67
|
// else. Naming one in `__vue_file` from a *different* route is the bypass in
|
|
68
|
-
// its clearest form. `module-only` is deliberately not gated the same way
|
|
68
|
+
// its clearest form. `module-only` is deliberately not gated the same way:
|
|
69
69
|
// a module exists to be embedded in someone else's page, so its actions are
|
|
70
70
|
// invoked from that page's route by design, and the generated stub does
|
|
71
71
|
// exactly that.
|
package/src/chunks.ts
CHANGED
|
@@ -21,7 +21,7 @@ const VUE_ENTRIES = {
|
|
|
21
21
|
} as const
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* The one canonical URL for the served Vue build
|
|
24
|
+
* The one canonical URL for the served Vue build: the import-map alias
|
|
25
25
|
* (`setup.ts`) and the request check below both read it, so they cannot drift.
|
|
26
26
|
*
|
|
27
27
|
* The variant is part of the filename, not just of the entry choice, because
|
|
@@ -66,7 +66,12 @@ export async function serveVueChunk(
|
|
|
66
66
|
entrypoints: [sourcePath],
|
|
67
67
|
target: 'browser',
|
|
68
68
|
format: 'esm',
|
|
69
|
-
|
|
69
|
+
// `Boolean(...)`, not the flag directly: the mode flags are `'1'`/`''`
|
|
70
|
+
// strings (`core/init.ts`), and `Bun.build` **rejects** a non-boolean
|
|
71
|
+
// `minify` rather than coercing it. Core's `compiler.ts` already wrapped
|
|
72
|
+
// its three; this one did not, and the build would have thrown on every
|
|
73
|
+
// production SFC chunk.
|
|
74
|
+
minify: Boolean(import.meta.env.PROD),
|
|
70
75
|
define: BUNDLE_DEFINES,
|
|
71
76
|
})
|
|
72
77
|
|
package/src/client.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `defineLayout()
|
|
2
|
+
* `defineLayout()`: browser-side navigation for catch-all pages.
|
|
3
3
|
*
|
|
4
4
|
* A catch-all page (`[...slug].vue`, `[...slug!].vue`) owns every URL under
|
|
5
5
|
* its directory: whatever the path, the server serves the same file. That
|
|
6
|
-
* invariant is what makes client-side navigation *safe* here
|
|
6
|
+
* invariant is what makes client-side navigation *safe* here: swapping
|
|
7
7
|
* content on a URL change can never disagree with what a hard reload would
|
|
8
|
-
* serve
|
|
8
|
+
* serve, and it is why this API is only available on catch-all pages: on any
|
|
9
9
|
* other page, two URLs mean two different files, and intercepting the
|
|
10
10
|
* navigation would show the wrong one. The server stamps the route's shape
|
|
11
11
|
* into `globalThis.__vue_route`; `defineLayout()` throws without it.
|
|
12
12
|
*
|
|
13
13
|
* The page becomes its subtree's layout: it reads `segments` and renders
|
|
14
|
-
* whichever of its own components the path means
|
|
14
|
+
* whichever of its own components the path means, no `<slot />`, no extra
|
|
15
15
|
* file. Clicks on same-origin links under the base are intercepted and become
|
|
16
16
|
* a `pushState` plus a reactive update; links that leave the base navigate
|
|
17
17
|
* normally; back/forward is handled the same way, falling back to a real
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { type Ref, ref } from 'vue'
|
|
25
25
|
|
|
26
|
-
/** What the server stamps on the page
|
|
26
|
+
/** What the server stamps on the page. See `handleHtml` in `handler.ts`. */
|
|
27
27
|
type StampedRoute = {
|
|
28
28
|
catchAll: boolean
|
|
29
29
|
/** URL prefix owned by the page: '' for a root catch-all, else '/admin'. */
|
|
@@ -31,9 +31,11 @@ type StampedRoute = {
|
|
|
31
31
|
/** The catch-all's param name (`slug` in `[...slug!]`). */
|
|
32
32
|
param: string | null
|
|
33
33
|
/**
|
|
34
|
-
* First path segments the catch-all's *
|
|
35
|
-
* `faculty/[id].vue` sits beside the catch-all. Those URLs belong to
|
|
36
|
-
* specific routes, so they get real navigations, not soft ones.
|
|
34
|
+
* First path segments the catch-all's sibling *routes* claim: `faculty`
|
|
35
|
+
* when `faculty/[id].vue` sits beside the catch-all. Those URLs belong to
|
|
36
|
+
* more specific routes, so they get real navigations, not soft ones. Route
|
|
37
|
+
* names only, and only on catch-all pages: the stamp is readable by every
|
|
38
|
+
* visitor, so it must never be a directory listing.
|
|
37
39
|
*/
|
|
38
40
|
claimed?: string[]
|
|
39
41
|
/** True when a `[param]` sibling claims every single-segment path. */
|
|
@@ -41,16 +43,16 @@ type StampedRoute = {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export type LayoutNavigation = {
|
|
44
|
-
/** Path segments under the base
|
|
46
|
+
/** Path segments under the base: `[]` on the bare directory. */
|
|
45
47
|
readonly segments: Ref<string[]>
|
|
46
48
|
/** The URL prefix this page owns. */
|
|
47
49
|
readonly base: string
|
|
48
50
|
/** Navigate within the subtree; segments or a path, `/`-prefixed or not. */
|
|
49
51
|
navigate(to: string | string[]): void
|
|
50
52
|
/**
|
|
51
|
-
* Listen for navigations. Return `false` from a listener to cancel one
|
|
53
|
+
* Listen for navigations. Return `false` from a listener to cancel one:
|
|
52
54
|
* cancellation applies to clicks and `navigate()`; back/forward cannot be
|
|
53
|
-
*
|
|
55
|
+
* canceled, only observed, because the history entry has already moved.
|
|
54
56
|
*/
|
|
55
57
|
on(listener: LayoutListener): () => void
|
|
56
58
|
}
|
|
@@ -67,7 +69,7 @@ export function isUnderBase(base: string, path: string): boolean {
|
|
|
67
69
|
return path === base || path.startsWith(`${base}/`)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
/** The segments of `path` below `base
|
|
72
|
+
/** The segments of `path` below `base`: `[]` for the base itself. */
|
|
71
73
|
export function segmentsUnder(base: string, path: string): string[] {
|
|
72
74
|
const rest = base === '' ? path : path.slice(base.length)
|
|
73
75
|
return rest.split('/').filter(Boolean)
|
|
@@ -85,7 +87,7 @@ function pathFor(base: string, to: string | string[]): string {
|
|
|
85
87
|
export function defineLayout(): LayoutNavigation {
|
|
86
88
|
const route = (globalThis as any).__vue_route as StampedRoute | undefined
|
|
87
89
|
|
|
88
|
-
// The guard is the contract, not a formality
|
|
90
|
+
// The guard is the contract, not a formality. See the module comment.
|
|
89
91
|
if (!route?.catchAll) {
|
|
90
92
|
throw new Error(
|
|
91
93
|
'defineLayout() is only available on catch-all pages ' +
|
|
@@ -100,7 +102,7 @@ export function defineLayout(): LayoutNavigation {
|
|
|
100
102
|
const listeners = new Set<LayoutListener>()
|
|
101
103
|
|
|
102
104
|
// The catch-all owns only what nothing else claims. A sibling route under
|
|
103
|
-
// the base
|
|
105
|
+
// the base: `faculty/[id].vue` beside `[...slug].vue`: wins those URLs on
|
|
104
106
|
// the server, so a soft-nav there would render this page where a hard load
|
|
105
107
|
// renders that one.
|
|
106
108
|
function claimedElsewhere(next: string[]): boolean {
|
|
@@ -126,14 +128,14 @@ export function defineLayout(): LayoutNavigation {
|
|
|
126
128
|
function go(to: string | string[], cause: 'click' | 'navigate'): void {
|
|
127
129
|
const path = pathFor(base, to)
|
|
128
130
|
if (!isUnderBase(base, path)) {
|
|
129
|
-
// Leaving the subtree is a real navigation
|
|
131
|
+
// Leaving the subtree is a real navigation: the next URL belongs to a
|
|
130
132
|
// different file, and pretending otherwise would render a lie.
|
|
131
133
|
if (typeof location !== 'undefined') location.href = path
|
|
132
134
|
return
|
|
133
135
|
}
|
|
134
136
|
const next = segmentsUnder(base, path)
|
|
135
137
|
if (claimedElsewhere(next)) {
|
|
136
|
-
// Under the base, but a more specific route's territory
|
|
138
|
+
// Under the base, but a more specific route's territory: real
|
|
137
139
|
// navigation, same reasoning as leaving the base.
|
|
138
140
|
if (typeof location !== 'undefined') location.href = path
|
|
139
141
|
return
|
|
@@ -186,7 +188,7 @@ export function defineLayout(): LayoutNavigation {
|
|
|
186
188
|
location.reload()
|
|
187
189
|
return
|
|
188
190
|
}
|
|
189
|
-
fire(next, 'history') // observable, not cancellable
|
|
191
|
+
fire(next, 'history') // observable, not cancellable. See `on`
|
|
190
192
|
segments.value = next
|
|
191
193
|
})
|
|
192
194
|
}
|
package/src/compile.ts
CHANGED
|
@@ -14,17 +14,49 @@ import type {
|
|
|
14
14
|
VuePluginOptions,
|
|
15
15
|
} from './types'
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The **promise**, not the module.
|
|
19
|
+
*
|
|
20
|
+
* `compiler` was assigned after the await, so two requests arriving before the
|
|
21
|
+
* first load finished each started their own `await import()`. Bun's module
|
|
22
|
+
* cache made that harmless rather than correct; memoizing the promise is what
|
|
23
|
+
* actually makes it one load, and it is what lets `preloadCompiler()` below
|
|
24
|
+
* overlap with the rest of boot while a request that beats it simply awaits
|
|
25
|
+
* the same thing.
|
|
26
|
+
*/
|
|
27
|
+
let compilerLoad: Promise<typeof import('@vue/compiler-sfc')> | null = null
|
|
18
28
|
|
|
19
29
|
async function loadCompiler() {
|
|
20
|
-
if (!
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
if (!compilerLoad) {
|
|
31
|
+
compilerLoad = import('@vue/compiler-sfc').catch(() => {
|
|
32
|
+
// Cleared so a later attempt can retry - a package installed while the
|
|
33
|
+
// dev server is running should not need a restart to be found.
|
|
34
|
+
compilerLoad = null
|
|
24
35
|
throw new Error('compiler-sfc not available. Run `bun add vue`.')
|
|
25
|
-
}
|
|
36
|
+
})
|
|
26
37
|
}
|
|
27
|
-
return
|
|
38
|
+
return await compilerLoad
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start loading the compiler now rather than on the first `.vue` request.
|
|
43
|
+
*
|
|
44
|
+
* `@vue/compiler-sfc` is a large module: measured from this repo's example
|
|
45
|
+
* app, **168-173 ms warm and 1,848 ms on a cold filesystem**, and every server
|
|
46
|
+
* process paid it inside the first Vue page it served. Called from the
|
|
47
|
+
* plugin's `onStart`, where it overlaps with the rest of boot.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately not awaited by the caller and deliberately not fatal. An app
|
|
50
|
+
* can register this plugin before it has written a single `.vue` file, and
|
|
51
|
+
* refusing to boot over a package it does not need yet would be worse than
|
|
52
|
+
* the clear error the first request already gets. The rejection is swallowed
|
|
53
|
+
* here and `loadCompiler` raises it properly when something actually needs
|
|
54
|
+
* the compiler.
|
|
55
|
+
*/
|
|
56
|
+
export function preloadCompiler(): void {
|
|
57
|
+
void loadCompiler().catch(() => {
|
|
58
|
+
// See above: absence is reported at the point of use, not at boot.
|
|
59
|
+
})
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
let vuePluginOptions: VuePluginOptions = {}
|
|
@@ -137,7 +169,7 @@ export async function compileTemplateBlock(
|
|
|
137
169
|
// Simple expression: `{{ value }}`.
|
|
138
170
|
//
|
|
139
171
|
// Comments inside an interpolation are not handled specially,
|
|
140
|
-
// deliberately: Vue itself cannot parse `{{ total // pesos }}
|
|
172
|
+
// deliberately: Vue itself cannot parse `{{ total // pesos }}`,
|
|
141
173
|
// measured against bare `compileTemplate`, which reports the same
|
|
142
174
|
// SyntaxError and emits the raw broken expression. Stripping the
|
|
143
175
|
// comment here just traded that for an unprefixed binding, because
|
|
@@ -166,13 +198,30 @@ export async function compileTemplateBlock(
|
|
|
166
198
|
|
|
167
199
|
// Reported to the caller, not merely logged: a template Vue cannot compile
|
|
168
200
|
// emits the raw unparseable expression into the render function, so the
|
|
169
|
-
// module *cannot run
|
|
201
|
+
// module *cannot run*, `{{ total // pesos }}` is the measured example, and
|
|
170
202
|
// Vue-alone behaves identically. Serving it anyway was a browser-side
|
|
171
203
|
// SyntaxError with a healthy-looking 200.
|
|
172
204
|
const errors = (result.errors ?? []).map(e =>
|
|
173
205
|
e instanceof Error ? e.message : String(e),
|
|
174
206
|
)
|
|
175
207
|
|
|
208
|
+
// `export function render` becomes a local `function render`, which
|
|
209
|
+
// `assembleComponent` then hangs off `__sfc__.render`.
|
|
210
|
+
//
|
|
211
|
+
// **This must stay above `compileText`, and the anchor is why.** The
|
|
212
|
+
// template compiler emits multi-line source with the export at a line
|
|
213
|
+
// start, so `^` matches. `compileText` minifies to a single line where the
|
|
214
|
+
// export lands after the `import{…}from"vue";` prologue, and `^` cannot
|
|
215
|
+
// reach it there. Measured over four render shapes: correct on all four
|
|
216
|
+
// before minification, wrong on the one real shape after. Swapping these
|
|
217
|
+
// two lines therefore breaks silently: the bundle still builds, still
|
|
218
|
+
// serves 200, and ships a module with a stray `export` in the middle of it.
|
|
219
|
+
//
|
|
220
|
+
// A regex is the wrong tool for this and no spelling fixes it. An
|
|
221
|
+
// anchor-free `\bexport\s+(?=function\b)` handles the minified shape and
|
|
222
|
+
// then eats the words out of a string literal that contains them. The
|
|
223
|
+
// trade was measured rather than argued; the ordering is the cheaper
|
|
224
|
+
// guarantee, so it is the one written down.
|
|
176
225
|
let code = result.code
|
|
177
226
|
code = code.replace(/^export\s+/m, '')
|
|
178
227
|
code = await compileText(code)
|
|
@@ -182,8 +231,8 @@ export async function compileTemplateBlock(
|
|
|
182
231
|
export async function compileStyleBlock(
|
|
183
232
|
options: CompileStyleOptions,
|
|
184
233
|
): Promise<SFCStyleCompileResults> {
|
|
185
|
-
await loadCompiler()
|
|
186
|
-
return
|
|
234
|
+
const sfc = await loadCompiler()
|
|
235
|
+
return sfc.compileStyle({
|
|
187
236
|
source: options.style.content,
|
|
188
237
|
filename: 'style.css',
|
|
189
238
|
id: options.id,
|
|
@@ -201,8 +250,20 @@ export function assembleComponent(options: AssembleComponentOptions): string {
|
|
|
201
250
|
)
|
|
202
251
|
|
|
203
252
|
if (renderCode) {
|
|
204
|
-
|
|
205
|
-
|
|
253
|
+
// No export strip here, deliberately. `compileTemplateBlock` has already
|
|
254
|
+
// removed it, above minification where the removal works, and repeating
|
|
255
|
+
// it on this input is a no-op that *reads* as a backstop.
|
|
256
|
+
//
|
|
257
|
+
// The line that used to sit here was `/^export\s+/m` over
|
|
258
|
+
// already-minified code. Measured against four render shapes it fired on
|
|
259
|
+
// three and missed the fourth, and the fourth is the only one this
|
|
260
|
+
// function is ever handed: minified, with the export sitting after the
|
|
261
|
+
// import prologue where `^` cannot see it. A guard that cannot fire on
|
|
262
|
+
// its own input is worse than none, because the next reader counts on it.
|
|
263
|
+
//
|
|
264
|
+
// Three lines above is what the right spelling looks like when the input
|
|
265
|
+
// may be minified: `\b` and no anchor.
|
|
266
|
+
output += `\n${renderCode}\n${COMPONENT_VAR}.render = render;`
|
|
206
267
|
}
|
|
207
268
|
|
|
208
269
|
if (scopeId) {
|
|
@@ -215,7 +276,7 @@ export function assembleComponent(options: AssembleComponentOptions): string {
|
|
|
215
276
|
if (layoutRoute) {
|
|
216
277
|
// The page renders into the layout's default <slot />. The import is a
|
|
217
278
|
// plain `.vue` specifier so `rewriteVueImports` gives it the same
|
|
218
|
-
// `?__vue_script=module` treatment as any component import
|
|
279
|
+
// `?__vue_script=module` treatment as any component import: the layout
|
|
219
280
|
// is just a component that happens to be discovered by convention.
|
|
220
281
|
output +=
|
|
221
282
|
`\nimport { createApp, h as __h } from 'vue';` +
|
|
@@ -228,7 +289,7 @@ export function assembleComponent(options: AssembleComponentOptions): string {
|
|
|
228
289
|
}
|
|
229
290
|
|
|
230
291
|
// Full build only. `app.config.compilerOptions` is read exclusively by the
|
|
231
|
-
// in-browser template compiler, which the runtime build does not carry
|
|
292
|
+
// in-browser template compiler, which the runtime build does not carry:
|
|
232
293
|
// there, the assignment does nothing except make Vue log a warning about
|
|
233
294
|
// itself on every page, even for apps that configured nothing.
|
|
234
295
|
if (vueBuildVariant() === 'full') {
|
|
@@ -246,7 +307,7 @@ export function assembleComponent(options: AssembleComponentOptions): string {
|
|
|
246
307
|
/**
|
|
247
308
|
* Mirror `resolveIsCustomElement` in the browser, so runtime-compiled templates
|
|
248
309
|
* agree with what the SFC compiler did. A predicate that closes over server-side
|
|
249
|
-
* state cannot be serialized
|
|
310
|
+
* state cannot be serialized: it falls back to the built-in tag.
|
|
250
311
|
*/
|
|
251
312
|
function buildRuntimeCustomElementCheck(): string {
|
|
252
313
|
const ce = vuePluginOptions.customElements
|
package/src/handler.ts
CHANGED
|
@@ -57,7 +57,7 @@ const RX_SERVER_DATA_TOKEN = new RegExp(`\\b${VUE_SERVER_DATA_TOKEN}\\b`, 'g')
|
|
|
57
57
|
/**
|
|
58
58
|
* Compiled module code with the server-data token still in place. Components
|
|
59
59
|
* with a `<script server>` block get per-request data, so the code cannot be
|
|
60
|
-
* cached on disk with the data baked in
|
|
60
|
+
* cached on disk with the data baked in, but the expensive compile can be.
|
|
61
61
|
*/
|
|
62
62
|
const tokenizedModuleCache = new LRUCache<
|
|
63
63
|
string,
|
|
@@ -84,10 +84,10 @@ function buildActionStub(fn: string, relPath: string) {
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Route path of the nearest `layout.vue`, walking from the page **file** up
|
|
87
|
-
* to the serve root
|
|
87
|
+
* to the serve root, the file, not the URL, so a catch-all page
|
|
88
88
|
* (`admin/[...slug!].vue`) is wrapped by `admin/layout.vue` no matter how
|
|
89
89
|
* deep the request path goes. Null when nothing is found, when the page
|
|
90
|
-
* opted out with `<meta no-layout />`, or when the page *is* a layout
|
|
90
|
+
* opted out with `<meta no-layout />`, or when the page *is* a layout:
|
|
91
91
|
* layouts do not nest in v1, deliberately: nesting needs an ordering story
|
|
92
92
|
* (which slot, whose styles win) that should be designed, not implied.
|
|
93
93
|
*/
|
|
@@ -113,42 +113,135 @@ function findLayoutRoute(filePath: string, meta: VueMeta): string | null {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
116
|
+
* Does `dir` hold a file this handler routes, at any depth? A sibling
|
|
117
|
+
* directory claims its first segment only when it does: the claim exists for
|
|
118
|
+
* `faculty/[id].vue`-shaped subtrees, and a directory of assets or helpers
|
|
119
|
+
* routes nowhere more specific than the catch-all, so stamping its name would
|
|
120
|
+
* disclose it for no navigational gain. Files at each level are checked
|
|
121
|
+
* before any subdirectory is entered, so the common shallow layout answers
|
|
122
|
+
* without recursing. `Dirent.isDirectory()` is false for symlinks, which is
|
|
123
|
+
* what keeps the walk from cycling.
|
|
124
|
+
*/
|
|
125
|
+
function containsRouteFile(dir: string, exts: string[]): boolean {
|
|
126
|
+
let entries: import('node:fs').Dirent[]
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
129
|
+
} catch {
|
|
130
|
+
// Unreadable: treated as holding no routes, same reasoning as the catch
|
|
131
|
+
// in claimedBeside below.
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dirs: string[] = []
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (entry.name.startsWith('.')) continue
|
|
138
|
+
if (entry.isDirectory()) dirs.push(`${dir}/${entry.name}`)
|
|
139
|
+
else if (exts.some(ext => entry.name.endsWith(ext))) return true
|
|
140
|
+
}
|
|
141
|
+
return dirs.some(sub => containsRouteFile(sub, exts))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What the catch-all's sibling *routes* claim, for the `defineLayout()` stamp.
|
|
117
146
|
*
|
|
118
147
|
* A catch-all owns only *what nothing else claims*: with
|
|
119
148
|
* `admin/[...slug].vue` beside `admin/faculty/[id].vue`, the URL
|
|
120
|
-
* `/admin/faculty/7` is under the base but belongs to `[id].vue
|
|
149
|
+
* `/admin/faculty/7` is under the base but belongs to `[id].vue`, so the
|
|
121
150
|
* client-side router must yield it to a real navigation, or a soft-nav shows
|
|
122
151
|
* the catch-all's rendering where a hard reload shows a different page.
|
|
123
152
|
*
|
|
124
153
|
* First-level granularity is exactly the server's precedence boundary: every
|
|
125
|
-
* more-specific route
|
|
126
|
-
* catch-all
|
|
154
|
+
* more-specific route (an exact sibling file, a child index, a deeper
|
|
155
|
+
* catch-all) lives inside some sibling entry, so excluding the entry
|
|
127
156
|
* excludes the whole claim. A `[param]` sibling claims *every* single-segment
|
|
128
157
|
* path, which is what `claimedSingle` carries. `layout.vue` claims nothing (it
|
|
129
158
|
* is not routable), and the catch-all file itself is the page being served.
|
|
130
159
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
160
|
+
* Only entries the handler's own extension table routes are claims. The stamp
|
|
161
|
+
* is serialized into the HTML of every served page, so each name in it is
|
|
162
|
+
* published to any visitor, and this function used to list *every* sibling
|
|
163
|
+
* stem, which put non-route file names (`sample.bin`, `script.ts`,
|
|
164
|
+
* `index.tsx`) from the source directory into production responses: a
|
|
165
|
+
* directory listing of `src/`, observed in a smoke test of the published
|
|
166
|
+
* alpha. A file sibling therefore counts only with a routed extension, and a
|
|
167
|
+
* directory sibling only when a route file exists somewhere under it.
|
|
168
|
+
*
|
|
169
|
+
* The boundary that filter accepts: a *non-route* file under the base is
|
|
170
|
+
* served by core's real-file-beats-catch-all rule (`findDynamicRoute`), and
|
|
171
|
+
* the stamp no longer names it, so a plain anchor to one soft-navigates into
|
|
172
|
+
* the catch-all's view. An anchor carrying `target` or `download` is never
|
|
173
|
+
* intercepted: that is the spelling for linking a raw file out of a
|
|
174
|
+
* catch-all's subtree, and what `docs/plugins/vue.md` prescribes.
|
|
175
|
+
*
|
|
176
|
+
* Computed per page request in development, so a file added or removed is
|
|
177
|
+
* seen on the next load without cache ceremony. **Memoized in production**,
|
|
178
|
+
* where it cannot change: there is no watcher, and the `SIGHUP` handler in
|
|
179
|
+
* `core/init.ts` is a deliberate no-op, so the only way the page tree changes
|
|
180
|
+
* is a restart.
|
|
181
|
+
*
|
|
182
|
+
* Worth the branch because the walk recurses into every sibling directory to
|
|
183
|
+
* ask whether it holds a route file, and it runs on **every** catch-all page
|
|
184
|
+
* request. Measured against directory trees of an app's shape, three rounds
|
|
185
|
+
* each with a CPU-bound control flat at 33 ms:
|
|
186
|
+
*
|
|
187
|
+
* 4 directories, depth 2, 10 claims 0.48 ms
|
|
188
|
+
* 12 directories, depth 3, 24 claims 1.24 ms
|
|
189
|
+
* 30 directories, depth 4, 50 claims 4.44 ms
|
|
190
|
+
*
|
|
191
|
+
* An `LRUCache` rather than a plain map, per convention 6. The key is a
|
|
192
|
+
* resolved disk path that routing produced, not anything a client sends, so
|
|
193
|
+
* it is bounded by the app's own catch-all count either way - but the bound
|
|
194
|
+
* is stated rather than argued.
|
|
195
|
+
*/
|
|
196
|
+
const claimedCache = new LRUCache<
|
|
197
|
+
string,
|
|
198
|
+
{ claimed: string[]; claimedSingle: boolean }
|
|
199
|
+
>(100)
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Test seam (convention 9).
|
|
203
|
+
*
|
|
204
|
+
* **A test process reports `PROD === '1'`**, so this memo is *on* by default
|
|
205
|
+
* under `bun test`: a test that writes into a page directory between two
|
|
206
|
+
* calls is running against the production behavior whether it meant to or
|
|
207
|
+
* not. Clear it between such calls, or ask for development explicitly with
|
|
208
|
+
* `withEnvFlag('PROD', false, …)`.
|
|
133
209
|
*/
|
|
210
|
+
export function __resetClaimedCache(): void {
|
|
211
|
+
claimedCache.clear()
|
|
212
|
+
}
|
|
213
|
+
|
|
134
214
|
export function claimedBeside(catchAllFile: string): {
|
|
135
215
|
claimed: string[]
|
|
136
216
|
claimedSingle: boolean
|
|
137
217
|
} {
|
|
218
|
+
const resolved = fs.resolve(catchAllFile)
|
|
219
|
+
const memoised = import.meta.env.PROD === '1'
|
|
220
|
+
if (memoised) {
|
|
221
|
+
const hit = claimedCache.get(resolved)
|
|
222
|
+
if (hit) return hit
|
|
223
|
+
}
|
|
224
|
+
|
|
138
225
|
const claimed = new Set<string>()
|
|
139
226
|
let claimedSingle = false
|
|
140
227
|
|
|
141
|
-
const dir =
|
|
142
|
-
const self =
|
|
228
|
+
const dir = resolved.replace(/\/[^/]*$/, '')
|
|
229
|
+
const self = resolved.slice(dir.length + 1)
|
|
230
|
+
// The handler's own table (`['vue']`), read at call time so the two cannot
|
|
231
|
+
// drift apart.
|
|
232
|
+
const exts = VueHandler.config.ext.map(ext => `.${ext}`)
|
|
143
233
|
|
|
144
234
|
let entries: import('node:fs').Dirent[]
|
|
145
235
|
try {
|
|
146
236
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
147
237
|
} catch {
|
|
148
238
|
// Unreadable directory: no visible siblings means nothing extra claimed,
|
|
149
|
-
// and a hard load still routes correctly
|
|
150
|
-
// of honesty, not the source of it.
|
|
151
|
-
|
|
239
|
+
// and a hard load still routes correctly. The stamp is an optimization
|
|
240
|
+
// of honesty, not the source of it. Remembered like any other answer, so
|
|
241
|
+
// an unreadable directory does not re-throw on every request.
|
|
242
|
+
const empty = { claimed: [], claimedSingle: false }
|
|
243
|
+
if (memoised) claimedCache.set(resolved, empty)
|
|
244
|
+
return empty
|
|
152
245
|
}
|
|
153
246
|
|
|
154
247
|
for (const entry of entries) {
|
|
@@ -157,19 +250,27 @@ export function claimedBeside(catchAllFile: string): {
|
|
|
157
250
|
if (name.startsWith('.')) continue
|
|
158
251
|
|
|
159
252
|
if (RX_CATCHALL.test(name) || RX_OPT_CATCHALL.test(name)) continue
|
|
253
|
+
|
|
254
|
+
const isRoute = entry.isDirectory()
|
|
255
|
+
? containsRouteFile(`${dir}/${name}`, exts)
|
|
256
|
+
: exts.some(ext => name.endsWith(ext))
|
|
257
|
+
if (!isRoute) continue
|
|
258
|
+
|
|
160
259
|
if (RX_DYNAMIC.test(name)) {
|
|
161
260
|
claimedSingle = true
|
|
162
261
|
continue
|
|
163
262
|
}
|
|
164
263
|
|
|
165
264
|
claimed.add(name)
|
|
166
|
-
// `reports.vue` also claims `/base/reports
|
|
265
|
+
// `reports.vue` also claims `/base/reports`: the extensionless spelling
|
|
167
266
|
// is the one links actually use.
|
|
168
267
|
const stem = name.replace(/\.[^.]+$/, '')
|
|
169
268
|
if (stem && stem !== name) claimed.add(stem)
|
|
170
269
|
}
|
|
171
270
|
|
|
172
|
-
|
|
271
|
+
const result = { claimed: [...claimed], claimedSingle }
|
|
272
|
+
if (memoised) claimedCache.set(resolved, result)
|
|
273
|
+
return result
|
|
173
274
|
}
|
|
174
275
|
|
|
175
276
|
export class VueHandler extends DynamicHandler {
|
|
@@ -254,11 +355,11 @@ export class VueHandler extends DynamicHandler {
|
|
|
254
355
|
// render blank: the injected block above has no `export default`, so
|
|
255
356
|
// `assembleComponent` had nothing to rewrite into `const __sfc__ =`
|
|
256
357
|
// and the module died with `ReferenceError: __sfc__ is not defined`.
|
|
257
|
-
// The documented workaround was a setup block
|
|
358
|
+
// The documented workaround was a setup block, so inject one. The
|
|
258
359
|
// comment inside is load-bearing: the SFC parser *discards* a block
|
|
259
360
|
// whose content is only whitespace, which is also why the workaround
|
|
260
361
|
// had to be a non-empty block. It also makes the server exports
|
|
261
|
-
// template-visible
|
|
362
|
+
// template-visible: compileScript only records plain-script bindings
|
|
262
363
|
// when a setup block exists.
|
|
263
364
|
if (!/<script\s[^>]*\bsetup\b|<script\s+setup/i.test(cleanContent)) {
|
|
264
365
|
cleanContent += `\n<script setup${langAttr}>\n// injected: carries the server-data bindings above\n</script>\n`
|
|
@@ -320,7 +421,7 @@ export class VueHandler extends DynamicHandler {
|
|
|
320
421
|
if (compiled.errors.length) {
|
|
321
422
|
// Thrown, not logged-and-served: a template Vue could not compile has
|
|
322
423
|
// the raw unparseable expression in its render function, so serving it
|
|
323
|
-
// is a browser-side SyntaxError behind a 200 and an empty page
|
|
424
|
+
// is a browser-side SyntaxError behind a 200 and an empty page, the
|
|
324
425
|
// report that surfaced this described exactly that. The throw lands in
|
|
325
426
|
// the error registry as a 500 that names the file and the error.
|
|
326
427
|
throw new Error(
|
|
@@ -371,7 +472,7 @@ export class VueHandler extends DynamicHandler {
|
|
|
371
472
|
// Root scripts carry the build variant in their name for the same reason
|
|
372
473
|
// the chunk does (`vueChunkPath`): the cache is keyed on the *source's*
|
|
373
474
|
// mtime, and flipping `build` in server.config.ts touches no source file
|
|
374
|
-
|
|
475
|
+
//, measured serving a root compiled under 'runtime' after the flip to
|
|
375
476
|
// 'full', missing the isCustomElement bridge the full build exists for.
|
|
376
477
|
// Only roots: the variant changes nothing in a subcomponent's output.
|
|
377
478
|
// The layout joins the name for the same reason the variant does: the
|
|
@@ -460,7 +561,13 @@ export class VueHandler extends DynamicHandler {
|
|
|
460
561
|
routePath: string,
|
|
461
562
|
serverParams: any,
|
|
462
563
|
parsed: ParsedCacheEntry,
|
|
463
|
-
route?: {
|
|
564
|
+
route?: {
|
|
565
|
+
catchAll: boolean
|
|
566
|
+
base: string
|
|
567
|
+
param: string | null
|
|
568
|
+
claimed?: string[]
|
|
569
|
+
claimedSingle?: boolean
|
|
570
|
+
},
|
|
464
571
|
) {
|
|
465
572
|
const { hasCss, serverScript } = parsed
|
|
466
573
|
const hasServerData =
|
|
@@ -479,7 +586,7 @@ export class VueHandler extends DynamicHandler {
|
|
|
479
586
|
|
|
480
587
|
// The route's shape, for `defineLayout()` (`client.ts`): the guard that
|
|
481
588
|
// restricts it to catch-all pages reads `catchAll` from here, so the
|
|
482
|
-
// stamp is the enforcement, not a convenience. Stamped on every page
|
|
589
|
+
// stamp is the enforcement, not a convenience. Stamped on every page:
|
|
483
590
|
// a non-catch-all page carries `catchAll: false`, which is what makes
|
|
484
591
|
// the client-side error message possible instead of a bare undefined.
|
|
485
592
|
const routeDecl = route
|
|
@@ -491,7 +598,7 @@ export class VueHandler extends DynamicHandler {
|
|
|
491
598
|
() => serverDecl + routeDecl,
|
|
492
599
|
)
|
|
493
600
|
|
|
494
|
-
// Static markup, injected verbatim
|
|
601
|
+
// Static markup, injected verbatim. See parseSkeleton for why it is
|
|
495
602
|
// never rendered. mount() replaces the container children, so it
|
|
496
603
|
// disappears the moment the real component is up.
|
|
497
604
|
if (parsed.skeleton) {
|
|
@@ -556,8 +663,8 @@ function asDirectResponse(value: any) {
|
|
|
556
663
|
* Serve `value` if the server script already produced a complete response,
|
|
557
664
|
* otherwise `null` so the caller treats it as page data.
|
|
558
665
|
*
|
|
559
|
-
* Both paths through `sharedHandler`
|
|
560
|
-
* page render
|
|
666
|
+
* Both paths through `sharedHandler` (the `__vue_action` call and the ordinary
|
|
667
|
+
* page render) need exactly this, and each carried its own verbatim copy. A
|
|
561
668
|
* `BunFile` goes through `ETag.sendFile` so a conditional request can 304; the
|
|
562
669
|
* `new Response` is the fallback for the un-cacheable case.
|
|
563
670
|
*/
|
|
@@ -570,7 +677,7 @@ async function serveIfDirect(value: any, req: Request) {
|
|
|
570
677
|
return (await ETag.sendFile(direct, req)) || new Response(direct as any)
|
|
571
678
|
}
|
|
572
679
|
|
|
573
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request handler
|
|
680
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: request handler, one branch per SFC render path
|
|
574
681
|
async function sharedHandler(
|
|
575
682
|
this: typeof DynamicHandler | typeof DynamicErrorHandler,
|
|
576
683
|
path: string,
|
|
@@ -613,7 +720,7 @@ async function sharedHandler(
|
|
|
613
720
|
}
|
|
614
721
|
|
|
615
722
|
// A layout is scaffolding, not a destination: /admin/layout must not render
|
|
616
|
-
// as a page. Script and css requests pass
|
|
723
|
+
// as a page. Script and css requests pass: they are how the root script of
|
|
617
724
|
// every page under it imports the thing.
|
|
618
725
|
if (routePath.endsWith('/layout.vue') && !isScript && !isCss) {
|
|
619
726
|
return response.error('Not Found', 404)
|
|
@@ -660,7 +767,7 @@ async function sharedHandler(
|
|
|
660
767
|
}
|
|
661
768
|
|
|
662
769
|
// The gates above ran against the route. This one runs against whatever
|
|
663
|
-
// `__vue_file` chose, which is the thing that is about to execute
|
|
770
|
+
// `__vue_file` chose, which is the thing that is about to execute, and it
|
|
664
771
|
// runs before the body is read, so a rejected request costs a parse it
|
|
665
772
|
// never needed.
|
|
666
773
|
const denied = validateActionTarget(
|
|
@@ -694,7 +801,7 @@ async function sharedHandler(
|
|
|
694
801
|
return response.json.success('OK', actionResult)
|
|
695
802
|
}
|
|
696
803
|
|
|
697
|
-
// Root scripts and stylesheets carry no per-request data
|
|
804
|
+
// Root scripts and stylesheets carry no per-request data: running the server
|
|
698
805
|
// block for them would re-execute every top-level query on each sub-request.
|
|
699
806
|
const needsServerData = !isCss && vueScriptParam !== 'root'
|
|
700
807
|
|
|
@@ -724,6 +831,7 @@ async function sharedHandler(
|
|
|
724
831
|
// `base` is the URL prefix the page owns: the file's directory. For
|
|
725
832
|
// `wiki/[...page!].vue` that is `/wiki`; for a root-level catch-all it is
|
|
726
833
|
// the empty string, which `defineLayout` treats as "everything".
|
|
834
|
+
const catchAll = Boolean(info.catchAll)
|
|
727
835
|
return VueHandler.handleHtml(
|
|
728
836
|
id,
|
|
729
837
|
finalParams,
|
|
@@ -731,10 +839,13 @@ async function sharedHandler(
|
|
|
731
839
|
serverParams,
|
|
732
840
|
parsed,
|
|
733
841
|
{
|
|
734
|
-
catchAll
|
|
842
|
+
catchAll,
|
|
735
843
|
base: routePath.slice(0, routePath.lastIndexOf('/')),
|
|
736
844
|
param: info.params.length ? info.params[info.params.length - 1] : null,
|
|
737
|
-
|
|
845
|
+
// Catch-all pages only: `defineLayout()` refuses every other page, so
|
|
846
|
+
// on those the claims would be sibling names published in the HTML with
|
|
847
|
+
// no reader. Skipping the stamp also skips the directory scan.
|
|
848
|
+
...(catchAll ? claimedBeside(diskFile.name ?? '') : null),
|
|
738
849
|
},
|
|
739
850
|
)
|
|
740
851
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { definePlugin } from '@bakery-framework/core/plugins'
|
|
2
|
+
import { preloadCompiler } from './compile'
|
|
2
3
|
import { setVuePluginOptions } from './compile'
|
|
3
4
|
import type { VuePluginOptions } from './types'
|
|
4
5
|
import { rewriteVueImports } from './utils'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `vuePlugin` is this package's only export, and its parameter type lived in a
|
|
8
|
-
* `.d.ts` reachable through no working specifier
|
|
9
|
+
* `.d.ts` reachable through no working specifier, so a consumer could pass
|
|
9
10
|
* options but never name their type. Re-exported here, where the function that
|
|
10
11
|
* takes them is.
|
|
11
12
|
*/
|
|
12
13
|
export type { CustomElementsOption, VuePluginOptions } from './types'
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The request body inside a `<script server>` block, and anything it calls.
|
|
17
|
+
*
|
|
18
|
+
* Pairs with `getRequest()` from `@bakery-framework/core`. Both replace the
|
|
19
|
+
* `req` and `body` globals this plugin declared in `vue.d.ts`, which only
|
|
20
|
+
* reached a file when that `.d.ts` happened to land in the tsconfig project
|
|
21
|
+
* an editor resolved for it. The globals still work; they are no longer the
|
|
22
|
+
* only way, and they are no longer load-bearing.
|
|
23
|
+
*/
|
|
24
|
+
export { getBody } from './server-context'
|
|
25
|
+
|
|
14
26
|
export default function vuePlugin(options?: VuePluginOptions) {
|
|
15
27
|
if (options) setVuePluginOptions(options)
|
|
16
28
|
return definePlugin({
|
|
@@ -19,7 +31,7 @@ export default function vuePlugin(options?: VuePluginOptions) {
|
|
|
19
31
|
// Declarative, read by the tsconfig generator at dev boot rather than by
|
|
20
32
|
// the server. `.vue` needs its own project for two reasons core cannot
|
|
21
33
|
// cover: plain `tsc` cannot parse an SFC at all (so this is for `vue-tsc`),
|
|
22
|
-
// and `vue.d.ts` declares `req` and `body` for SFC scope
|
|
34
|
+
// and `vue.d.ts` declares `req` and `body` for SFC scope. Globals core
|
|
23
35
|
// deliberately does not provide. Before this, those declarations shipped in
|
|
24
36
|
// the package and were reachable by no app.
|
|
25
37
|
tsconfig: {
|
|
@@ -27,7 +39,7 @@ export default function vuePlugin(options?: VuePluginOptions) {
|
|
|
27
39
|
name: 'vue',
|
|
28
40
|
// An SFC's `<script>` is rendered on the server, so it reaches the ORM
|
|
29
41
|
// and the app's schema registration the way an API route does. Without
|
|
30
|
-
// this the tables fall back to `any` in every `.vue` file
|
|
42
|
+
// this the tables fall back to `any` in every `.vue` file: silently,
|
|
31
43
|
// because the untyped mode is a supported state and does not error.
|
|
32
44
|
server: true,
|
|
33
45
|
extends: '@bakery-framework/core/tsconfig.vue.json',
|
|
@@ -38,7 +50,7 @@ export default function vuePlugin(options?: VuePluginOptions) {
|
|
|
38
50
|
// An SFC's `<script>` is browser code, so an `importMap` alias inside one
|
|
39
51
|
// is resolved by the browser's own import map and has to typecheck. The
|
|
40
52
|
// flag is off by default precisely because the *server* project must not
|
|
41
|
-
// have it
|
|
53
|
+
// have it: an alias only the browser can satisfy would typecheck there
|
|
42
54
|
// and fail at runtime. Vue is the case the flag exists to allow.
|
|
43
55
|
importMapPaths: true,
|
|
44
56
|
},
|
|
@@ -48,6 +60,19 @@ export default function vuePlugin(options?: VuePluginOptions) {
|
|
|
48
60
|
const { setupVue } = await import('./setup')
|
|
49
61
|
await setupVue()
|
|
50
62
|
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Start loading `@vue/compiler-sfc` while the rest of boot runs.
|
|
66
|
+
*
|
|
67
|
+
* It is 168-173 ms warm and 1,848 ms on a cold filesystem, and without
|
|
68
|
+
* this every server process paid it inside the first Vue page it served.
|
|
69
|
+
* Not awaited: the load overlaps with everything else here, and a request
|
|
70
|
+
* that arrives before it finishes awaits the same promise rather than
|
|
71
|
+
* starting a second one.
|
|
72
|
+
*/
|
|
73
|
+
onStart() {
|
|
74
|
+
preloadCompiler()
|
|
75
|
+
},
|
|
51
76
|
onCompile(content, path) {
|
|
52
77
|
if (
|
|
53
78
|
!path.endsWith('.ts') &&
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The parsed body of the request a `<script server>` block is running for.
|
|
5
|
+
*
|
|
6
|
+
* Separate from core's `getRequest()` deliberately: the request is core's and
|
|
7
|
+
* every surface has one, while `body` is this plugin's own computation
|
|
8
|
+
* (`VueHandler.params(req, finalParams)`, which merges route params with the
|
|
9
|
+
* parsed payload). A `.tsx` page receives its body as a function argument and
|
|
10
|
+
* has no use for this.
|
|
11
|
+
*
|
|
12
|
+
* Why it exists at all is the same reason as `getRequest()`. The block used
|
|
13
|
+
* to reach `body` as a global declared in `vue.d.ts`, which only works if
|
|
14
|
+
* that `.d.ts` lands in whatever tsconfig project an editor resolves for the
|
|
15
|
+
* SFC, and for an SFC it routinely does not. An import cannot fail that way,
|
|
16
|
+
* it carries a real type instead of `any`, and it reaches a helper the block
|
|
17
|
+
* calls rather than stopping at the wrapper's parameter list.
|
|
18
|
+
*/
|
|
19
|
+
const bodyStore = new AsyncLocalStorage<{ body: unknown }>()
|
|
20
|
+
|
|
21
|
+
/** Run `fn` with `body` visible to `getBody()`. Called by the plugin only. */
|
|
22
|
+
export function runWithServerBody<T>(body: unknown, fn: () => T): T {
|
|
23
|
+
return bodyStore.run({ body }, fn)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The request body, inside a `<script server>` block or anything it calls.
|
|
28
|
+
*
|
|
29
|
+
* Unlike {@link import('@bakery-framework/core').getRequest} this does **not**
|
|
30
|
+
* throw when absent: a GET with no payload and no route params is an ordinary
|
|
31
|
+
* request, and `undefined` is the honest answer rather than an error
|
|
32
|
+
* condition. The type parameter is the caller's assertion about a shape the
|
|
33
|
+
* framework cannot know, exactly as it was when this was an `any` global.
|
|
34
|
+
*/
|
|
35
|
+
export function getBody<T = unknown>(): T | undefined {
|
|
36
|
+
return bodyStore.getStore()?.body as T | undefined
|
|
37
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function setupVue() {
|
|
|
20
20
|
initVueVersion()
|
|
21
21
|
Bakery.handlers.fetch.set(VueHandler, 58)
|
|
22
22
|
Bakery.handlers.error.set(VueErrorHandler, 18)
|
|
23
|
-
// `vueChunkPath` is the single writer of this URL
|
|
23
|
+
// `vueChunkPath` is the single writer of this URL: it carries the build
|
|
24
24
|
// variant (`<version>.runtime.js` / `<version>.full.js`), and the serving
|
|
25
25
|
// check in `chunks.ts` reads the same function.
|
|
26
26
|
Bakery.config.importMap.vue = vueChunkPath()
|
package/src/types.d.ts
CHANGED
|
@@ -79,8 +79,8 @@ export interface ParsedCacheEntry {
|
|
|
79
79
|
meta: VueMeta
|
|
80
80
|
/**
|
|
81
81
|
* Inner markup of a `<template skeleton>` block, or null. Static by
|
|
82
|
-
* construction
|
|
83
|
-
* server
|
|
82
|
+
* construction (extracted before compilation, never rendered on the
|
|
83
|
+
* server), so nothing request- or user-derived can reach it.
|
|
84
84
|
*/
|
|
85
85
|
skeleton: string | null
|
|
86
86
|
/**
|
|
@@ -113,7 +113,7 @@ export interface VuePluginOptions {
|
|
|
113
113
|
* normally needs: SFC templates are compiled to render functions on the
|
|
114
114
|
* server, and `customElements` is applied there too, so the browser never
|
|
115
115
|
* compiles a template. Opt into `'full'` only for components that hand Vue a
|
|
116
|
-
* raw `template:` string at runtime
|
|
116
|
+
* raw `template:` string at runtime: those are compiled in the browser and
|
|
117
117
|
* fail on the runtime build with Vue's "runtime compilation is not
|
|
118
118
|
* supported" error.
|
|
119
119
|
*/
|
package/src/utils.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { LRUCache } from '@bakery-framework/core/cache/lru'
|
|
|
7
7
|
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
8
8
|
import { Logger } from '@bakery-framework/core/logger'
|
|
9
9
|
import { fs, is, Try } from '@bakery-framework/core/utils'
|
|
10
|
+
import { runWithServerBody } from './server-context'
|
|
10
11
|
import type { ParsedCacheEntry, ServerResponseOptions, VueMeta } from './types'
|
|
11
12
|
|
|
12
13
|
const logger = new Logger('vue')
|
|
@@ -32,7 +33,7 @@ export const RX_IMPORT_VUE_FILE =
|
|
|
32
33
|
export const RX_EXPORT_BRACE = /\bexport\s*\{([\s\S]*?)\}/g
|
|
33
34
|
export const RX_EXPORT_HANGING = /\bexport\s+(const|let|var)\s+(\w+)\s*=\s*/g
|
|
34
35
|
export const RX_EXPORT_FUNCTION = /\bexport\s+(?:async\s+)?function\s+(\w+)/g
|
|
35
|
-
/** `export const fn = async (a) => {}` / `= function () {}
|
|
36
|
+
/** `export const fn = async (a) => {}` / `= function () {}`: callable, not data. */
|
|
36
37
|
export const RX_EXPORT_CALLABLE_CONST =
|
|
37
38
|
/\bexport\s+(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|\w+)\s*=>)/g
|
|
38
39
|
export const RX_TOP_LEVEL_IMPORT =
|
|
@@ -61,10 +62,10 @@ const CLOSING_TAG = '</script'
|
|
|
61
62
|
/**
|
|
62
63
|
* Find the `</script>` that actually closes a server block, skipping ones that
|
|
63
64
|
* appear inside string literals, template literals, or comments. A plain
|
|
64
|
-
* non-greedy regex stops at the first match
|
|
65
|
+
* non-greedy regex stops at the first match, so server code containing
|
|
65
66
|
* `"</script>"` would be cut short and its remainder left in the client bundle.
|
|
66
67
|
*/
|
|
67
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner
|
|
68
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner, locates a block end past nested quotes
|
|
68
69
|
function findServerScriptEnd(raw: string, start: number): number {
|
|
69
70
|
let index = start
|
|
70
71
|
|
|
@@ -191,9 +192,9 @@ export function rewriteRelativeImports(
|
|
|
191
192
|
/**
|
|
192
193
|
* Find where the expression starting at `start` ends. Tracks bracket depth and
|
|
193
194
|
* skips strings/comments, so a multi-line object, function, or arrow body
|
|
194
|
-
* survives intact
|
|
195
|
+
* survives intact: stopping at the first newline breaks all three.
|
|
195
196
|
*/
|
|
196
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner
|
|
197
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: character scanner, brace and quote state machine
|
|
197
198
|
function findExpressionEnd(code: string, start: number): number {
|
|
198
199
|
let depth = 0
|
|
199
200
|
let index = start
|
|
@@ -378,12 +379,12 @@ export function compileServerBlock(
|
|
|
378
379
|
const ${p}actions = new Set(${actionAllowList})
|
|
379
380
|
|
|
380
381
|
// NOTE: the top-level statements below run before middleware. Middleware can
|
|
381
|
-
// stop the response but cannot stop top-level code from executing
|
|
382
|
+
// stop the response but cannot stop top-level code from executing. Put
|
|
382
383
|
// auth-gated work inside an exported function, not at the top level.
|
|
383
384
|
export default async function ${p}server(req: any, body: any, actionName?: string, actionArgs?: any[]) {
|
|
384
385
|
const ${p}result: any = {}
|
|
385
386
|
|
|
386
|
-
// The allow-list is static
|
|
387
|
+
// The allow-list is static (it is this script's own exports), so it can
|
|
387
388
|
// be answered before a line of the component runs. It used to be checked
|
|
388
389
|
// after the body, which meant naming an action that does not exist still
|
|
389
390
|
// executed every top-level statement in the file; with \`__vue_file\`
|
|
@@ -524,7 +525,11 @@ export async function getServerResponse(options: ServerResponseOptions) {
|
|
|
524
525
|
})
|
|
525
526
|
const execPromise = Promise.resolve(
|
|
526
527
|
is.function(exported)
|
|
527
|
-
?
|
|
528
|
+
? // The parameters stay, so every existing block keeps working; the
|
|
529
|
+
// store is what lets `getBody()` reach a helper the block calls.
|
|
530
|
+
runWithServerBody(body, () =>
|
|
531
|
+
exported(req, body, actionName, actionArgs),
|
|
532
|
+
)
|
|
528
533
|
: exported,
|
|
529
534
|
)
|
|
530
535
|
const params = await Promise.race([execPromise, timeoutPromise])
|
|
@@ -573,13 +578,13 @@ const RX_META_SKIPPABLE = /^\s+|^<!--[\s\S]*?-->/
|
|
|
573
578
|
/**
|
|
574
579
|
* Extract a `<template skeleton>` block: its inner markup goes into the HTML
|
|
575
580
|
* shell's `#app` so the user sees something before the bundle hydrates, and
|
|
576
|
-
* the block is removed from the SFC
|
|
581
|
+
* the block is removed from the SFC, the compiler allows only one template.
|
|
577
582
|
*
|
|
578
583
|
* **Static by design, and the design is a security decision.** The markup is
|
|
579
584
|
* injected verbatim: never compiled, never rendered on the server, so
|
|
580
585
|
* interpolations do not evaluate and nothing request- or session-derived can
|
|
581
586
|
* end up in it. A server-rendered skeleton cached across requests would serve
|
|
582
|
-
* one user's data to another. Scoped styles do not reach it either
|
|
587
|
+
* one user's data to another. Scoped styles do not reach it either: the
|
|
583
588
|
* scope attributes are stamped by the compiler this block never meets.
|
|
584
589
|
*
|
|
585
590
|
* One block per file; nested `<template>` elements inside it are not
|