@finesoft/front 0.5.1 → 0.5.3

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-CR5vhgXg.mjs +1317 -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-BiRlUanX.d.mts +786 -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-2dSWO-Xw.d.mts +53 -0
  39. package/dist/proxy-z02VvGIj.mjs +7520 -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-BQBfaaPO.mjs +3825 -0
  49. package/dist/src-Ftl_0rhu.mjs +28 -0
  50. package/dist/ssr-BLzYP4wU.d.mts +207 -0
  51. package/dist/ssr-Tn4YkuxM.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-B1BT0N3t.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 +7 -332
  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 +9 -155
  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 +7 -332
  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 +9 -155
  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,317 +1,15 @@
1
- # 工程实践:测试
1
+ # 测试
2
2
 
3
- 框架为可测试性而设计。路由、Controller、中间件在服务端和浏览器都走同一个 dispatch 路径,所以一个测试同时检验两端。
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
- let scope: Container | null = null;
258
-
259
- afterEach(() => {
260
- scope?.dispose();
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
- `afterLoad` rewrite(canonicalization),断言 `Content-Location` 头:
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
- # 陷阱:Container scope 泄漏
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,不支持时返回能力错误。取消阻止框架后续提交,不回滚外部修改。