@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,317 @@
1
+ # Engineering: testing
2
+
3
+ The framework is built to be tested. Routes, controllers, and middleware all run through the same dispatch path on server and browser, so a single test exercises both worlds.
4
+
5
+ ## What to test
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`:
255
+
256
+ ```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 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
287
+ });
288
+ ```
289
+
290
+ For `afterLoad` rewrites (canonicalization), assert the `Content-Location` header:
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
@@ -0,0 +1,215 @@
1
+ # Pitfall: container scope leak
2
+
3
+ ## Symptom
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)