@finesoft/front 0.1.76 → 0.1.78

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 (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +203 -0
  3. package/docs/03-middleware.md +220 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +203 -0
  28. package/docs/zh/03-middleware.md +220 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. package/package.json +2 -1
@@ -0,0 +1,271 @@
1
+ # 4. Rendering & hydration
2
+
3
+ How a page travels from controller output to bytes on the wire, then back into a live browser app. This chapter covers SSR, CSR, prerender, and the `PrefetchedIntents` machinery that ties them together.
4
+
5
+ ## The three modes side by side
6
+
7
+ | | SSR | CSR | Prerender |
8
+ | --------------------------- | -------------------------------- | -------------------------------- | -------------------------------- |
9
+ | When HTML is built | Per request, on the server | At build time (shell only) | At build time, per route |
10
+ | Initial body | Fully rendered | Empty `<div id="app"></div>` | Fully rendered |
11
+ | Initial fetch on hydration? | No (data in `PrefetchedIntents`) | Yes (controller runs in browser) | No (data in `PrefetchedIntents`) |
12
+ | TTFB | One controller execution | Near-zero | Static file serve |
13
+ | Personalization | Per-request OK | Best — runs entirely client-side | None (same HTML for everyone) |
14
+ | SEO | Good | Requires JS-aware crawlers | Best |
15
+
16
+ Mode is **per-route**. Mix freely.
17
+
18
+ ## SSR pipeline
19
+
20
+ ```
21
+ Request URL
22
+
23
+
24
+ Router.resolve() → RouteMatch
25
+
26
+
27
+ beforeLoad guards → may rewrite (internal) / redirect / deny
28
+
29
+
30
+ IntentDispatcher.dispatch() → Page
31
+
32
+
33
+ afterLoad guards → may redirect / deny / signal canonicalization
34
+
35
+
36
+ renderApp(page) → { html, head, css }
37
+
38
+
39
+ injectSSRContent() → final HTML with:
40
+ • rendered body in <!--ssr-->
41
+ • head fragment in <!--head-->
42
+ • serialized PrefetchedIntents in a <script> tag
43
+ • <html lang="..." dir="..."> attributes
44
+ ```
45
+
46
+ ### SSR entry
47
+
48
+ ```ts
49
+ // src/ssr.ts
50
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
51
+ import { createSSRApp } from "vue";
52
+ import { renderToString } from "vue/server-renderer";
53
+ import App from "./App.vue";
54
+ import { bootstrap } from "./bootstrap";
55
+
56
+ export const render = createSSRRender({
57
+ bootstrap,
58
+ getErrorPage: () => ({ kind: "error", title: "Something went wrong" }),
59
+ async renderApp(page) {
60
+ const app = createSSRApp(App, { page });
61
+ const html = await renderToString(app);
62
+ return {
63
+ html,
64
+ head: `<title>${escape(page.title)}</title>`,
65
+ css: "",
66
+ };
67
+ },
68
+ });
69
+
70
+ export { serializeServerData };
71
+ ```
72
+
73
+ The Vite plugin and adapters call `render(url, options)` for you. You return `{ html, head, css }`; the framework handles injection and serialization.
74
+
75
+ ### What `createSSRRender` does for you
76
+
77
+ - Runs `bootstrap()` once on the server (cached across requests in the same worker)
78
+ - Creates a request-scoped DI container per request
79
+ - Runs the middleware pipeline
80
+ - Calls your `renderApp()` to produce the body
81
+ - Serializes prefetched intent results into a `<script id="__finesoft_data__">` tag
82
+ - Sets `<html lang dir>` from the resolved locale
83
+ - Sets HTTP status from `deny()` / `redirect()` / `rewrite()` results
84
+ - Adds `Content-Location` header when `afterLoad` signaled a rewrite
85
+
86
+ ## CSR (client-side render)
87
+
88
+ For routes marked `renderMode: "csr"`, the server returns a minimal shell:
89
+
90
+ ```html
91
+ <!doctype html>
92
+ <html lang="en">
93
+ <head>
94
+ <!-- head injected here -->
95
+ </head>
96
+ <body>
97
+ <div id="app"></div>
98
+ <!-- no PrefetchedIntents script — controller runs in browser -->
99
+ <script type="module" src="/src/main.ts"></script>
100
+ </body>
101
+ </html>
102
+ ```
103
+
104
+ The controller runs in the browser when `startBrowserApp()` triggers the first navigation. Use CSR for:
105
+
106
+ - Heavily personalized dashboards behind auth
107
+ - Pages where SEO doesn't matter
108
+ - Pages where server-side rendering cost outweighs the latency benefit
109
+
110
+ ## Prerender (static + ISR)
111
+
112
+ ```ts
113
+ { path: "/about", intentId: "about", controller: new AboutController(), renderMode: "prerender" }
114
+ ```
115
+
116
+ At build time the framework:
117
+
118
+ 1. Calls `controller.execute({}, container)` (path params from the static path)
119
+ 2. Runs `renderApp()` to produce HTML
120
+ 3. Writes `dist/about.html` to disk
121
+
122
+ The adapter serves these static files directly. No controller runs at request time.
123
+
124
+ ### Incremental Static Regeneration (ISR)
125
+
126
+ The bundled server (`createServer`) and the preview server (`vp preview`) support cached on-demand regeneration. Configure via `finesoftFrontViteConfig`:
127
+
128
+ ```ts
129
+ finesoftFrontViteConfig({
130
+ ssr: { entry: "src/ssr.ts" },
131
+ isr: {
132
+ // routes that should regenerate on demand
133
+ routes: ["/blog/*"],
134
+ // cache TTL in seconds
135
+ ttl: 300,
136
+ },
137
+ });
138
+ ```
139
+
140
+ The first request after expiry triggers a fresh render; concurrent requests get the stale version until the regeneration completes. See [server & deployment](./09-server-and-deployment.md#isr) for details.
141
+
142
+ ## `PrefetchedIntents` — the SSR → CSR bridge
143
+
144
+ The crucial mechanic: **the same controller produces a page on the server, and the browser reuses that result without refetching.**
145
+
146
+ ### How it works
147
+
148
+ 1. SSR: controller runs, returns `Page`. The framework stores `(intentId, paramsKey) → Page` in a `PrefetchedIntents` map.
149
+ 2. Render: the map is JSON-stringified into `<script id="__finesoft_data__">{...}</script>`.
150
+ 3. Browser: `startBrowserApp` reads the script, calls `createPrefetchedIntentsFromDom()`, passes it to `Framework.create()`.
151
+ 4. First navigation in the browser: `IntentDispatcher.dispatch()` checks the map by `(intentId, paramsKey)` — if hit, returns the cached `Page` directly without calling the controller.
152
+
153
+ ### Stable key generation
154
+
155
+ The lookup key is generated from `intentId` + the **stable JSON stringification** of `params`. Object key order does not affect the key:
156
+
157
+ ```ts
158
+ // These produce the same paramsKey:
159
+ dispatch({ intentId: "product", params: { id: "42", color: "red" } });
160
+ dispatch({ intentId: "product", params: { color: "red", id: "42" } });
161
+ ```
162
+
163
+ If you write a controller that resolves the same logical request from different `params` shapes, factor it into a normalization step before dispatch.
164
+
165
+ ### When the cache misses
166
+
167
+ - New navigation to an intent not prefetched on the server (e.g., dynamic route the user clicked)
168
+ - Stale cache after `PrefetchedIntents.invalidate(intentId, params)`
169
+ - Browser-side mutation guards (custom)
170
+
171
+ A miss falls through to the regular dispatcher path — `execute()` runs in the browser.
172
+
173
+ ## Hydration step-by-step
174
+
175
+ ```
176
+ Server Browser
177
+ ────── ───────
178
+ bootstrap(framework)
179
+ ▼ │
180
+ controller.execute() │
181
+ ▼ │
182
+ Page A │
183
+ ▼ │
184
+ serialize → <script> │
185
+ ▼ │
186
+ HTML response ────────────────▶ Receive HTML
187
+
188
+ createPrefetchedIntentsFromDom()
189
+
190
+ Framework.create({ prefetchedIntents })
191
+
192
+ bootstrap(framework) ← same code, same routes
193
+
194
+ dispatch(currentIntent)
195
+
196
+ Cache hit → Page A ← no refetch
197
+
198
+ mount(app)
199
+ ```
200
+
201
+ The bootstrap runs twice — once on each side — with identical inputs. This is what guarantees the browser-side initial route matches the server-rendered HTML.
202
+
203
+ ## SSR head injection
204
+
205
+ `renderApp()` returns a `head` fragment. The framework injects it at the `<!--head-->` placeholder along with:
206
+
207
+ - `<script id="__finesoft_data__">` with serialized data (SSR mode only)
208
+ - `<link>` / `<script>` for client entry (production builds)
209
+ - `<html lang="..." dir="...">` attributes from the resolved locale
210
+
211
+ Custom meta tags go in your `head` string:
212
+
213
+ ```ts
214
+ async renderApp(page) {
215
+ return {
216
+ html: await renderToString(/*...*/),
217
+ head: [
218
+ `<title>${escape(page.title)}</title>`,
219
+ `<meta name="description" content="${escape(page.description)}">`,
220
+ `<meta property="og:title" content="${escape(page.title)}">`,
221
+ ].join(""),
222
+ css: "",
223
+ };
224
+ }
225
+ ```
226
+
227
+ Always escape user-provided strings — they go straight into HTML.
228
+
229
+ ## CSS injection
230
+
231
+ If your render produces critical CSS (e.g., Vue scoped styles or `vanilla-extract`), return it as `css`:
232
+
233
+ ```ts
234
+ return {
235
+ html,
236
+ head: `<title>${title}</title>`,
237
+ css: extractedCriticalCss, // injected as <style> in <head>
238
+ };
239
+ ```
240
+
241
+ For Vite-managed stylesheets, leave `css: ""` — the Vite plugin handles them.
242
+
243
+ ## Status codes
244
+
245
+ The HTTP status of the SSR response follows this priority:
246
+
247
+ 1. Middleware result: `deny(404)` → 404; `redirect(url, 301)` → 301 with `Location` header.
248
+ 2. Page-level: a `Page` of `kind: "error"` returned by `fallback()` results in 500 (configurable via `getErrorPage`).
249
+ 3. Default: 200.
250
+
251
+ Override via `afterLoad`:
252
+
253
+ ```ts
254
+ afterLoad: [
255
+ (ctx) => {
256
+ if (ctx.page.kind === "not-found") return deny(404, "Not found");
257
+ return next();
258
+ },
259
+ ],
260
+ ```
261
+
262
+ ## Streaming SSR
263
+
264
+ Currently not supported. The framework awaits `renderApp()` fully before sending bytes. For most apps this is fine — `IntentDispatcher` parallelizes data fetching inside `execute()` if your controller awaits multiple HTTP calls together.
265
+
266
+ If you need streaming for a specific large page, consider rendering it CSR and using your view layer's own streaming primitives.
267
+
268
+ ## Next
269
+
270
+ - [i18n](./05-i18n.md) — locale resolution and dictionary loading
271
+ - [Pitfalls: SSR hydration mismatch](./pitfalls/ssr-hydration-mismatch.md) — when the two sides disagree
@@ -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