@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,243 +1,13 @@
1
- # 5. 国际化
1
+ # 语言与文案
2
2
 
3
- 框架处理四个 i18n 关注点:
3
+ 文案加载放在应用声明,请求语言放在 invocation。浏览器语言属性属于各自的挂载目标。
4
4
 
5
- 1. **解析用户的 locale**(cookie、accept-language、手动覆盖)
6
- 2. **加载正确的字典**而不让 bundle 膨胀
7
- 3. **翻译字符串**(带插值和复数)
8
- 4. **渲染正确的文本方向**(LTR / RTL)
9
-
10
- ## Locale 解析
11
-
12
- 给 `Framework.create()` 传默认 `locale`:
13
-
14
- ```ts
15
- const framework = Framework.create({ locale: "zh-Hans" });
16
- ```
17
-
18
- SSR 的 locale 优先级(最高优先):
19
-
20
- 1. `createSSRRender({ resolveLocale })` 里的 `resolveLocale` 回调 —— 能访问请求头 / cookie
21
- 2. `frameworkConfig` 里的 `locale`(通过 DI 容器)
22
-
23
- 浏览器的 locale 是服务端解析出的结果(通过 `<html lang>` 属性传过来)。`startBrowserApp` hydration 时读取并写回 `documentElement.lang`/`dir`。
24
-
25
- ### 运行时读取
26
-
27
- ```ts
28
- const { lang, dir } = framework.getLocale();
29
- // { lang: "zh-Hans", dir: "ltr" }
30
- ```
31
-
32
- ## 自定义 resolver
33
-
34
- ```ts
35
- import { parseAcceptLanguage } from "@finesoft/front";
36
-
37
- createSSRRender({
38
- bootstrap,
39
- resolveLocale(ctx) {
40
- // 1. cookie
41
- const fromCookie = ctx.getCookie("locale");
42
- if (fromCookie && isSupported(fromCookie)) return fromCookie;
43
-
44
- // 2. accept-language
45
- const accept = ctx.getHeader("accept-language");
46
- const best = parseAcceptLanguage(accept ?? "").find((l) => isSupported(l.tag));
47
- if (best) return best.tag;
48
-
49
- // 3. fallback
50
- return "en-US";
51
- },
52
- async renderApp(page) {
53
- /* ... */
54
- },
55
- });
56
- ```
57
-
58
- `parseAcceptLanguage` 解析 `Accept-Language: en;q=0.9,fr;q=0.8` 为排序后的标签数组。`q=0` 的标签被过滤掉。
59
-
60
- ## 通过 `messagesDir` 加载字典
61
-
62
- 推荐方式是 JSON 文件 + Vite 插件:
63
-
64
- ```ts
65
- // vite.config.ts
66
- finesoftFrontViteConfig({
67
- ssr: { entry: "src/ssr.ts" },
68
- i18n: { messagesDir: "src/locales" },
69
- });
70
- ```
71
-
72
- ```
73
- src/locales/
74
- ├── en-US.json
75
- ├── zh-Hans.json
76
- └── ja-JP.json
77
- ```
78
-
79
- ```json
80
- // src/locales/zh-Hans.json
81
- {
82
- "hello": "你好",
83
- "welcome": "欢迎,{name}",
84
- "items.one": "{count} 个项目",
85
- "items.other": "{count} 个项目"
86
- }
87
- ```
88
-
89
- 插件生成的代码会:
90
-
91
- - 只加载解析后的 locale 对应的 JSON(服务端:从磁盘读;浏览器端:动态 import chunk)
92
- - 服务端跨请求缓存
93
- - 按解析后的 locale 加 key,切换 locale 时重新拉取
94
-
95
- 你**不**用把字典序列化进 HTML 载荷。浏览器和初始渲染并行拉自己的 locale chunk。
96
-
97
- ## `SimpleTranslator`
98
-
99
- 用于内存字典(小型应用、测试、或手动加载的数据):
100
-
101
- ```ts
102
- import { SimpleTranslator } from "@finesoft/front";
103
-
104
- const t = new SimpleTranslator({
105
- locale: "zh-Hans",
106
- messages: {
107
- hello: "你好",
108
- welcome: "欢迎,{name}",
109
- "items.one": "{count} 个项目",
110
- "items.other": "{count} 个项目",
111
- },
112
- });
113
-
114
- t.t("hello"); // "你好"
115
- t.t("welcome", { name: "World" }); // "欢迎,World"
116
- t.plural("items", 5); // "5 个项目"
117
- t.plural("items", 1); // "1 个项目"
118
- ```
119
-
120
- ### 插值
121
-
122
- 花括号占位:`{name}`、`{count}`、`{0}`。值是否 HTML 转义取决于你视图层的 escape —— `SimpleTranslator` 返回原始字符串。
123
-
124
- ### 复数
125
-
126
- 基于 `Intl.PluralRules`。key 用 CLDR 复数类别:`zero`、`one`、`two`、`few`、`many`、`other`。总要提供 `other` 作为兜底。
127
-
128
- ```json
129
- {
130
- "messages.zero": "No messages",
131
- "messages.one": "1 message",
132
- "messages.other": "{count} messages"
133
- }
134
- ```
135
-
136
- ```ts
137
- t.plural("messages", 0); // "No messages"
138
- t.plural("messages", 1); // "1 message"
139
- t.plural("messages", 5); // "5 messages"
140
- ```
141
-
142
- 复数系统更复杂的语言(俄语、阿拉伯语)自动选 `few`/`many`(如有)。
143
-
144
- ## 与 DI 集成
145
-
146
- 在容器里注册 translator:
147
-
148
- ```ts
149
- container.register(
150
- "translator",
151
- () =>
152
- new SimpleTranslator({
153
- locale: framework.getLocale().lang,
154
- messages: loadedMessages,
155
- }),
156
- );
157
- ```
158
-
159
- 或用框架内置的 DI key:
160
-
161
- ```ts
162
- import { DEP_KEYS } from "@finesoft/front";
163
-
164
- container.register(DEP_KEYS.TRANSLATOR, () => translator);
165
-
166
- // 之后在任何组件或 Controller 里:
167
- const t = framework.container.resolve(DEP_KEYS.TRANSLATOR);
168
- t.t("hello");
169
- ```
170
-
171
- ## 自定义消息源
172
-
173
- 如果消息要从 API 或 CDN 加载,在 `createSSRRender` / `startBrowserApp` 上覆盖 `loadMessages`:
5
+ ## Configuration / 配置
174
6
 
