@finesoft/front 0.5.1 → 0.5.3

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-CR5vhgXg.mjs +1317 -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-BiRlUanX.d.mts +786 -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-2dSWO-Xw.d.mts +53 -0
  39. package/dist/proxy-z02VvGIj.mjs +7520 -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-BQBfaaPO.mjs +3825 -0
  49. package/dist/src-Ftl_0rhu.mjs +28 -0
  50. package/dist/ssr-BLzYP4wU.d.mts +207 -0
  51. package/dist/ssr-Tn4YkuxM.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-B1BT0N3t.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 +7 -332
  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 +9 -155
  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 +7 -332
  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 +9 -155
  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,314 +1,236 @@
1
- # 2. 路由与 Controller
1
+ # 路由、控制器与类型化页面
2
2
 
3
- 框架的路由层把 URL 映射到 **intent**,把 intent 映射到 **Controller**,Controller 产出 **Page**。本章覆盖这三个概念。
3
+ 六个模板均使用 `BaseController` 加载页面。它继续作为公开 API 提供;`definePage` 将控制器工厂或函数处理器接入 Web 应用,两种写法共用运行时、执行作用域和策略。
4
4
 
5
- ## 心智模型
5
+ ## 各层职责
6
6
 
7
- ```
8
- URL ──Router.resolve()──▶ RouteMatch { intent, renderMode, guards }
9
-
10
-
11
- IntentDispatcher.dispatch(intent)
12
-
13
-
14
- Controller.execute() → Page
15
- ```
7
+ | API | 职责 |
8
+ | --------------------------------- | ----------------------------------------------------------------- |
9
+ | `BaseController<TInput, TResult>` | 实现 `execute`,通过继承的 `perform` 处理参数、取消和 `fallback` |
10
+ | `definePage({ id, create })` | 声明页面加载工厂,生成可复用的 `route`、`leaf` 和 `bindView` 引用 |
11
+ | `defineWebApp` | 组装页面声明、路由、守卫及可选导航结构 |
12
+ | 原生页面组件 | 接收 `page` 数据,渲染 UI 和处理交互 |
16
13
 
17
- 一条路由定义包含:
14
+ 控制器、页面、路由与公开数据声明统一从 `@finesoft/front` 导入。
18
15
 
19
- - **路径模式**(`/products/:id`)
20
- - **intent id**(操作的逻辑名;一个 intent 可以对应多条路由)
21
- - **Controller 实例**(产出页面数据)
22
- - 可选的**参数 codec**(`params` / `query`)—— 校验并类型化 URL 参数
23
- - 可选的**渲染模式**(`ssr` / `csr` / `prerender`)
24
- - 可选的**守卫**(`beforeLoad` / `afterLoad`)
16
+ ## 用 BaseController 加载页面
25
17
 
26
- ## 定义路由
18
+ `src/lib/controllers/product.ts`:
27
19
 
28
20
  ```ts
29
- // src/bootstrap.ts
30
- import { type Framework, defineRoutes } from "@finesoft/front";
31
- import { HomeController } from "./lib/controllers/home";
32
- import { ProductController } from "./lib/controllers/product";
33
- import { authGuard } from "./lib/guards/auth";
34
-
35
- export function bootstrap(framework: Framework): void {
36
- defineRoutes(framework, [
37
- // 普通 SSR 路由
38
- { path: "/", intentId: "home", controller: new HomeController() },
39
-
40
- // 动态参数段
41
- { path: "/products/:id", intentId: "product", controller: new ProductController() },
42
-
43
- // 仅 CSR(服务端只返回空壳)
44
- {
45
- path: "/dashboard",
46
- intentId: "dashboard",
47
- controller: new DashboardController(),
48
- renderMode: "csr",
49
- },
50
-
51
- // 构建期静态化
52
- {
53
- path: "/about",
54
- intentId: "about",
55
- controller: new AboutController(),
56
- renderMode: "prerender",
57
- },
58
-
59
- // 受保护路由 —— 复用 home intent,但加守卫
60
- {
61
- path: "/admin",
62
- intentId: "home",
63
- controller: new HomeController(),
64
- beforeLoad: [authGuard],
65
- },
66
- ]);
67
- }
68
- ```
69
-
70
- ### 路由选项
71
-
72
- | 字段 | 类型 | 说明 |
73
- | ------------ | -------------------------------- | -------------------------------------------------------------- |
74
- | `path` | `string` | 路径模式,含 `:param` 占位。结尾的 `/` 会被归一化。 |
75
- | `intentId` | `string` | 操作的逻辑名。用于注册 Controller。 |
76
- | `controller` | `BaseController<TParams, TPage>` | 若 intent 已注册可省略。 |
77
- | `params` | codec map | 校验/转换 **path** 参数。key 必须出现在 `path` 中。见下文。 |
78
- | `query` | codec map | 校验/转换 **query** 参数。key 开放。见下文。 |
79
- | `renderMode` | `"ssr" \| "csr" \| "prerender"` | 默认 `"ssr"`。详见[第 4 章](./04-rendering-and-hydration.md)。 |
80
- | `beforeLoad` | `BeforeLoadGuard[]` | Controller 之前执行。详见[第 3 章](./03-middleware.md)。 |
81
- | `afterLoad` | `AfterLoadGuard[]` | Page 产出之后执行。 |
82
-
83
- ### 路径模式
21
+ import { BaseController, DEP_KEYS } from "@finesoft/front";
22
+ import { markPublic, type BasePage } from "@finesoft/front";
84
23
 
