@finesoft/front 0.1.76 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +203 -0
  3. package/docs/03-middleware.md +220 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +203 -0
  28. package/docs/zh/03-middleware.md +220 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. package/package.json +2 -1
@@ -0,0 +1,243 @@
1
+ # 5. 国际化
2
+
3
+ 框架处理四个 i18n 关注点:
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`:
174
+
175
+ ```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
+ });
186
+ ```
187
+
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) —— 把翻译留在关键路径之外
@@ -0,0 +1,286 @@
1
+ # 6. HTTP 客户端
2
+
3
+ `HttpClient` 是 `fetch` 之上的薄层、强类型包装,给你:
4
+
5
+ - 面向类的子类化,便于组织 API 表面
6
+ - 请求/响应拦截器:鉴权、日志、重试
7
+ - 结构化的 `HttpError` 替代不透明的 reject
8
+ - 大小写不敏感的头处理,与 `Response.headers.get()` 语义一致
9
+
10
+ 它**不是** axios 的替代。它是为框架需求设计的小而锋利的工具。
11
+
12
+ ## 子类化
13
+
14
+ 预期用法是按每个逻辑 API 表面子类化:
15
+
16
+ ```ts
17
+ import { HttpClient } from "@finesoft/front";
18
+
19
+ interface User {
20
+ id: string;
21
+ name: string;
22
+ }
23
+
24
+ interface NewUser {
25
+ name: string;
26
+ email: string;
27
+ }
28
+
29
+ export class UserApi extends HttpClient {
30
+ async list(): Promise<User[]> {
31
+ return this.get<User[]>("/users");
32
+ }
33
+
34
+ async getById(id: string): Promise<User> {
35
+ return this.get<User>(`/users/${id}`);
36
+ }
37
+
38
+ async create(data: NewUser): Promise<User> {
39
+ return this.post<User>("/users", data);
40
+ }
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}`);
48
+ }
49
+ }
50
+ ```
51
+
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:
66
+
67
+ ```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
+ }
81
+ ```
82
+
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
+ });
162
+
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
+ ```
280
+
281
+ 客户端对对象自动设 `Content-Type: application/json`,`FormData` 不动头(让浏览器自己设 multipart 边界)。
282
+
283
+ ## 下一步
284
+
285
+ - [DI 容器](./07-di-container.md) —— 注册 API 客户端,按请求 scope 化实例
286
+ - [可观测性](./08-observability.md) —— 记录请求失败,监控里捕获