175
7
  ```ts
176
- createSSRRender({
177
- bootstrap,
178
- async loadMessages(locale) {
179
- const resp = await fetch(`https://cdn.example.com/i18n/${locale}.json`);
180
- return resp.json();
181
- },
182
- async renderApp(page) {
183
- /* ... */
184
- },
185
- });
8
+ import { loadMessages } from "virtual:finesoft-front/i18n-loader";
9
+ // defineWebApp({ ..., loadMessages, configuration: { locale: "zh-Hans" } })
10
+ // finesoftFrontViteConfig({ ..., i18n: { messagesDir: "src/locales" } })
186
11
  ```
187
12
 
188
- 这**覆盖** Vite 生成的 loader。适用于:
189
-
190
- - 翻译由服务管理(Lokalise、Phrase)并在运行时拉取
191
- - 需要 stale-while-revalidate 缓存
192
- - 需要合并来自不同源的多个命名空间
193
-
194
- 大多数应用文件 loader 就够 —— 只发当前 locale 一份字节,运行时不拉取。
195
-
196
- ## RTL 支持
197
-
198
- ```ts
199
- import { isRtl, getTextDirection, getLocaleAttributes } from "@finesoft/front";
200
-
201
- isRtl("ar"); // true
202
- isRtl("he"); // true
203
- isRtl("zh-Hans"); // false
204
-
205
- getTextDirection("ar"); // "rtl"
206
- getTextDirection("en"); // "ltr"
207
-
208
- getLocaleAttributes("ar-SA"); // { lang: "ar-SA", dir: "rtl" }
209
- ```
210
-
211
- 框架对 RTL locale 自动设 `<html dir="rtl">`。CSS 用逻辑属性(`margin-inline-start` 替代 `margin-left`)保证布局能镜像。
212
-
213
- ```css
214
- /* 推荐 */
215
- .card {
216
- padding-inline-start: 16px;
217
- }
218
-
219
- /* 避免 —— RTL 下不会镜像 */
220
- .card {
221
- padding-left: 16px;
222
- }
223
- ```
224
-
225
- ## Locale 切换
226
-
227
- 用户触发的 locale 切换:
228
-
229
- 1. 在服务端更新 cookie / 用户偏好:`Set-Cookie: locale=ja-JP`。
230
- 2. 触发整页刷新(`window.location.reload()`),让服务端解析新 locale、加载新字典、重新渲染。
231
-
232
- 纯客户端切换技术上可行,但跳过 SSR 重渲染 —— 首屏会显示旧 locale 直到新字典加载。大多数应用整页刷新更简单也更正确。
233
-
234
- ## 注意事项
235
-
236
- - **不要把整个字典序列化进 HTML。** 拖慢首屏。文件 loader 只发当前 locale,只发当前的 chunk。详见 [陷阱:i18n 包体积](./pitfalls/i18n-bundle-size.md)。
237
- - **运行时别改字典。** 缓存假设不可变。需要动态字符串(用户生成内容)就和翻译分开存。
238
- - **`SimpleTranslator` 是同步的。** 如果你的翻译源是异步的,在 Controller 跑之前加载(如 `beforeLoad` 或 `onBeforeStart`)。
239
-
240
- ## 下一步
241
-
242
- - [HTTP 客户端](./06-http-client.md) —— 发请求,必要时带 locale 头
243
- - [陷阱:i18n 包体积](./pitfalls/i18n-bundle-size.md) —— 把翻译留在关键路径之外
13
+ 使用语言文件时启用 Vite loader,避免浏览器提前导入所有语言。通过框架依赖读取配置,勿在并发请求中修改进程级语言。可移植调用可向 `runtime.execute` 传入 `{ locale }`。
@@ -1,286 +1,43 @@
1
- # 6. HTTP 客户端
1
+ # HTTP 客户端
2
2
 
3
- `HttpClient` `fetch` 之上的薄层、强类型包装,给你:
4
-
5
- - 面向类的子类化,便于组织 API 表面
6
- - 请求/响应拦截器:鉴权、日志、重试
7
- - 结构化的 `HttpError` 替代不透明的 reject
8
- - 大小写不敏感的头处理,与 `Response.headers.get()` 语义一致
9
-
10
- 它**不是** axios 的替代。它是为框架需求设计的小而锋利的工具。
11
-
12
- ## 子类化
13
-
14
- 预期用法是按每个逻辑 API 表面子类化:
3
+ `HttpClient` 是可选的类型化 fetch 封装,需要显式传入 `fetch`。服务端的连接地址校验由主机提供;浏览器应用也必须显式选择 DNS 策略。
15
4
 
16
5
  ```ts
