@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,182 +1,12 @@
1
- # 陷阱:i18n 包体积
1
+ # 语言包体积
2
2
 
3
- ## 症状
3
+ 静态导入所有语言会增加浏览器包体积。在应用声明中使用生成的语言 loader。
4
4
 
5
- Lighthouse 抱怨首屏 JS 载荷过大。网络面板首屏有个巨大 chunk。你的 `dist/client/assets/index-*.js` 比应有的大,`vp build --analyze` 显示 messages 文件夹占了 bundle 的大头。
6
-
7
- ## 根因
8
-
9
- 翻译被打进了主客户端 chunk 而不是按 locale 拆。要么:
10
-
11
- - 你在模块顶层直接 import 了 `src/locales/*.json`:
12
-
13
- ```ts
14
- import zh from "../locales/zh-Hans.json";
15
- import en from "../locales/en-US.json";
16
- import ja from "../locales/ja-JP.json";
17
- ```
18
-
19
- 三个 locale 都进了每个用户的 bundle,即使每个用户只看到一种。
20
-
21
- - 你在模块顶层构造了一个 `Translator`,所有 messages 都内联:
22
-
23
- ```ts
24
- const t = new SimpleTranslator({
25
- locale: "en-US",
26
- messages: { ...zhMessages, ...enMessages, ...jaMessages },
27
- });
28
- ```
29
-
30
- - 你通过 `serializeServerData` 把翻译序列化进了 HTML,每个 SSR 页面响应都带全字典。
31
-
32
- ## 修法
33
-
34
- ### 用 `messagesDir` 而不是静态 import
35
-
36
- 配置 Vite 插件:
5
+ ## Loader / 加载器
37
6
 
38
7
  ```ts
39
- finesoftFrontViteConfig({
40
- i18n: { messagesDir: "src/locales" },
41
- });
42
- ```
43
-
44
- 插件生成按 locale 的 loader。服务端从磁盘读;浏览器端动态 import 对应 chunk。Vite 把每个 locale 的 JSON 拆成独立 chunk,只有匹配解析后 locale 的 chunk 被请求。
45
-
46
- ```
47
- dist/client/assets/
48
- ├── index-abc123.js ← 主 bundle(无翻译)
49
- ├── locale-en-US-def456.js ← 只有 en-US 访客加载
50
- ├── locale-zh-Hans-789.js ← 只有 zh-Hans 访客加载
51
- └── locale-ja-JP-xyz.js ← 只有 ja-JP 访客加载
8
+ import { loadMessages } from "virtual:finesoft-front/i18n-loader";
9
+ // defineWebApp({ ..., loadMessages })
52
10
  ```
53
11
 
