@finesoft/front 0.1.75 → 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/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- 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 +12 -3
package/docs/05-i18n.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# 5. Internationalization
|
|
2
|
+
|
|
3
|
+
The framework handles four i18n concerns:
|
|
4
|
+
|
|
5
|
+
1. **Resolving the user's locale** (cookie, accept-language, manual override)
|
|
6
|
+
2. **Loading the right dictionary** without bloating the bundle
|
|
7
|
+
3. **Translating strings** with interpolation and pluralization
|
|
8
|
+
4. **Rendering the correct text direction** (LTR / RTL)
|
|
9
|
+
|
|
10
|
+
## Locale resolution
|
|
11
|
+
|
|
12
|
+
Pass a default `locale` to `Framework.create()`:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const framework = Framework.create({ locale: "zh-Hans" });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For SSR, locale priority (highest wins):
|
|
19
|
+
|
|
20
|
+
1. `resolveLocale` callback in `createSSRRender({ resolveLocale })` — has access to request headers / cookies
|
|
21
|
+
2. `locale` in `frameworkConfig` (via DI container)
|
|
22
|
+
|
|
23
|
+
For the browser, the locale is whatever the server resolved (sent via the `<html lang>` attribute). `startBrowserApp` reads it and writes it back into `documentElement.lang`/`dir` on hydration.
|
|
24
|
+
|
|
25
|
+
### Reading at runtime
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const { lang, dir } = framework.getLocale();
|
|
29
|
+
// { lang: "zh-Hans", dir: "ltr" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Custom resolver
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { parseAcceptLanguage } from "@finesoft/front";
|
|
36
|
+
|
|
37
|
+
createSSRRender({
|
|
38
|
+
bootstrap,
|
|
39
|
+
resolveLocale(ctx) {
|
|
40
|
+
// 1. cookie
|
|
41
|
+
const fromCookie = ctx.getCookie("locale");
|
|
42
|
+
if (fromCookie && isSupported(fromCookie)) return fromCookie;
|
|
43
|
+
|
|
44
|
+
// 2. accept-language
|
|
45
|
+
const accept = ctx.getHeader("accept-language");
|
|
46
|
+
const best = parseAcceptLanguage(accept ?? "").find((l) => isSupported(l.tag));
|
|
47
|
+
if (best) return best.tag;
|
|
48
|
+
|
|
49
|
+
// 3. fallback
|
|
50
|
+
return "en-US";
|
|
51
|
+
},
|
|
52
|
+
async renderApp(page) {
|
|
53
|
+
/* ... */
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`parseAcceptLanguage` parses `Accept-Language: en;q=0.9,fr;q=0.8` into ranked tags. Tags with `q=0` are filtered out.
|
|
59
|
+
|
|
60
|
+
## Dictionary loading via `messagesDir`
|
|
61
|
+
|
|
62
|
+
The recommended pattern is JSON files + the Vite plugin:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// vite.config.ts
|
|
66
|
+
finesoftFrontViteConfig({
|
|
67
|
+
ssr: { entry: "src/ssr.ts" },
|
|
68
|
+
i18n: { messagesDir: "src/locales" },
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
src/locales/
|
|
74
|
+
├── en-US.json
|
|
75
|
+
├── zh-Hans.json
|
|
76
|
+
└── ja-JP.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
// src/locales/zh-Hans.json
|
|
81
|
+
{
|
|
82
|
+
"hello": "你好",
|
|
83
|
+
"welcome": "欢迎,{name}",
|
|
84
|
+
"items.one": "{count} 个项目",
|
|
85
|
+
"items.other": "{count} 个项目"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The plugin generates code that:
|
|
90
|
+
|
|
91
|
+
- Loads only the resolved locale's JSON (server side: read from disk; browser side: dynamic import chunk)
|
|
92
|
+
- Caches across requests on the server
|
|
93
|
+
- Is keyed by the resolved locale, so changing locale triggers a re-fetch
|
|
94
|
+
|
|
95
|
+
You **do not** serialize the dictionary into the HTML payload. The browser fetches its locale chunk in parallel with the initial render.
|
|
96
|
+
|
|
97
|
+
## `SimpleTranslator`
|
|
98
|
+
|
|
99
|
+
For in-memory dictionaries (small apps, tests, or manually loaded data):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { SimpleTranslator } from "@finesoft/front";
|
|
103
|
+
|
|
104
|
+
const t = new SimpleTranslator({
|
|
105
|
+
locale: "zh-Hans",
|
|
106
|
+
messages: {
|
|
107
|
+
hello: "你好",
|
|
108
|
+
welcome: "欢迎,{name}",
|
|
109
|
+
"items.one": "{count} 个项目",
|
|
110
|
+
"items.other": "{count} 个项目",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
t.t("hello"); // "你好"
|
|
115
|
+
t.t("welcome", { name: "World" }); // "欢迎,World"
|
|
116
|
+
t.plural("items", 5); // "5 个项目"
|
|
117
|
+
t.plural("items", 1); // "1 个项目"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Interpolation
|
|
121
|
+
|
|
122
|
+
Curly-brace placeholders: `{name}`, `{count}`, `{0}`. Values are HTML-escaped only if you pass them through your view layer's escape — `SimpleTranslator` returns raw strings.
|
|
123
|
+
|
|
124
|
+
### Pluralization
|
|
125
|
+
|
|
126
|
+
Backed by `Intl.PluralRules`. Keys use CLDR plural categories: `zero`, `one`, `two`, `few`, `many`, `other`. Always provide `other` as the fallback.
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"messages.zero": "No messages",
|
|
131
|
+
"messages.one": "1 message",
|
|
132
|
+
"messages.other": "{count} messages"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
t.plural("messages", 0); // "No messages"
|
|
138
|
+
t.plural("messages", 1); // "1 message"
|
|
139
|
+
t.plural("messages", 5); // "5 messages"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Languages with richer plural systems (Russian, Arabic) automatically pick `few`/`many` if present.
|
|
143
|
+
|
|
144
|
+
## Integrating with DI
|
|
145
|
+
|
|
146
|
+
Register the translator in your container:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
container.register(
|
|
150
|
+
"translator",
|
|
151
|
+
() =>
|
|
152
|
+
new SimpleTranslator({
|
|
153
|
+
locale: framework.getLocale().lang,
|
|
154
|
+
messages: loadedMessages,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Or use the framework's built-in DI key:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
163
|
+
|
|
164
|
+
container.register(DEP_KEYS.TRANSLATOR, () => translator);
|
|
165
|
+
|
|
166
|
+
// Later, in any component or controller:
|
|
167
|
+
const t = framework.container.resolve(DEP_KEYS.TRANSLATOR);
|
|
168
|
+
t.t("hello");
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Custom message source
|
|
172
|
+
|
|
173
|
+
If you need to load messages from an API or CDN, override `loadMessages` on `createSSRRender` / `startBrowserApp`:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
createSSRRender({
|
|
177
|
+
bootstrap,
|
|
178
|
+
async loadMessages(locale) {
|
|
179
|
+
const resp = await fetch(`https://cdn.example.com/i18n/${locale}.json`);
|
|
180
|
+
return resp.json();
|
|
181
|
+
},
|
|
182
|
+
async renderApp(page) {
|
|
183
|
+
/* ... */
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This **overrides** the Vite-generated loader. Use it when:
|
|
189
|
+
|
|
190
|
+
- Translations are managed by a service (Lokalise, Phrase) and fetched at runtime
|
|
191
|
+
- You want stale-while-revalidate caching
|
|
192
|
+
- You need to merge multiple namespaces from different sources
|
|
193
|
+
|
|
194
|
+
For most apps, the file-based loader is enough — it ships exactly one locale's bytes, no runtime fetch needed.
|
|
195
|
+
|
|
196
|
+
## RTL support
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { isRtl, getTextDirection, getLocaleAttributes } from "@finesoft/front";
|
|
200
|
+
|
|
201
|
+
isRtl("ar"); // true
|
|
202
|
+
isRtl("he"); // true
|
|
203
|
+
isRtl("zh-Hans"); // false
|
|
204
|
+
|
|
205
|
+
getTextDirection("ar"); // "rtl"
|
|
206
|
+
getTextDirection("en"); // "ltr"
|
|
207
|
+
|
|
208
|
+
getLocaleAttributes("ar-SA"); // { lang: "ar-SA", dir: "rtl" }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The framework sets `<html dir="rtl">` automatically for RTL locales. Your CSS should use logical properties (`margin-inline-start` instead of `margin-left`) for layout that mirrors correctly.
|
|
212
|
+
|
|
213
|
+
```css
|
|
214
|
+
/* good */
|
|
215
|
+
.card {
|
|
216
|
+
padding-inline-start: 16px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* avoid — won't mirror in RTL */
|
|
220
|
+
.card {
|
|
221
|
+
padding-left: 16px;
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Locale switching
|
|
226
|
+
|
|
227
|
+
For a user-initiated locale switch:
|
|
228
|
+
|
|
229
|
+
1. Update the cookie / user preference on the server: `Set-Cookie: locale=ja-JP`.
|
|
230
|
+
2. Trigger a full reload (`window.location.reload()`) so the server resolves the new locale, loads the new dictionary, and re-renders.
|
|
231
|
+
|
|
232
|
+
A purely client-side switch is possible but skips SSR re-render — first-paint will show the old locale until the new dictionary loads. For most apps the full reload is simpler and correct.
|
|
233
|
+
|
|
234
|
+
## Caveats
|
|
235
|
+
|
|
236
|
+
- **Don't serialize the entire dictionary into HTML.** It bloats first paint. The file-based loader sends only the current locale, and only its chunk. See [pitfalls: i18n bundle size](./pitfalls/i18n-bundle-size.md).
|
|
237
|
+
- **Don't mutate the dictionary at runtime.** The cache assumes immutability. If you need dynamic strings (user-generated content), keep them separate from translations.
|
|
238
|
+
- **`SimpleTranslator` is synchronous.** If your translation source is async, load it before the controller runs (e.g., in `beforeLoad` or `onBeforeStart`).
|
|
239
|
+
|
|
240
|
+
## Next
|
|
241
|
+
|
|
242
|
+
- [HTTP client](./06-http-client.md) — making requests, with locale headers when needed
|
|
243
|
+
- [Pitfalls: i18n bundle size](./pitfalls/i18n-bundle-size.md) — keeping translations off the critical path
|
|
@@ -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
|