@finesoft/front 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
@@ -1,220 +1,18 @@
1
- # 3. 中间件
1
+ # 页面守卫
2
2
 
3
- 中间件分两个阶段,包在 Controller 周围。守卫检查导航后返回四种结果之一,决定下一步发生什么。
3
+ URL、SSR、结构化导航共用页面加载器;执行前后运行全局、路由和导航守卫,每个 Split 目标都要检查。
4
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
5
+ ## Guard / 守卫
160
6
 
161
7
  ```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
- }
8
+ import { next, redirect, type BeforeLoadGuard } from "@finesoft/front";
9
+ export const signedIn: BeforeLoadGuard = (context) =>
10
+ context.getCookie("session")
11
+ ? next()
12
+ : redirect("/login?next=" + encodeURIComponent(context.url));
13
+ // app.beforeLoad: [signedIn], or home.route("/account", { beforeLoad: [signedIn] })
167
14
  ```
168
15
 
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
- 构造一条三守卫的 `beforeLoad` 链,逐个选定每个 guard 的返回值,然后交给 `@finesoft/core` 里**真实**的 `runBeforeLoadGuards()` 跑一次。下方流水线会显示链路在哪一步短路、最终的 `MiddlewareResult` 是什么。
16
+ 守卫返回 `next()`、`deny(status, message)`、`redirect(url, status)` 或 `rewrite(url)`;首个非 next 结果终止当前链。浏览器重定向仍经过主机导航准入,SSR 则返回 HTTP 重定向。操作策略属于可移植运行时层,数据接口也需在那里授权。已缓存 HTML 不能绕过当前请求的检查。
214
17
 
215
18
  <Ch03MiddlewarePlayground />
216
-
217
- ## 下一步
218
-
219
- - [渲染与 Hydration](./04-rendering-and-hydration.md) —— `afterLoad` 到 HTML 输出之间发生什么
220
- - [陷阱:redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md) —— 二者之间怎么选
@@ -1,338 +1,11 @@
1
- # 4. 渲染与 Hydration
1
+ # 渲染与水合
2
2
 
3
- 页面从 Controller 输出到字节流再回到活着的浏览器应用要走的路。本章覆盖 SSR、CSR、prerender,它们正交组合的第二根轴 —— **应用架构**(扁平单页 vs 结构化导航 + islands)—— 以及把两端绑在一起的 `PrefetchedIntents` 机制。
3
+ SSR、CSR、prerender 决定 HTML 的生成时机。三种模式都使用相同页面会话和单个原生应用根,布局包住 Outlet;不再选择 root/entries 模式,也没有单独 chrome 根。
4
4
 
5
- ## 三种模式并排比
5
+ 浏览器调用 `createBrowserApp({ definition, target })`,用原生 API 挂载或水合相同的 App,再等待 `app.ready`。SSR 调用 `createSSRRender({ definition, render: app => nativeRender(app) })`。完整 React 代码见[快速开始](./01-getting-started.md),Vue/Svelte 模板使用各自的原生 API。
6
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 的爬虫 | 最好 |
7
+ Outlet 订阅稳定的 `AppSnapshot`,以 EntryId 保持外层元素,以 EntryId 与 pageType 共同确定页面组件身份。隐藏 entry 仍在同一组件树中,草稿与 context 保留;类型变化重建内部页面。原生提交钩子只确认实际提交的 revision,框架随后恢复对应页面的滚动和 DOM 状态。
15
8
 
16
- 模式是**按路由**配置,自由混合。
9
+ SSR 在释放请求资源前物化公开投影,原生组件只渲染一次。水合协议明确包含 `{ tree, pages }`,每份页面数据关联 entry。协议版本或 buildId 不匹配时重新加载;会话持久化另有版本。被守卫拒绝的页面数据不进入 wire。
17
10
 
