@finesoft/front 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-CR5vhgXg.mjs +1317 -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-BiRlUanX.d.mts +786 -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 -697
- 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-2dSWO-Xw.d.mts +53 -0
- package/dist/proxy-z02VvGIj.mjs +7520 -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-BQBfaaPO.mjs +3825 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/ssr-BLzYP4wU.d.mts +207 -0
- package/dist/ssr-Tn4YkuxM.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-B1BT0N3t.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 +7 -332
- 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 +9 -155
- 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 +7 -332
- 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 +9 -155
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
package/docs/05-i18n.md
CHANGED
|
@@ -1,243 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Locale and messages
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
Keep messages in application declarations and request locale in the invocation. The browser target owns locale attributes for its instance.
|
|
4
4
|
|
|
5
|
-
|
|
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`:
|
|
5
|
+
## Configuration / 配置
|
|
174
6
|
|
|
175
7
|
```ts
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const resp = await fetch(`https://cdn.example.com/i18n/${locale}.json`);
|
|
180
|
-
return resp.json();
|
|
181
|
-
},
|
|
182
|
-
async renderApp(page) {
|
|
183
|
-
/* ... */
|
|
184
|
-
},
|
|
185
|
-
});
|
|
8
|
+
import { loadMessages } from "virtual:finesoft-front/i18n-loader";
|
|
9
|
+
// defineWebApp({ ..., loadMessages, configuration: { locale: "zh-Hans" } })
|
|
10
|
+
// finesoftFrontViteConfig({ ..., i18n: { messagesDir: "src/locales" } })
|
|
186
11
|
```
|
|
187
12
|
|
|
188
|
-
|
|
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
|
|
13
|
+
Enable the Vite loader only when using locale files. It avoids an eager import of every language in the browser. Read the configured translator with `app.translator` in native views or `await context.get(DEP_KEYS.TRANSLATOR)` in operations. `app.locale` / `DEP_KEYS.LOCALE` provide `{ lang, dir }` attributes; do not mutate a process-wide locale during concurrent requests. Portable callers may pass `{ locale }` to `runtime.execute`.
|
package/docs/06-http-client.md
CHANGED
|
@@ -1,286 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# HTTP client
|
|
2
2
|
|
|
3
|
-
`HttpClient` is
|
|
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:
|
|
3
|
+
`HttpClient` is an optional typed fetch wrapper. Its `fetch` dependency is explicit. Server connection-address validation is host-provided; browser applications must explicitly choose their browser DNS policy.
|
|
15
4
|
|
|
16
5
|
```ts
|
|
17
|
-
import { HttpClient } from "@finesoft/front";
|
|
18
|
-
|
|
6
|
+
import { HttpClient, type HttpClientConfig } from "@finesoft/front";
|
|
19
7
|
interface User {
|
|
20
8
|
id: string;
|
|
21
9
|
name: string;
|
|
22
10
|
}
|
|
23
|
-
|
|
24
|
-
interface NewUser {
|
|
25
|
-
name: string;
|
|
26
|
-
email: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
11
|
export class UserApi extends HttpClient {
|
|
30
|
-
|
|
31
|
-
|
|
12
|
+
constructor(config: HttpClientConfig) {
|
|
13
|
+
super(config);
|
|
32
14
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return this.get<User>(`/users/${id}`);
|
|
15
|
+
getById(id: string, signal?: AbortSignal) {
|
|
16
|
+
return this.get<User>(`/users/${encodeURIComponent(id)}`, undefined, { signal });
|
|
36
17
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return this.post<User>("/users", data);
|
|
18
|
+
create(user: Omit<User, "id">) {
|
|
19
|
+
return this.post<User>("/users", user);
|
|
40
20
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return this.patch<User>(`/users/${id}`, data);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async delete(id: string): Promise<void> {
|
|
47
|
-
await this.delete(`/users/${id}`);
|
|
21
|
+
remove(id: string) {
|
|
22
|
+
return this.del<void>(`/users/${encodeURIComponent(id)}`);
|
|
48
23
|
}
|
|
49
24
|
}
|
|
50
25
|
```
|
|
51
26
|
|
|
52
|
-
|
|
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:
|
|
27
|
+
## Host binding
|
|
66
28
|
|
|
67
29
|
```ts
|
|
68
|
-
import
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
}
|
|
30
|
+
// Node host code; keep this import out of browser and Worker modules.
|
|
31
|
+
import { nodeSafeFetchOptions } from "@finesoft/front";
|
|
32
|
+
const api = new UserApi({ baseUrl: "https://api.example.com", fetch, ...nodeSafeFetchOptions });
|
|
81
33
|
```
|
|
82
34
|
|
|
83
|
-
|
|
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
|
-
});
|
|
35
|
+
Browser code may construct the same class with an absolute API URL, its chosen fetch implementation and `validateDns: false`. That explicitly gives DNS resolution to the browser. Do not silently disable a required server capability. Internal/loopback targets are blocked by default; host configuration must deliberately opt in where required.
|
|
162
36
|
|
|
163
|
-
|
|
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
|
-
```
|
|
37
|
+
The Node entry includes its connection transport as a lazy chunk; consumers need no extra transport package. Standard Node/Vercel/Netlify, development and preview hosts use `nodeSafeFetchOptions`: all DNS answers are checked inside connection creation, and those exact addresses reach the socket while hostname, TLS SNI, cancellation and pooling remain intact. The pool loads lazily and stays outside browser/Worker graphs. Custom Node fetch implementations must honor Undici's `dispatcher` option. `nodeDnsLookup` alone is only a preflight and does not prevent a DNS change before connection.
|
|
280
38
|
|
|
281
|
-
|
|
39
|
+
Protected fetch checks each redirect, follows at most 20 hops and removes authentication/cookie headers across origins. Explicit `manual` / `error` modes retain their meaning. Opaque browser redirects are rejected because their destination is unreadable. Redirects requiring replay of `Request.body` or a streamed body are rejected without unbounded tee buffering; use a replayable `RequestInit.body` (such as a string) or the final URL. `allowInternalHosts: true` explicitly opts out of these protections.
|
|
282
40
|
|
|
283
|
-
|
|
41
|
+
For scoped business services, declare a typed provider and acquire it with `context.get(token)`; see [Dependencies](./07-di-container.md). Bind the invocation's fetch and signal so request cancellation propagates. A cancelled command may already have performed a write; cancellation is not rollback. The operation runtime does not implicitly retry or cache commands.
|
|
284
42
|
|
|
285
|
-
-
|
|
286
|
-
- [Observability](./08-observability.md) — logging request failures, capturing them in monitoring
|
|
43
|
+
Request and response interceptors run in registration order. `HttpError` represents non-success HTTP results. The generic result type describes expected data; validate untrusted response payloads at the business boundary when necessary. The lower-level protected methods are `get`, `post`, `put`, `del` and `request`; `get`/`del` accept query parameters before request options.
|