@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.
Files changed (58) hide show
  1. package/README.md +2 -411
  2. package/dist/browser.d.mts +2 -0
  3. package/dist/browser.mjs +1 -0
  4. package/dist/index.d.mts +2 -1248
  5. package/dist/index.mjs +54 -3557
  6. package/dist/server-data-DGbiKzMS.d.mts +1249 -0
  7. package/dist/start-app-BdXBCcor.mjs +2 -0
  8. package/docs/01-getting-started.md +230 -0
  9. package/docs/02-routing-and-controllers.md +197 -0
  10. package/docs/03-middleware.md +214 -0
  11. package/docs/04-rendering-and-hydration.md +271 -0
  12. package/docs/05-i18n.md +243 -0
  13. package/docs/06-http-client.md +286 -0
  14. package/docs/07-di-container.md +264 -0
  15. package/docs/08-observability.md +290 -0
  16. package/docs/09-server-and-deployment.md +242 -0
  17. package/docs/10-features-platform-pwa.md +238 -0
  18. package/docs/README.md +72 -0
  19. package/docs/advanced/custom-action-handler.md +248 -0
  20. package/docs/advanced/custom-adapter.md +264 -0
  21. package/docs/advanced/custom-event-recorder.md +318 -0
  22. package/docs/advanced/inline-proxy-codegen.md +200 -0
  23. package/docs/advanced/multi-tenant-scopes.md +330 -0
  24. package/docs/engineering/ci-release-flow.md +244 -0
  25. package/docs/engineering/project-structure.md +296 -0
  26. package/docs/engineering/testing.md +317 -0
  27. package/docs/pitfalls/container-scope-leak.md +215 -0
  28. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  29. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  30. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  31. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  32. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  33. package/docs/zh/01-getting-started.md +230 -0
  34. package/docs/zh/02-routing-and-controllers.md +197 -0
  35. package/docs/zh/03-middleware.md +214 -0
  36. package/docs/zh/04-rendering-and-hydration.md +271 -0
  37. package/docs/zh/05-i18n.md +243 -0
  38. package/docs/zh/06-http-client.md +286 -0
  39. package/docs/zh/07-di-container.md +264 -0
  40. package/docs/zh/08-observability.md +287 -0
  41. package/docs/zh/09-server-and-deployment.md +242 -0
  42. package/docs/zh/10-features-platform-pwa.md +238 -0
  43. package/docs/zh/README.md +72 -0
  44. package/docs/zh/advanced/custom-action-handler.md +248 -0
  45. package/docs/zh/advanced/custom-adapter.md +264 -0
  46. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  47. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  48. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  49. package/docs/zh/engineering/ci-release-flow.md +244 -0
  50. package/docs/zh/engineering/project-structure.md +296 -0
  51. package/docs/zh/engineering/testing.md +317 -0
  52. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  53. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  54. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  55. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  56. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  57. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  58. package/package.json +12 -3
@@ -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