@finesoft/front 0.5.0 → 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 -698
- 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-BYZq9Jp7.mjs +0 -2
- package/dist/browser-JTs2jqVY.d.mts +0 -2811
|
@@ -1,317 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Testing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Test operation contracts directly, then verify actual host responses and native UI lifetimes. Source exports alone do not prove packed consumer isolation.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
| Subject | What to assert | Layer |
|
|
8
|
-
| -------------- | ------------------------------------------------------------------------------------------------ | ----------- |
|
|
9
|
-
| Controllers | Given params + scoped container, the page produced is correct. | unit |
|
|
10
|
-
| Guards | Given a `NavigationContext`, the result is `next` / `redirect` / `rewrite` / `deny` as expected. | unit |
|
|
11
|
-
| Routes | URL → expected intent + render mode. | unit |
|
|
12
|
-
| Full request | URL → final HTML / status code through the full pipeline. | integration |
|
|
13
|
-
| Proxy / server | Hono routes return the right responses for synthetic requests. | integration |
|
|
14
|
-
|
|
15
|
-
## Vitest setup
|
|
16
|
-
|
|
17
|
-
The repo uses Vite+. Always import from `vite-plus/test`:
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
import { describe, expect, test, vi, beforeEach, afterEach } from "vite-plus/test";
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Run tests with:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
vp test # all
|
|
27
|
-
vp test path/to/file.test.ts # one file
|
|
28
|
-
vp test -t "name match" # filter by test name
|
|
29
|
-
vp test --coverage # with coverage
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Testing a 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
|
-
Key idea: **build a `Container` per test, register only what the controller needs.** Don't pull in a real `Framework` — you'd be testing the framework, not your controller.
|
|
76
|
-
|
|
77
|
-
## Testing a guard
|
|
78
|
-
|
|
79
|
-
Guards take a `NavigationContext` and return a `MiddlewareResult`. Build a fake context inline:
|
|
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
|
-
The factory function (`makeCtx`) is the pattern — keep it co-located with the guard, parameterize the bits the test actually cares about.
|
|
119
|
-
|
|
120
|
-
## Testing routes
|
|
121
|
-
|
|
122
|
-
To assert URL → intent mapping:
|
|
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
|
-
This catches route regressions during refactors — a renamed intent shows up as a failing test, not a 404 in production.
|
|
152
|
-
|
|
153
|
-
## Testing the full request pipeline
|
|
154
|
-
|
|
155
|
-
For SSR end-to-end tests, exercise `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
|
-
This is the highest-value test layer — it exercises routing, middleware, controllers, and rendering together.
|
|
198
|
-
|
|
199
|
-
## Mocking the network
|
|
200
|
-
|
|
201
|
-
`HttpClient` uses `fetch` directly. Stub it via `vi.stubGlobal`:
|
|
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
|
-
For tests with many fetches, build a small registry:
|
|
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
|
-
This makes "what does my test expect to be fetched" readable at a glance.
|
|
251
|
-
|
|
252
|
-
## Disposing scopes in tests
|
|
253
|
-
|
|
254
|
-
If your test creates a scope, dispose it in `afterEach`:
|
|
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 isolates tests by default, but disposing exposes leaks if the scope had `destroy()`-able resources (recorders, etc.).
|
|
272
|
-
|
|
273
|
-
## Testing middleware with `rewrite`
|
|
274
|
-
|
|
275
|
-
`rewrite` in `beforeLoad` recurses through the router. Test both the rewrite signal and the resolved final route:
|
|
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
|
-
// The user-visible URL stays unchanged
|
|
283
|
-
expect(result.status).toBe(200);
|
|
284
|
-
|
|
285
|
-
// But the rendered controller was for /products/42 — assert via the rendered HTML
|
|
286
|
-
expect(result.html).toContain("Widget"); // product 42's name
|
|
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
|
-
## Coverage targets
|
|
298
|
-
|
|
299
|
-
The framework itself targets >95% on `core` and >85% on `server`. For application code, aim for:
|
|
300
|
-
|
|
301
|
-
- **Controllers**: 100% of `execute()` happy paths + at least one `fallback()` test.
|
|
302
|
-
- **Guards**: every branch (pass / redirect / deny).
|
|
303
|
-
- **Routes**: at least one assertion per route group that the URLs resolve as expected.
|
|
304
|
-
|
|
305
|
-
Don't chase 100% on view components — those test the view layer, not the framework. Test the page-shape contracts the controllers produce instead.
|
|
306
|
-
|
|
307
|
-
## Speed
|
|
308
|
-
|
|
309
|
-
Vitest with vite-plus is fast — ~1ms per test for unit, ~10ms for integration. If you see slower:
|
|
310
|
-
|
|
311
|
-
- Avoid creating a full `Framework` in tight loops; build a `Container` directly.
|
|
312
|
-
- Mock heavy `bootstrap()` calls in unit tests.
|
|
313
|
-
- Use `vi.useFakeTimers()` for tests that wait on `setTimeout` (retry logic, debouncing).
|
|
314
|
-
|
|
315
|
-
## See also
|
|
316
|
-
|
|
317
|
-
- [Testing the proxy](../09-server-and-deployment.md#proxy-routes) — the framework's own tests at `packages/server/test/proxy.test.ts` are good references
|
|
15
|
+
Run `vp check`, `vp test --coverage` and `vp run -r build`. Repository artifact runners cover Node/workerd, real browser Worker, native React/Vue/Svelte, wire protocol and installed local tarballs. Dispose runtimes, listeners and browser instances owned by each test. `scripts/verify-runtime-boundaries.mjs` exercises public packed entries without workspace aliases.
|
|
@@ -1,215 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Resource cleanup boundaries
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Memory usage on the server climbs over hours of uptime and never recovers. Eventually:
|
|
6
|
-
|
|
7
|
-
- Garbage collection pauses get longer and longer
|
|
8
|
-
- Heap snapshots show retained `Container`, `HttpClient`, `Logger`, `EventRecorder` instances that should have died with their requests
|
|
9
|
-
- The server eventually OOMs or gets killed by your orchestrator
|
|
10
|
-
|
|
11
|
-
This is a leak that doesn't show up in tests — they finish too fast — but compounds in production.
|
|
12
|
-
|
|
13
|
-
## Root cause
|
|
14
|
-
|
|
15
|
-
A scoped `Container` (typically a request scope) was created but **never disposed**. The framework caches every resolved factory result inside the scope. Anything resolved during the request stays referenced until the scope is collected.
|
|
16
|
-
|
|
17
|
-
Worse: if the scope has child scopes, **they** also stay referenced. A request that creates 3 child scopes for sub-operations leaks all 4.
|
|
18
|
-
|
|
19
|
-
The fix (already in the framework) tracks children explicitly and recursively disposes:
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
// packages/core/src/dependencies/container.ts
|
|
23
|
-
dispose(): void {
|
|
24
|
-
// Snapshot children first — child.dispose() removes itself from 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 own resources...
|
|
31
|
-
if (this.parent) {
|
|
32
|
-
this.parent.children.delete(this);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
But this only helps if **someone calls `dispose()` on the root scope.**
|
|
38
|
-
|
|
39
|
-
## When the framework disposes for you
|
|
40
|
-
|
|
41
|
-
- Request scopes created by `createSSRRender` are disposed after the response is sent (success or failure)
|
|
42
|
-
- The browser-side framework's main container lives for the lifetime of the page, then is GC'd when the page navigates away
|
|
43
|
-
|
|
44
|
-
So if you're only using the standard request lifecycle, you don't leak.
|
|
45
|
-
|
|
46
|
-
## When you leak
|
|
47
|
-
|
|
48
|
-
### Long-lived background work
|
|
49
|
-
|
|
50
|
-
```ts
|
|
51
|
-
// BAD
|
|
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
|
-
The `container` reference inside the closure keeps the request scope alive for 60 seconds **after the response was already sent**. The framework disposed the scope, but your closure resurrected the reference. Anything else resolved through `container.resolve()` is now reached through this dangling closure.
|
|
62
|
-
|
|
63
|
-
Fix: capture the resolved value before the response, not the container:
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
// GOOD
|
|
67
|
-
async execute(params, container) {
|
|
68
|
-
const api = container.resolve("api");
|
|
69
|
-
setTimeout(async () => {
|
|
70
|
-
await api.cleanup(); // closure captures the resolved value, not the scope
|
|
71
|
-
}, 60_000);
|
|
72
|
-
return { kind: "done" };
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Even better: don't fire-and-forget from inside a request. Queue the work somewhere persistent.
|
|
77
|
-
|
|
78
|
-
### Manually created scopes you forgot to dispose
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
// BAD
|
|
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
|
-
// forgot scope.dispose()
|
|
90
|
-
}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The scope outlives the function. Every `processItem` call resolved logger, API client, recorder — all retained. If `bulkOperation` runs once per request, that's a leak per request.
|
|
94
|
-
|
|
95
|
-
Fix: dispose in `finally`:
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
// GOOD
|
|
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
|
-
### Storing references at module scope
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
// BAD
|
|
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
|
-
The scope grows monotonically — `cachedScope` survives forever, and every dependency resolved through it is pinned in memory.
|
|
128
|
-
|
|
129
|
-
Fix: either (a) make the scope properly request-scoped, or (b) make it deliberately app-scoped on the parent container instead of a scope.
|
|
130
|
-
|
|
131
|
-
## Diagnosing
|
|
132
|
-
|
|
133
|
-
### Symptom-level check
|
|
134
|
-
|
|
135
|
-
Watch RSS over time with a steady workload:
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
# In production
|
|
139
|
-
ps -o pid,rss,command -p $(pidof node)
|
|
140
|
-
# RSS climbing without bound = likely leak
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
A healthy server has fluctuating but bounded RSS. A leaking server's RSS grows monotonically.
|
|
144
|
-
|
|
145
|
-
### Heap snapshots
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
# Add to your Node startup
|
|
149
|
-
node --inspect=0.0.0.0:9229 server.js
|
|
150
|
-
|
|
151
|
-
# In Chrome DevTools → Memory → Take heap snapshot
|
|
152
|
-
# Run load, take another snapshot, look at "Comparison"
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Look for:
|
|
156
|
-
|
|
157
|
-
- `Container` instances increasing
|
|
158
|
-
- `HttpClient` instances increasing
|
|
159
|
-
- `EventRecorder` instances increasing
|
|
160
|
-
- Any of your own registered service classes increasing
|
|
161
|
-
|
|
162
|
-
The retainer chain in DevTools tells you what holds the reference. Usually a closure or a setTimeout / setInterval.
|
|
163
|
-
|
|
164
|
-
### Targeted test
|
|
165
|
-
|
|
166
|
-
For unit testing, instrument `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
|
-
## Idempotent disposal
|
|
185
|
-
|
|
186
|
-
The framework's `dispose()` is **idempotent** — calling it twice is safe:
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
scope.dispose();
|
|
190
|
-
scope.dispose(); // no-op, no error
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
So if you're unsure whether something already disposed, just call dispose anyway in your cleanup. Defensive coding here costs nothing.
|
|
194
|
-
|
|
195
|
-
## What `destroy()` does
|
|
196
|
-
|
|
197
|
-
If your registered factory returns something with a `destroy()` method (loggers, recorders, custom services), `dispose()` calls it:
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
class MyService {
|
|
201
|
-
destroy() {
|
|
202
|
-
// close DB connections, flush queues, etc.
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
container.register("myService", () => new MyService());
|
|
207
|
-
// When the scope is disposed, MyService.destroy() runs.
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
Failure inside `destroy()` is swallowed and logged — one failing service can't prevent the rest from being cleaned up.
|
|
211
|
-
|
|
212
|
-
## Related
|
|
213
|
-
|
|
214
|
-
- [Chapter 7: DI container](../07-di-container.md) — the full lifecycle model
|
|
215
|
-
- The fix that introduced recursive child disposal: `packages/core/src/dependencies/container.ts` (see the `children: Set<Container>` field)
|
|
3
|
+
Use the standard runtime/HTTP/browser owners instead of manually opening scopes in ordinary application code. runtime.execute completes its invocation and cleanup; HTTP streaming retains the scope until the body finishes, fails or is cancelled. Await browser handle.dispose when removing an embedded app. Disposal waits for in-flight initialization and releases owned values once in dependency order. Never retain a request context, resolved request resource or getter in detached work. runManagedTask provides a separate task context and uses host waitUntil; unavailable host support is an explicit capability error. Cancellation prevents later framework commits, but does not roll back external mutations.
|