17
- import { HttpClient } from "@finesoft/front";
18
-
6
+ import { HttpClient, type HttpClientConfig } from "@finesoft/front";
19
7
  interface User {
20
8
  id: string;
21
9
  name: string;
22
10
  }
23
-
24
- interface NewUser {
25
- name: string;
26
- email: string;
27
- }
28
-
29
11
  export class UserApi extends HttpClient {
30
- async list(): Promise<User[]> {
31
- return this.get<User[]>("/users");
12
+ constructor(config: HttpClientConfig) {
13
+ super(config);
32
14
  }
33
-
34
- async getById(id: string): Promise<User> {
35
- return this.get<User>(`/users/${id}`);
15
+ getById(id: string, signal?: AbortSignal) {
16
+ return this.get<User>(`/users/${encodeURIComponent(id)}`, undefined, { signal });
36
17
  }
37
-
38
- async create(data: NewUser): Promise<User> {
39
- return this.post<User>("/users", data);
18
+ create(user: Omit<User, "id">) {
19
+ return this.post<User>("/users", user);
40
20
  }
41
-
42
- async update(id: string, data: Partial<NewUser>): Promise<User> {
43
- return this.patch<User>(`/users/${id}`, data);
44
- }
45
-
46
- async delete(id: string): Promise<void> {
47
- await this.delete(`/users/${id}`);
21
+ remove(id: string) {
22
+ return this.del<void>(`/users/${encodeURIComponent(id)}`);
48
23
  }
49
24
  }
50
25
  ```
51
26
 
52
- 每个子类实例绑定一个 `baseUrl` 和共享 options。
53
-
54
- ## 实例化
55
-
56
- ```ts
57
- const api = new UserApi({
58
- baseUrl: "/api",
59
- defaultHeaders: {
60
- "X-App-Version": "1.0.0",
61
- },
62
- });
63
- ```
64
-
65
- 注册到 DI 里让 Controller 能 resolve:
27
+ ## 主机绑定
66
28
 
67
29
  ```ts
68
- import { DEP_KEYS } from "@finesoft/front";
69
-
70
- container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
71
- ```
72
-
73
- 然后在 Controller 里:
74
-
75
- ```ts
76
- async execute(params, container) {
77
- const api = container.resolve<UserApi>("userApi");
78
- const users = await api.list();
79
- return { kind: "users", items: users };
80
- }
30
+ // Node 主机代码;浏览器和 Worker 模块不要引入此入口。
31
+ import { nodeSafeFetchOptions } from "@finesoft/front";
32
+ const api = new UserApi({ baseUrl: "https://api.example.com", fetch, ...nodeSafeFetchOptions });
81
33
  ```
82
34
 
83
- ## 方法
84
-
85
- | 方法 | HTTP 动词 | 带 body? |
86
- | --------------------------------- | --------- | --------- |
87
- | `get<T>(path, options?)` | GET | 否 |
88
- | `post<T>(path, body?, options?)` | POST | 是 |
89
- | `put<T>(path, body?, options?)` | PUT | 是 |
90
- | `patch<T>(path, body?, options?)` | PATCH | 是 |
91
- | `delete<T>(path, options?)` | DELETE | 否 |
92
-
93
- 所有方法返回 `Promise<T>`。响应 body 按 `Content-Type` 解析:
94
-
95
- - `application/json` → `JSON.parse`
96
- - `text/*` → `string`
97
- - 其他 → `Response`(你自己处理解析)
98
-
99
- ## 单次请求 options
100
-
101
- ```ts
102
- await api.get<User>("/users/42", {
103
- headers: { "X-Request-Id": requestId },
104
- signal: abortController.signal,
105
- credentials: "include",
106
- });
107
- ```
108
-
109
- 所有标准 `RequestInit` 字段直通。单次请求头与 `defaultHeaders` 合并(key 冲突时单次请求胜)。
110
-
111
- ## 拦截器
112
-
113
- ### 请求拦截器
114
-
115
- 请求发出前改 URL 和 `RequestInit`。
116
-
117
- ```ts
118
- const api = new UserApi({
119
- baseUrl: "/api",
120
- requestInterceptors: [
121
- (url, init) => {
122
- init.headers = {
123
- ...init.headers,
124
- Authorization: `Bearer ${getToken()}`,
125
- };
126
- return init;
127
- },
128
- ],
129
- });
130
- ```
131
-
132
- 多个拦截器按数组顺序跑。每个接收上一个返回的 `init`。
133
-
134
- ### 响应拦截器
135
-
136
- `fetch` resolve 之后、body 解析之前检查 `Response`。
137
-
138
- ```ts
139
- new UserApi({
140
- baseUrl: "/api",
141
- responseInterceptors: [
142
- async (response, url) => {
143
- if (response.status === 401) {
144
- await refreshToken();
145
- // 可选:throw 让你自己的代码触发重试
146
- }
147
- return response;
148
- },
149
- ],
150
- });
151
- ```
152
-
153
- 返回不同的 `Response` 可以替换响应(如 5xx 时走缓存)。
154
-
155
- ### 动态添加拦截器
156
-
157
- ```ts
158
- api.useRequestInterceptor((url, init) => {
159
- init.headers = { ...init.headers, "X-Trace-Id": traceId };
160
- return init;
161
- });
35
+ 浏览器可用绝对 API URL、所选 fetch 实现和 `validateDns: false` 构造同一个类,这表示明确交由浏览器解析 DNS。服务端缺少必需能力时,不应静默关闭校验。默认禁止内部或回环地址;确有需要时,由主机配置显式允许。
162
36
 
163
- api.useResponseInterceptor((resp) => {
164
- metrics.recordLatency(resp.url, performance.now() - start);
165
- return resp;
166
- });
167
- ```
168
-
169
- 用于构造时不知道的横切关注点。
170
-
171
- ## 错误处理
172
-
173
- `HttpClient` 对非 2xx 响应抛 `HttpError`:
174
-
175
- ```ts
176
- import { HttpError } from "@finesoft/front";
177
-
178
- try {
179
- const user = await api.getById("missing");
180
- } catch (e) {
181
- if (e instanceof HttpError) {
182
- e.status; // 404
183
- e.statusText; // "Not Found"
184
- e.url; // "/api/users/missing"
185
- e.body; // unknown —— 已解析的响应 body(如可解析)
186
- }
187
- }
188
- ```
189
-
190
- 网络错误(DNS、连接被拒、abort)会以标准 `TypeError` / `DOMException` 形式抛出,不是 `HttpError`。需要都关心的话两个都 catch:
191
-
192
- ```ts
193
- try {
194
- await api.list();
195
- } catch (e) {
196
- if (e instanceof HttpError) {
197
- if (e.status >= 500) showRetryBanner();
198
- else showInputError(e.body);
199
- } else {
200
- showOfflineBanner();
201
- }
202
- }
203
- ```
204
-
205
- ## 服务端 vs 浏览器
206
-
207
- `HttpClient` 直接用 `fetch`,Node 22+ 原生支持。无需平台特定代码。
208
-
209
- 浏览器端请求可以打:
210
-
211
- - 你的框架自己的 proxy 路由(`/api/*` → 通过 `proxies` 配置去上游)
212
- - 公网源直接(上游配好 CORS)
213
-
214
- 服务端请求通常打:
215
-
216
- - 内网的内部服务
217
- - 直接打 proxy 上游(SSR 时跳过 proxy hop)
218
-
219
- 如果你把 `/api` proxy 到 `https://upstream.example`,Controller 在 SSR 期间调 `api.get("/api/users")` 会走 proxy 再出网 —— 浪费。在服务端用 `baseUrl: process.env.UPSTREAM_URL`,在浏览器端用 `baseUrl: "/api"`,按 `framework.platform.isServer` 决定。
220
-
221
- ## 重试
222
-
223
- 框架不内置重试拦截器。自己包:
224
-
225
- ```ts
226
- async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
227
- for (let i = 0; i < attempts; i++) {
228
- try {
229
- return await fn();
230
- } catch (e) {
231
- if (i === attempts - 1) throw e;
232
- if (e instanceof HttpError && e.status < 500) throw e; // 4xx 不重试
233
- await new Promise((r) => setTimeout(r, 2 ** i * 200));
234
- }
235
- }
236
- throw new Error("unreachable");
237
- }
238
-
239
- const user = await withRetry(() => api.getById(id));
240
- ```
241
-
242
- 作为外层包装而不是拦截器 —— 拦截器每次请求跑一次,重试逻辑需要重跑包括所有早期拦截器在内的整次请求。
243
-
244
- ## Abort 和超时
245
-
246
- 用 `AbortController`:
247
-
248
- ```ts
249
- const controller = new AbortController();
250
- const timeout = setTimeout(() => controller.abort(), 5000);
251
-
252
- try {
253
- const user = await api.getById(id, { signal: controller.signal });
254
- } finally {
255
- clearTimeout(timeout);
256
- }
257
- ```
258
-
259
- 中途导航走的 Controller,把 controller 存起来并在 `fallback()` 清理或下次 dispatch 时 abort。
260
-
261
- ## 发非 JSON body
262
-
263
- `post`/`put`/`patch` 默认 JSON 序列化 body,除非已经是 string、`FormData`、`URLSearchParams` 或 `Blob`:
264
-
265
- ```ts
266
- // JSON(默认)
267
- api.post("/users", { name: "Alice" });
268
-
269
- // 表单
270
- const form = new FormData();
271
- form.append("file", file);
272
- api.post("/upload", form);
273
-
274
- // URL 编码
275
- api.post("/login", new URLSearchParams({ user: "alice", pass: "secret" }));
276
-
277
- // 原始文本
278
- api.post("/webhook", "raw payload", { headers: { "Content-Type": "text/plain" } });
279
- ```
37
+ Node 入口随包提供按需加载的连接传输实现,无需额外安装传输依赖。标准 Node/Vercel/Netlify 主机、开发和预览主机使用 `nodeSafeFetchOptions`:实际创建连接时查询并校验全部 DNS 地址,将同一结果交给 socket,保留主机名、TLS SNI、请求取消及连接复用。连接池按需加载,不进入浏览器或 Worker 模块。自定义 Node fetch 必须支持 Undici `dispatcher` 选项。单独使用 `nodeDnsLookup` 只做预检,不能防止查询与连接之间的 DNS 重绑定。
280
38
 
281
- 客户端对对象自动设 `Content-Type: application/json`,`FormData` 不动头(让浏览器自己设 multipart 边界)。
39
+ 受保护的 fetch 逐跳校验重定向,最多跟随 20 次,跨源时移除认证和 Cookie 请求头。显式 `manual` / `error` 模式保持原意。浏览器不可读取的 opaque redirect 会拒绝;需要重发 body 的重定向不缓冲或重放 `Request.body` / 流式 body,请使用可重放的 `RequestInit.body`(如字符串)或最终 URL。`allowInternalHosts: true` 是完全退出这些保护的显式配置。
282
40
 
283
- ## 下一步
41
+ 需要作用域业务服务时,声明类型化 provider,并通过 `context.get(token)` 获取,见[依赖注入](./07-di-container.md)。绑定本次调用的 fetch 和 signal,让请求能够接收取消信号。命令取消前可能已完成写入,取消不会回滚;操作运行时不会自动重试或缓存命令。
284
42
 
285
- - [DI 容器](./07-di-container.md) —— 注册 API 客户端,按请求 scope 化实例
286
- - [可观测性](./08-observability.md) —— 记录请求失败,监控里捕获
43
+ 请求和响应拦截器按注册顺序执行。`HttpError` 表示非成功 HTTP 结果。泛型返回类型描述预期数据;必要时应在业务边界校验不可信的响应内容。底层受保护方法为 `get`、`post`、`put`、`del` `request`;`get` / `del` 的查询参数位于请求选项之前。