@finesoft/front 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
package/docs/05-i18n.md CHANGED
@@ -1,243 +1,13 @@
1
- # 5. Internationalization
1
+ # Locale and messages
2
2
 
3
- The framework handles four i18n concerns:
3
+ Keep messages in application declarations and request locale in the invocation. The browser target owns locale attributes for its instance.
4
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`:
5
+ ## Configuration / 配置
174
6
 
175
7
  ```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
- });
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
- 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
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`.
@@ -1,286 +1,43 @@
1
- # 6. HTTP client
1
+ # HTTP client
2
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:
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
- async list(): Promise<User[]> {
31
- return this.get<User[]>("/users");
12
+ constructor(config: HttpClientConfig) {
13
+ super(config);
32
14
  }
33
-
34
- async getById(id: string): Promise<User> {
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
- async create(data: NewUser): Promise<User> {
39
- return this.post<User>("/users", data);
18
+ create(user: Omit<User, "id">) {
19
+ return this.post<User>("/users", user);
40
20
  }
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}`);
21
+ remove(id: string) {
22
+ return this.del<void>(`/users/${encodeURIComponent(id)}`);
48
23
  }
49
24
  }
50
25
  ```
51
26
 
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:
27
+ ## Host binding
66
28
 
67
29
  ```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
- }
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
- ## 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
- });
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
- 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
- ```
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
- 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).
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
- ## Next
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
- - [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
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.