@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,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 之后发生什么
@@ -0,0 +1,214 @@
1
+ # 3. 中间件
2
+
3
+ 中间件分两个阶段,包在 Controller 周围。守卫检查导航后返回四种结果之一,决定下一步发生什么。
4
+
5
+ ## 管线
6
+
7
+ ```
8
+ Router.resolve()
9
+
10
+
11
+ beforeLoad 链 ← NavigationContext(此时还没有 Page)
12
+
13
+ next()? ──否──▶ 短路(redirect / rewrite / deny)
14
+ │ 是
15
+
16
+ IntentDispatcher.dispatch()
17
+
18
+
19
+ afterLoad 链 ← PostLoadContext(已有 Page)
20
+
21
+ next()? ──否──▶ 短路
22
+ │ 是
23
+
24
+ render
25
+ ```
26
+
27
+ 守卫按数组顺序执行。第一个非 `next()` 的结果短路后续链。
28
+
29
+ ## 四种结果
30
+
31
+ ```ts
32
+ import { next, redirect, rewrite, deny } from "@finesoft/front";
33
+
34
+ next(); // 继续下一个守卫 / dispatcher
35
+ redirect("/login"); // HTTP 302;导航到 URL
36
+ redirect("/old", 301); // HTTP 301(永久)
37
+ rewrite("/canonical"); // beforeLoad 里:内部重路由;afterLoad 里:canonical 信号
38
+ deny(); // 403 Forbidden
39
+ deny(404, "Not found"); // 自定义状态码 + 消息
40
+ ```
41
+
42
+ ### `next()`
43
+
44
+ 直通。管线继续。
45
+
46
+ ### `redirect(url, status?)`
47
+
48
+ 浏览器导航到 `url`,原始渲染被丢弃。服务端表现为 HTTP 重定向;浏览器表现为导航(通过 `History.pushState`)。
49
+
50
+ 适用:登录跳转、废弃路径、locale 前缀归一化。
51
+
52
+ ### `rewrite(url)`
53
+
54
+ **`beforeLoad` 里的 rewrite** —— 内部重路由。Router 改为解析 `url`,_新_ match 的守卫和 Controller 跑。不产生 HTTP 重定向;地址栏保持原 URL。深度限制为 5 层,防死循环。
55
+
56
+ **`afterLoad` 里的 rewrite** —— canonical 信号。框架在 SSR 响应里包含 `Content-Location` 头,但不重定向。浏览器收到原 URL 加一个提示:存在 canonical 版本。
57
+
58
+ 何时用哪个,见 [redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md)。
59
+
60
+ ### `deny(status?, message?)`
61
+
62
+ 停止请求。默认 `403 Forbidden`。常见:`deny(401, "Login required")`、`deny(404, "Not found")`。
63
+
64
+ ## 写守卫
65
+
66
+ 守卫是从 context 到 `MiddlewareResult`(或 `Promise<MiddlewareResult>`)的函数。
67
+
68
+ ```ts
69
+ // src/lib/guards/auth.ts
70
+ import { next, redirect, type NavigationContext } from "@finesoft/front";
71
+
72
+ export function authGuard(ctx: NavigationContext) {
73
+ const token = ctx.getCookie("token");
74
+ if (!token) {
75
+ return redirect(`/login?next=${encodeURIComponent(ctx.url.pathname)}`);
76
+ }
77
+ return next();
78
+ }
79
+ ```
80
+
81
+ ### `NavigationContext`(beforeLoad)
82
+
83
+ | 字段 | 类型 | 说明 |
84
+ | ----------------- | ---------------------------------- | --------------------------------------- |
85
+ | `url` | `URL` | 完整请求 URL。 |
86
+ | `intent` | `Intent` | 解析后的 intent,包含路径参数。 |
87
+ | `container` | `Container` | 请求级 DI 容器。 |
88
+ | `getCookie(name)` | `(name: string) => string \| null` | 读 cookie(服务端 + 浏览器都可用)。 |
89
+ | `getHeader(name)` | `(name: string) => string \| null` | 读请求头(仅服务端;浏览器返回 null)。 |
90
+ | `isSsr` | `boolean` | 服务端 `true`,浏览器 `false`。 |
91
+
92
+ ### `PostLoadContext`(afterLoad)
93
+
94
+ 继承自 `NavigationContext`,新增:
95
+
96
+ | 字段 | 类型 | 说明 |
97
+ | ------ | ---------- | ------------------------ |
98
+ | `page` | `BasePage` | Controller 产出的 Page。 |
99
+
100
+ ## 给路由挂守卫
101
+
102
+ ```ts
103
+ defineRoutes(framework, [
104
+ {
105
+ path: "/admin",
106
+ intentId: "admin",
107
+ controller: new AdminController(),
108
+ beforeLoad: [authGuard, requireAdminRole],
109
+ afterLoad: [trackPageView],
110
+ },
111
+ ]);
112
+ ```
113
+
114
+ 路由上的守卫**叠加**在框架全局守卫之上(见下)。顺序:先全局,后路由级。
115
+
116
+ ## 全局守卫
117
+
118
+ 注册对每个导航都生效的守卫:
119
+
120
+ ```ts
121
+ framework.middleware.use("beforeLoad", trackingGuard);
122
+ framework.middleware.use("afterLoad", metricsGuard);
123
+ ```
124
+
125
+ 慎用。全局守卫每页都跑,包括 SSR —— 慢的全局守卫会乘到整个表面积上。
126
+
127
+ ## 常见模式
128
+
129
+ ### 鉴权
130
+
131
+ ```ts
132
+ function authGuard(ctx: NavigationContext) {
133
+ const token = ctx.getCookie("session");
134
+ if (!token) return redirect("/login?next=" + encodeURIComponent(ctx.url.pathname));
135
+ return next();
136
+ }
137
+ ```
138
+
139
+ ### 角色校验
140
+
141
+ ```ts
142
+ async function requireAdmin(ctx: NavigationContext) {
143
+ const session = await ctx.container.resolve<SessionService>("session").current();
144
+ if (!session?.isAdmin) return deny(403, "Admin only");
145
+ return next();
146
+ }
147
+ ```
148
+
149
+ ### Locale 前缀重定向
150
+
151
+ ```ts
152
+ function localePrefixGuard(ctx: NavigationContext) {
153
+ if (/^\/(en|zh|ja)\//.test(ctx.url.pathname)) return next();
154
+ const detected = detectLocale(ctx); // 你自己的逻辑
155
+ return redirect(`/${detected}${ctx.url.pathname}`, 301);
156
+ }
157
+ ```
158
+
159
+ ### A/B 测试 rewrite
160
+
161
+ ```ts
162
+ function abTestGuard(ctx: NavigationContext) {
163
+ if (ctx.url.pathname !== "/landing") return next();
164
+ const variant = bucket(ctx.getCookie("uid"));
165
+ return variant === "B" ? rewrite("/landing-v2") : next();
166
+ }
167
+ ```
168
+
169
+ 用户在地址栏看到 `/landing`;服务端渲染 `/landing-v2`。客户端无可见重定向,无闪屏。
170
+
171
+ ### afterLoad 分析
172
+
173
+ ```ts
174
+ function trackPageView(ctx: PostLoadContext) {
175
+ ctx.container.resolve<EventRecorder>("eventRecorder").record({
176
+ name: "PageView",
177
+ fields: { intentId: ctx.intent.intentId, url: ctx.url.pathname },
178
+ });
179
+ return next();
180
+ }
181
+ ```
182
+
183
+ ## 守卫顺序规则
184
+
185
+ 1. 全局 `beforeLoad` 守卫(注册顺序)
186
+ 2. 路由级 `beforeLoad` 守卫(数组顺序)
187
+ 3. Controller `execute()`
188
+ 4. 全局 `afterLoad` 守卫
189
+ 5. 路由级 `afterLoad` 守卫
190
+
191
+ 任一步返回非 `next()` 则停止。后续守卫不再跑。
192
+
193
+ ## 异步守卫
194
+
195
+ 守卫可以 `async`。管线在每个结果之间 await。全局守卫里别 await 太久(会乘到每个请求)。
196
+
197
+ ```ts
198
+ async function rateLimitGuard(ctx: NavigationContext) {
199
+ const limiter = ctx.container.resolve<RateLimiter>("rateLimiter");
200
+ const allowed = await limiter.tryConsume(ctx.getCookie("uid") ?? "anon");
201
+ return allowed ? next() : deny(429, "Too many requests");
202
+ }
203
+ ```
204
+
205
+ ## 注意事项
206
+
207
+ - **守卫对框架状态必须是纯的。** 不要改 `ctx.intent.params` —— 需要改参数就构造新 intent 然后 `rewrite`。
208
+ - **`afterLoad` 里的 `deny()` 会丢弃已产出的页面。** Controller 已经跑过了;deny 只阻断响应。如果 `execute()` 有副作用(写操作),副作用已经发生。
209
+ - **浏览器端守卫拿不到请求头。** `getHeader()` 在客户端返回 `null`。cookie 仍然可用。
210
+
211
+ ## 下一步
212
+
213
+ - [渲染与 Hydration](./04-rendering-and-hydration.md) —— `afterLoad` 到 HTML 输出之间发生什么
214
+ - [陷阱:redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md) —— 二者之间怎么选
@@ -0,0 +1,271 @@
1
+ # 4. 渲染与 Hydration
2
+
3
+ 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,以及把两端绑在一起的 `PrefetchedIntents` 机制。
4
+
5
+ ## 三种模式并排比
6
+
7
+ | | SSR | CSR | Prerender |
8
+ | ------------------------ | --------------------------------------- | ----------------------------- | --------------------------------------- |
9
+ | HTML 何时生成 | 每次请求时在服务端 | 构建期(仅空壳) | 构建期,按路由 |
10
+ | 初始 body | 完整渲染 | 空 `<div id="app"></div>` | 完整渲染 |
11
+ | Hydration 时需重新请求? | 不需要(数据在 `PrefetchedIntents` 里) | 需要(Controller 在浏览器跑) | 不需要(数据在 `PrefetchedIntents` 里) |
12
+ | TTFB | 一次 Controller 执行 | 接近零 | 静态文件直接发 |
13
+ | 个性化 | 按请求 OK | 最好 —— 完全在客户端跑 | 无(所有人同一份 HTML) |
14
+ | SEO | 好 | 需要支持 JS 的爬虫 | 最好 |
15
+
16
+ 模式是**按路由**配置,自由混合。
17
+
18
+ ## SSR 管线
19
+
20
+ ```
21
+ 请求 URL
22
+
23
+
24
+ Router.resolve() → RouteMatch
25
+
26
+
27
+ beforeLoad 守卫 → 可能 rewrite(内部)/ redirect / deny
28
+
29
+
30
+ IntentDispatcher.dispatch() → Page
31
+
32
+
33
+ afterLoad 守卫 → 可能 redirect / deny / 发出 canonical 信号
34
+
35
+
36
+ renderApp(page) → { html, head, css }
37
+
38
+
39
+ injectSSRContent() → 最终 HTML,含:
40
+ • 渲染后的 body 放在 <!--ssr--> 里
41
+ • head 片段放在 <!--head--> 里
42
+ • 序列化的 PrefetchedIntents 放在 <script> 里
43
+ • <html lang="..." dir="..."> 属性
44
+ ```
45
+
46
+ ### SSR 入口
47
+
48
+ ```ts
49
+ // src/ssr.ts
50
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
51
+ import { createSSRApp } from "vue";
52
+ import { renderToString } from "vue/server-renderer";
53
+ import App from "./App.vue";
54
+ import { bootstrap } from "./bootstrap";
55
+
56
+ export const render = createSSRRender({
57
+ bootstrap,
58
+ getErrorPage: () => ({ kind: "error", title: "Something went wrong" }),
59
+ async renderApp(page) {
60
+ const app = createSSRApp(App, { page });
61
+ const html = await renderToString(app);
62
+ return {
63
+ html,
64
+ head: `<title>${escape(page.title)}</title>`,
65
+ css: "",
66
+ };
67
+ },
68
+ });
69
+
70
+ export { serializeServerData };
71
+ ```
72
+
73
+ Vite 插件和 adapter 替你调用 `render(url, options)`。你返回 `{ html, head, css }`,框架处理注入和序列化。
74
+
75
+ ### `createSSRRender` 替你做了什么
76
+
77
+ - 服务端跑一次 `bootstrap()`(同一个 worker 内跨请求缓存)
78
+ - 每个请求创建请求级 DI 容器
79
+ - 跑中间件管线
80
+ - 调用你的 `renderApp()` 产出 body
81
+ - 把 prefetched intent 结果序列化进 `<script id="__finesoft_data__">`
82
+ - 根据解析出的 locale 设置 `<html lang dir>`
83
+ - 根据 `deny()` / `redirect()` / `rewrite()` 结果设置 HTTP status
84
+ - `afterLoad` 发出 rewrite 信号时加 `Content-Location` 头
85
+
86
+ ## CSR(客户端渲染)
87
+
88
+ `renderMode: "csr"` 的路由,服务端返回最小空壳:
89
+
90
+ ```html
91
+ <!doctype html>
92
+ <html lang="en">
93
+ <head>
94
+ <!-- 这里注入 head -->
95
+ </head>
96
+ <body>
97
+ <div id="app"></div>
98
+ <!-- 没有 PrefetchedIntents script —— Controller 在浏览器跑 -->
99
+ <script type="module" src="/src/main.ts"></script>
100
+ </body>
101
+ </html>
102
+ ```
103
+
104
+ `startBrowserApp()` 触发首次导航时 Controller 在浏览器跑。CSR 适用:
105
+
106
+ - 鉴权背后高度个性化的 dashboard
107
+ - SEO 不重要的页面
108
+ - 服务端渲染成本盖过延迟收益的页面
109
+
110
+ ## Prerender(静态 + ISR)
111
+
112
+ ```ts
113
+ { path: "/about", intentId: "about", controller: new AboutController(), renderMode: "prerender" }
114
+ ```
115
+
116
+ 构建期框架:
117
+
118
+ 1. 调 `controller.execute({}, container)`(参数来自静态路径)
119
+ 2. 跑 `renderApp()` 产 HTML
120
+ 3. 写 `dist/about.html` 到磁盘
121
+
122
+ adapter 直接服务这些静态文件。请求时不跑 Controller。
123
+
124
+ ### 增量静态再生成(ISR)
125
+
126
+ 打包的服务器(`createServer`)和预览服务器(`vp preview`)支持按需缓存的再生成。通过 `finesoftFrontViteConfig` 配置:
127
+
128
+ ```ts
129
+ finesoftFrontViteConfig({
130
+ ssr: { entry: "src/ssr.ts" },
131
+ isr: {
132
+ // 哪些路由按需再生成
133
+ routes: ["/blog/*"],
134
+ // 缓存 TTL(秒)
135
+ ttl: 300,
136
+ },
137
+ });
138
+ ```
139
+
140
+ 过期后的首个请求触发新一轮渲染;并发请求拿到陈旧版本直到新版生成完成。详见 [服务器与部署](./09-server-and-deployment.md#isr)。
141
+
142
+ ## `PrefetchedIntents` —— SSR → CSR 的桥梁
143
+
144
+ 关键机制:**同一个 Controller 在服务器产出页面,浏览器复用结果不重新请求。**
145
+
146
+ ### 工作原理
147
+
148
+ 1. SSR:Controller 跑,返回 `Page`。框架把 `(intentId, paramsKey) → Page` 存入 `PrefetchedIntents` map。
149
+ 2. 渲染:map 被 JSON 序列化进 `<script id="__finesoft_data__">{...}</script>`。
150
+ 3. 浏览器:`startBrowserApp` 读这个 script,调 `createPrefetchedIntentsFromDom()`,传给 `Framework.create()`。
151
+ 4. 浏览器首次导航:`IntentDispatcher.dispatch()` 用 `(intentId, paramsKey)` 查 map —— 命中则直接返回缓存的 `Page`,不调 Controller。
152
+
153
+ ### 稳定 key 生成
154
+
155
+ 查找 key 由 `intentId` + `params` 的**稳定 JSON 字符串化**生成。对象键的顺序不影响 key:
156
+
157
+ ```ts
158
+ // 下面两个产出相同的 paramsKey:
159
+ dispatch({ intentId: "product", params: { id: "42", color: "red" } });
160
+ dispatch({ intentId: "product", params: { color: "red", id: "42" } });
161
+ ```
162
+
163
+ 如果你写的 Controller 用不同 `params` 形状解析同一个逻辑请求,dispatch 之前先归一化。
164
+
165
+ ### 缓存什么时候不命中
166
+
167
+ - 浏览器导航到未在服务端 prefetch 的 intent(例如用户点击的动态路由)
168
+ - `PrefetchedIntents.invalidate(intentId, params)` 之后变陈旧
169
+ - 浏览器端的 mutation 守卫(自定义)
170
+
171
+ 不命中走普通 dispatcher 路径 —— `execute()` 在浏览器跑。
172
+
173
+ ## 一步一步看 Hydration
174
+
175
+ ```
176
+ 服务器 浏览器
177
+ ────── ───────
178
+ bootstrap(framework)
179
+ ▼ │
180
+ controller.execute() │
181
+ ▼ │
182
+ Page A │
183
+ ▼ │
184
+ serialize → <script> │
185
+ ▼ │
186
+ HTML 响应 ─────────────────────▶ 接收 HTML
187
+
188
+ createPrefetchedIntentsFromDom()
189
+
190
+ Framework.create({ prefetchedIntents })
191
+
192
+ bootstrap(framework) ← 同份代码、同份路由
193
+
194
+ dispatch(currentIntent)
195
+
196
+ 缓存命中 → Page A ← 不重新请求
197
+
198
+ mount(app)
199
+ ```
200
+
201
+ bootstrap 跑两遍 —— 两端各一次 —— 输入相同。这就是浏览器初始路由和服务端渲染 HTML 一致的保证。
202
+
203
+ ## SSR head 注入
204
+
205
+ `renderApp()` 返回 `head` 片段。框架把它注入到 `<!--head-->` 占位,同时还会注入:
206
+
207
+ - `<script id="__finesoft_data__">` 序列化数据(仅 SSR 模式)
208
+ - 客户端入口的 `<link>` / `<script>`(生产构建)
209
+ - 来自解析 locale 的 `<html lang="..." dir="...">` 属性
210
+
211
+ 自定义 meta 标签放进你的 `head` 字符串:
212
+
213
+ ```ts
214
+ async renderApp(page) {
215
+ return {
216
+ html: await renderToString(/*...*/),
217
+ head: [
218
+ `<title>${escape(page.title)}</title>`,
219
+ `<meta name="description" content="${escape(page.description)}">`,
220
+ `<meta property="og:title" content="${escape(page.title)}">`,
221
+ ].join(""),
222
+ css: "",
223
+ };
224
+ }
225
+ ```
226
+
227
+ 用户提供的字符串永远要 escape —— 它们直接进 HTML。
228
+
229
+ ## CSS 注入
230
+
231
+ 如果渲染产出关键 CSS(如 Vue scoped 样式、`vanilla-extract`),通过 `css` 返回:
232
+
233
+ ```ts
234
+ return {
235
+ html,
236
+ head: `<title>${title}</title>`,
237
+ css: extractedCriticalCss, // 作为 <style> 注入到 <head>
238
+ };
239
+ ```
240
+
241
+ Vite 管理的样式表保持 `css: ""` —— Vite 插件会处理。
242
+
243
+ ## 状态码
244
+
245
+ SSR 响应的 HTTP 状态按以下优先级决定:
246
+
247
+ 1. 中间件结果:`deny(404)` → 404;`redirect(url, 301)` → 301 + `Location` 头。
248
+ 2. 页面级:`fallback()` 返回 `kind: "error"` 的 `Page` → 500(可通过 `getErrorPage` 配置)。
249
+ 3. 默认:200。
250
+
251
+ 通过 `afterLoad` 覆盖:
252
+
253
+ ```ts
254
+ afterLoad: [
255
+ (ctx) => {
256
+ if (ctx.page.kind === "not-found") return deny(404, "Not found");
257
+ return next();
258
+ },
259
+ ],
260
+ ```
261
+
262
+ ## 流式 SSR
263
+
264
+ 当前不支持。框架在发字节前完整 await `renderApp()`。对大多数应用够用 —— Controller 内部 `execute()` 里并发 await 多个 HTTP 调用就能并行抓数据。
265
+
266
+ 如果某个大页面确实需要流式渲染,考虑用 CSR 渲染该页面 + 使用视图层自己的流式原语。
267
+
268
+ ## 下一步
269
+
270
+ - [国际化](./05-i18n.md) —— locale 解析和字典加载
271
+ - [陷阱:SSR Hydration 不匹配](./pitfalls/ssr-hydration-mismatch.md) —— 两端不一致时