54
- ### 别把翻译序列化进 HTML
55
-
56
- 框架**故意不**把字典放进 `PrefetchedIntents`。浏览器和初始渲染并行拉自己的 locale chunk。
57
-
58
- 如果你在用自己的机制手动注入翻译进页面,停下:
59
-
60
- ```html
61
- <!-- 不好 —— 每个 SSR 响应都带字典 -->
62
- <script>
63
- window.__TRANSLATIONS__ = { hello: "你好" /* 几百个 key */ };
64
- </script>
65
- ```
66
-
67
- ```ts
68
- // 好 —— 框架作为独立 chunk 加载
69
- // (用 messagesDir 时自动处理)
70
- ```
71
-
72
- ### 检查实际发了什么
73
-
74
- ```bash
75
- vp build
76
- ls -lah dist/client/assets/locale-*
77
- ls -lah dist/client/assets/index-*
78
- ```
79
-
80
- 加新 locale JSON 时 index chunk 应该不变。变了就有问题。
81
-
82
- 可视化拆解:
83
-
84
- ```bash
85
- vp build --analyze
86
- ```
87
-
88
- 打开交互式 bundle treemap。locale chunk 应该小(KB 级)、独立、有名字。
89
-
90
- ## 「多大算太大」
91
-
92
- 首屏关键 JS(index chunk)大致阈值:
93
-
94
- - 静态营销站:<50 KB gzipped
95
- - 标准 SPA:<150 KB gzipped
96
- - 重 dashboard:<300 KB gzipped
97
-
98
- 翻译把 index chunk 推过这些,就该拆开。按 locale 的 10-50 KB chunk 正常,不用担心。
99
-
100
- ## 服务端:字典被缓存,不打包
101
-
102
- 服务端框架第一次请求时从磁盘读 locale JSON 并缓存:
103
-
104
- ```
105
- 请求 1(zh-Hans):磁盘读 zh-Hans.json,缓存
106
- 请求 2(zh-Hans):从缓存返回
107
- 请求 3(en-US):磁盘读 en-US.json,缓存
108
- ```
109
-
110
- 你也不发巨大的 SSR bundle —— `tsdown` 打包服务端入口,但 locale JSON 是运行时从磁盘读的,没嵌入。
111
-
112
- 这意味着:
113
-
114
- - ✅ 冷启动成本:每个 locale 一次磁盘读,每个 worker 一次
115
- - ✅ 稳态:零开销 —— locale 留在 `Map` 里
116
- - ❌ 可变性:编辑 JSON,服务器保持缓存的旧版本直到重启
117
-
118
- 可变性问题通常不是问题 —— 翻译入源代码控制,重新部署就重新加载。运行时更新翻译的话,用自定义 `loadMessages` 回调从服务拉。
119
-
120
- ## 字典确实巨大怎么办
121
-
122
- 单 locale 字典是几 MB(罕见 —— 大多数应用 <100 KB):
123
-
124
- ### 按 namespace 拆
125
-
126
- ```
127
- src/locales/
128
- ├── en-US/
129
- │ ├── common.json
130
- │ ├── checkout.json
131
- │ ├── admin.json
132
- │ └── help-center.json
133
- └── zh-Hans/
134
- └── ...
135
- ```
136
-
137
- 写自定义 `loadMessages` 只加载某页面需要的 namespace:
138
-
139
- ```ts
140
- createSSRRender({
141
- bootstrap,
142
- async loadMessages(locale) {
143
- // 只 eager 加载 "common";其他按需懒加载
144
- return import(`./locales/${locale}/common.json`);
145
- },
146
- async renderApp(page) {
147
- /* ... */
148
- },
149
- });
150
- ```
151
-
152
- 视图层渲染 admin 字符串前调 `await translator.loadNamespace("admin")`。
153
-
154
- ### 视图挂载时懒加载
155
-
156
- 非常大的可选字典(帮助内容、错误码消息),从视图层按需拉,而不是框架启动时。框架不需要知道 —— 它们只是数据。
157
-
158
- ## 网络侧优化
159
-
160
- 正确拆分后还能加速 locale 拉取:
161
-
162
- - 给 locale chunk 设长 `Cache-Control`(Vite 内容 hash 文件名让这安全)
163
- - 预加载用户的 locale chunk:
164
- ```html
165
- <link rel="preload" href="/assets/locale-en-US-def456.js" as="script" crossorigin />
166
- ```
167
- - 高流量应用,让 locale chunk 走主 JS 同一 HTTP/2 连接 push
168
-
169
- ## 为什么不直接把翻译放 HTML
170
-
171
- 因为:
172
-
173
- - 每个页面响应都带完整字典 —— 包括用户从不访问的页面的内容
174
- - 当 HTML 按 locale 变化并含字典时,CDN 层无法缓存
175
- - SSR 延迟随字典大小线性增长
176
-
177
- 按 locale 的 chunk 是正确权衡:发一次、永久缓存、只对用户实际拥有的 locale 发。
178
-
179
- ## 参考
180
-
181
- - [第 5 章:i18n](../05-i18n.md) —— locale 处理全貌
182
- - Vite 插件源:`packages/server/src/vite-plugin.ts`(搜 `messagesDir`)
12
+ 使用相同路由、构建设置检查生产分块及 gzip 总量。入口变小可能只是字节移到别的分块。水合前 SSR 与客户端语言需一致。
@@ -20,11 +20,12 @@
20
20
 
21
21
  PNG 以 `0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A` 开头 —— 开头的 `0x89` 不是合法 UTF-8,变成 `0xEF 0xBF 0xBD`。浏览器的图片解码从第 0 字节起看到垃圾,直接放弃。
22
22
 
23
- 当前实现用 `response.arrayBuffer()`,字节原样转发:
23
+ 当前实现按块读取并执行 10 MiB 上限,最终合并为 `ArrayBuffer`,字节原样转发:
24
24
 
25
25
  ```ts
26
26
  // packages/server/src/proxy.ts
27
- const body = await resp.arrayBuffer();
27
+ const body = await readProxyBody(resp);
28
+ if (!body) return c.text("Proxy response too large", 502);
28
29
  return c.newResponse(body, resp.status, respHeaders);
29
30
  ```
30
31
 
@@ -61,7 +62,7 @@ curl -s http://localhost:3000/api/image.png | sha256sum
61
62
 
62
63
  ## 什么情况下会撞到
63
64
 
64
- 当前版本(用 `arrayBuffer`)不会撞到。这条陷阱主要作为以下场景的历史参照:
65
+ 当前版本(按原始字节读取)不会撞到。这条陷阱主要作为以下场景的历史参照:
65
66
 
66
67
  - **从旧版本升级** —— 升级后验证二进制端点
67
68
  - **写自己的自定义 proxy 逻辑** —— 抄旧示例会把 bug 引回来
