@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.
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# 6. HTTP client
|
|
2
|
+
|
|
3
|
+
`HttpClient` is a thin, typed wrapper over `fetch` that gives you:
|
|
4
|
+
|
|
5
|
+
- Class-based subclassing for organizing API surface
|
|
6
|
+
- Request/response interceptors for auth, logging, retries
|
|
7
|
+
- Structured `HttpError` instead of opaque rejections
|
|
8
|
+
- Case-insensitive header handling that matches `Response.headers.get()` semantics
|
|
9
|
+
|
|
10
|
+
It is **not** an attempt to be axios. It is a sharp small tool aimed at the framework's needs.
|
|
11
|
+
|
|
12
|
+
## Subclassing
|
|
13
|
+
|
|
14
|
+
The intended usage is to subclass for each logical API surface:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { HttpClient } from "@finesoft/front";
|
|
18
|
+
|
|
19
|
+
interface User {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NewUser {
|
|
25
|
+
name: string;
|
|
26
|
+
email: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class UserApi extends HttpClient {
|
|
30
|
+
async list(): Promise<User[]> {
|
|
31
|
+
return this.get<User[]>("/users");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getById(id: string): Promise<User> {
|
|
35
|
+
return this.get<User>(`/users/${id}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async create(data: NewUser): Promise<User> {
|
|
39
|
+
return this.post<User>("/users", data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async update(id: string, data: Partial<NewUser>): Promise<User> {
|
|
43
|
+
return this.patch<User>(`/users/${id}`, data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async delete(id: string): Promise<void> {
|
|
47
|
+
await this.delete(`/users/${id}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each subclass instance binds a `baseUrl` and shared options.
|
|
53
|
+
|
|
54
|
+
## Instantiation
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const api = new UserApi({
|
|
58
|
+
baseUrl: "/api",
|
|
59
|
+
defaultHeaders: {
|
|
60
|
+
"X-App-Version": "1.0.0",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Register in DI so controllers can resolve it:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
69
|
+
|
|
70
|
+
container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then in a controller:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
async execute(params, container) {
|
|
77
|
+
const api = container.resolve<UserApi>("userApi");
|
|
78
|
+
const users = await api.list();
|
|
79
|
+
return { kind: "users", items: users };
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Methods
|
|
84
|
+
|
|
85
|
+
| Method | HTTP verb | Body? |
|
|
86
|
+
| --------------------------------- | --------- | ----- |
|
|
87
|
+
| `get<T>(path, options?)` | GET | no |
|
|
88
|
+
| `post<T>(path, body?, options?)` | POST | yes |
|
|
89
|
+
| `put<T>(path, body?, options?)` | PUT | yes |
|
|
90
|
+
| `patch<T>(path, body?, options?)` | PATCH | yes |
|
|
91
|
+
| `delete<T>(path, options?)` | DELETE | no |
|
|
92
|
+
|
|
93
|
+
All methods return `Promise<T>`. The response body is parsed based on `Content-Type`:
|
|
94
|
+
|
|
95
|
+
- `application/json` → `JSON.parse`
|
|
96
|
+
- `text/*` → `string`
|
|
97
|
+
- everything else → `Response` (you handle parsing)
|
|
98
|
+
|
|
99
|
+
## Per-request options
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await api.get<User>("/users/42", {
|
|
103
|
+
headers: { "X-Request-Id": requestId },
|
|
104
|
+
signal: abortController.signal,
|
|
105
|
+
credentials: "include",
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
All standard `RequestInit` fields pass through. Per-request headers merge with `defaultHeaders` (per-request wins on key conflict).
|
|
110
|
+
|
|
111
|
+
## Interceptors
|
|
112
|
+
|
|
113
|
+
### Request interceptors
|
|
114
|
+
|
|
115
|
+
Transform the URL and `RequestInit` before the request is sent.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const api = new UserApi({
|
|
119
|
+
baseUrl: "/api",
|
|
120
|
+
requestInterceptors: [
|
|
121
|
+
(url, init) => {
|
|
122
|
+
init.headers = {
|
|
123
|
+
...init.headers,
|
|
124
|
+
Authorization: `Bearer ${getToken()}`,
|
|
125
|
+
};
|
|
126
|
+
return init;
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Multiple interceptors run in array order. Each one receives the `init` returned by the previous one.
|
|
133
|
+
|
|
134
|
+
### Response interceptors
|
|
135
|
+
|
|
136
|
+
Inspect the `Response` after `fetch` resolves but before the body is parsed.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
new UserApi({
|
|
140
|
+
baseUrl: "/api",
|
|
141
|
+
responseInterceptors: [
|
|
142
|
+
async (response, url) => {
|
|
143
|
+
if (response.status === 401) {
|
|
144
|
+
await refreshToken();
|
|
145
|
+
// optionally re-throw to trigger a retry in your own code
|
|
146
|
+
}
|
|
147
|
+
return response;
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Returning a different `Response` lets you replace the response (e.g., serve from cache on 5xx).
|
|
154
|
+
|
|
155
|
+
### Adding interceptors dynamically
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
api.useRequestInterceptor((url, init) => {
|
|
159
|
+
init.headers = { ...init.headers, "X-Trace-Id": traceId };
|
|
160
|
+
return init;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
api.useResponseInterceptor((resp) => {
|
|
164
|
+
metrics.recordLatency(resp.url, performance.now() - start);
|
|
165
|
+
return resp;
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Use this for cross-cutting concerns that aren't known at construction time.
|
|
170
|
+
|
|
171
|
+
## Error handling
|
|
172
|
+
|
|
173
|
+
`HttpClient` throws `HttpError` for non-2xx responses:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { HttpError } from "@finesoft/front";
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const user = await api.getById("missing");
|
|
180
|
+
} catch (e) {
|
|
181
|
+
if (e instanceof HttpError) {
|
|
182
|
+
e.status; // 404
|
|
183
|
+
e.statusText; // "Not Found"
|
|
184
|
+
e.url; // "/api/users/missing"
|
|
185
|
+
e.body; // unknown — parsed response body if available
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Network errors (DNS, refused connection, abort) come through as standard `TypeError` / `DOMException`, not `HttpError`. Catch both if you care about either:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
try {
|
|
194
|
+
await api.list();
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (e instanceof HttpError) {
|
|
197
|
+
if (e.status >= 500) showRetryBanner();
|
|
198
|
+
else showInputError(e.body);
|
|
199
|
+
} else {
|
|
200
|
+
showOfflineBanner();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Server-side vs browser
|
|
206
|
+
|
|
207
|
+
`HttpClient` uses `fetch` directly, which is now native on Node 22+. No platform-specific code is needed.
|
|
208
|
+
|
|
209
|
+
Browser-side requests can hit:
|
|
210
|
+
|
|
211
|
+
- Your framework's own proxy routes (`/api/*` → upstream via `proxies` config)
|
|
212
|
+
- Public origins directly (with CORS configured upstream)
|
|
213
|
+
|
|
214
|
+
Server-side requests typically hit:
|
|
215
|
+
|
|
216
|
+
- Internal services on the private network
|
|
217
|
+
- The proxy upstream directly (skipping the proxy hop on SSR)
|
|
218
|
+
|
|
219
|
+
If you proxy `/api` to `https://upstream.example` and a controller calls `api.get("/api/users")` during SSR, the request goes through your proxy on the way back out to the network — which is wasteful. Configure the API client with `baseUrl: process.env.UPSTREAM_URL` on the server and `baseUrl: "/api"` in the browser, deciding by `framework.platform.isServer`.
|
|
220
|
+
|
|
221
|
+
## Retries
|
|
222
|
+
|
|
223
|
+
The framework does not ship a retry interceptor. Wrap your client:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
|
227
|
+
for (let i = 0; i < attempts; i++) {
|
|
228
|
+
try {
|
|
229
|
+
return await fn();
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (i === attempts - 1) throw e;
|
|
232
|
+
if (e instanceof HttpError && e.status < 500) throw e; // don't retry 4xx
|
|
233
|
+
await new Promise((r) => setTimeout(r, 2 ** i * 200));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
throw new Error("unreachable");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const user = await withRetry(() => api.getById(id));
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Add this as a wrapper rather than an interceptor — interceptors run once per request, and retry logic needs to re-run the entire request including all earlier interceptors.
|
|
243
|
+
|
|
244
|
+
## Abort and timeouts
|
|
245
|
+
|
|
246
|
+
Use `AbortController`:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const user = await api.getById(id, { signal: controller.signal });
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
For controllers that may navigate away mid-fetch, store the controller and abort in `fallback()` cleanup or on the next dispatch.
|
|
260
|
+
|
|
261
|
+
## Sending non-JSON bodies
|
|
262
|
+
|
|
263
|
+
`post`/`put`/`patch` JSON-stringify the body unless it's already a string, `FormData`, `URLSearchParams`, or `Blob`:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// JSON (default)
|
|
267
|
+
api.post("/users", { name: "Alice" });
|
|
268
|
+
|
|
269
|
+
// Form data
|
|
270
|
+
const form = new FormData();
|
|
271
|
+
form.append("file", file);
|
|
272
|
+
api.post("/upload", form);
|
|
273
|
+
|
|
274
|
+
// URL-encoded
|
|
275
|
+
api.post("/login", new URLSearchParams({ user: "alice", pass: "secret" }));
|
|
276
|
+
|
|
277
|
+
// Raw text
|
|
278
|
+
api.post("/webhook", "raw payload", { headers: { "Content-Type": "text/plain" } });
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The client sets `Content-Type: application/json` automatically for objects, and leaves the header alone for `FormData` (so the browser can set the multipart boundary).
|
|
282
|
+
|
|
283
|
+
## Next
|
|
284
|
+
|
|
285
|
+
- [DI container](./07-di-container.md) — registering API clients, scoped instances per request
|
|
286
|
+
- [Observability](./08-observability.md) — logging request failures, capturing them in monitoring
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# 7. DI container
|
|
2
|
+
|
|
3
|
+
The framework uses a small dependency-injection container with parent/child scopes. It exists for two reasons:
|
|
4
|
+
|
|
5
|
+
1. **Request isolation on the server** — every SSR request gets its own scope, so per-request state (auth, request id) doesn't leak across requests.
|
|
6
|
+
2. **Decoupled testing** — controllers resolve their dependencies from the container, so tests can swap any of them.
|
|
7
|
+
|
|
8
|
+
The container is intentionally small. There are no decorators, no annotations, no auto-wiring. You register factories, you resolve by key.
|
|
9
|
+
|
|
10
|
+
## Registering
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Container } from "@finesoft/front";
|
|
14
|
+
|
|
15
|
+
const container = new Container();
|
|
16
|
+
|
|
17
|
+
container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
|
|
18
|
+
container.register("logger", () => new ConsoleLogger());
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The factory is invoked **once per container** by default. The result is cached.
|
|
22
|
+
|
|
23
|
+
For per-resolve construction (non-singleton):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
container.register("requestId", () => crypto.randomUUID(), false);
|
|
27
|
+
container.resolve("requestId"); // new id every time
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Resolving
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const api = container.resolve<UserApi>("userApi");
|
|
34
|
+
const logger = container.resolve<Logger>("logger");
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The generic parameter is for TypeScript only — there is no runtime type check.
|
|
38
|
+
|
|
39
|
+
Resolving an unregistered key throws:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
container.resolve("missing"); // Error: Dependency "missing" not registered
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Scopes — the core feature
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const requestScope = framework.container.createScope();
|
|
49
|
+
requestScope.register("currentUser", () => loadUserFromSession(request));
|
|
50
|
+
|
|
51
|
+
// Falls back to parent for keys not in the scope:
|
|
52
|
+
requestScope.resolve("userApi"); // parent
|
|
53
|
+
requestScope.resolve("currentUser"); // scope
|
|
54
|
+
|
|
55
|
+
requestScope.dispose();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A child scope:
|
|
59
|
+
|
|
60
|
+
- **Inherits** all parent keys via fallback resolution
|
|
61
|
+
- **Overrides** any key by registering its own factory
|
|
62
|
+
- **Cleans up** on `dispose()` — children are recursively disposed; the scope removes itself from the parent's children set
|
|
63
|
+
|
|
64
|
+
The framework creates a request-scoped container automatically per SSR request and passes it to your guards and controllers as `ctx.container` / the second argument of `execute()`. You should not need to manually create scopes for normal request handling.
|
|
65
|
+
|
|
66
|
+
## When to create your own scope
|
|
67
|
+
|
|
68
|
+
- Multi-tenant apps where each tenant has its own config / API client (see [advanced/multi-tenant-scopes](./advanced/multi-tenant-scopes.md))
|
|
69
|
+
- Long-running operations that need their own short-lived dependencies
|
|
70
|
+
- Test setup where you want to layer overrides on top of a base container
|
|
71
|
+
|
|
72
|
+
## Disposal and leaks
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const scope = container.createScope();
|
|
76
|
+
// ... use scope ...
|
|
77
|
+
scope.dispose();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`dispose()`:
|
|
81
|
+
|
|
82
|
+
1. Recursively disposes any unfinished child scopes
|
|
83
|
+
2. Calls `destroy()` on any registered factory whose result implements it (loggers, recorders)
|
|
84
|
+
3. Removes itself from the parent's children set
|
|
85
|
+
4. Is idempotent — calling twice is safe
|
|
86
|
+
|
|
87
|
+
**Not disposing a scope leaks every cached value in it.** Request scopes that survive past the response will hold onto:
|
|
88
|
+
|
|
89
|
+
- HTTP clients (and their pending request state)
|
|
90
|
+
- Loggers / recorders
|
|
91
|
+
- Anything else the controllers resolved
|
|
92
|
+
|
|
93
|
+
The framework handles disposal for request scopes it creates. Scopes **you** create are yours to dispose.
|
|
94
|
+
|
|
95
|
+
See [pitfalls: container scope leak](./pitfalls/container-scope-leak.md) for the symptoms when you forget.
|
|
96
|
+
|
|
97
|
+
## Standard DI keys
|
|
98
|
+
|
|
99
|
+
Use `DEP_KEYS` constants instead of string literals to catch typos at type-check time:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
103
|
+
|
|
104
|
+
container.register(DEP_KEYS.LOGGER, () => new ConsoleLogger());
|
|
105
|
+
container.register(DEP_KEYS.EVENT_RECORDER, () => myRecorder);
|
|
106
|
+
|
|
107
|
+
const logger = container.resolve(DEP_KEYS.LOGGER);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The constants:
|
|
111
|
+
|
|
112
|
+
| Key | Standard type | Used by |
|
|
113
|
+
| ------------------------- | ------------------ | --------------------------------------------- |
|
|
114
|
+
| `DEP_KEYS.LOGGER` | `Logger` | Framework logging |
|
|
115
|
+
| `DEP_KEYS.LOGGER_FACTORY` | `LoggerFactory` | Per-category loggers (`logger.scope("auth")`) |
|
|
116
|
+
| `DEP_KEYS.NET` | `Net` | Network state checks (offline / metered) |
|
|
117
|
+
| `DEP_KEYS.STORAGE` | `Storage` | Key-value persistence (localStorage / memory) |
|
|
118
|
+
| `DEP_KEYS.FEATURE_FLAGS` | `FeatureFlags` | Feature flag reads |
|
|
119
|
+
| `DEP_KEYS.METRICS` | `MetricsClient` | Counter / gauge / timing |
|
|
120
|
+
| `DEP_KEYS.FETCH` | `typeof fetch` | `HttpClient`'s underlying fetch (mockable) |
|
|
121
|
+
| `DEP_KEYS.EVENT_RECORDER` | `EventRecorder` | Structured event recording |
|
|
122
|
+
| `DEP_KEYS.LOCALE` | `LocaleAttributes` | Resolved locale (lang + dir) |
|
|
123
|
+
| `DEP_KEYS.PLATFORM` | `PlatformInfo` | Detected user-agent platform info |
|
|
124
|
+
| `DEP_KEYS.TRANSLATOR` | `Translator` | Translation function |
|
|
125
|
+
|
|
126
|
+
The framework registers default implementations for these during `Framework.create()`. Override them by registering after framework creation:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const framework = Framework.create({
|
|
130
|
+
/* ... */
|
|
131
|
+
});
|
|
132
|
+
framework.container.register(DEP_KEYS.LOGGER, () => myCustomLogger);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Custom keys
|
|
136
|
+
|
|
137
|
+
For your own services, use string keys directly:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
container.register("userApi", () => new UserApi({ baseUrl: "/api" }));
|
|
141
|
+
container.register("session", () => new SessionService());
|
|
142
|
+
container.register("featureBucketing", () => new BucketingService());
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
To get type safety for your own keys, define your own const map:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// src/lib/di-keys.ts
|
|
149
|
+
export const APP_KEYS = {
|
|
150
|
+
USER_API: "userApi",
|
|
151
|
+
SESSION: "session",
|
|
152
|
+
FEATURE_BUCKETING: "featureBucketing",
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
155
|
+
// Usage
|
|
156
|
+
container.register(APP_KEYS.USER_API, () => new UserApi({ baseUrl: "/api" }));
|
|
157
|
+
const api = container.resolve<UserApi>(APP_KEYS.USER_API);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Lifecycle ordering
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Framework.create({ ... })
|
|
164
|
+
│
|
|
165
|
+
▼
|
|
166
|
+
default DEP_KEYS registered (logger, locale, platform, ...)
|
|
167
|
+
│
|
|
168
|
+
▼
|
|
169
|
+
your custom registrations (in onBeforeStart or bootstrap)
|
|
170
|
+
│
|
|
171
|
+
▼
|
|
172
|
+
─── per request ───────────────────────────────────
|
|
173
|
+
framework.container.createScope() ← request scope
|
|
174
|
+
│
|
|
175
|
+
▼
|
|
176
|
+
beforeLoad guards (ctx.container = scope)
|
|
177
|
+
│
|
|
178
|
+
▼
|
|
179
|
+
controller.execute(params, scope)
|
|
180
|
+
│
|
|
181
|
+
▼
|
|
182
|
+
afterLoad guards (ctx.container = scope)
|
|
183
|
+
│
|
|
184
|
+
▼
|
|
185
|
+
renderApp() / response
|
|
186
|
+
│
|
|
187
|
+
▼
|
|
188
|
+
scope.dispose() ← framework cleans up
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Testing with the container
|
|
192
|
+
|
|
193
|
+
Inject mocks at the scope level:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { Framework } from "@finesoft/front";
|
|
197
|
+
|
|
198
|
+
const framework = Framework.create({
|
|
199
|
+
/* ... */
|
|
200
|
+
});
|
|
201
|
+
const testScope = framework.container.createScope();
|
|
202
|
+
testScope.register("userApi", () => mockUserApi);
|
|
203
|
+
|
|
204
|
+
const controller = new UserListController();
|
|
205
|
+
const page = await controller.execute({}, testScope);
|
|
206
|
+
|
|
207
|
+
testScope.dispose();
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The full pattern is in [engineering/testing](./engineering/testing.md).
|
|
211
|
+
|
|
212
|
+
## Antipatterns
|
|
213
|
+
|
|
214
|
+
### Don't resolve in module top-level
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// BAD — runs at import time, before framework.create()
|
|
218
|
+
const logger = container.resolve(DEP_KEYS.LOGGER);
|
|
219
|
+
export function log(msg: string) {
|
|
220
|
+
logger.info(msg);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The `container` you'd reach here isn't the request scope; you'd get the parent and lose request isolation.
|
|
225
|
+
|
|
226
|
+
Instead, resolve inside the function that has access to the scope:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
export function logFromController(container: Container, msg: string) {
|
|
230
|
+
container.resolve<Logger>(DEP_KEYS.LOGGER).info(msg);
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Don't keep refs to scoped instances after dispose
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// BAD
|
|
238
|
+
let api: UserApi;
|
|
239
|
+
beforeLoad: (ctx) => {
|
|
240
|
+
api = ctx.container.resolve("userApi");
|
|
241
|
+
return next();
|
|
242
|
+
};
|
|
243
|
+
// `api` now points at an instance whose scope was disposed
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
If you need to share state across requests, register it on the parent container, not the request scope.
|
|
247
|
+
|
|
248
|
+
### Don't register inside a guard
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// BAD — runs per request
|
|
252
|
+
beforeLoad: (ctx) => {
|
|
253
|
+
ctx.container.register("userApi", () => new UserApi(/*...*/));
|
|
254
|
+
return next();
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This creates a fresh factory closure per request. Register once at framework setup; the scope inherits.
|
|
259
|
+
|
|
260
|
+
## Next
|
|
261
|
+
|
|
262
|
+
- [Observability](./08-observability.md) — wiring Logger / EventRecorder / ReportCallback via DI
|
|
263
|
+
- [Engineering: testing](./engineering/testing.md) — using scopes to isolate tests
|
|
264
|
+
- [Pitfalls: container scope leak](./pitfalls/container-scope-leak.md) — what happens when you forget to dispose
|