@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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -1,317 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 测试
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
先直接测操作契约,再验证主机响应和原生 UI 生命周期。仅测源码导出不能证明打包消费者隔离。
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
| 对象 | 断言什么 | 层级 |
|
|
8
|
-
| -------------- | ------------------------------------------------------------------------------------ | ---- |
|
|
9
|
-
| Controller | 给定 params + scope 化容器,产出的 page 正确。 | 单元 |
|
|
10
|
-
| 守卫 | 给定 `NavigationContext`,结果是 `next` / `redirect` / `rewrite` / `deny` 的预期值。 | 单元 |
|
|
11
|
-
| 路由 | URL → 预期的 intent + 渲染模式。 | 单元 |
|
|
12
|
-
| 完整请求 | URL → 经完整管线产出的最终 HTML / 状态码。 | 集成 |
|
|
13
|
-
| Proxy / 服务器 | Hono 路由对合成请求返回正确的响应。 | 集成 |
|
|
14
|
-
|
|
15
|
-
## Vitest 设置
|
|
16
|
-
|
|
17
|
-
仓库用 Vite+。总是从 `vite-plus/test` 导入:
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import { describe, expect, test, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
跑测试:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
vp test # 全部
|
|
27
|
-
vp test path/to/file.test.ts # 一个文件
|
|
28
|
-
vp test -t "name match" # 按测试名过滤
|
|
29
|
-
vp test --coverage # 带覆盖率
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## 测一个 Controller
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
// src/controllers/product.test.ts
|
|
36
|
-
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
37
|
-
import { Container } from "@finesoft/front";
|
|
38
|
-
import { ProductController } from "./product";
|
|
39
|
-
|
|
40
|
-
describe("ProductController", () => {
|
|
41
|
-
afterEach(() => {
|
|
42
|
-
vi.restoreAllMocks();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("returns product page on success", async () => {
|
|
46
|
-
const container = new Container();
|
|
47
|
-
container.register("productApi", () => ({
|
|
48
|
-
getById: vi.fn(async (id) => ({ name: "Widget", price: 9.99 })),
|
|
49
|
-
}));
|
|
50
|
-
|
|
51
|
-
const controller = new ProductController();
|
|
52
|
-
const page = await controller.execute({ id: "42" }, container);
|
|
53
|
-
|
|
54
|
-
expect(page).toEqual({
|
|
55
|
-
kind: "product",
|
|
56
|
-
id: "42",
|
|
57
|
-
name: "Widget",
|
|
58
|
-
price: 9.99,
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("fallback returns degraded page on api failure", () => {
|
|
63
|
-
const controller = new ProductController();
|
|
64
|
-
const page = controller.fallback({ id: "42" }, new Error("network down"));
|
|
65
|
-
|
|
66
|
-
expect(page).toMatchObject({
|
|
67
|
-
kind: "product",
|
|
68
|
-
id: "42",
|
|
69
|
-
name: "Not available",
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
关键点:**每个测试建一个 `Container`,只注册 Controller 真正需要的。** 别拉一个真实的 `Framework` 进来 —— 那是在测框架而不是测你的 Controller。
|
|
76
|
-
|
|
77
|
-
## 测一个守卫
|
|
78
|
-
|
|
79
|
-
守卫接 `NavigationContext` 返回 `MiddlewareResult`。内联构造一个假 context:
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
83
|
-
import { Container } from "@finesoft/front";
|
|
84
|
-
import { authGuard } from "./auth";
|
|
85
|
-
|
|
86
|
-
function makeCtx(overrides: Partial<{ cookie: string | null }> = {}) {
|
|
87
|
-
return {
|
|
88
|
-
url: new URL("http://app.test/admin"),
|
|
89
|
-
intent: { intentId: "admin", params: {} },
|
|
90
|
-
container: new Container(),
|
|
91
|
-
getCookie: vi.fn((name: string) => overrides.cookie ?? null),
|
|
92
|
-
getHeader: vi.fn(() => null),
|
|
93
|
-
isSsr: true,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
describe("authGuard", () => {
|
|
98
|
-
test("redirects unauthenticated user to /login", () => {
|
|
99
|
-
const ctx = makeCtx({ cookie: null });
|
|
100
|
-
const result = authGuard(ctx);
|
|
101
|
-
|
|
102
|
-
expect(result).toEqual({
|
|
103
|
-
kind: "redirect",
|
|
104
|
-
url: "/login?next=%2Fadmin",
|
|
105
|
-
status: 302,
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("passes through when token is present", () => {
|
|
110
|
-
const ctx = makeCtx({ cookie: "valid-token" });
|
|
111
|
-
const result = authGuard(ctx);
|
|
112
|
-
|
|
113
|
-
expect(result).toEqual({ kind: "next" });
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
工厂函数(`makeCtx`)是套路 —— 跟守卫放一起,把测试真正关心的位参数化。
|
|
119
|
-
|
|
120
|
-
## 测路由
|
|
121
|
-
|
|
122
|
-
断言 URL → intent 映射:
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
import { describe, expect, test } from "vite-plus/test";
|
|
126
|
-
import { Framework } from "@finesoft/front";
|
|
127
|
-
import { bootstrap } from "./bootstrap";
|
|
128
|
-
|
|
129
|
-
describe("routes", () => {
|
|
130
|
-
test("resolves /products/42 to product intent", () => {
|
|
131
|
-
const framework = Framework.create({});
|
|
132
|
-
bootstrap(framework);
|
|
133
|
-
|
|
134
|
-
const match = framework.router.resolve("/products/42");
|
|
135
|
-
|
|
136
|
-
expect(match).toMatchObject({
|
|
137
|
-
intent: { intentId: "product", params: { id: "42" } },
|
|
138
|
-
renderMode: "ssr",
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("returns null for unmatched URL", () => {
|
|
143
|
-
const framework = Framework.create({});
|
|
144
|
-
bootstrap(framework);
|
|
145
|
-
|
|
146
|
-
expect(framework.router.resolve("/does-not-exist")).toBeNull();
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
这能在重构时抓住路由回归 —— 一个改名的 intent 表现为失败的测试,而不是生产里的 404。
|
|
152
|
-
|
|
153
|
-
## 测完整请求管线
|
|
154
|
-
|
|
155
|
-
SSR 端到端测试,调 `createSSRRender`:
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
import { describe, expect, test } from "vite-plus/test";
|
|
159
|
-
import { createSSRRender } from "@finesoft/front";
|
|
160
|
-
import { bootstrap } from "./bootstrap";
|
|
161
|
-
|
|
162
|
-
describe("SSR pipeline", () => {
|
|
163
|
-
test("renders home page with serialized data", async () => {
|
|
164
|
-
const render = createSSRRender({
|
|
165
|
-
bootstrap,
|
|
166
|
-
getErrorPage: () => ({ kind: "error", title: "Error" }),
|
|
167
|
-
async renderApp(page) {
|
|
168
|
-
return {
|
|
169
|
-
html: `<main>${(page as any).title}</main>`,
|
|
170
|
-
head: "",
|
|
171
|
-
css: "",
|
|
172
|
-
};
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const result = await render("/", {
|
|
177
|
-
template: `<!doctype html><html><head><!--head--></head><body><!--ssr--></body></html>`,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
expect(result.status).toBe(200);
|
|
181
|
-
expect(result.html).toContain("<main>Welcome</main>");
|
|
182
|
-
expect(result.html).toContain('id="__finesoft_data__"');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("returns 302 when guard redirects", async () => {
|
|
186
|
-
const render = createSSRRender({
|
|
187
|
-
/* ... */
|
|
188
|
-
});
|
|
189
|
-
const result = await render("/admin");
|
|
190
|
-
|
|
191
|
-
expect(result.status).toBe(302);
|
|
192
|
-
expect(result.redirectUrl).toBe("/login?next=%2Fadmin");
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
这是最高价值的测试层 —— 同时检验路由、中间件、Controller、渲染。
|
|
198
|
-
|
|
199
|
-
## Mock 网络
|
|
200
|
-
|
|
201
|
-
`HttpClient` 直接用 `fetch`。通过 `vi.stubGlobal` stub:
|
|
202
|
-
|
|
203
|
-
```ts
|
|
204
|
-
import { afterEach, beforeEach, test, vi, expect } from "vite-plus/test";
|
|
205
|
-
|
|
206
|
-
let fetchMock: ReturnType<typeof vi.fn>;
|
|
207
|
-
|
|
208
|
-
beforeEach(() => {
|
|
209
|
-
fetchMock = vi.fn();
|
|
210
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
afterEach(() => {
|
|
214
|
-
vi.unstubAllGlobals();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("UserApi.list parses JSON response", async () => {
|
|
218
|
-
fetchMock.mockResolvedValueOnce(
|
|
219
|
-
new Response(JSON.stringify([{ id: "1", name: "Alice" }]), {
|
|
220
|
-
status: 200,
|
|
221
|
-
headers: { "Content-Type": "application/json" },
|
|
222
|
-
}),
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
const api = new UserApi({ baseUrl: "/api" });
|
|
226
|
-
const users = await api.list();
|
|
227
|
-
|
|
228
|
-
expect(users).toEqual([{ id: "1", name: "Alice" }]);
|
|
229
|
-
expect(fetchMock).toHaveBeenCalledWith("/api/users", expect.any(Object));
|
|
230
|
-
});
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
测试里 fetch 多时建个小注册表:
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
function setupFetch(routes: Record<string, () => Response>) {
|
|
237
|
-
fetchMock.mockImplementation(async (url: string) => {
|
|
238
|
-
const handler = routes[url];
|
|
239
|
-
if (!handler) throw new Error(`Unexpected fetch: ${url}`);
|
|
240
|
-
return handler();
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
setupFetch({
|
|
245
|
-
"/api/users": () => new Response(JSON.stringify(users), { status: 200 }),
|
|
246
|
-
"/api/products": () => new Response(JSON.stringify(products), { status: 200 }),
|
|
247
|
-
});
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
让「测试预期 fetch 什么」一眼可读。
|
|
251
|
-
|
|
252
|
-
## 测试中 dispose scope
|
|
253
|
-
|
|
254
|
-
测试里创建了 scope,在 `afterEach` 里 dispose:
|
|
5
|
+
## Direct operation / 直接操作
|
|
255
6
|
|
|
256
7
|
```ts
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
scope = null;
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("...", () => {
|
|
265
|
-
scope = framework.container.createScope();
|
|
266
|
-
scope.register("api", () => mockApi);
|
|
267
|
-
// ...
|
|
268
|
-
});
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
Vitest 默认隔离测试,但 dispose 能暴露 scope 含 `destroy()` 资源(recorder 等)时的泄漏。
|
|
272
|
-
|
|
273
|
-
## 测带 `rewrite` 的中间件
|
|
274
|
-
|
|
275
|
-
`beforeLoad` 里的 rewrite 通过 router 递归。测试时同时断言 rewrite 信号和最终解析到的路由:
|
|
276
|
-
|
|
277
|
-
```ts
|
|
278
|
-
test("legacy URL rewrites to canonical", async () => {
|
|
279
|
-
const render = createSSRRender({ bootstrap /* ... */ });
|
|
280
|
-
const result = await render("/old/products/42");
|
|
281
|
-
|
|
282
|
-
// 用户可见的 URL 不变
|
|
283
|
-
expect(result.status).toBe(200);
|
|
284
|
-
|
|
285
|
-
// 但渲染走的 Controller 是 /products/42 的 —— 通过渲染后 HTML 断言
|
|
286
|
-
expect(result.html).toContain("Widget"); // product 42 的名字
|
|
8
|
+
import { expect, test } from "vite-plus/test";
|
|
9
|
+
import { runtime, double } from "./data-app";
|
|
10
|
+
test("double", async () => {
|
|
11
|
+
expect(await runtime.execute(double, 3)).toBe(6);
|
|
287
12
|
});
|
|
288
13
|
```
|
|
289
14
|
|
|
290
|
-
`
|
|
291
|
-
|
|
292
|
-
```ts
|
|
293
|
-
const result = await render("/page?utm=x");
|
|
294
|
-
expect(result.headers["Content-Location"]).toBe("/page");
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
## 覆盖率目标
|
|
298
|
-
|
|
299
|
-
框架本身在 `core` 上瞄准 >95%,`server` 上 >85%。应用代码目标:
|
|
300
|
-
|
|
301
|
-
- **Controller**:100% `execute()` 主路径 + 至少一个 `fallback()` 测试。
|
|
302
|
-
- **守卫**:每个分支(通过 / redirect / deny)。
|
|
303
|
-
- **路由**:每个路由组至少一个 URL 解析断言。
|
|
304
|
-
|
|
305
|
-
视图组件不必追求 100% —— 那是测视图层,不是测框架。测 Controller 产出的 page 形状契约就够了。
|
|
306
|
-
|
|
307
|
-
## 速度
|
|
308
|
-
|
|
309
|
-
vite-plus 的 Vitest 很快 —— 单元 ~1ms 一个测试,集成 ~10ms。慢的话:
|
|
310
|
-
|
|
311
|
-
- 紧密循环里别建完整 `Framework`;直接建 `Container`。
|
|
312
|
-
- 单元测试里 mock 重的 `bootstrap()`。
|
|
313
|
-
- 等 `setTimeout`(重试、debounce)的测试用 `vi.useFakeTimers()`。
|
|
314
|
-
|
|
315
|
-
## 参考
|
|
316
|
-
|
|
317
|
-
- [测试 proxy](../09-server-and-deployment.md#proxy-路由) —— 框架自己的 proxy 测试 `packages/server/test/proxy.test.ts` 是好参考
|
|
15
|
+
运行 `vp check`、`vp test --coverage`、`vp run -r build`。仓库产物验证覆盖 Node/workerd、真实浏览器 Worker、React/Vue/Svelte、wire 协议、本地 tarball 安装。测试需释放自己拥有的运行时、监听器、浏览器实例。`scripts/verify-runtime-boundaries.mjs` 在无 workspace 别名的环境验证公开打包入口。
|
|
@@ -1,215 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 资源释放边界
|
|
2
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>` 字段)
|
|
3
|
+
普通业务使用 runtime/HTTP/browser 标准所有者,不手动开 scope。runtime.execute 完成调用与清理;HTTP 流保留 scope 至响应体完成、失败或取消。移除嵌入应用时等待 handle.dispose。释放等待正在初始化的资源,再按依赖顺序恰好一次释放拥有值。后台任务不能保留请求上下文、请求资源或 getter。runManagedTask 使用独立任务上下文及主机 waitUntil,不支持时返回能力错误。取消阻止框架后续提交,不回滚外部修改。
|