@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.
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# 陷阱:Container scope 泄漏
|
|
2
|
+
|
|
3
|
+
## 症状
|
|
4
|
+
|
|
5
|
+
服务器内存随运行时间上涨从不回落。最终:
|
|
6
|
+
|
|
7
|
+
- GC 暂停越来越长
|
|
8
|
+
- 堆快照显示该跟着请求死掉的 `Container`、`HttpClient`、`Logger`、`EventRecorder` 实例被保留
|
|
9
|
+
- 服务器最终 OOM 或被编排器杀掉
|
|
10
|
+
|
|
11
|
+
测试里看不到的泄漏 —— 测试结束太快 —— 但生产里累积。
|
|
12
|
+
|
|
13
|
+
## 根因
|
|
14
|
+
|
|
15
|
+
scope 化的 `Container`(通常是请求 scope)被创建了但**从未 dispose**。框架在 scope 里缓存每个 resolve 过的工厂结果。请求中 resolve 的任何东西都被引用持有直到 scope 被 GC。
|
|
16
|
+
|
|
17
|
+
更糟:如果 scope 有子 scope,**它们**也持续被引用。一个请求创建 3 个子 scope 做子操作,泄漏 4 个。
|
|
18
|
+
|
|
19
|
+
修复(框架内已有)显式跟踪子 scope 并递归 dispose:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// packages/core/src/dependencies/container.ts
|
|
23
|
+
dispose(): void {
|
|
24
|
+
// 先快照 children —— child.dispose() 会自己从 this.children 移除
|
|
25
|
+
const childSnapshot = Array.from(this.children);
|
|
26
|
+
for (const child of childSnapshot) {
|
|
27
|
+
child.dispose();
|
|
28
|
+
}
|
|
29
|
+
this.children.clear();
|
|
30
|
+
// ...dispose 自身资源...
|
|
31
|
+
if (this.parent) {
|
|
32
|
+
this.parent.children.delete(this);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
但这只在**有人调用根 scope 的 `dispose()`** 时管用。
|
|
38
|
+
|
|
39
|
+
## 什么时候框架替你 dispose
|
|
40
|
+
|
|
41
|
+
- `createSSRRender` 创建的请求 scope,在响应发送后(无论成功失败)dispose
|
|
42
|
+
- 浏览器端框架的主容器活到页面生命周期结束,导航离开后 GC
|
|
43
|
+
|
|
44
|
+
只用标准请求生命周期就不会泄漏。
|
|
45
|
+
|
|
46
|
+
## 什么时候你会泄漏
|
|
47
|
+
|
|
48
|
+
### 长跑后台工作
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// 不好
|
|
52
|
+
async execute(params, container) {
|
|
53
|
+
setTimeout(async () => {
|
|
54
|
+
const api = container.resolve("api");
|
|
55
|
+
await api.cleanup();
|
|
56
|
+
}, 60_000);
|
|
57
|
+
return { kind: "done" };
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
闭包里 `container` 引用让请求 scope 在响应已发送**之后**活了 60 秒。框架 dispose 了 scope,但你的闭包让引用复活。任何通过 `container.resolve()` resolve 出的东西现在都通过这个悬挂闭包能到达。
|
|
62
|
+
|
|
63
|
+
修:捕获 resolve 后的值,不捕获 container:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// 好
|
|
67
|
+
async execute(params, container) {
|
|
68
|
+
const api = container.resolve("api");
|
|
69
|
+
setTimeout(async () => {
|
|
70
|
+
await api.cleanup(); // 闭包捕获 resolve 后的值,不是 scope
|
|
71
|
+
}, 60_000);
|
|
72
|
+
return { kind: "done" };
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
更好:请求内别 fire-and-forget。把工作排到持久的地方。
|
|
77
|
+
|
|
78
|
+
### 自己开的 scope 忘了 dispose
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// 不好
|
|
82
|
+
async function bulkOperation() {
|
|
83
|
+
const scope = framework.container.createScope();
|
|
84
|
+
scope.register("tenantId", () => "tenant-42");
|
|
85
|
+
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
await processItem(scope, item);
|
|
88
|
+
}
|
|
89
|
+
// 忘了 scope.dispose()
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
scope 活过函数。每次 `processItem` 调用 resolve 了 logger、API 客户端、recorder —— 都被保留。`bulkOperation` 一次请求跑一次,每个请求都泄漏。
|
|
94
|
+
|
|
95
|
+
修:在 `finally` 里 dispose:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// 好
|
|
99
|
+
async function bulkOperation() {
|
|
100
|
+
const scope = framework.container.createScope();
|
|
101
|
+
try {
|
|
102
|
+
scope.register("tenantId", () => "tenant-42");
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
await processItem(scope, item);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
scope.dispose();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 模块级存引用
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// 不好
|
|
116
|
+
let cachedScope: Container | null = null;
|
|
117
|
+
|
|
118
|
+
async function withTenantContext(tenantId: string, fn: () => Promise<void>) {
|
|
119
|
+
if (!cachedScope) {
|
|
120
|
+
cachedScope = framework.container.createScope();
|
|
121
|
+
cachedScope.register("tenantId", () => tenantId);
|
|
122
|
+
}
|
|
123
|
+
return fn();
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
scope 单调增长 —— `cachedScope` 永远活着,通过它 resolve 的每个依赖都被钉在内存里。
|
|
128
|
+
|
|
129
|
+
修:要么 (a) 让 scope 正确按请求 scope 化,要么 (b) 把它有意按应用级注册到父容器,而不是 scope。
|
|
130
|
+
|
|
131
|
+
## 诊断
|
|
132
|
+
|
|
133
|
+
### 症状级检查
|
|
134
|
+
|
|
135
|
+
稳定负载下观察 RSS:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 生产
|
|
139
|
+
ps -o pid,rss,command -p $(pidof node)
|
|
140
|
+
# RSS 无界增长 = 多半泄漏
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
健康服务器 RSS 浮动但有界。泄漏服务器 RSS 单调增长。
|
|
144
|
+
|
|
145
|
+
### 堆快照
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Node 启动加
|
|
149
|
+
node --inspect=0.0.0.0:9229 server.js
|
|
150
|
+
|
|
151
|
+
# Chrome DevTools → Memory → Take heap snapshot
|
|
152
|
+
# 跑负载,再拍一张,看 "Comparison"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
找:
|
|
156
|
+
|
|
157
|
+
- `Container` 实例增多
|
|
158
|
+
- `HttpClient` 实例增多
|
|
159
|
+
- `EventRecorder` 实例增多
|
|
160
|
+
- 你自己注册的服务类增多
|
|
161
|
+
|
|
162
|
+
DevTools 里的 retainer chain 告诉你什么持有引用。通常是闭包或 setTimeout / setInterval。
|
|
163
|
+
|
|
164
|
+
### 针对性测试
|
|
165
|
+
|
|
166
|
+
单元测试,给 `dispose()` 加监控:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
test("scope is disposed after request", async () => {
|
|
170
|
+
const disposeSpy = vi.fn();
|
|
171
|
+
const scope = framework.container.createScope();
|
|
172
|
+
const original = scope.dispose.bind(scope);
|
|
173
|
+
scope.dispose = vi.fn(() => {
|
|
174
|
+
disposeSpy();
|
|
175
|
+
original();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await processRequest(scope);
|
|
179
|
+
|
|
180
|
+
expect(disposeSpy).toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## 幂等 dispose
|
|
185
|
+
|
|
186
|
+
框架的 `dispose()` 是**幂等**的 —— 调两次安全:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
scope.dispose();
|
|
190
|
+
scope.dispose(); // no-op,不报错
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
不确定是不是已经 dispose 过,直接调 dispose。这里防御性写代码不花成本。
|
|
194
|
+
|
|
195
|
+
## `destroy()` 做什么
|
|
196
|
+
|
|
197
|
+
注册的工厂返回的对象有 `destroy()` 方法(logger、recorder、自定义服务),`dispose()` 会调它:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
class MyService {
|
|
201
|
+
destroy() {
|
|
202
|
+
// 关 DB 连接、flush 队列等
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
container.register("myService", () => new MyService());
|
|
207
|
+
// scope dispose 时,MyService.destroy() 跑。
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`destroy()` 里抛错被吞掉并记录 —— 一个服务失败不阻止其他服务清理。
|
|
211
|
+
|
|
212
|
+
## 参考
|
|
213
|
+
|
|
214
|
+
- [第 7 章:DI 容器](../07-di-container.md) —— 完整生命周期模型
|
|
215
|
+
- 引入递归子 dispose 的修复:`packages/core/src/dependencies/container.ts`(看 `children: Set<Container>` 字段)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# 陷阱:i18n 包体积
|
|
2
|
+
|
|
3
|
+
## 症状
|
|
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 插件:
|
|
37
|
+
|
|
38
|
+
```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 访客加载
|
|
52
|
+
```
|
|
53
|
+
|
|
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`)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# 陷阱:proxy 二进制载荷
|
|
2
|
+
|
|
3
|
+
## 症状
|
|
4
|
+
|
|
5
|
+
你把上游 proxy 出去,上游返回图片 / PDF / protobuf,然后:
|
|
6
|
+
|
|
7
|
+
- 图片过来损坏(缩略图碎裂、灰色块)
|
|
8
|
+
- PDF 打不开("invalid PDF structure")
|
|
9
|
+
- protobuf 客户端抛 "unexpected wire type" / 解码错
|
|
10
|
+
- 文件大小在源和 proxy 出去后略不一样
|
|
11
|
+
|
|
12
|
+
头看起来正常。状态 200。body 是坏的。
|
|
13
|
+
|
|
14
|
+
## 根因
|
|
15
|
+
|
|
16
|
+
早期版本的 proxy 通过 `response.text()` 转发响应。`text()` 把字节按 **UTF-8** 解码 —— JSON 和 HTML 可以,但**会破坏**任何非 UTF-8 字节序列:
|
|
17
|
+
|
|
18
|
+
- 非合法 UTF-8 的字节被替换为 `U+FFFD`(替换字符 `0xEF 0xBF 0xBD`)
|
|
19
|
+
- 解码后的字符串再 UTF-8 编码回响应时**得到与原始不同的字节序列**
|
|
20
|
+
|
|
21
|
+
PNG 以 `0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A` 开头 —— 开头的 `0x89` 不是合法 UTF-8,变成 `0xEF 0xBF 0xBD`。浏览器的图片解码从第 0 字节起看到垃圾,直接放弃。
|
|
22
|
+
|
|
23
|
+
当前实现用 `response.arrayBuffer()`,字节原样转发:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// packages/server/src/proxy.ts
|
|
27
|
+
const body = await resp.arrayBuffer();
|
|
28
|
+
return c.newResponse(body, resp.status, respHeaders);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
字节级保留响应。图片正确显示、PDF 能打开、protobuf 能解码。
|
|
32
|
+
|
|
33
|
+
## 验证
|
|
34
|
+
|
|
35
|
+
框架自己的测试(`packages/server/test/proxy.test.ts`)用 PNG signature 检查:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]);
|
|
39
|
+
const fetchMock = vi.fn(
|
|
40
|
+
async () =>
|
|
41
|
+
new Response(binary, {
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: { "Content-Type": "image/png" },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ... handler 跑过之后 ...
|
|
48
|
+
|
|
49
|
+
expect(new Uint8Array(capturedBuffer)).toEqual(binary);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
怀疑 proxy 在破坏二进制:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 比较直连和 proxy 后的哈希
|
|
56
|
+
curl -s https://upstream.example/image.png | sha256sum
|
|
57
|
+
curl -s http://localhost:3000/api/image.png | sha256sum
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
哈希不同 = 破坏。哈希相同 = proxy 没事,看别处。
|
|
61
|
+
|
|
62
|
+
## 什么情况下会撞到
|
|
63
|
+
|
|
64
|
+
当前版本(用 `arrayBuffer`)不会撞到。这条陷阱主要作为以下场景的历史参照:
|
|
65
|
+
|
|
66
|
+
- **从旧版本升级** —— 升级后验证二进制端点
|
|
67
|
+
- **写自己的自定义 proxy 逻辑** —— 抄旧示例会把 bug 引回来
|
|
68
|
+
- **诊断你前面的第三方 proxy 是否也有同问题** —— 对它做同样的 `arrayBuffer` 测试
|
|
69
|
+
|
|
70
|
+
## 自定义 proxy —— 写对
|
|
71
|
+
|
|
72
|
+
你自己写 proxy 代码(在框架的 `registerProxyRoutes` 之外),用 `arrayBuffer`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// 好
|
|
76
|
+
app.all("/api/*", async (c) => {
|
|
77
|
+
const resp = await fetch(targetUrl);
|
|
78
|
+
const body = await resp.arrayBuffer();
|
|
79
|
+
return c.newResponse(body, resp.status, {
|
|
80
|
+
"Content-Type": resp.headers.get("Content-Type") ?? "application/octet-stream",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// 不好 —— 破坏非 UTF-8 字节
|
|
87
|
+
app.all("/api/*", async (c) => {
|
|
88
|
+
const resp = await fetch(targetUrl);
|
|
89
|
+
return c.text(await resp.text(), resp.status);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 大 body 流式转发
|
|
94
|
+
|
|
95
|
+
> 10 MB 的响应,框架打包的 proxy 返回 HTTP 502,避免全部加载进内存。需要支持更大的响应,写流式 proxy:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
app.all("/api/*", async (c) => {
|
|
99
|
+
const resp = await fetch(targetUrl);
|
|
100
|
+
return new Response(resp.body, {
|
|
101
|
+
status: resp.status,
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": resp.headers.get("Content-Type") ?? "application/octet-stream",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`resp.body` 是 `ReadableStream`。直接返回它流式转字节不缓冲。但你失去大小守卫 —— 只在信任上游时这么干。
|
|
110
|
+
|
|
111
|
+
## 大小限制在两处
|
|
112
|
+
|
|
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)` 强制这点。
|
|
119
|
+
|
|
120
|
+
## 为什么 `Content-Length` 和 `byteLength` 都要
|
|
121
|
+
|
|
122
|
+
`Content-Length` 是上游**声明**的。`byteLength` 是实际到达的。有些上游声明 `Content-Length: 1000` 但流 10MB。有些完全不发 `Content-Length`。
|
|
123
|
+
|
|
124
|
+
两次检查覆盖两种:
|
|
125
|
+
|
|
126
|
+
- 声明的 `Content-Length` 触发快速拒绝,避免下载 100MB 再拒
|
|
127
|
+
- 实际收到的字节数最终拒绝,防 `Content-Length` 缺失或撒谎
|
|
128
|
+
|
|
129
|
+
## 参考
|
|
130
|
+
|
|
131
|
+
- [第 9 章:服务器与部署 · proxy 路由](../09-server-and-deployment.md#proxy-路由)
|
|
132
|
+
- 实际实现:`packages/server/src/proxy.ts`
|
|
133
|
+
- 回归测试:`packages/server/test/proxy.test.ts`
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# 陷阱:redirect 与 rewrite
|
|
2
|
+
|
|
3
|
+
## 症状 A —— 地址栏里 URL 不对
|
|
4
|
+
|
|
5
|
+
守卫跑了 `rewrite("/canonical")`,但用户在地址栏看到 `/canonical`。你本来想保留原 URL。
|
|
6
|
+
|
|
7
|
+
## 症状 B —— 多了一次往返
|
|
8
|
+
|
|
9
|
+
守卫跑了 `redirect("/login")`,浏览器闪 / 网络面板里看到 302 → 200 一来一回。你本来想进程内重路由。
|
|
10
|
+
|
|
11
|
+
## 症状 C —— `afterLoad` rewrite 看起来发了 301
|
|
12
|
+
|
|
13
|
+
`afterLoad` 里的守卫返回 `rewrite("/clean-url")`。浏览器去访问 rewrite URL,拿到 canonical 内容,服务器日志里两次请求。你只想要一次。
|
|
14
|
+
|
|
15
|
+
## 根因
|
|
16
|
+
|
|
17
|
+
`redirect` 和 `rewrite` 看起来像但语义完全不同,并且 `rewrite` 在 `beforeLoad` 和 `afterLoad` 里行为也不同。
|
|
18
|
+
|
|
19
|
+
| 结果 | 发生什么 | 用户视角 | 适用 |
|
|
20
|
+
| --------------------------------- | ---------------------------------------------------------------------- | -------------------------- | --------------------------------------- |
|
|
21
|
+
| `redirect("/foo", 302)` | HTTP 302 带 `Location: /foo`(服务端)或 `pushState("/foo")`(浏览器) | 地址栏变成 `/foo` | auth 拦截、locale 重定向、废弃路径 |
|
|
22
|
+
| `redirect("/foo", 301)` | 同上但可缓存为永久 | 地址栏变;缓存 | 永久 canonicalization |
|
|
23
|
+
| `rewrite("/foo")` 在 `beforeLoad` | Router 改解析 `/foo`;新 match 的守卫 + Controller 跑 | 地址栏保持原样 | A/B 测试、feature-flag 路由、内部别名 |
|
|
24
|
+
| `rewrite("/foo")` 在 `afterLoad` | `Content-Location: /foo` 头;Controller 已经跑过 | 地址栏保持原样;无额外请求 | 给爬虫的 canonical 信号;analytics 去重 |
|
|
25
|
+
|
|
26
|
+
## 语义差异
|
|
27
|
+
|
|
28
|
+
**Redirect** = 「用户应该去另一个 URL」。地址栏是真相之源,框架告诉浏览器更新。
|
|
29
|
+
|
|
30
|
+
**`beforeLoad` 里的 rewrite** = 「这个 URL 内部映射到另一个」。用户 URL 不变;框架挑不同的 Controller 满足请求。类似 Nginx 的 `rewrite ... last;`。
|
|
31
|
+
|
|
32
|
+
**`afterLoad` 里的 rewrite** = 「这个内容也能在 canonical URL 找到」。页面已经渲染(Controller 已经跑),响应里只是带个 `Content-Location` hint。浏览器**不**像 redirect 那样跟着跳 —— 这是元数据。
|
|
33
|
+
|
|
34
|
+
## 修症状 A —— 想要 `rewrite` 用成了 `redirect`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// 不好 —— 用户在地址栏看到 /landing-v2
|
|
38
|
+
function abTestGuard(ctx: NavigationContext) {
|
|
39
|
+
if (ctx.url.pathname !== "/landing") return next();
|
|
40
|
+
return bucket(ctx) === "B" ? redirect("/landing-v2") : next();
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// 好 —— 用户保持 /landing,服务端内部渲染 /landing-v2
|
|
46
|
+
function abTestGuard(ctx: NavigationContext) {
|
|
47
|
+
if (ctx.url.pathname !== "/landing") return next();
|
|
48
|
+
return bucket(ctx) === "B" ? rewrite("/landing-v2") : next();
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
移动路由、feature flag、locale 内容切换 —— 任何你不希望用户察觉底层 URL 变了的场景都一样。
|
|
53
|
+
|
|
54
|
+
## 修症状 B —— 想要 `redirect` 用成了 `rewrite`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// 不好 —— 用户仍在受保护 URL;跑了错的 Controller
|
|
58
|
+
function authGuard(ctx: NavigationContext) {
|
|
59
|
+
if (!ctx.getCookie("token")) return rewrite("/login");
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
这里用 `rewrite` 会:
|
|
65
|
+
|
|
66
|
+
- 地址栏停在 `/admin`(迷惑 —— 用户以为已经到 admin)
|
|
67
|
+
- reload 重跑登录页逻辑但 URL 不变
|
|
68
|
+
- 这种状态下书签 `/admin` 是个坏 URL
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// 好 —— 真的导航到 /login
|
|
72
|
+
function authGuard(ctx: NavigationContext) {
|
|
73
|
+
if (!ctx.getCookie("token"))
|
|
74
|
+
return redirect("/login?next=" + encodeURIComponent(ctx.url.pathname));
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 修症状 C —— `afterLoad` rewrite 是 canonicalization 不是 301
|
|
80
|
+
|
|
81
|
+
你确实想从 `afterLoad` 发 301,用 `redirect`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
afterLoad: [
|
|
85
|
+
(ctx) => {
|
|
86
|
+
if (ctx.url.search.includes("utm_")) {
|
|
87
|
+
const clean = ctx.url.pathname;
|
|
88
|
+
return redirect(clean, 301);
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
但注意:`afterLoad` 跑的时候,**Controller 已经执行完了**。如果 `execute()` 有副作用(写、昂贵计算),副作用已经发生。要在工作跑之前 redirect 就用 `beforeLoad`。
|
|
96
|
+
|
|
97
|
+
如果你想发出已渲染的页面**并且**信号「另外,canonical URL 是 /clean」:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
afterLoad: [
|
|
101
|
+
(ctx) => {
|
|
102
|
+
if (ctx.url.search.includes("utm_")) {
|
|
103
|
+
return rewrite(ctx.url.pathname); // 无额外请求
|
|
104
|
+
}
|
|
105
|
+
return next();
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
响应带 `Content-Location: /clean`。爬虫(Google、Bing)用它解析 canonical;分析工具能去重各种变体。
|
|
111
|
+
|
|
112
|
+
## `rewrite` 递归深度限制
|
|
113
|
+
|
|
114
|
+
`beforeLoad` 里的 rewrite 会递归 —— 新 URL 的 `beforeLoad` 链完整跑,包括它触发的任何 rewrite。框架限定**最多 5 层**(`MAX_SSR_REWRITE_DEPTH`),防失控循环。
|
|
115
|
+
|
|
116
|
+
撞到:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Error: Too many SSR rewrites (max 5): /a → /b → /c → /d → /e → /f
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
说明你有守卫循环。常见原因:一个守卫 rewrite 到某 URL,那 URL 的守卫又 rewrite 回来。
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// 不好 —— /landing rewrite 到 /v2,/v2 又 rewrite 回 /landing
|
|
126
|
+
const landingGuard = (ctx) => (ctx.url.pathname === "/landing" ? rewrite("/v2") : next());
|
|
127
|
+
const v2Guard = (ctx) =>
|
|
128
|
+
ctx.url.pathname === "/v2" && !ctx.getCookie("v2") ? rewrite("/landing") : next();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
修循环本身,别动深度限制。
|
|
132
|
+
|
|
133
|
+
## 决策树
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
要改用户看到的 URL 吗?
|
|
137
|
+
├── 是 → redirect(临时用 302,永久用 301)
|
|
138
|
+
└── 否,URL 保持
|
|
139
|
+
├── 要切换跑哪个 Controller? → beforeLoad 里 rewrite
|
|
140
|
+
├── 已经渲染,想要 canonical 信号? → afterLoad 里 rewrite
|
|
141
|
+
└── 要带错误码中止? → deny(status, message)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 参考
|
|
145
|
+
|
|
146
|
+
- [第 3 章:中间件](../03-middleware.md) —— 四种结果详解
|
|
147
|
+
- 行为变更是有意引入的 —— 见 `packages/ssr/src/render.ts` 的 `ssrRenderInternal` 和 `rewriteUrl` 字段
|