@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,318 @@
1
+ # 高阶:自定义 event recorder
2
+
3
+ 构建生产级的 `EventRecorder`,做批处理、重试、跨导航存活。本配方假设你已经读了[第 8 章:可观测性](../08-observability.md)。
4
+
5
+ ## 目标
6
+
7
+ 好 recorder 的特性:
8
+
9
+ - **不阻塞。** `record()` 同步返回;传输后台进行。
10
+ - **批处理。** 每 N 个事件或 T 秒一个 HTTP 请求,不是每个事件一个。
11
+ - **跨导航存活。** unload 时通过 `sendBeacon` flush 待发事件。
12
+ - **优雅降级。** 网络失败不让应用崩;发不出去的事件不无限堆积。
13
+ - **生命周期感知。** `destroy()` flush 所有待发事件后销毁。
14
+
15
+ ## 骨架
16
+
17
+ ```ts
18
+ // src/lib/recorders/http-recorder.ts
19
+ import type { EventRecorder, EventRecord } from "@finesoft/front";
20
+
21
+ export interface HttpRecorderOptions {
22
+ endpoint: string;
23
+ batchSize?: number;
24
+ flushIntervalMs?: number;
25
+ maxQueueSize?: number;
26
+ }
27
+
28
+ export class HttpEventRecorder implements EventRecorder {
29
+ private queue: EventRecord[] = [];
30
+ private timer: ReturnType<typeof setInterval> | null = null;
31
+ private flushing = false;
32
+ private readonly opts: Required<HttpRecorderOptions>;
33
+
34
+ constructor(options: HttpRecorderOptions) {
35
+ this.opts = {
36
+ batchSize: 50,
37
+ flushIntervalMs: 5000,
38
+ maxQueueSize: 1000,
39
+ ...options,
40
+ };
41
+
42
+ if (typeof window !== "undefined") {
43
+ this.timer = setInterval(() => this.flush(), this.opts.flushIntervalMs);
44
+ window.addEventListener("pagehide", this.beaconFlush);
45
+ window.addEventListener("beforeunload", this.beaconFlush);
46
+ }
47
+ }
48
+
49
+ record(event: EventRecord): void {
50
+ if (this.queue.length >= this.opts.maxQueueSize) {
51
+ // 溢出保护 —— 丢最旧,控内存
52
+ this.queue.shift();
53
+ }
54
+ this.queue.push(event);
55
+ if (this.queue.length >= this.opts.batchSize) {
56
+ void this.flush();
57
+ }
58
+ }
59
+
60
+ destroy(): void {
61
+ if (this.timer) clearInterval(this.timer);
62
+ if (typeof window !== "undefined") {
63
+ window.removeEventListener("pagehide", this.beaconFlush);
64
+ window.removeEventListener("beforeunload", this.beaconFlush);
65
+ }
66
+ this.beaconFlush();
67
+ }
68
+
69
+ private async flush(): Promise<void> {
70
+ if (this.flushing || this.queue.length === 0) return;
71
+ this.flushing = true;
72
+ const batch = this.queue.splice(0, this.opts.batchSize);
73
+
74
+ try {
75
+ const resp = await fetch(this.opts.endpoint, {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify(batch),
79
+ keepalive: true,
80
+ });
81
+ if (!resp.ok) {
82
+ // 4xx —— 丢。5xx —— 放回队头。
83
+ if (resp.status >= 500) this.queue.unshift(...batch);
84
+ }
85
+ } catch {
86
+ // 网络失败 —— 放回队头
87
+ this.queue.unshift(...batch);
88
+ } finally {
89
+ this.flushing = false;
90
+ }
91
+ }
92
+
93
+ private beaconFlush = (): void => {
94
+ if (this.queue.length === 0) return;
95
+ if (typeof navigator === "undefined" || !navigator.sendBeacon) return;
96
+ const batch = this.queue.splice(0, this.queue.length);
97
+ navigator.sendBeacon(this.opts.endpoint, JSON.stringify(batch));
98
+ };
99
+ }
100
+ ```
101
+
102
+ ## 每块为什么存在
103
+
104
+ ### `keepalive: true`
105
+
106
+ 告诉浏览器即使页面正在导航离开也要完成请求。有 body 大小上限(~64 KB)但能跨导航。配 `sendBeacon` 处理 unload —— beacon 更小更可靠。
107
+
108
+ ### `pagehide` 和 `beforeunload`
109
+
110
+ `pagehide` 在页面进 bfcache(前进/后退)时触发。`beforeunload` 在常规导航/关闭时触发。两者都该 flush 待发事件。有些浏览器只触发一个,所以两个都监听。
111
+
112
+ ### `keepalive` vs `sendBeacon`
113
+
114
+ | 方法 | body 上限 | 返回响应? | 时机 |
115
+ | ---------------------- | --------- | ---------- | ---------------------- |
116
+ | `fetch(..keepalive)` | ~64KB | 是 | 页面生命中的周期 flush |
117
+ | `navigator.sendBeacon` | ~64KB | 否 | unload 时最终 flush |
118
+
119
+ 两个都用:周期 `fetch` 看成功/失败,`sendBeacon` 作为最后逃生口。
120
+
121
+ ### 5xx 放回,4xx 丢
122
+
123
+ 5xx 是服务端错 —— 之后重试。4xx 是你错 —— 重试无用,无限重试会冲垮服务器。丢 batch 继续。
124
+
125
+ ### 溢出保护
126
+
127
+ 网络挂几小时,应用还在发事件,队列无界增长。`maxQueueSize` 限制;新事件来时最旧的掉。这是用完整性换内存安全 —— 按你能丢多少 vs 能用多少内存挑大小。
128
+
129
+ ## 接上
130
+
131
+ ```ts
132
+ // src/main.ts
133
+ import { startBrowserApp, CompositeEventRecorder, ConsoleEventRecorder } from "@finesoft/front/browser";
134
+ import { bootstrap } from "./bootstrap";
135
+ import { HttpEventRecorder } from "./lib/recorders/http-recorder";
136
+
137
+ startBrowserApp({
138
+ bootstrap,
139
+ frameworkConfig: {
140
+ eventRecorder: new CompositeEventRecorder([
141
+ new ConsoleEventRecorder(),
142
+ new HttpEventRecorder({
143
+ endpoint: "/api/events",
144
+ batchSize: 50,
145
+ flushIntervalMs: 5000,
146
+ }),
147
+ ]),
148
+ },
149
+ mount: /* ... */,
150
+ });
151
+ ```
152
+
153
+ `CompositeEventRecorder` 包两个,事件**同时**送到 console(dev 可见)**和**后端。
154
+
155
+ ## 加横切字段
156
+
157
+ 用 `WithFieldsRecorder` 装饰附加 session 级字段:
158
+
159
+ ```ts
160
+ import { WithFieldsRecorder, type FieldProvider } from "@finesoft/front";
161
+
162
+ const sessionFields: FieldProvider = {
163
+ getFields: () => ({
164
+ sessionId: getSessionId(),
165
+ appVersion: __APP_VERSION__,
166
+ userAgent: navigator.userAgent,
167
+ }),
168
+ };
169
+
170
+ const userFields: FieldProvider = {
171
+ getFields: () => {
172
+ const user = getCurrentUser();
173
+ return user ? { userId: user.id, role: user.role } : {};
174
+ },
175
+ };
176
+
177
+ new WithFieldsRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), [
178
+ sessionFields,
179
+ userFields,
180
+ ]);
181
+ ```
182
+
183
+ `getFields` **每个事件**跑一次,所以事件之间的用户状态变化能正确反映。
184
+
185
+ ## 服务端记录
186
+
187
+ 框架对每个 SSR 请求记录 `PageView`。要在服务端捕获:
188
+
189
+ ```ts
190
+ // src/ssr.ts
191
+ import { createSSRRender } from "@finesoft/front";
192
+ import { HttpEventRecorder } from "./lib/recorders/http-recorder";
193
+
194
+ // 跨所有 SSR 请求共享的单实例 recorder
195
+ const serverRecorder = new HttpEventRecorder({
196
+ endpoint: "https://internal-events.example/v1/events",
197
+ batchSize: 100, // 服务端更激进的批量
198
+ flushIntervalMs: 1000,
199
+ });
200
+
201
+ export const render = createSSRRender({
202
+ bootstrap,
203
+ frameworkConfig: {
204
+ eventRecorder: serverRecorder,
205
+ },
206
+ /* ... */
207
+ });
208
+
209
+ process.on("SIGTERM", () => serverRecorder.destroy());
210
+ ```
211
+
212
+ 服务端 recorder 应该:
213
+
214
+ - 跨请求共享单实例(别按请求构造)
215
+ - 用大 batch / 长 flush 间隔(没 UI 要阻塞)
216
+ - 把 `destroy()` 接进优雅关闭,在途事件能 flush
217
+
218
+ ## 与 Sentry / Datadog 集成
219
+
220
+ 同时用 `ReportCallback`(送 `warn`/`error` 到 Sentry)和 `EventRecorder`(送结构化事件到后端)的话,分开:
221
+
222
+ ```ts
223
+ Framework.create({
224
+ reportCallback: (level, category, args) => {
225
+ Sentry.captureMessage(`[${category}] ${args.join(" ")}`, level);
226
+ },
227
+ eventRecorder: new CompositeEventRecorder([
228
+ new HttpEventRecorder({ endpoint: "/api/events" }),
229
+ // 可选:也转给 Datadog
230
+ new DatadogEventRecorder({ apiKey: env.DD_API_KEY }),
231
+ ]),
232
+ });
233
+ ```
234
+
235
+ 不同 sink 不同用途 —— 错误送 Sentry triage,结构化事件送数据仓库做分析。别想着一个 recorder 包办两件。
236
+
237
+ ## 采样
238
+
239
+ 高流量应用要采样事件:
240
+
241
+ ```ts
242
+ class SamplingRecorder implements EventRecorder {
243
+ constructor(
244
+ private inner: EventRecorder,
245
+ private rate: number,
246
+ ) {}
247
+ record(event: EventRecord): void {
248
+ if (Math.random() < this.rate) this.inner.record(event);
249
+ }
250
+ destroy(): void {
251
+ this.inner.destroy?.();
252
+ }
253
+ }
254
+
255
+ new SamplingRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), 0.1);
256
+ // 记 10% 的事件
257
+ ```
258
+
259
+ 在 recorder 层采样,不在调用点 —— 调用点不该知道是否被采样。
260
+
261
+ ## 测试
262
+
263
+ ```ts
264
+ import { afterEach, describe, expect, test, vi } from "vite-plus/test";
265
+ import { HttpEventRecorder } from "./http-recorder";
266
+
267
+ afterEach(() => {
268
+ vi.useRealTimers();
269
+ vi.unstubAllGlobals();
270
+ });
271
+
272
+ describe("HttpEventRecorder", () => {
273
+ test("flushes when batch fills", async () => {
274
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
275
+ vi.stubGlobal("fetch", fetchMock);
276
+
277
+ const recorder = new HttpEventRecorder({
278
+ endpoint: "/api/events",
279
+ batchSize: 3,
280
+ flushIntervalMs: 60_000,
281
+ });
282
+
283
+ recorder.record({ name: "A", fields: {} });
284
+ recorder.record({ name: "B", fields: {} });
285
+ recorder.record({ name: "C", fields: {} });
286
+
287
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
288
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toHaveLength(3);
289
+ });
290
+
291
+ test("re-queues batch on 5xx", async () => {
292
+ const fetchMock = vi
293
+ .fn()
294
+ .mockResolvedValueOnce(new Response(null, { status: 503 }))
295
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
296
+ vi.stubGlobal("fetch", fetchMock);
297
+
298
+ const recorder = new HttpEventRecorder({
299
+ endpoint: "/api/events",
300
+ batchSize: 1,
301
+ flushIntervalMs: 60_000,
302
+ });
303
+
304
+ recorder.record({ name: "A", fields: {} });
305
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
306
+
307
+ // 模拟下一次 flush
308
+ await (recorder as any).flush();
309
+
310
+ expect(fetchMock).toHaveBeenCalledTimes(2);
311
+ });
312
+ });
313
+ ```
314
+
315
+ ## 参考
316
+
317
+ - [第 8 章:可观测性](../08-observability.md) —— 基础原语和内置事件
318
+ - 框架自己的 composite / with-fields recorder:`packages/core/src/metrics/`
@@ -0,0 +1,200 @@
1
+ # 高阶:内联 proxy 代码生成
2
+
3
+ serverless 和 edge 部署,想把 proxy 逻辑内联到函数 bundle 里 —— 不在运行时调 `registerProxyRoutes`,不带额外依赖 —— 框架暴露 `generateProxyCode`。
4
+
5
+ ## 用例
6
+
7
+ 部署到 Cloudflare Workers / Vercel Edge / AWS Lambda@Edge。每个函数有:
8
+
9
+ - 紧的冷启动预算
10
+ - 紧的冷 bundle 体积预算(Workers:1 MB 压缩后)
11
+ - 某些运行时没有 `process.env`
12
+
13
+ import proxy router 和它的支持文件(validator、Hono 集成)增字节。`generateProxyCode` **只**发出你声明的路由需要的那几行。输出自包含:几个 `app.get(...)` / `app.all(...)` 调用加一个 `_sanitizeProxyPath` helper。
14
+
15
+ ## 生成的输出
16
+
17
+ 输入:
18
+
19
+ ```ts
20
+ import { generateProxyCode } from "@finesoft/front";
21
+
22
+ const code = generateProxyCode([
23
+ {
24
+ prefix: "/api",
25
+ target: "https://upstream.example",
26
+ headers: { "X-App": "myapp" },
27
+ auth: { type: "bearer", envKey: "API_TOKEN" },
28
+ cache: "max-age=60",
29
+ },
30
+ ]);
31
+
32
+ console.log(code);
33
+ ```
34
+
35
+ 得到大概这样的:
36
+
37
+ ```js
38
+ // ─── 框架声明式代理路由 ───
39
+ function _sanitizeProxyPath(raw) {
40
+ if (raw.length > 2048) return null;
41
+ try {
42
+ if (decodeURIComponent(raw) !== raw) return null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ if (raw.startsWith("//")) return null;
47
+ if (!/^[/\w.\-~%:@!$&'()*+,;=]*$/.test(raw)) return null;
48
+ return raw.startsWith("/") ? raw : "/" + raw;
49
+ }
50
+
51
+ app.all("/api/*", async (c) => {
52
+ const _sub = _sanitizeProxyPath(c.req.path.replace("/api", ""));
53
+ if (!_sub) return c.text("Invalid path", 400);
54
+ const _target = new URL(_sub, "https://upstream.example");
55
+ if (_target.origin !== "https://upstream.example") return c.text("Invalid proxy target", 400);
56
+ const _reqUrl = new URL(c.req.url);
57
+ _reqUrl.searchParams.forEach((v, k) => _target.searchParams.set(k, v));
58
+ const _headers = { "X-App": "myapp" };
59
+ const _token =
60
+ (typeof process !== "undefined" && process.env && process.env["API_TOKEN"]) || "";
61
+ if (_token) _headers.Authorization = "Bearer " + _token;
62
+ try {
63
+ const _resp = await fetch(_target.toString(), { headers: _headers, redirect: "manual" });
64
+ const _cl = _resp.headers.get("Content-Length");
65
+ if (_cl && parseInt(_cl, 10) > 10485760) {
66
+ return c.text("Proxy response too large", 502);
67
+ }
68
+ const _body = await _resp.arrayBuffer();
69
+ if (_body.byteLength > 10485760) {
70
+ return c.text("Proxy response too large", 502);
71
+ }
72
+ const _rh = { "Content-Type": _resp.headers.get("Content-Type") || "application/json" };
73
+ if ("max-age=60") _rh["Cache-Control"] = "max-age=60";
74
+ return c.newResponse(_body, _resp.status, _rh);
75
+ } catch (_e) {
76
+ console.error("[Proxy /api]", _e);
77
+ return c.json({ error: "Proxy request failed" }, 502);
78
+ }
79
+ });
80
+ ```
81
+
82
+ 所有都内联。proxy 路径不从 `@finesoft/front` import 任何东西。把这放进函数 bundle 里,跟 SSR 入口一起。
83
+
84
+ ## 代码生成 vs 运行时注册 怎么选
85
+
86
+ | 关注点 | 运行时(`registerProxyRoutes`) | 代码生成(`generateProxyCode`) |
87
+ | ----------------------------- | ------------------------------- | ------------------------------- |
88
+ | 长跑服务器(Node、Workers) | ✅ 优先 | ✅ 也行 |
89
+ | 微小 edge 函数(Lambda@Edge) | import 更重 | ✅ 最小 |
90
+ | 不重新部署就更新路由 | ✅ 改配置,重启 | ❌ 需重新部署 |
91
+ | 配置来自远端服务 | ✅ 支持 | ❌ 代码生成在构建期跑 |
92
+ | 多个 proxy 共享 helper | ✅ 运行时共享 | 自己去重否则代码重复 |
93
+
94
+ bundle 体积重要时用代码生成。多数部署运行时路径就好。
95
+
96
+ ## 构建期集成
97
+
98
+ 典型设置:
99
+
100
+ ```ts
101
+ // scripts/build-proxy.mjs
102
+ import { generateProxyCode } from "@finesoft/front";
103
+ import { writeFile } from "node:fs/promises";
104
+
105
+ const code = generateProxyCode([
106
+ { prefix: "/api/users", target: "https://users.internal" },
107
+ { prefix: "/api/products", target: "https://products.internal", cache: "max-age=30" },
108
+ {
109
+ prefix: "/api/orders",
110
+ target: "https://orders.internal",
111
+ auth: { type: "bearer", envKey: "ORDERS_TOKEN" },
112
+ },
113
+ ]);
114
+
115
+ const wrapper = `
116
+ import { Hono } from "hono";
117
+ const app = new Hono();
118
+
119
+ ${code}
120
+
121
+ export default app;
122
+ `;
123
+
124
+ await writeFile("dist/proxy.js", wrapper, "utf8");
125
+ ```
126
+
127
+ 然后在 serverless 函数入口 import `./proxy.js`:
128
+
129
+ ```ts
130
+ // dist/main.ts(Cloudflare Worker)
131
+ import proxyApp from "./proxy.js";
132
+ import ssrApp from "./ssr-bundle.js";
133
+
134
+ const app = new Hono();
135
+ app.route("/", proxyApp);
136
+ app.route("/", ssrApp);
137
+
138
+ export default app;
139
+ ```
140
+
141
+ ## 生成代码替你做了什么
142
+
143
+ 生成的 handler 强制和运行时路径同样的保证:
144
+
145
+ - **SSRF 保护**:path 校验拒绝编码字符、`//` 前缀、不允许字符集外字符
146
+ - **开放重定向保护**:`target.origin` 必须与配置的 target origin 一致
147
+ - **10 MB 响应大小限制**:`Content-Length` 快速拒绝 + `byteLength` 实际字节检查
148
+ - **二进制完整性**:`arrayBuffer()` 转发(不 UTF-8 解码)
149
+ - **从环境读 auth**:请求时读 `process.env[envKey]`
150
+
151
+ 框架测试套件断言运行时和生成代码的**parity**:
152
+
153
+ ```ts
154
+ // packages/server/test/proxy.test.ts
155
+ test("generated proxy code embeds the same response size limit as runtime (parity)", () => {
156
+ const code = generateProxyCode([{ prefix: "/api", target: "https://upstream.example" }]);
157
+
158
+ const MAX = String(10 * 1024 * 1024);
159
+ expect(code).toContain(`parseInt(_cl, 10) > ${MAX}`);
160
+ expect(code).toContain(`_body.byteLength > ${MAX}`);
161
+ });
162
+ ```
163
+
164
+ 你改运行时路径的大小限制,生成代码的限制锁步更新。
165
+
166
+ ## 注意事项
167
+
168
+ ### `process.env` 可能不存在
169
+
170
+ 生成的代码用 `typeof process !== "undefined"` 守卫。没有 `process` 的运行时(某些 edge 环境),auth 头根本不会加 —— 上游看不到 auth。
171
+
172
+ 像 Cloudflare Workers 用函数 arg 注入 env 而不是 `process.env` 的平台,你要:
173
+
174
+ - 包一层生成代码,从 worker 的 env arg 注入 auth 头
175
+ - 或生成后替换 auth 那段,改成平台对应的访问方式
176
+
177
+ ### 无重试、无熔断
178
+
179
+ 生成的 handler 一次 `fetch`,失败冒成 `502 Proxy request failed`。要重试 / 熔断逻辑,自己写 proxy 代码 —— `generateProxyCode` 有意最小。
180
+
181
+ ### 多个 proxy 共享 helper 代码
182
+
183
+ `_sanitizeProxyPath` 在生成字符串顶部发一次。多个 `app.all` 共用。每个路由单独 `generateProxyCode` 然后拼接,helper 会重复 —— 一次传所有路由调用。
184
+
185
+ ### 生成时校验
186
+
187
+ `generateProxyCode` 跑和 `registerProxyRoutes` 一样的 `validateConfig`。无效配置构建期就抛:
188
+
189
+ ```ts
190
+ generateProxyCode([{ prefix: "/api", target: "file:///etc/passwd" }]);
191
+ // Error: [proxy] target must start with "https://" or "http://": "file:///etc/passwd"
192
+ ```
193
+
194
+ 抓配置错误在部署发出之前。
195
+
196
+ ## 参考
197
+
198
+ - [第 9 章:服务器与部署 · proxy 路由](../09-server-and-deployment.md#proxy-路由)
199
+ - 实现:`packages/server/src/proxy.ts`
200
+ - parity 测试:`packages/server/test/proxy.test.ts`