@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.
Files changed (58) hide show
  1. package/README.md +2 -411
  2. package/dist/browser.d.mts +2 -0
  3. package/dist/browser.mjs +1 -0
  4. package/dist/index.d.mts +2 -1248
  5. package/dist/index.mjs +54 -3557
  6. package/dist/server-data-DGbiKzMS.d.mts +1249 -0
  7. package/dist/start-app-BdXBCcor.mjs +2 -0
  8. package/docs/01-getting-started.md +230 -0
  9. package/docs/02-routing-and-controllers.md +197 -0
  10. package/docs/03-middleware.md +214 -0
  11. package/docs/04-rendering-and-hydration.md +271 -0
  12. package/docs/05-i18n.md +243 -0
  13. package/docs/06-http-client.md +286 -0
  14. package/docs/07-di-container.md +264 -0
  15. package/docs/08-observability.md +290 -0
  16. package/docs/09-server-and-deployment.md +242 -0
  17. package/docs/10-features-platform-pwa.md +238 -0
  18. package/docs/README.md +72 -0
  19. package/docs/advanced/custom-action-handler.md +248 -0
  20. package/docs/advanced/custom-adapter.md +264 -0
  21. package/docs/advanced/custom-event-recorder.md +318 -0
  22. package/docs/advanced/inline-proxy-codegen.md +200 -0
  23. package/docs/advanced/multi-tenant-scopes.md +330 -0
  24. package/docs/engineering/ci-release-flow.md +244 -0
  25. package/docs/engineering/project-structure.md +296 -0
  26. package/docs/engineering/testing.md +317 -0
  27. package/docs/pitfalls/container-scope-leak.md +215 -0
  28. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  29. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  30. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  31. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  32. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  33. package/docs/zh/01-getting-started.md +230 -0
  34. package/docs/zh/02-routing-and-controllers.md +197 -0
  35. package/docs/zh/03-middleware.md +214 -0
  36. package/docs/zh/04-rendering-and-hydration.md +271 -0
  37. package/docs/zh/05-i18n.md +243 -0
  38. package/docs/zh/06-http-client.md +286 -0
  39. package/docs/zh/07-di-container.md +264 -0
  40. package/docs/zh/08-observability.md +287 -0
  41. package/docs/zh/09-server-and-deployment.md +242 -0
  42. package/docs/zh/10-features-platform-pwa.md +238 -0
  43. package/docs/zh/README.md +72 -0
  44. package/docs/zh/advanced/custom-action-handler.md +248 -0
  45. package/docs/zh/advanced/custom-adapter.md +264 -0
  46. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  47. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  48. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  49. package/docs/zh/engineering/ci-release-flow.md +244 -0
  50. package/docs/zh/engineering/project-structure.md +296 -0
  51. package/docs/zh/engineering/testing.md +317 -0
  52. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  53. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  54. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  55. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  56. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  57. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  58. 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) —— 应用长大后的推荐布局