@@ -69,7 +70,7 @@ curl -s http://localhost:3000/api/image.png | sha256sum
69
70
 
70
71
  ## 自定义 proxy —— 写对
71
72
 
72
- 你自己写 proxy 代码(在框架的 `registerProxyRoutes` 之外),用 `arrayBuffer`:
73
+ 自定义代理也必须保留原始字节;以下 `arrayBuffer` 示例仅适用于已有独立体积限制的响应。对不可信上游优先使用框架的有界读取实现:
73
74
 
74
75
  ```ts
75
76
  // 好
@@ -108,14 +109,9 @@ app.all("/api/*", async (c) => {
108
109
 
109
110
  `resp.body` 是 `ReadableStream`。直接返回它流式转字节不缓冲。但你失去大小守卫 —— 只在信任上游时这么干。
110
111
 
111
- ## 大小限制在两处
112
+ ## 一套实现执行两次大小检查
112
113
 
113
- 框架强制 `MAX_RESPONSE_SIZE = 10 * 1024 * 1024`(10 MB)在两条路径:
114
-
115
- 1. **运行时**(`registerProxyRoutes`):先查 `Content-Length` 头,再查 fetch 后的 `body.byteLength`
116
- 2. **生成代码**(`generateProxyCode`):serverless 内联版本发同样两条检查
117
-
118
- 改一处大小限制,两处都改。测试 `generated proxy code embeds the same response size limit as runtime (parity)` 强制这点。
114
+ 框架在 `registerProxyRoutes` 中执行 `MAX_RESPONSE_SIZE = 10 * 1024 * 1024`(10 MB)限制:先查 `Content-Length` 头,再按块累计实际字节;超过限制立即取消读取。`generateProxyCode` 只生成对同一实现的注册调用,开发环境和部署产物不会各自维护不同的限制。测试实际执行生成的注册代码与构建后的部署入口,覆盖二进制载荷及超限拒绝。
119
115
 
120
116
  ## 为什么 `Content-Length` 和 `byteLength` 都要
121
117
 
@@ -124,7 +120,7 @@ app.all("/api/*", async (c) => {
124
120
  两次检查覆盖两种:
125
121
 
126
122
  - 声明的 `Content-Length` 触发快速拒绝,避免下载 100MB 再拒
127
- - 实际收到的字节数最终拒绝,防 `Content-Length` 缺失或撒谎
123
+ - 实际收到的字节数超限时立即拒绝,防 `Content-Length` 缺失或撒谎
128
124
 
129
125
  ## 参考
130
126
 
@@ -1,163 +1,7 @@
1
- # 陷阱:SSR Hydration 不匹配
1
+ # 水合不一致
2
2
 
3
- ## 症状
3
+ 浏览器与 SSR 共享同一页面声明、App 和 Outlet 视图表。SSR 使用 `createSSRRender({ definition, render: app => nativeRender(app) })`;浏览器根据 `app.shouldHydrate` 选择原生 hydrate 或 mount,完成挂载后再等待 `app.ready`。
4
4
 
5
- SSR 之后浏览器控制台打 hydration warning:
5
+ 先水合服务器快照,再恢复持久化状态。避免首次渲染读取随机数、时间或浏览器独有全局。不要在挂载前等待 ready,也不要手动改动 Outlet 的子树。
6
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` 怎么工作
7
+ 显式声明公开数据投影;嵌套对象需要嵌套声明或 codec。wire/buildId 不匹配时重新加载。排查时同时检查实际浏览器警告、页面 DOM、网络请求和 entry 身份。
@@ -1,176 +1,15 @@
1
- # 陷阱:SSR 与 CSR 的全局变量
1
+ # 运行环境边界
2
2
 
3
- ## 症状
3
+ 全部 API 从 `@finesoft/front` 导入,各自的运行环境要求仍然保留。包条件与编译器选择内部实现,不加载未使用的 UI 或平台依赖。
4
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:
5
+ ## Boundaries / 边界
160
6
 
161
7
  ```ts
162
- // import 时检查
163
- if (typeof window !== "undefined") {
164
- // SSR 永不调用
165
- setupAnalytics();
166
- }
8
+ import { defineOperation } from "@finesoft/front";
9
+ // createBrowserApp: browser
10
+ // startNodeHandler: Node
11
+ // createHttpHandler: Request/Response host (Node or Worker)
12
+ // finesoftFrontViteConfig: build configuration
167
13
  ```
168
14
 
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) —— 注册跨平台服务
15
+ 共享声明模块加载时勿读取 window/document/storage。UI 绑定只选择需要的框架。Node DNS、文件系统不能进入浏览器/Worker 图。使用实际安装产物验证对应运行环境,源码别名可能掩盖缺失产物。