@finesoft/front 0.1.75 → 0.1.77
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 +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +12 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Pitfall: SSR hydration mismatch
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
After SSR, the browser console logs a hydration warning:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
[Vue warn]: Hydration node mismatch — server rendered "<div>Loading...</div>" but client expected "<div>Welcome, Alice</div>"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The page flickers between the SSR-rendered content and the client-rendered content. State that should be already loaded triggers a refetch.
|
|
12
|
+
|
|
13
|
+
## Root cause (most common)
|
|
14
|
+
|
|
15
|
+
The server and the browser produced **different `Page` objects** for the same URL because something they read disagreed between sides:
|
|
16
|
+
|
|
17
|
+
- Random / time-based values (`Math.random()`, `Date.now()`)
|
|
18
|
+
- Reading `window` / `localStorage` / `document.cookie` on the server (these are `undefined`)
|
|
19
|
+
- Reading `process.env` on the browser (these are `undefined` after bundling)
|
|
20
|
+
- User-Agent-dependent rendering when SSR didn't see the real UA
|
|
21
|
+
- Async race: the controller's `execute()` returned different data on each call
|
|
22
|
+
|
|
23
|
+
The hydration cache (`PrefetchedIntents`) lookup missed, so the browser re-ran the controller — and got a different result.
|
|
24
|
+
|
|
25
|
+
## Root cause (less common)
|
|
26
|
+
|
|
27
|
+
The `PrefetchedIntents` key (intentId + stable-stringified params) doesn't match between server and browser:
|
|
28
|
+
|
|
29
|
+
- Params object has values that don't stringify deterministically (Maps, Sets, class instances, Symbols)
|
|
30
|
+
- Controller mutates `params` in place — the dispatch key was computed from the original, but the controller saw the mutated version
|
|
31
|
+
|
|
32
|
+
## Diagnosis
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// In your view, log the page on both sides:
|
|
36
|
+
console.log("[hydration]", typeof window === "undefined" ? "SSR" : "CSR", page);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Compare the two logs. The first different field is the root cause.
|
|
40
|
+
|
|
41
|
+
For `PrefetchedIntents` debugging, log the cache state in the browser:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
startBrowserApp({
|
|
45
|
+
bootstrap,
|
|
46
|
+
onBeforeStart(framework) {
|
|
47
|
+
console.log("[prefetched]", framework.prefetchedIntents.dump());
|
|
48
|
+
},
|
|
49
|
+
mount: /* ... */,
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the dump shows the intent **with different params** than what the browser's first navigation tries to dispatch, you've got a key mismatch.
|
|
54
|
+
|
|
55
|
+
## Fix
|
|
56
|
+
|
|
57
|
+
### Stop reading platform-only globals at module level
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// BAD
|
|
61
|
+
const userId = localStorage.getItem("uid"); // throws on SSR
|
|
62
|
+
const isDarkMode = matchMedia("(prefers-color-scheme: dark)").matches; // throws on SSR
|
|
63
|
+
const csrfToken = document.querySelector("meta[name=csrf]")?.content; // null on SSR
|
|
64
|
+
|
|
65
|
+
export class HomeController extends BaseController {
|
|
66
|
+
/* uses userId */
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// GOOD
|
|
72
|
+
export class HomeController extends BaseController {
|
|
73
|
+
async execute(_params, container) {
|
|
74
|
+
// resolve from DI; the request scope has the right value on each side
|
|
75
|
+
const session = container.resolve<Session>("session");
|
|
76
|
+
return { kind: "home", userId: session.userId };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Cookies are accessible on both sides via `container.resolve("session")` (after you register it). `localStorage` is browser-only — if the SSR side needs the same value, surface it via a cookie or query param.
|
|
82
|
+
|
|
83
|
+
### Don't use randomness / time-based logic in `execute()`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// BAD — server and browser compute different values
|
|
87
|
+
async execute() {
|
|
88
|
+
return { kind: "home", randomGreeting: pick(greetings) };
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If you need randomness, compute it once on the server and let the client reuse it via `PrefetchedIntents` (it does, automatically). Don't try to "re-randomize on the client" — that's exactly what causes mismatch.
|
|
93
|
+
|
|
94
|
+
For time-based logic, decide on the server and ship the result:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
async execute() {
|
|
98
|
+
const isOfficeHours = new Date().getHours() >= 9 && new Date().getHours() < 17;
|
|
99
|
+
return { kind: "home", isOfficeHours };
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both sides will see `isOfficeHours: true` because the browser reads from cache, not re-evaluates.
|
|
104
|
+
|
|
105
|
+
### Make `params` JSON-clean
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// BAD — dispatchAction with non-serializable params
|
|
109
|
+
framework.dispatch({
|
|
110
|
+
intentId: "search",
|
|
111
|
+
params: {
|
|
112
|
+
query: "widget",
|
|
113
|
+
filters: new Set(["red", "small"]), // Sets don't JSON.stringify well
|
|
114
|
+
startDate: new Date(), // becomes ISO string, OK, but...
|
|
115
|
+
validator: new Validator(), // class instance — won't survive
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// GOOD — primitives + plain objects only
|
|
122
|
+
framework.dispatch({
|
|
123
|
+
intentId: "search",
|
|
124
|
+
params: {
|
|
125
|
+
query: "widget",
|
|
126
|
+
filters: ["red", "small"],
|
|
127
|
+
startDate: "2026-05-14",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The `PrefetchedIntents` cache uses **stable stringification** — same keys in different order produce the same key, and circular references are detected. But non-JSON values are coerced to strings or dropped silently.
|
|
133
|
+
|
|
134
|
+
### Don't mutate `params`
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// BAD
|
|
138
|
+
async execute(params, container) {
|
|
139
|
+
params.userId = container.resolve("session").userId; // mutation
|
|
140
|
+
return loadFor(params);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// GOOD
|
|
146
|
+
async execute(params, container) {
|
|
147
|
+
const effective = { ...params, userId: container.resolve("session").userId };
|
|
148
|
+
return loadFor(effective);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The dispatcher computed the cache key from the original `params`. If you mutate it, the next dispatch with the original shape misses the cache.
|
|
153
|
+
|
|
154
|
+
## Why `stableStringify` matters
|
|
155
|
+
|
|
156
|
+
The framework's `stableStringify` (in `packages/core/src/prefetched-intents/stable-stringify.ts`) handles object key ordering. It uses a `seen` Set with `try/finally` cleanup to support DAGs (same object referenced multiple times) — without the cleanup, a DAG would be reported as a false circular reference and the key would silently differ between server and browser.
|
|
157
|
+
|
|
158
|
+
If you see "Circular reference detected" warnings during SSR but the data is genuinely a DAG, file a bug — the cleanup is supposed to handle this.
|
|
159
|
+
|
|
160
|
+
## Related
|
|
161
|
+
|
|
162
|
+
- [Pitfall: SSR vs CSR globals](./ssr-vs-csr-globals.md) — where the platform-only globals live
|
|
163
|
+
- [Chapter 4: Rendering & hydration](../04-rendering-and-hydration.md) — how `PrefetchedIntents` works
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Pitfall: SSR vs CSR globals
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
The build succeeds. The dev server starts. The first request to any SSR route crashes with:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
ReferenceError: window is not defined
|
|
9
|
+
at /src/lib/foo.ts:3:13
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or, more subtly:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
TypeError: Cannot read properties of undefined (reading 'getItem')
|
|
16
|
+
at /src/lib/storage.ts:5:34
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The browser-only global (`window`, `document`, `localStorage`, `navigator`, `matchMedia`, `IntersectionObserver`, ...) doesn't exist on Node — Node has none of them.
|
|
20
|
+
|
|
21
|
+
## Root cause
|
|
22
|
+
|
|
23
|
+
You're reading a browser-only global at **module evaluation time** in a file imported by your SSR entry. The module graph dragged it in even though you only use it on the client.
|
|
24
|
+
|
|
25
|
+
Common entry points:
|
|
26
|
+
|
|
27
|
+
- A `controllers/foo.ts` that imports a `lib/analytics.ts` with `window.gtag` at module top
|
|
28
|
+
- A `lib/storage.ts` factory that calls `localStorage.getItem` at import time
|
|
29
|
+
- An animation library auto-running `requestAnimationFrame` on import
|
|
30
|
+
|
|
31
|
+
The same problem in reverse hits the browser:
|
|
32
|
+
|
|
33
|
+
- Server-only code (`process.env.X`, Node `fs`, `path`) imported by something the browser bundle pulled in
|
|
34
|
+
- Vite tree-shakes most, but not all, and dynamic imports can defeat tree-shaking
|
|
35
|
+
|
|
36
|
+
## Diagnosis
|
|
37
|
+
|
|
38
|
+
When the SSR entry crashes, the error message includes the file. Read top-to-bottom — the first `import` chain that touches a browser global is the offender.
|
|
39
|
+
|
|
40
|
+
To find browser-only code preemptively, grep:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
rg -n '\b(window|document|localStorage|sessionStorage|navigator|matchMedia|location)\b' src/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Cross-reference with what's imported transitively from `src/ssr.ts`. Anything reachable from `ssr.ts` must be SSR-safe.
|
|
47
|
+
|
|
48
|
+
## Fix
|
|
49
|
+
|
|
50
|
+
### Guard with an environment check
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// GOOD — safe on both sides
|
|
54
|
+
function getStoredTheme(): "light" | "dark" {
|
|
55
|
+
if (typeof window === "undefined") return "light";
|
|
56
|
+
return (localStorage.getItem("theme") as "light" | "dark") ?? "light";
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`typeof window === "undefined"` is the canonical SSR check. It's safer than `typeof process !== "undefined"` because some bundlers polyfill `process` on the client.
|
|
61
|
+
|
|
62
|
+
### Move to lifecycle hooks
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// BAD — runs at import time
|
|
66
|
+
const analytics = createAnalytics(window.location.host);
|
|
67
|
+
export function track(event: string) {
|
|
68
|
+
analytics.send(event);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// GOOD — runs after framework start in the browser
|
|
74
|
+
let analytics: Analytics | null = null;
|
|
75
|
+
|
|
76
|
+
export function track(event: string) {
|
|
77
|
+
if (!analytics) {
|
|
78
|
+
if (typeof window === "undefined") return;
|
|
79
|
+
analytics = createAnalytics(window.location.host);
|
|
80
|
+
}
|
|
81
|
+
analytics.send(event);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or use `startBrowserApp`'s `onBeforeStart`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
startBrowserApp({
|
|
89
|
+
bootstrap,
|
|
90
|
+
onBeforeStart(framework) {
|
|
91
|
+
const analytics = createAnalytics(window.location.host);
|
|
92
|
+
framework.container.register("analytics", () => analytics);
|
|
93
|
+
},
|
|
94
|
+
mount: /* ... */,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then resolve from DI in controllers/views — never touch `window` directly in shared code.
|
|
99
|
+
|
|
100
|
+
### Conditional import
|
|
101
|
+
|
|
102
|
+
For libraries that crash on import in Node (animation libs, audio libs), import dynamically only on the browser:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
let confetti: ((options?: any) => void) | null = null;
|
|
106
|
+
|
|
107
|
+
if (typeof window !== "undefined") {
|
|
108
|
+
import("canvas-confetti").then((m) => {
|
|
109
|
+
confetti = m.default;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function celebrate() {
|
|
114
|
+
confetti?.();
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or register the import in `onBeforeStart`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
onBeforeStart: async (framework) => {
|
|
122
|
+
const { default: confetti } = await import("canvas-confetti");
|
|
123
|
+
framework.container.register("confetti", () => confetti);
|
|
124
|
+
},
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Use the framework's abstractions
|
|
128
|
+
|
|
129
|
+
The framework provides DI keys that work on both sides:
|
|
130
|
+
|
|
131
|
+
- `DEP_KEYS.PLATFORM` — the parsed user-agent on the server, navigator-derived on the client
|
|
132
|
+
- `DEP_KEYS.STORAGE` — `localStorage` on the client, in-memory map on the server
|
|
133
|
+
- `DEP_KEYS.LOCALE` — resolved locale on both sides
|
|
134
|
+
|
|
135
|
+
Use these instead of reading globals directly. They're cross-platform by design.
|
|
136
|
+
|
|
137
|
+
## Symptom: works locally, fails in production build
|
|
138
|
+
|
|
139
|
+
Sometimes the dev server tolerates a global access (via Vite's lazy evaluation) but the production build crashes. The cause is usually a module that's tree-shaken in dev but not in prod, or vice versa.
|
|
140
|
+
|
|
141
|
+
Test the production build before deploying:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pnpm build
|
|
145
|
+
pnpm preview
|
|
146
|
+
# hit the SSR routes
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The `vp preview` server runs the same code path as production — if it doesn't crash, the deploy won't either (at least not from this class of bug).
|
|
150
|
+
|
|
151
|
+
## Symptom: works in production but blank page in dev
|
|
152
|
+
|
|
153
|
+
Inverse problem — server-only code leaked into the client bundle, and the browser crashed during hydration before the view layer mounted.
|
|
154
|
+
|
|
155
|
+
Open browser devtools, check the console for `process is not defined` / `require is not defined`. The fix is the same: guard with `typeof window === "undefined"` (inverted: guard with `typeof window !== "undefined"`) or move to a lifecycle hook.
|
|
156
|
+
|
|
157
|
+
## Why imports matter, not "code that runs"
|
|
158
|
+
|
|
159
|
+
You may be tempted to "just not call the function" instead of guarding the import:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// import-time check
|
|
163
|
+
if (typeof window !== "undefined") {
|
|
164
|
+
// never actually called on SSR
|
|
165
|
+
setupAnalytics();
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
But the `import` itself runs the module's top-level code. If `lib/analytics.ts` calls `window.gtag` at module top-level (e.g., as part of `const analytics = window.gtag.bind(window)`), the crash happens **at import**, before your `if` check.
|
|
170
|
+
|
|
171
|
+
Fix the imported module to be import-safe, not just call-safe.
|
|
172
|
+
|
|
173
|
+
## Related
|
|
174
|
+
|
|
175
|
+
- [Pitfall: SSR hydration mismatch](./ssr-hydration-mismatch.md) — when SSR runs but produces different output than CSR
|
|
176
|
+
- [DI container](../07-di-container.md) — registering cross-platform services
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# 1. 快速开始
|
|
2
|
+
|
|
3
|
+
五分钟跑通一个 `@finesoft/front` 应用。读完你会有一个 Vue/React/Svelte 页面在服务端渲染、在浏览器 hydrate、并准备好部署。
|
|
4
|
+
|
|
5
|
+
## 先决条件
|
|
6
|
+
|
|
7
|
+
- Node.js `>= 22.12.0`
|
|
8
|
+
- 一个包管理器:pnpm(推荐)、npm 或 yarn
|
|
9
|
+
- 一个视图层:Vue 3、React 19 或 Svelte 5 —— 文档用 Vue 举例,但每个示例都能 1:1 翻译
|
|
10
|
+
|
|
11
|
+
## 脚手架创建新应用
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @finesoft/create-app my-app
|
|
15
|
+
cd my-app
|
|
16
|
+
pnpm install
|
|
17
|
+
pnpm dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
脚手架生成一个可运行的应用,路由、SSR、proxy 已经接好。打开 <http://localhost:5173>。
|
|
21
|
+
|
|
22
|
+
如果你想理解里面是怎么搭起来的,本页后面从零搭一份同样的配置。
|
|
23
|
+
|
|
24
|
+
## 加进已有项目
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add @finesoft/front
|
|
28
|
+
pnpm add -D vite hono
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Peer 依赖:`hono >= 4.0.0`。可选但推荐:`@hono/node-server`(Node 部署)、`vite >= 5.0.0`(dev server)。
|
|
32
|
+
|
|
33
|
+
## 最小项目布局
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
my-app/
|
|
37
|
+
├── src/
|
|
38
|
+
│ ├── bootstrap.ts # 路由 + Controller(SSR + CSR 共享)
|
|
39
|
+
│ ├── main.ts # 浏览器入口
|
|
40
|
+
│ ├── ssr.ts # SSR 入口
|
|
41
|
+
│ ├── App.vue # 根组件
|
|
42
|
+
│ └── lib/
|
|
43
|
+
│ └── controllers/
|
|
44
|
+
│ └── home.ts
|
|
45
|
+
├── index.html
|
|
46
|
+
├── vite.config.ts
|
|
47
|
+
├── package.json
|
|
48
|
+
└── tsconfig.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Vite 配置
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// vite.config.ts
|
|
55
|
+
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
56
|
+
import vue from "@vitejs/plugin-vue";
|
|
57
|
+
import { defineConfig } from "vite";
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
plugins: [
|
|
61
|
+
vue(),
|
|
62
|
+
finesoftFrontViteConfig({
|
|
63
|
+
ssr: { entry: "src/ssr.ts" },
|
|
64
|
+
i18n: { messagesDir: "src/locales" },
|
|
65
|
+
adapter: "auto",
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`finesoftFrontViteConfig` 加的东西:
|
|
72
|
+
|
|
73
|
+
- 基于 Hono 的 dev server,每个请求都跑你的 SSR 入口
|
|
74
|
+
- 从 `messagesDir` 自动加载 locale JSON
|
|
75
|
+
- 构建管线同时产出客户端 bundle 和服务端入口
|
|
76
|
+
- `adapter: "auto" | "node" | "vercel" | "cloudflare" | "netlify" | "static"` 自动接入平台 adapter
|
|
77
|
+
|
|
78
|
+
## Bootstrap(SSR 与 CSR 共享)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/bootstrap.ts
|
|
82
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
83
|
+
import { HomeController } from "./lib/controllers/home";
|
|
84
|
+
|
|
85
|
+
export function bootstrap(framework: Framework): void {
|
|
86
|
+
defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
同一个 `bootstrap()` 在两端都跑。这就是 SSR 和 CSR 解析 URL 完全一致的保证。
|
|
91
|
+
|
|
92
|
+
## Controller
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// src/lib/controllers/home.ts
|
|
96
|
+
import { BaseController, type Container } from "@finesoft/front";
|
|
97
|
+
|
|
98
|
+
interface HomePage {
|
|
99
|
+
kind: "home";
|
|
100
|
+
title: string;
|
|
101
|
+
items: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class HomeController extends BaseController<Record<string, string>, HomePage> {
|
|
105
|
+
readonly intentId = "home";
|
|
106
|
+
|
|
107
|
+
async execute(_params: Record<string, string>, _container: Container): Promise<HomePage> {
|
|
108
|
+
return {
|
|
109
|
+
kind: "home",
|
|
110
|
+
title: "Welcome",
|
|
111
|
+
items: ["one", "two", "three"],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fallback(_params: Record<string, string>, error: unknown): HomePage {
|
|
116
|
+
return { kind: "home", title: "Failed", items: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`BaseController` 把 `execute()` 包在 `try/catch` 里,出错时调 `fallback()`。永远要写 `fallback` —— 原因见 [可观测性 · 错误处理](./08-observability.md#通过-fallback-处理错误)。
|
|
122
|
+
|
|
123
|
+
## SSR 入口
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// src/ssr.ts
|
|
127
|
+
import { createSSRRender, serializeServerData } from "@finesoft/front";
|
|
128
|
+
import { createSSRApp } from "vue";
|
|
129
|
+
import { renderToString } from "vue/server-renderer";
|
|
130
|
+
import App from "./App.vue";
|
|
131
|
+
import { bootstrap } from "./bootstrap";
|
|
132
|
+
|
|
133
|
+
export const render = createSSRRender({
|
|
134
|
+
bootstrap,
|
|
135
|
+
getErrorPage: () => ({ kind: "error", title: "Error" }),
|
|
136
|
+
async renderApp(page) {
|
|
137
|
+
const html = await renderToString(createSSRApp(App, { page }));
|
|
138
|
+
return { html, head: `<title>${(page as { title: string }).title}</title>`, css: "" };
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export { serializeServerData };
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`createSSRRender` 返回一个函数,接 URL 输出渲染后的 HTML + 序列化的 prefetched intent 数据。Vite 插件和 adapter 会替你调用,不需要手动调。
|
|
146
|
+
|
|
147
|
+
## 浏览器入口
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/main.ts
|
|
151
|
+
import { startBrowserApp } from "@finesoft/front/browser";
|
|
152
|
+
import { createSSRApp } from "vue";
|
|
153
|
+
import App from "./App.vue";
|
|
154
|
+
import { bootstrap } from "./bootstrap";
|
|
155
|
+
|
|
156
|
+
startBrowserApp({
|
|
157
|
+
bootstrap,
|
|
158
|
+
mount(target, { framework }) {
|
|
159
|
+
const app = createSSRApp(App, { framework });
|
|
160
|
+
app.mount(target);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`startBrowserApp` 从 DOM 读 SSR 注入的 `PrefetchedIntents`,创建 `Framework`,跑同样的 `bootstrap()`,并触发首屏页面。`mount()` 跑的时候,框架已经准备好了初始的 `Page`。
|
|
166
|
+
|
|
167
|
+
客户端从 `@finesoft/front/browser` 导入(不是 `@finesoft/front`),避免把服务端模块带进客户端 bundle。
|
|
168
|
+
|
|
169
|
+
## 根组件(Vue 示例)
|
|
170
|
+
|
|
171
|
+
```vue
|
|
172
|
+
<!-- src/App.vue -->
|
|
173
|
+
<script setup lang="ts">
|
|
174
|
+
import { computed } from "vue";
|
|
175
|
+
import type { Framework } from "@finesoft/front";
|
|
176
|
+
|
|
177
|
+
const props = defineProps<{ framework?: Framework; page?: { title: string; items: string[] } }>();
|
|
178
|
+
|
|
179
|
+
// SSR 直接收到 page;CSR 从 framework 取当前页面。
|
|
180
|
+
const page = computed(() => props.page ?? props.framework?.getCurrentPage());
|
|
181
|
+
</script>
|
|
182
|
+
|
|
183
|
+
<template>
|
|
184
|
+
<main>
|
|
185
|
+
<h1>{{ page?.title }}</h1>
|
|
186
|
+
<ul>
|
|
187
|
+
<li v-for="item in page?.items" :key="item">{{ item }}</li>
|
|
188
|
+
</ul>
|
|
189
|
+
</main>
|
|
190
|
+
</template>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## index.html
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<!doctype html>
|
|
197
|
+
<html lang="en">
|
|
198
|
+
<head>
|
|
199
|
+
<meta charset="UTF-8" />
|
|
200
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
201
|
+
<!--head-->
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<div id="app"><!--ssr--></div>
|
|
205
|
+
<script type="module" src="/src/main.ts"></script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`<!--head-->` 和 `<!--ssr-->` 是框架注入 head 片段和渲染 HTML 的占位。占位名也通过 `SSR_PLACEHOLDERS` 导出。
|
|
211
|
+
|
|
212
|
+
## 跑起来
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pnpm dev # 带 HMR 的开发服务器
|
|
216
|
+
pnpm build # 生产构建
|
|
217
|
+
pnpm preview # 本地预览生产构建
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
完成。你现在有一个应用:
|
|
221
|
+
|
|
222
|
+
- 服务端渲染,带 prefetch 数据
|
|
223
|
+
- Hydrate 不再多发请求(SSR 数据通过 `PrefetchedIntents` 复用)
|
|
224
|
+
- 客户端路由无刷新
|
|
225
|
+
- 已准备好部署到任一支持的 adapter
|
|
226
|
+
|
|
227
|
+
## 下一步
|
|
228
|
+
|
|
229
|
+
- [路由与 Controller](./02-routing-and-controllers.md) —— 定义更多路由、控制渲染方式
|
|
230
|
+
- [项目结构](./engineering/project-structure.md) —— 应用长大后的推荐布局
|