@finesoft/front 0.1.76 → 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 (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +197 -0
  3. package/docs/03-middleware.md +214 -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 +197 -0
  28. package/docs/zh/03-middleware.md +214 -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,163 @@
1
+ # 陷阱:SSR Hydration 不匹配
2
+
3
+ ## 症状
4
+
5
+ SSR 之后浏览器控制台打 hydration warning:
6
+
7
+ ```
8
+ [Vue warn]: Hydration node mismatch — server rendered "<div>Loading...</div>" but client expected "<div>Welcome, Alice</div>"
9
+ ```
10
+
11
+ 页面在 SSR 渲染内容和客户端渲染内容之间闪。本该已经加载完毕的状态触发重新请求。
12
+
13
+ ## 根因(最常见)
14
+
15
+ 服务端和浏览器为同一 URL 产出了**不同的 `Page` 对象**,因为两端读的东西在某处不一致:
16
+
17
+ - 随机/时间相关值(`Math.random()`、`Date.now()`)
18
+ - 在服务端读 `window` / `localStorage` / `document.cookie`(都是 `undefined`)
19
+ - 在浏览器读 `process.env`(打包后是 `undefined`)
20
+ - SSR 看不到真实 UA 时却做了 UA 相关的渲染
21
+ - 异步竞争:Controller 的 `execute()` 每次返回不同数据
22
+
23
+ hydration 缓存(`PrefetchedIntents`)查找未命中,浏览器重跑 Controller —— 拿到不同结果。
24
+
25
+ ## 根因(较不常见)
26
+
27
+ `PrefetchedIntents` 的 key(intentId + 稳定字符串化的 params)在两端不匹配:
28
+
29
+ - params 对象含不能确定性序列化的值(Map、Set、类实例、Symbol)
30
+ - Controller 原地改 `params` —— dispatch key 是从原始 params 算的,Controller 看到的是改过的
31
+
32
+ ## 诊断
33
+
34
+ ```ts
35
+ // 在 view 里两端都 log page:
36
+ console.log("[hydration]", typeof window === "undefined" ? "SSR" : "CSR", page);
37
+ ```
38
+
39
+ 对比两份 log。第一个不同的字段就是根因。
40
+
41
+ `PrefetchedIntents` 调试,浏览器里 log 缓存状态:
42
+
43
+ ```ts
44
+ startBrowserApp({
45
+ bootstrap,
46
+ onBeforeStart(framework) {
47
+ console.log("[prefetched]", framework.prefetchedIntents.dump());
48
+ },
49
+ mount: /* ... */,
50
+ });
51
+ ```
52
+
53
+ dump 里有 intent 但 **params 跟浏览器首次 dispatch 不同**就是 key 不匹配。
54
+
55
+ ## 修法
56
+
57
+ ### 别在模块顶层读平台专属全局
58
+
59
+ ```ts
60
+ // 不好
61
+ const userId = localStorage.getItem("uid"); // SSR 抛错
62
+ const isDarkMode = matchMedia("(prefers-color-scheme: dark)").matches; // SSR 抛错
63
+ const csrfToken = document.querySelector("meta[name=csrf]")?.content; // SSR 是 null
64
+
65
+ export class HomeController extends BaseController {
66
+ /* 用 userId */
67
+ }
68
+ ```
69
+
70
+ ```ts
71
+ // 好
72
+ export class HomeController extends BaseController {
73
+ async execute(_params, container) {
74
+ // 从 DI resolve;请求 scope 里两端都有正确的值
75
+ const session = container.resolve<Session>("session");
76
+ return { kind: "home", userId: session.userId };
77
+ }
78
+ }
79
+ ```
80
+
81
+ cookie 两端都能通过 `container.resolve("session")` 拿到(注册之后)。`localStorage` 只在浏览器 —— SSR 也要同一个值时,通过 cookie 或 query 参数暴露。
82
+
83
+ ### `execute()` 里别用随机性/时间相关逻辑
84
+
85
+ ```ts
86
+ // 不好 —— 服务端和浏览器算不同的值
87
+ async execute() {
88
+ return { kind: "home", randomGreeting: pick(greetings) };
89
+ }
90
+ ```
91
+
92
+ 需要随机性的话在服务端算一次,让客户端通过 `PrefetchedIntents` 复用(它会自动复用)。别尝试「在客户端重新随机」—— 那正是 hydration mismatch 的成因。
93
+
94
+ 时间相关逻辑在服务端决定后送出结果:
95
+
96
+ ```ts
97
+ async execute() {
98
+ const isOfficeHours = new Date().getHours() >= 9 && new Date().getHours() < 17;
99
+ return { kind: "home", isOfficeHours };
100
+ }
101
+ ```
102
+
103
+ 两端都看到同一个 `isOfficeHours: true`,因为浏览器从缓存读,不重新评估。
104
+
105
+ ### `params` 用纯 JSON 类型
106
+
107
+ ```ts
108
+ // 不好 —— dispatchAction 带非可序列化 params
109
+ framework.dispatch({
110
+ intentId: "search",
111
+ params: {
112
+ query: "widget",
113
+ filters: new Set(["red", "small"]), // Set JSON.stringify 不好
114
+ startDate: new Date(), // 变 ISO string,能用,但...
115
+ validator: new Validator(), // 类实例 —— 不会留下
116
+ },
117
+ });
118
+ ```
119
+
120
+ ```ts
121
+ // 好 —— 仅原语 + 纯对象
122
+ framework.dispatch({
123
+ intentId: "search",
124
+ params: {
125
+ query: "widget",
126
+ filters: ["red", "small"],
127
+ startDate: "2026-05-14",
128
+ },
129
+ });
130
+ ```
131
+
132
+ `PrefetchedIntents` 缓存用**稳定字符串化** —— 同 key 不同顺序产出相同 key,能检测循环引用。但非 JSON 值会被强转为字符串或静默丢弃。
133
+
134
+ ### 别改 `params`
135
+
136
+ ```ts
137
+ // 不好
138
+ async execute(params, container) {
139
+ params.userId = container.resolve("session").userId; // 改了
140
+ return loadFor(params);
141
+ }
142
+ ```
143
+
144
+ ```ts
145
+ // 好
146
+ async execute(params, container) {
147
+ const effective = { ...params, userId: container.resolve("session").userId };
148
+ return loadFor(effective);
149
+ }
150
+ ```
151
+
152
+ dispatcher 用原始 `params` 算了 cache key。改了之后,下次同样形状的 dispatch 就缓存未命中。
153
+
154
+ ## 为什么 `stableStringify` 重要
155
+
156
+ 框架的 `stableStringify`(在 `packages/core/src/prefetched-intents/stable-stringify.ts`)处理对象键顺序。它用 `seen` Set + `try/finally` 清理来支持 DAG(同一对象被多次引用)—— 没有清理的话 DAG 会被误判为循环引用,key 在两端会悄悄不同。
157
+
158
+ 如果 SSR 期间看到 "Circular reference detected" warning 但数据确实是 DAG,提 bug —— 清理本该处理这个。
159
+
160
+ ## 参考
161
+
162
+ - [陷阱:SSR vs CSR 全局变量](./ssr-vs-csr-globals.md) —— 平台专属全局住在哪
163
+ - [第 4 章:渲染与 Hydration](../04-rendering-and-hydration.md) —— `PrefetchedIntents` 怎么工作
@@ -0,0 +1,176 @@
1
+ # 陷阱:SSR 与 CSR 的全局变量
2
+
3
+ ## 症状
4
+
5
+ build 成功。dev server 起来。任何 SSR 路由的首次请求崩:
6
+
7
+ ```
8
+ ReferenceError: window is not defined
9
+ at /src/lib/foo.ts:3:13
10
+ ```
11
+
12
+ 或者更隐蔽:
13
+
14
+ ```
15
+ TypeError: Cannot read properties of undefined (reading 'getItem')
16
+ at /src/lib/storage.ts:5:34
17
+ ```
18
+
19
+ 浏览器专属全局(`window`、`document`、`localStorage`、`navigator`、`matchMedia`、`IntersectionObserver` 等)在 Node 上不存在 —— Node 没有。
20
+
21
+ ## 根因
22
+
23
+ 你在被 SSR 入口导入的文件**模块求值时**读了浏览器专属全局。即使你只在客户端用它,模块图也把它拖进来了。
24
+
25
+ 常见入口:
26
+
27
+ - `controllers/foo.ts` 导入 `lib/analytics.ts`,后者顶层用 `window.gtag`
28
+ - `lib/storage.ts` 工厂在 import 时调 `localStorage.getItem`
29
+ - 动画库 import 时自动跑 `requestAnimationFrame`
30
+
31
+ 反过来浏览器也会遇到同样问题:
32
+
33
+ - 服务端专属代码(`process.env.X`、Node `fs`、`path`)被浏览器 bundle 拖进来的东西 import 了
34
+ - Vite 把大多数 tree-shake 掉,但不是全部,动态 import 也可能让 tree-shake 失效
35
+
36
+ ## 诊断
37
+
38
+ SSR 入口崩时错误信息含文件。从上往下读 —— 第一条 `import` 链触到浏览器全局的就是元凶。
39
+
40
+ 预先找浏览器专属代码,grep:
41
+
42
+ ```bash
43
+ rg -n '\b(window|document|localStorage|sessionStorage|navigator|matchMedia|location)\b' src/
44
+ ```
45
+
46
+ 交叉对照从 `src/ssr.ts` 传递可达的东西。从 `ssr.ts` 可达的任何代码都必须 SSR 安全。
47
+
48
+ ## 修法
49
+
50
+ ### 用环境检查守卫
51
+
52
+ ```ts
53
+ // 好 —— 两端都安全
54
+ function getStoredTheme(): "light" | "dark" {
55
+ if (typeof window === "undefined") return "light";
56
+ return (localStorage.getItem("theme") as "light" | "dark") ?? "light";
57
+ }
58
+ ```
59
+
60
+ `typeof window === "undefined"` 是 SSR 检查的标准写法。比 `typeof process !== "undefined"` 更安全,因为有些打包器在客户端 polyfill `process`。
61
+
62
+ ### 移到生命周期 hook
63
+
64
+ ```ts
65
+ // 不好 —— import 时跑
66
+ const analytics = createAnalytics(window.location.host);
67
+ export function track(event: string) {
68
+ analytics.send(event);
69
+ }
70
+ ```
71
+
72
+ ```ts
73
+ // 好 —— 浏览器里 framework 启动之后跑
74
+ let analytics: Analytics | null = null;
75
+
76
+ export function track(event: string) {
77
+ if (!analytics) {
78
+ if (typeof window === "undefined") return;
79
+ analytics = createAnalytics(window.location.host);
80
+ }
81
+ analytics.send(event);
82
+ }
83
+ ```
84
+
85
+ 或用 `startBrowserApp` 的 `onBeforeStart`:
86
+
87
+ ```ts
88
+ startBrowserApp({
89
+ bootstrap,
90
+ onBeforeStart(framework) {
91
+ const analytics = createAnalytics(window.location.host);
92
+ framework.container.register("analytics", () => analytics);
93
+ },
94
+ mount: /* ... */,
95
+ });
96
+ ```
97
+
98
+ 然后在 Controller / view 里从 DI resolve —— 共享代码里永远别直接碰 `window`。
99
+
100
+ ### 条件 import
101
+
102
+ import 时崩 Node 的库(动画库、音频库),只在浏览器动态 import:
103
+
104
+ ```ts
105
+ let confetti: ((options?: any) => void) | null = null;
106
+
107
+ if (typeof window !== "undefined") {
108
+ import("canvas-confetti").then((m) => {
109
+ confetti = m.default;
110
+ });
111
+ }
112
+
113
+ export function celebrate() {
114
+ confetti?.();
115
+ }
116
+ ```
117
+
118
+ 或在 `onBeforeStart` 里 import:
119
+
120
+ ```ts
121
+ onBeforeStart: async (framework) => {
122
+ const { default: confetti } = await import("canvas-confetti");
123
+ framework.container.register("confetti", () => confetti);
124
+ },
125
+ ```
126
+
127
+ ### 用框架的抽象
128
+
129
+ 框架提供两端都能用的 DI key:
130
+
131
+ - `DEP_KEYS.PLATFORM` —— 服务端是解析的 user-agent,客户端是 navigator 派生
132
+ - `DEP_KEYS.STORAGE` —— 客户端 `localStorage`,服务端内存 map
133
+ - `DEP_KEYS.LOCALE` —— 两端解析后的 locale
134
+
135
+ 用这些替代直接读全局。它们就是为跨平台设计的。
136
+
137
+ ## 症状:本地能跑,生产构建挂
138
+
139
+ 有时 dev server 容忍某个全局访问(Vite 的懒求值),但生产构建崩。原因通常是某个模块 dev 下被 tree-shake 掉而 prod 下没被,或者反过来。
140
+
141
+ 部署前测生产构建:
142
+
143
+ ```bash
144
+ pnpm build
145
+ pnpm preview
146
+ # 访问 SSR 路由
147
+ ```
148
+
149
+ `vp preview` 跑生产同一代码路径 —— 它不崩,部署也不会崩(至少不会因为这类 bug)。
150
+
151
+ ## 症状:生产能跑,dev 里空白页
152
+
153
+ 反向问题 —— 服务端专属代码漏进了客户端 bundle,浏览器 hydration 之前就崩了。
154
+
155
+ 打开浏览器 devtools,看 console 里有没有 `process is not defined` / `require is not defined`。修法同前:用 `typeof window === "undefined"`(反过来用 `typeof window !== "undefined"`)守卫,或移到生命周期 hook。
156
+
157
+ ## 为什么 import 重要,不是「不调函数」
158
+
159
+ 你可能想「不调那个函数」而不是守卫 import:
160
+
161
+ ```ts
162
+ // 在 import 时检查
163
+ if (typeof window !== "undefined") {
164
+ // SSR 永不调用
165
+ setupAnalytics();
166
+ }
167
+ ```
168
+
169
+ 但 `import` 本身会跑模块顶层代码。如果 `lib/analytics.ts` 顶层调了 `window.gtag`(如 `const analytics = window.gtag.bind(window)`),崩**发生在 import 时**,在你的 `if` 检查之前。
170
+
171
+ 修被 import 的模块让它 import 安全,不是只让 call 安全。
172
+
173
+ ## 参考
174
+
175
+ - [陷阱:SSR Hydration 不匹配](./ssr-hydration-mismatch.md) —— SSR 跑了但产出与 CSR 不同
176
+ - [DI 容器](../07-di-container.md) —— 注册跨平台服务
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finesoft/front",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "description": "Full-stack framework: router, DI, actions, SSR, and server — all in one package",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -10,6 +10,7 @@
10
10
  "dist/index.mjs",
11
11
  "dist/server-data-DGbiKzMS.d.mts",
12
12
  "dist/start-app-BdXBCcor.mjs",
13
+ "docs",
13
14
  "README.md"
14
15
  ],
15
16
  "type": "module",