85
- - 静态:`/about`
86
- - 带参数:`/products/:id`、`/users/:userId/posts/:postId`
87
- - 可选参数:`/blog/:slug?` 同时匹配 `/blog` `/blog/hello`。
88
-
89
- 默认情况下,path 和 query 参数以**字符串键对象**的形式进入 `controller.execute(params, container)`。挂上 codec(下一节)即可校验并转换它们。
90
-
91
- ## 路由参数类型化
24
+ export interface ProductPage extends BasePage {
25
+ pageType: "product";
26
+ product: { id: number; name: string };
27
+ }
92
28
 
93
- codec 把原始字符串参数变成经校验、已转换、**编译期类型化**的值。内置原语零依赖覆盖常见场景;任意 [Standard Schema](https://standardschema.dev)(zod、valibot、arktype……)也能用。
29
+ export class ProductController extends BaseController {
30
+ async execute({ params, context }): Promise<ProductPage> {
31
+ const logger = await context.get(DEP_KEYS.LOGGER_FACTORY);
32
+ logger.loggerFor("ProductController").info(`Loading product ${params.id}`);
33
+ context.record("product.load", { productId: params.id });
34
+
35
+ return markPublic(
36
+ {
37
+ id: String(params.id),
38
+ pageType: "product",
39
+ title: `Product ${params.id}`,
40
+ product: { id: params.id, name: `Product ${params.id}` },
41
+ },
42
+ { product: { id: true, name: true } },
43
+ );
44
+ }
94
45
 
95
- ```ts
96
- import { defineRoutes, int, list, oneOf, optional, str, withDefault } from "@finesoft/front";
97
-
98
- defineRoutes(framework, [
99
- {
100
- path: "/products/:id",
101
- intentId: "product",
102
- controller: new ProductController(),
103
- params: { id: int({ min: 1 }) }, // :id 校验为正整数,转换为 number
104
- query: {
105
- page: withDefault(int({ min: 1 }), 1), // ?page= → number,缺失时默认 1
106
- sort: optional(oneOf(["asc", "desc"] as const)), // 可选的 "asc" | "desc"
107
- tags: list(str()), // ?tags=a&tags=b → string[]
108
- },
109
- },
110
- ]);
46
+ override fallback({ params }): ProductPage {
47
+ return markPublic(
48
+ {
49
+ id: String(params.id),
50
+ pageType: "product",
51
+ title: "Product unavailable",
52
+ product: { id: params.id, name: "Unavailable" },
53
+ },
54
+ { product: { id: true, name: true } },
55
+ );
56
+ }
57
+ }
111
58
  ```
112
59
 
113
- ### 内置 codec
114
-
115
- | codec | 输出 | 校验 |
116
- | ----------------------- | ---------- | ------------------------------------------------- |
117
- | `str(opts?)` | `string` | `minLength` / `maxLength` / `pattern`(`RegExp`) |
118
- | `int(opts?)` | `number` | 整数 + `min` / `max` |
119
- | `num(opts?)` | `number` | 有限数 + `min` / `max` |
120
- | `bool()` | `boolean` | `"true" \| "1" \| "false" \| "0"` |
121
- | `oneOf([...] as const)` | 字面量联合 | 成员检查 |
122
- | `uuid()` | `string` | UUID v1–v5 |
123
- | `list(item, opts?)` | `T[]` | 多值 query;每项过 `item` + `min`/`max` 个数 |
124
-
125
- 修饰器包裹 codec(codec 保持可序列化纯数据,不用链式 `.optional()`):
60
+ 框架根据下方路由声明维护类型导入、方法注解和基类泛型;这里展示首次编写时的源码,省略自动生成的引用。运行 `vp dev`、`vp check` 或构建即可生成。类型集中在 `.finesoft/controller-types.d.ts`,Controller 文件末尾不再追加声明区块。配置方式见[控制器类型自动关联](./11-navigation.md)。
126
61
 
127
- - `optional(codec)` —— 输入缺失 `undefined`;把该 key 渲染为**可选属性**(`page?: T`)。
128
- - `withDefault(codec, fallback)` —— 输入缺失 → `fallback`;key 保持必选。
62
+ 两个方法统一接收一个对象:`execute({ params, query, context })` `fallback({ params, query, context, error })`,只解构需要的字段即可。Query 与路径参数分开,根据路由中的 `query` 声明推断类型。
129
63
 
130
- ### 校验失败 = fall-through 404
64
+ 这个示例使用本地数据;实际业务可以在 `execute` 中调用服务。`context` 提供当前执行的 `signal`、`fetch`、`get(token)` 和 `execute(operation, input)`。执行异步请求时,将 `context.signal` 传给请求 API,取消才能传递到实际工作。
131
65
 
132
- codec 校验失败意味着该路由**不匹配** —— 路由器继续尝试下一条,全不中时落到既有 404。**没有**单独的 `400` 通道。这让重叠路由可以按类型消歧:
133
-
134
- ```ts
135
- defineRoutes(framework, [
136
- { path: "/item/:id", intentId: "item-by-id", controller, params: { id: int() } },
137
- { path: "/item/:slug", intentId: "item-by-slug", controller, params: { slug: str() } },
138
- ]);
139
- // /item/42 → item-by-id (int 匹配)
140
- // /item/hello → item-by-slug (int 拒绝 → fall-through 到 str)
141
- ```
66
+ `fallback` 仅在 `execute` 发生普通异常时调用,返回类型仍是 `ProductPage`;示例选择展示不可用状态。未覆写时默认重新抛出错误,交给页面加载流程的错误处理。取消、`AbortError` `ExecutionError("cancelled")` 会继续抛出,不进入 `fallback`。运行时策略在控制器外执行,策略拒绝也不会由控制器回退处理。
142
67
 
143
- ### 编译期参数类型
68
+ ## 注册工厂、路由与导航目标
144
69
 
145
- `InferParams` / `InferQuery` 直接从 codec 对象推导 Controller 的参数类型 —— 无需手写、也不必和路由保持同步:
70
+ `src/app-definition.ts`:
146
71
 
147
72
  ```ts
148
- import {
149
- BaseController,
150
- type InferParams,
151
- type InferQuery,
152
- int,
153
- oneOf,
154
- optional,
155
- } from "@finesoft/front";
73
+ import { int } from "@finesoft/front";
74
+ import { definePage, defineWebApp } from "@finesoft/front";
75
+ import { ProductController } from "./lib/controllers/product";
156
76
 
157
- const params = { id: int() };
158
- const query = { sort: optional(oneOf(["asc", "desc"] as const)) };
77
+ export const product = definePage({
78
+ id: "load-product",
79
+ create: () => new ProductController(),
80
+ routes: [{ path: "/products/:id", params: { id: int() } }],
81
+ });
82
+ export const target = product.leaf({ id: 42 });
159
83
 
160
- class ProductController extends BaseController<
161
- InferParams<typeof params> & InferQuery<typeof query>, // { id: number; sort?: "asc" | "desc" }
162
- ProductPage
163
- > {
164
- readonly intentId = "product";
165
- execute(params) {
166
- // params.id: number, params.sort: "asc" | "desc" | undefined
167
- }
168
- }
84
+ export const app = defineWebApp({
85
+ id: "example",
86
+ pages: [product],
87
+ getErrorPage: (status, message) => ({ id: String(status), pageType: "error", title: message }),
88
+ });
169
89
  ```
170
90
 
171
- ### `route()` —— 参数 key 安全
91
+ 页面声明拥有唯一操作标识,控制器不再重复声明 intentId。URL `/products/42` 经 `int()` 解码后,`execute` 收到 `params: { id: 42 }`;代码内的 `product.leaf({ id: 42 })` 保留同样的参数类型。
172
92
 
173
- 数组对象形态本身已会检查 `params` 的每个 key 都出现在 `path` 中。`route(path, def)` helper 把同样的检查做成独立、可组合的条目:
93
+ `create` 每次实际执行时返回新控制器。声明、路由发现及引用辅助方法只读取定义;命中已预取或保留的页面结果时也无需创建控制器。请求身份放在执行上下文或有作用域的 provider 中;页面草稿放在页面实例中。
174
94
 
175
- ```ts
176
- route("/products/:id", { intentId: "product", params: { id: int() } }); // ✓
177
- route("/products/:id", { intentId: "product", params: { slug: str() } }); // ✗ 编译报错:"slug" 不在 path 中
178
- ```
95
+ `id` 是操作标识,`pageType` 选择视图,每个叶子节点生成独立 EntryId。`product.bindView("product", ProductView)` 检查结果中的字面量页面类型,不从操作 id 推断类型。显式保留 `ProductPage.pageType: "product"` 才能获得该检查;宽泛的 `BasePage.pageType: string` 无法约束视图名称。
96
+
97
+ 类型化叶子参数保留控制器类型;外部 URL 仍需显式 codec 解码。泛型提供编译期类型约束,不替代运行期输入校验,也不会自动证明任意 codec 与业务校验规则等价。
179
98
 
180
- ### `defineRoute()` —— 自动类型化的 handler
99
+ ## 简单页面的函数写法
181
100
 
182
- `defineRoute(path, def)` 接受一个 **handler** 函数而非 Controller 类,并从 codec 自动推导其入参类型 —— 无需 `InferParams`。它复刻 `BaseController` 的 `try/catch → fallback`:
101
+ 只需直接返回页面数据时,可以使用 `handler`。它也能通过执行上下文使用依赖、取消和嵌套操作;需要回退逻辑时,在函数内自行处理。`create` `handler` 二选一。
183
102
 
184
103
  ```ts
185
- defineRoute("/products/:id", {
186
- intentId: "product",
187
- params: { id: int() },
188
- query: { page: withDefault(int(), 1) },
189
- handler: (params, container) => {
190
- // params: { id: number; page: number } —— 从 codec 推导
191
- return loadProduct(params.id, params.page);
192
- },
193
- fallback: (params, error) => errorPage(error), // 可选
104
+ import { definePage, markPublic } from "@finesoft/front";
105
+
106
+ export const home = definePage({
107
+ id: "load-home",
108
+ handler: () => markPublic({ id: "home", pageType: "home" as const, title: "Home" }, []),
194
109
  });
195
110
  ```
196
111
 
197
- 没有 `params` / `query` 的路由行为完全不变 —— 参数保持字符串,运行时无变化。
198
-
199
- ## 写一个 Controller
112
+ 模板采用控制器类来保持组织方式一致;函数形式适用于简短加载逻辑。既有函数处理器接收 `(params, context, query)`。继承基类是可选的,实现 `perform(params, context, query)` 契约也能由 `create` 工厂接入。
200
113
 
201
- ```ts
202
- // src/lib/controllers/product.ts
203
- import { BaseController, type Container, type HttpClient } from "@finesoft/front";
204
-
205
- interface ProductPage {
206
- kind: "product";
207
- id: string;
208
- name: string;
209
- price: number;
210
- }
114
+ ## 服务端 Controller 与两种上下文
211
115
 
212
- export class ProductController extends BaseController<{ id: string }, ProductPage> {
213
- readonly intentId = "product";
116
+ 需要机密、HttpOnly Cookie 或响应设置的页面使用 `@finesoft/front` 的 `BaseServerController`。方法仍然是 `execute({ params, query, context })` / `fallback({ params, query, context, error })`,路由仍通过 `definePage({ create, routes })` 注册。类型生成器识别基类并维护同样简短的 `Input` / `Failure` 引用,不需要第二套路由声明。
214
117
 
215
- async execute(params: { id: string }, container: Container): Promise<ProductPage> {
216
- const http = container.resolve<HttpClient>("http");
217
- const product = await http.get<{ name: string; price: number }>(
218
- `/api/products/${params.id}`,
118
+ ```ts
119
+ import { BaseServerController } from "@finesoft/front";
120
+ import { markPublic } from "@finesoft/front";
121
+ import { loadAccount } from "./account-service";
122
+
123
+ export class AccountController extends BaseServerController {
124
+ async execute({ params, context }) {
125
+ const account = await loadAccount(params.id, context.getCookie("session"));
126
+ context.responseHeaders.set("Cache-Control", "private, no-store");
127
+ return markPublic(
128
+ { id: String(params.id), pageType: "account", title: account.name, account },
129
+ { account: { id: true, name: true } },
219
130
  );
220
- return {
221
- kind: "product",
222
- id: params.id,
223
- name: product.name,
224
- price: product.price,
225
- };
226
- }
227
-
228
- fallback(params: { id: string }, _error: unknown): ProductPage {
229
- return { kind: "product", id: params.id, name: "Not available", price: 0 };
230
131
  }
231
132
  }
232
133
  ```
233
134
 
234
- ### Controller 契约
135
+ 示例省略框架自动维护的类型注解。`account-service` 是业务实现;Controller 可以放在任意业务目录。Vite 根据继承关系替换整个服务端 Controller 模块,浏览器获得调用代理;SSR 保留原实现并直接执行。客户端导航通过同站 `POST /__finesoft/controller` 加载公开页面数据,服务端重新解析路由、执行策略和守卫。首次水合使用已有 SSR 数据,不重复请求。
136
+
137
+ | 能力 | 共享 `BaseController` 的页面上下文 | `BaseServerController` |
138
+ | ------------------------------------------------------ | -------------------------------------- | ----------------------------------- |
139
+ | `params`、`query` | 独立输入,按路由推断 | 相同 |
140
+ | `context.url`、`path`、`intent`、`isServer` | 当前页面与执行位置 | 相同,`isServer` 为 `true` |
141
+ | `getCookie(name)` | 浏览器可读 Cookie;SSR 当前请求 Cookie | 读取原始请求 Cookie,包含 HttpOnly |
142
+ | `getHeader(name)` | SSR 请求头;浏览器无服务端请求头 | 当前服务端请求头 |
143
+ | `get(token)`、`fetch`、`signal`、`execute`、日志和追踪 | 现有执行上下文 | 相同 |
144
+ | `request`、`responseHeaders` | 无专用接口 | 原始 `Request` 与最终响应头 |
145
+ | `setCookie`、`deleteCookie` | 无专用接口 | 写入最终响应,不改变原始请求 Cookie |
235
146
 
236
- | 成员 | 必需 | 用途 |
237
- | ---------- | ---- | --------------------------------------------------------------------------- |
238
- | `intentId` | 是 | 必须与路由的 `intentId` 一致(或与 `IntentDispatcher.register` 调用一致)。 |
239
- | `execute` | 是 | 产出页面。接受解析后的路径参数和请求级 DI 容器。 |
240
- | `fallback` | 是 | `execute()` 抛错时返回降级页面。必须同步且总能返回。 |
147
+ 自动生成的页面 `Input` 使用 `ControllerContext` 或 `ServerControllerContext`。独立数据操作仍使用可移植的 `ExecutionContext`;直接执行 Page operation、没有导航时,`url` / `path` 为空。远程调用的 `request.url` 是传输端点地址,页面地址请使用 `context.url`。
241
148
 
242
- `BaseController` 把 `execute()` 包在 `try/catch` 里,错误都走 `fallback()`。框架从不让 `dispatch()` 抛错 —— 你的 `fallback()` 是最后一道防线。
149
+ ### 构建边界
243
150
 
244
- ### 为什么 `fallback` 是必需的
151
+ 服务端 Controller 模块只导出 Controller 类和类型。该模块的本地运行时依赖也属于服务端边界;客户端直接导入这些依赖会报错。共享类型请使用 `import type`,共享的客户端实现放在独立模块。构造函数内的依赖创建属于受保护模块;不要在共享的 `create` 工厂参数、共享配置或页面组件中写入机密。
245
152
 
246
- SSR 期间 `execute()` 抛错本来会让整个请求崩 —— 要么 500,要么渲出空白文档。`fallback()` 让你返回一个结构化的「错误」`Page`,让视图层渲成优雅失败(banner、重试按钮等)。完整模式见 [可观测性 · 错误处理](./08-observability.md#通过-fallback-处理错误)。
153
+ 生产客户端 JS / source map 不包含被替换的实现。开发服务拒绝 Controller 的原始资源导入和受保护依赖的源码请求。模块与依赖索引在启动时建立,后续缓存 AST,只重新分析变更文件;开发期间已识别的私有依赖保持保护,若要将它迁回共享模块,应重启开发服务。动态计算的文件路径、`public/` 资源和另行复制的源码不属于静态导入边界。开发服务器仍只用于受信任的开发环境。
247
154
 
248
- ## 渲染模式
155
+ 使用框架 Vite 插件及具有 SSR 请求处理能力的 Node / Worker 等宿主。纯静态托管没有远程执行端点。此入口用于注册的服务端页面 Controller,不会自动发布任意方法或代替登录、注册等命令端点。
249
156
 
250
- | 模式 | 服务端返回的内容 | 适用场景 |
251
- | ------------- | -------------------------------- | ------------------------------------------- |
252
- | `"ssr"` | 完整渲染的 HTML + 序列化数据 | 默认。SEO 和 TTFB 敏感的页面。 |
253
- | `"csr"` | 空壳 HTML;Controller 在浏览器跑 | 鉴权后的 dashboard、高度个性化的页面。 |
254
- | `"prerender"` | 部署期生成的静态 HTML | 营销页、文档、博客。结合 ISR(见第 4 章)。 |
157
+ ### 显式传递请求与响应信息
255
158
 
256
- 模式是**按路由**配置,可以自由混合。框架在构建期重生成 prerendered 路由;SSR 路由每次请求都执行。
159
+ 框架提供 `request`、`getHeader`、`getCookie`、`fetch`、`responseHeaders`、`setCookie` `deleteCookie` 等通用工具。登录状态、鉴权、令牌存储与刷新,以及凭据是否传递,均由业务代码决定。
257
160
 
258
- ## 不绑路由也能注册 Controller
161
+ 服务端 `context.fetch` 只使用调用者提供的请求头,不会从外层请求自动继承 Cookie / Authorization,也不会把内部响应的 `Set-Cookie` 自动复制到页面响应。`credentials` 选项不会在服务端创建或启用 Cookie 存储。浏览器发起请求时,Cookie 仍遵循浏览器原生的同站请求规则。
259
162
 
260
- 可以为 intent 注册 Controller 但不暴露成路由。这对只通过 `dispatchAction` 触发的 intent 有用:
163
+ 例如,业务选择给一个内部接口传递 Authorization,并将该接口返回的 Cookie 写入最终响应:
261
164
 
262
165
  ```ts
263
- framework.intentDispatcher.register("checkout", new CheckoutController());
166
+ const headers = new Headers();
167
+ const authorization = context.getHeader("authorization");
168
+ if (authorization) headers.set("authorization", authorization);
264
169
 
265
- // 别处:
266
- const page = await framework.intentDispatcher.dispatch({
267
- intentId: "checkout",
268
- params: { cartId },
269
- });
170
+ const response = await context.fetch("/api/account", { headers });
171
+ for (const cookie of response.headers.getSetCookie()) {
172
+ context.responseHeaders.append("set-cookie", cookie);
173
+ }
270
174
  ```
271
175
 
272
- 路由本质上就是按 URL 索引的 intent dispatch。
273
-
274
- ## 一个 intent 多个路由
176
+ 选择哪些请求头、哪些响应 Cookie、发给哪个接口,都是上面业务代码的决定。需要多处复用时,可放进业务 `HttpClient` 拦截器或请求作用域服务,通过 `context.get` 使用。
275
177
 
276
- 同一个 intent 可以服务不同 URL
178
+ 业务也可以直接构造响应 Cookie
277
179
 
278
180
  ```ts
279
- defineRoutes(framework, [
280
- { path: "/", intentId: "home", controller: new HomeController() },
281
- { path: "/welcome", intentId: "home" }, // 复用已注册的 HomeController
282
- { path: "/landing/:slug", intentId: "home" }, // 同 intent,参数不同
283
- ]);
181
+ context.setCookie("theme", "dark", { path: "/", sameSite: "Lax" });
182
+ context.deleteCookie("theme", { path: "/" });
284
183
  ```
285
184
 
286
- 避免只是 URL 表面不同就复制 Controller 实例。前面例子里 `/admin` 复用 `home` intent 也是同一个套路。
185
+ `getCookie` 始终读取本次收到的请求;写响应 Cookie 不会修改它,也不会自动更新任何用户身份。后续业务逻辑需要新值时,使用业务自身保存的结果。`responseHeaders` 是显式响应写入工具;框架负责组装最终响应,包括错误响应中的业务响应头,同时保留框架错误正文类型及禁用缓存的约束。
287
186
 
288
- ## 检查解析后的 match
187
+ 远程请求提供的 `context` / `bindings` 不会成为可信身份。`markPublic` 继续控制发给浏览器的数据字段,机密也不能直接渲染进 HTML。
289
188
 
290
- 诊断或自定义路由时,可以直接调 `Router.resolve()`。它是**异步**的(codec 可能异步校验),需 `await`:
189
+ ## 独立业务操作也可以用控制器
190
+
191
+ `BaseController` 的结果泛型不要求是页面。通过 `implementController` 将类绑定到操作后,可以在独立数据运行时执行;HTTP / Worker 入口也可以调用该操作。
291
192
 
292
193
  ```ts
293
- const match = await framework.router.resolve("/products/42");
294
- // {
295
- // intent: { id: "product", params: { id: "42" } },
296
- // action: { kind: "flow", url: "/products/42" },
297
- // renderMode: "ssr",
298
- // beforeGuards: [...],
299
- // afterGuards: [...],
300
- // }
301
- ```
194
+ import {
195
+ BaseController,
196
+ createRuntime,
197
+ defineApp,
198
+ defineOperation,
199
+ implementController,
200
+ type ControllerInput,
201
+ } from "@finesoft/front";
302
202
 
303
- 未命中时 resolve `null` —— 在服务端 404 逻辑里处理它。
203
+ type TotalInput = { unitPrice: number; quantity: number };
204
+ interface TotalResult {
205
+ total: number;
206
+ }
304
207
 
305
- ## 实时演示
208
+ class TotalController extends BaseController<ControllerInput<TotalInput>, TotalResult> {
209
+ execute({ params }: ControllerInput<TotalInput>): TotalResult {
210
+ return { total: params.unitPrice * params.quantity };
211
+ }
212
+ }
306
213
 
307
- 下方注册了一个真实的 `Router` 实例(包含示例路由)。在左侧输入 URL,右侧实时显示 `Router.resolve()` 的 `RouteMatch` —— 与框架运行时走的是同一段代码。
214
+ const calculateTotal = defineOperation<TotalInput, TotalResult>({
215
+ id: "calculate-total",
216
+ kind: "query",
217
+ });
218
+ const runtime = createRuntime({
219
+ app: defineApp({
220
+ id: "data-example",
221
+ operations: [calculateTotal],
222
+ implementations: [implementController(calculateTotal, () => new TotalController())],
223
+ }),
224
+ });
308
225
 
309
- <Ch02RouteResolver />
226
+ try {
227
+ const result = await runtime.execute(calculateTotal, { unitPrice: 29, quantity: 2 });
228
+ console.log(result); // { total: 58 }
229
+ } finally {
230
+ await runtime.dispose();
231
+ }
232
+ ```
310
233
 
311
- ## 下一步
234
+ 操作声明定义输入输出契约,控制器负责实现。将操作暴露为 HTTP 接口仍需显式配置端点,注册控制器本身不会公开接口。
312
235
 
313
- - [中间件](./03-middleware.md) —— 守卫导航、重定向、拒绝
314
- - [渲染与 Hydration](./04-rendering-and-hydration.md) —— Controller 产出 Page 之后发生什么
236
+ <Ch02RouteResolver />