@finesoft/front 0.1.76 → 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 (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +197 -0
  3. package/docs/03-middleware.md +214 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +197 -0
  28. package/docs/zh/03-middleware.md +214 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. package/package.json +2 -1
@@ -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) —— 应用长大后的推荐布局
@@ -0,0 +1,197 @@
1
+ # 2. 路由与 Controller
2
+
3
+ 框架的路由层把 URL 映射到 **intent**,把 intent 映射到 **Controller**,Controller 产出 **Page**。本章覆盖这三个概念。
4
+
5
+ ## 心智模型
6
+
7
+ ```
8
+ URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
9
+
10
+
11
+ IntentDispatcher.dispatch(intent)
12
+
13
+
14
+ Controller.execute() → Page
15
+ ```
16
+
17
+ 一条路由定义包含:
18
+
19
+ - **路径模式**(`/products/:id`)
20
+ - **intent id**(操作的逻辑名;一个 intent 可以对应多条路由)
21
+ - **Controller 实例**(产出页面数据)
22
+ - 可选的**渲染模式**(`ssr` / `csr` / `prerender`)
23
+ - 可选的**守卫**(`beforeLoad` / `afterLoad`)
24
+
25
+ ## 定义路由
26
+
27
+ ```ts
28
+ // src/bootstrap.ts
29
+ import { type Framework, defineRoutes } from "@finesoft/front";
30
+ import { HomeController } from "./lib/controllers/home";
31
+ import { ProductController } from "./lib/controllers/product";
32
+ import { authGuard } from "./lib/guards/auth";
33
+
34
+ export function bootstrap(framework: Framework): void {
35
+ defineRoutes(framework, [
36
+ // 普通 SSR 路由
37
+ { path: "/", intentId: "home", controller: new HomeController() },
38
+
39
+ // 动态参数段
40
+ { path: "/products/:id", intentId: "product", controller: new ProductController() },
41
+
42
+ // 仅 CSR(服务端只返回空壳)
43
+ {
44
+ path: "/dashboard",
45
+ intentId: "dashboard",
46
+ controller: new DashboardController(),
47
+ renderMode: "csr",
48
+ },
49
+
50
+ // 构建期静态化
51
+ {
52
+ path: "/about",
53
+ intentId: "about",
54
+ controller: new AboutController(),
55
+ renderMode: "prerender",
56
+ },
57
+
58
+ // 受保护路由 —— 复用 home intent,但加守卫
59
+ {
60
+ path: "/admin",
61
+ intentId: "home",
62
+ controller: new HomeController(),
63
+ beforeLoad: [authGuard],
64
+ },
65
+ ]);
66
+ }
67
+ ```
68
+
69
+ ### 路由选项
70
+
71
+ | 字段 | 类型 | 说明 |
72
+ | ------------ | -------------------------------- | -------------------------------------------------------------- |
73
+ | `path` | `string` | 路径模式,含 `:param` 占位。结尾的 `/` 会被归一化。 |
74
+ | `intentId` | `string` | 操作的逻辑名。用于注册 Controller。 |
75
+ | `controller` | `BaseController<TParams, TPage>` | 若 intent 已注册可省略。 |
76
+ | `renderMode` | `"ssr" \| "csr" \| "prerender"` | 默认 `"ssr"`。详见[第 4 章](./04-rendering-and-hydration.md)。 |
77
+ | `beforeLoad` | `BeforeLoadGuard[]` | Controller 之前执行。详见[第 3 章](./03-middleware.md)。 |
78
+ | `afterLoad` | `AfterLoadGuard[]` | Page 产出之后执行。 |
79
+
80
+ ### 路径模式
81
+
82
+ - 静态:`/about`
83
+ - 带参数:`/products/:id`、`/users/:userId/posts/:postId`
84
+ - 尾部通配:`/files/*`
85
+ - **不**支持可选段 —— 写两条路由代替。
86
+
87
+ 参数以字符串键对象的形式传给 `controller.execute(params, container)`。
88
+
89
+ ## 写一个 Controller
90
+
91
+ ```ts
92
+ // src/lib/controllers/product.ts
93
+ import { BaseController, type Container, type HttpClient } from "@finesoft/front";
94
+
95
+ interface ProductPage {
96
+ kind: "product";
97
+ id: string;
98
+ name: string;
99
+ price: number;
100
+ }
101
+
102
+ export class ProductController extends BaseController<{ id: string }, ProductPage> {
103
+ readonly intentId = "product";
104
+
105
+ async execute(params: { id: string }, container: Container): Promise<ProductPage> {
106
+ const http = container.resolve<HttpClient>("http");
107
+ const product = await http.get<{ name: string; price: number }>(
108
+ `/api/products/${params.id}`,
109
+ );
110
+ return {
111
+ kind: "product",
112
+ id: params.id,
113
+ name: product.name,
114
+ price: product.price,
115
+ };
116
+ }
117
+
118
+ fallback(params: { id: string }, _error: unknown): ProductPage {
119
+ return { kind: "product", id: params.id, name: "Not available", price: 0 };
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### Controller 契约
125
+
126
+ | 成员 | 必需 | 用途 |
127
+ | ---------- | ---- | --------------------------------------------------------------------------- |
128
+ | `intentId` | 是 | 必须与路由的 `intentId` 一致(或与 `IntentDispatcher.register` 调用一致)。 |
129
+ | `execute` | 是 | 产出页面。接受解析后的路径参数和请求级 DI 容器。 |
130
+ | `fallback` | 是 | `execute()` 抛错时返回降级页面。必须同步且总能返回。 |
131
+
132
+ `BaseController` 把 `execute()` 包在 `try/catch` 里,错误都走 `fallback()`。框架从不让 `dispatch()` 抛错 —— 你的 `fallback()` 是最后一道防线。
133
+
134
+ ### 为什么 `fallback` 是必需的
135
+
136
+ SSR 期间 `execute()` 抛错本来会让整个请求崩 —— 要么 500,要么渲出空白文档。`fallback()` 让你返回一个结构化的「错误」`Page`,让视图层渲成优雅失败(banner、重试按钮等)。完整模式见 [可观测性 · 错误处理](./08-observability.md#通过-fallback-处理错误)。
137
+
138
+ ## 渲染模式
139
+
140
+ | 模式 | 服务端返回的内容 | 适用场景 |
141
+ | ------------- | -------------------------------- | ------------------------------------------- |
142
+ | `"ssr"` | 完整渲染的 HTML + 序列化数据 | 默认。SEO 和 TTFB 敏感的页面。 |
143
+ | `"csr"` | 空壳 HTML;Controller 在浏览器跑 | 鉴权后的 dashboard、高度个性化的页面。 |
144
+ | `"prerender"` | 部署期生成的静态 HTML | 营销页、文档、博客。结合 ISR(见第 4 章)。 |
145
+
146
+ 模式是**按路由**配置,可以自由混合。框架在构建期重生成 prerendered 路由;SSR 路由每次请求都执行。
147
+
148
+ ## 不绑路由也能注册 Controller
149
+
150
+ 可以为 intent 注册 Controller 但不暴露成路由。这对只通过 `dispatchAction` 触发的 intent 有用:
151
+
152
+ ```ts
153
+ framework.intentDispatcher.register("checkout", new CheckoutController());
154
+
155
+ // 别处:
156
+ const page = await framework.intentDispatcher.dispatch({
157
+ intentId: "checkout",
158
+ params: { cartId },
159
+ });
160
+ ```
161
+
162
+ 路由本质上就是按 URL 索引的 intent dispatch。
163
+
164
+ ## 一个 intent 多个路由
165
+
166
+ 同一个 intent 可以服务不同 URL:
167
+
168
+ ```ts
169
+ defineRoutes(framework, [
170
+ { path: "/", intentId: "home", controller: new HomeController() },
171
+ { path: "/welcome", intentId: "home" }, // 复用已注册的 HomeController
172
+ { path: "/landing/:slug", intentId: "home" }, // 同 intent,参数不同
173
+ ]);
174
+ ```
175
+
176
+ 避免只是 URL 表面不同就复制 Controller 实例。前面例子里 `/admin` 复用 `home` intent 也是同一个套路。
177
+
178
+ ## 检查解析后的 match
179
+
180
+ 诊断或自定义路由时,可以直接调 `Router.resolve()`:
181
+
182
+ ```ts
183
+ const match = framework.router.resolve("/products/42");
184
+ // {
185
+ // intent: { intentId: "product", params: { id: "42" } },
186
+ // action: { kind: "flow", url: "/products/42" },
187
+ // renderMode: "ssr",
188
+ // guards: { before: [...], after: [...] },
189
+ // }
190
+ ```
191
+
192
+ 未命中返回 `null` —— 在服务端 404 逻辑里处理它。
193
+
194
+ ## 下一步
195
+
196
+ - [中间件](./03-middleware.md) —— 守卫导航、重定向、拒绝
197
+ - [渲染与 Hydration](./04-rendering-and-hydration.md) —— Controller 产出 Page 之后发生什么