18
- ## 两根轴:渲染模式 × 应用架构
19
-
20
- 渲染模式是一根轴。**应用架构**是正交的第二根轴:
21
-
22
- - **扁平单页** —— 服务端 `createSSRRender`,客户端单次挂载、每次导航重渲。一个 root、一个可见页。(见下方 [SSR 管线](#ssr-管线)。)
23
- - **结构化导航 + islands** —— 服务端 `createSSRNavigationRender`,客户端按目标挂为 _islands_:独立 root,切 tab / 栈时保活不销毁。(见 [导航](./11-navigation.md) 与下方 [Islands SSR](#islands-ssr结构化架构方案-c)。)
24
-
25
- 两轴组合成矩阵 —— 渲染模式决定 HTML _何时/何地_ 产出,架构决定应用 _怎么组织_:
26
-
27
- | | 扁平单页 | 结构化导航 + islands(方案 C) |
28
- | ------------- | ----------------------- | --------------------------------- |
29
- | **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`、`react-minimal` |
30
- | **csr** | ◐ 空壳 → 单 client root | ◐ 空壳 → islands 客户端挂 |
31
- | **prerender** | ◐ 缓存扁平 SSR | ◐ 缓存方案 C SSR |
32
-
33
- ✅ 有 starter 模板实证 · ◐ 设计上成立,暂无 starter 模板。
34
-
35
- **islands 是 SSR 还是 CSR,是模式的结果、不是独立选项**:`ssr`/`prerender` 下框架服务端渲出每个可见 island、客户端 _收养并水合_;`csr` 下无服务端 HTML,每个 island 都在客户端新挂。每种模式的子维度仍叠加其上 —— CSR 有两个触发点([见下](#csr客户端渲染)),prerender 有构建期静态与运行时 ISR 两种形态([见下](#prerender静态--isr))。会话恢复 + DOM 恢复是再一层正交(客户端、post-hydration),可叠加在任意格上 —— 见 [会话恢复](./12-session-restoration.md)。
36
-
37
- ## SSR 管线
38
-
39
- ```
40
- 请求 URL
41
-
42
-
43
- Router.resolve() → RouteMatch
44
-
45
-
46
- beforeLoad 守卫 → 可能 rewrite(内部)/ redirect / deny
47
-
48
-
49
- IntentDispatcher.dispatch() → Page
50
-
51
-
52
- afterLoad 守卫 → 可能 redirect / deny / 发出 canonical 信号
53
-
54
-
55
- renderApp(page) → { html, head, css }
56
-
57
-
58
- injectSSRContent() → 最终 HTML,含:
59
- • 渲染后的 body 放在 <!--ssr--> 里
60
- • head 片段放在 <!--head--> 里
61
- • 序列化的 PrefetchedIntents 放在 <script> 里
62
- • <html lang="..." dir="..."> 属性
63
- ```
64
-
65
- ### SSR 入口
66
-
67
- ```ts
68
- // src/ssr.ts
69
- import { createSSRRender, serializeServerData } from "@finesoft/front";
70
- import { createSSRApp } from "vue";
71
- import { renderToString } from "vue/server-renderer";
72
- import App from "./App.vue";
73
- import { bootstrap } from "./bootstrap";
74
-
75
- export const render = createSSRRender({
76
- bootstrap,
77
- getErrorPage: () => ({ kind: "error", title: "Something went wrong" }),
78
- async renderApp(page) {
79
- const app = createSSRApp(App, { page });
80
- const html = await renderToString(app);
81
- return {
82
- html,
83
- head: `<title>${escape(page.title)}</title>`,
84
- css: "",
85
- };
86
- },
87
- });
88
-
89
- export { serializeServerData };
90
- ```
91
-
92
- Vite 插件和 adapter 替你调用 `render(url, options)`。你返回 `{ html, head, css }`,框架处理注入和序列化。
93
-
94
- ### `createSSRRender` 替你做了什么
95
-
96
- - 服务端跑一次 `bootstrap()`(同一个 worker 内跨请求缓存)
97
- - 每个请求创建请求级 DI 容器
98
- - 跑中间件管线
99
- - 调用你的 `renderApp()` 产出 body
100
- - 把 prefetched intent 结果序列化进 `<script id="__finesoft_data__">`
101
- - 根据解析出的 locale 设置 `<html lang dir>`
102
- - 根据 `deny()` / `redirect()` / `rewrite()` 结果设置 HTTP status
103
- - `afterLoad` 发出 rewrite 信号时加 `Content-Location` 头
104
-
105
- ## Islands SSR(结构化架构,"方案 C")
106
-
107
- 结构化架构把 **chrome**(tab bar、header —— 持久外框)和 **island 内容**(当前活动页)渲为**独立的水合 root**,作为挂载节点下的 sibling:
108
-
109
- ```html
110
- <div id="app">
111
- <div data-fs-chrome><!-- chrome 渲在这 --></div>
112
- <main data-fs-outlet><!-- 每个可见 island 渲在这 --></main>
113
- </div>
114
- ```
115
-
116
- **服务端** —— `renderApp` 渲 chrome;`renderIslandsHtml(snapshot, renderEntry)` 把每个可见目标渲进 outlet,带上共享标记(`data-fs-entry` / `data-fs-intent` / `data-fs-key`)供客户端匹配:
117
-
118
- ```ts
119
- // src/ssr.ts —— 结构化入口(createSSRNavigationRender)
120
- async renderApp(page, _framework, snapshot) {
121
- const chromeHtml = await renderToString(createSSRApp(App, { snapshot }));
122
- const islandsHtml = await renderIslandsHtml(snapshot, (entry) =>
123
- renderToString(createSSRApp(VIEWS[entry.intent], { page: entry.page })),
124
- );
125
- return {
126
- html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
127
- head: `<title>${page.title}</title>`,
128
- css: "",
129
- };
130
- }
131
- ```
132
-
133
- **客户端** —— `resolveIslandsShell(target)` 找到(或创建)chrome/outlet sibling,并报告 chrome 是否服务端渲过(`hydrate`)。island 编排器按 `data-fs-key` 收养每个 SSR'd 容器,调你的 `mountEntry(entry, container)` 时置 `entry.hydrate = true`,让你水合已有 DOM 而非新建:
134
-
135
- ```ts
136
- // src/main.ts
137
- const mountEntry = (entry, container) => {
138
- const factory = entry.hydrate ? createSSRApp : createApp; // 水合 SSR'd vs 新建(客户端导航)
139
- const app = factory(VIEWS[entry.intent], { page: entry.page, controller: ctx.app });
140
- app.mount(container);
141
- return { unmount: () => app.unmount() };
142
- };
143
-
144
- startBrowserApp({
145
- bootstrap,
146
- mount,
147
- callbacks,
148
- navigation: { ...navigation.toBrowserConfig(), mountEntry },
149
- });
150
- ```
151
-
152
- > **同步挂载契约。** `mountEntry` 返回后,island 的 DOM 必须已就绪:框架在下一帧回填 `data-restore-root` 字段(见 [会话恢复](./12-session-restoration.md))。Vue/Svelte 的 `.mount()` 同步满足。**React** 异步提交 DOM,故须把**客户端新挂**路径用 `flushSync(() => root.render(view))` 包住 —— 仅客户端新挂的 island 需要(SSR'd island 的 DOM 已来自服务端)。见 `templates/react-minimal/src/main.tsx`。
153
-
154
- 完整示例:`templates/vue-minimal` 与 `templates/react-minimal`(均为 `ssr` + 结构化导航 + islands + 会话恢复)。
155
-
156
- ## CSR(客户端渲染)
157
-
158
- `renderMode: "csr"` 的路由,服务端返回最小空壳:
159
-
160
- ```html
161
- <!doctype html>
162
- <html lang="en">
163
- <head>
164
- <!-- 这里注入 head -->
165
- </head>
166
- <body>
167
- <div id="app"></div>
168
- <!-- 没有 PrefetchedIntents script —— Controller 在浏览器跑 -->
169
- <script type="module" src="/src/main.ts"></script>
170
- </body>
171
- </html>
172
- ```
173
-
174
- `startBrowserApp()` 触发首次导航时 Controller 在浏览器跑。CSR 适用:
175
-
176
- - 鉴权背后高度个性化的 dashboard
177
- - SEO 不重要的页面
178
- - 服务端渲染成本盖过延迟收益的页面
179
-
180
- ## Prerender(静态 + ISR)
181
-
182
- ```ts
183
- { path: "/about", intentId: "about", controller: new AboutController(), renderMode: "prerender" }
184
- ```
185
-
186
- 构建期框架:
187
-
188
- 1. 调 `controller.execute({}, container)`(参数来自静态路径)
189
- 2. 跑 `renderApp()` 产 HTML
190
- 3. 写 `dist/about.html` 到磁盘
191
-
192
- adapter 直接服务这些静态文件。请求时不跑 Controller。
193
-
194
- ### 增量静态再生成(ISR)
195
-
196
- 打包的服务器(`createServer`)和预览服务器(`vp preview`)会在运行时缓存 `prerender` 路由:路由在**首次**请求时渲染,HTML 存入内存 LRU(`ISR_CACHE_MAX = 1000` 条,按最近最少使用驱逐)。后续请求直接吐缓存、不再跑 controller。
197
-
198
- 把路由标 `prerender`:路由级(`renderMode: "prerender"`)或经 Vite 插件按 glob 配置(配置级优先于路由级):
199
-
200
- ```ts
201
- finesoftFrontViteConfig({
202
- ssr: { entry: "src/ssr.ts" },
203
- renderModes: { "/blog/*": "prerender" },
204
- });
205
- ```
206
-
207
- 运行时缓存**无 TTL、无后台再生成** —— 条目存活到被 LRU 驱逐或进程重启为止。基于时间的 stale-while-revalidate 由平台 adapter 委托给 CDN(Netlify 发真正的 `stale-while-revalidate` 头;Cloudflare 发普通 `max-age`;node/Vercel 不发)。完整说明见 [服务器与部署](./09-server-and-deployment.md#isr增量静态再生成)。
208
-
209
- ## `PrefetchedIntents` —— SSR → CSR 的桥梁
210
-
211
- 关键机制:**同一个 Controller 在服务器产出页面,浏览器复用结果不重新请求。**
212
-
213
- ### 工作原理
214
-
215
- 1. SSR:Controller 跑,返回 `Page`。框架把 `(intentId, paramsKey) → Page` 存入 `PrefetchedIntents` map。
216
- 2. 渲染:map 被 JSON 序列化进 `<script id="__finesoft_data__">{...}</script>`。
217
- 3. 浏览器:`startBrowserApp` 读这个 script,调 `createPrefetchedIntentsFromDom()`,传给 `Framework.create()`。
218
- 4. 浏览器首次导航:`IntentDispatcher.dispatch()` 用 `(intentId, paramsKey)` 查 map —— 命中则直接返回缓存的 `Page`,不调 Controller。
219
-
220
- ### 稳定 key 生成
221
-
222
- 查找 key 由 `intentId` + `params` 的**稳定 JSON 字符串化**生成。对象键的顺序不影响 key:
223
-
224
- ```ts
225
- // 下面两个产出相同的 paramsKey:
226
- dispatch({ intentId: "product", params: { id: "42", color: "red" } });
227
- dispatch({ intentId: "product", params: { color: "red", id: "42" } });
228
- ```
229
-
230
- 如果你写的 Controller 用不同 `params` 形状解析同一个逻辑请求,dispatch 之前先归一化。
231
-
232
- ### 缓存什么时候不命中
233
-
234
- - 浏览器导航到未在服务端 prefetch 的 intent(例如用户点击的动态路由)
235
- - `PrefetchedIntents.invalidate(intentId, params)` 之后变陈旧
236
- - 浏览器端的 mutation 守卫(自定义)
237
-
238
- 不命中走普通 dispatcher 路径 —— `execute()` 在浏览器跑。
239
-
240
- ## 一步一步看 Hydration
241
-
242
- ```
243
- 服务器 浏览器
244
- ────── ───────
245
- bootstrap(framework)
246
- ▼ │
247
- controller.execute() │
248
- ▼ │
249
- Page A │
250
- ▼ │
251
- serialize → <script> │
252
- ▼ │
253
- HTML 响应 ─────────────────────▶ 接收 HTML
254
-
255
- createPrefetchedIntentsFromDom()
256
-
257
- Framework.create({ prefetchedIntents })
258
-
259
- bootstrap(framework) ← 同份代码、同份路由
260
-
261
- dispatch(currentIntent)
262
-
263
- 缓存命中 → Page A ← 不重新请求
264
-
265
- mount(app)
266
- ```
267
-
268
- bootstrap 跑两遍 —— 两端各一次 —— 输入相同。这就是浏览器初始路由和服务端渲染 HTML 一致的保证。
269
-
270
- ## SSR head 注入
271
-
272
- `renderApp()` 返回 `head` 片段。框架把它注入到 `<!--head-->` 占位,同时还会注入:
273
-
274
- - `<script id="__finesoft_data__">` 序列化数据(仅 SSR 模式)
275
- - 客户端入口的 `<link>` / `<script>`(生产构建)
276
- - 来自解析 locale 的 `<html lang="..." dir="...">` 属性
277
-
278
- 自定义 meta 标签放进你的 `head` 字符串:
279
-
280
- ```ts
281
- async renderApp(page) {
282
- return {
283
- html: await renderToString(/*...*/),
284
- head: [
285
- `<title>${escape(page.title)}</title>`,
286
- `<meta name="description" content="${escape(page.description)}">`,
287
- `<meta property="og:title" content="${escape(page.title)}">`,
288
- ].join(""),
289
- css: "",
290
- };
291
- }
292
- ```
293
-
294
- 用户提供的字符串永远要 escape —— 它们直接进 HTML。
295
-
296
- ## CSS 注入
297
-
298
- 如果渲染产出关键 CSS(如 Vue scoped 样式、`vanilla-extract`),通过 `css` 返回:
299
-
300
- ```ts
301
- return {
302
- html,
303
- head: `<title>${title}</title>`,
304
- css: extractedCriticalCss, // 作为 <style> 注入到 <head>
305
- };
306
- ```
307
-
308
- Vite 管理的样式表保持 `css: ""` —— Vite 插件会处理。
309
-
310
- ## 状态码
311
-
312
- SSR 响应的 HTTP 状态按以下优先级决定:
313
-
314
- 1. 中间件结果:`deny(404)` → 404;`redirect(url, 301)` → 301 + `Location` 头。
315
- 2. 页面级:`fallback()` 返回 `kind: "error"` 的 `Page` → 500(可通过 `getErrorPage` 配置)。
316
- 3. 默认:200。
317
-
318
- 通过 `afterLoad` 覆盖:
319
-
320
- ```ts
321
- afterLoad: [
322
- (ctx) => {
323
- if (ctx.page.kind === "not-found") return deny(404, "Not found");
324
- return next();
325
- },
326
- ],
327
- ```
328
-
329
- ## 流式 SSR
330
-
331
- 当前不支持。框架在发字节前完整 await `renderApp()`。对大多数应用够用 —— Controller 内部 `execute()` 里并发 await 多个 HTTP 调用就能并行抓数据。
332
-
333
- 如果某个大页面确实需要流式渲染,考虑用 CSR 渲染该页面 + 使用视图层自己的流式原语。
334
-
335
- ## 下一步
336
-
337
- - [国际化](./05-i18n.md) —— locale 解析和字典加载
338
- - [陷阱:SSR Hydration 不匹配](./pitfalls/ssr-hydration-mismatch.md) —— 两端不一致时
11
+ CSR 返回 HTML shell。prerender 输出静态 HTML;运行时公开 HTML 缓存仍先运行本次守卫与渲染,不能视为跳过业务执行。清理时应用先等待会话 dispose,再卸载自己的原生根。