@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
@@ -1,220 +1,18 @@
1
- # 3. Middleware
1
+ # Guarded page loading
2
2
 
3
- Middleware runs in two phases around the controller. A guard inspects the navigation, then returns one of four results to control what happens next.
3
+ URL, SSR and structured navigation use the same guarded page loader. Global, route and navigation guards run before and after page execution; every Split destination is checked.
4
4
 
5
- ## Pipeline
6
-
7
- ```
8
- Router.resolve()
9
-
10
-
11
- beforeLoad chain ← NavigationContext (no page yet)
12
-
13
- next()? ──no──▶ short-circuit (redirect / rewrite / deny)
14
- │ yes
15
-
16
- IntentDispatcher.dispatch()
17
-
18
-
19
- afterLoad chain ← PostLoadContext (page exists)
20
-
21
- next()? ──no──▶ short-circuit
22
- │ yes
23
-
24
- render
25
- ```
26
-
27
- Guards run in array order. The first non-`next()` result short-circuits the rest of the chain.
28
-
29
- ## The four results
30
-
31
- ```ts
32
- import { next, redirect, rewrite, deny } from "@finesoft/front";
33
-
34
- next(); // continue to the next guard / dispatcher
35
- redirect("/login"); // HTTP 302; navigate to URL
36
- redirect("/old", 301); // HTTP 301 (permanent)
37
- rewrite("/canonical"); // internal re-route in beforeLoad; canonicalization signal in afterLoad
38
- deny(); // 403 Forbidden
39
- deny(404, "Not found"); // custom status + message
40
- ```
41
-
42
- ### `next()`
43
-
44
- Pass-through. The pipeline continues.
45
-
46
- ### `redirect(url, status?)`
47
-
48
- The browser navigates to `url` and the original render is abandoned. On the server this becomes an HTTP redirect; on the browser it becomes a navigation (via `History.pushState`).
49
-
50
- Use for: login redirects, deprecated paths, locale-prefix canonicalization.
51
-
52
- ### `rewrite(url)`
53
-
54
- **`beforeLoad` rewrite** — internal re-route. The router resolves `url` instead, and the _new_ match's guards + controller run. No HTTP redirect is emitted; the original URL stays in the address bar. Bounded depth (5 levels) to prevent loops.
55
-
56
- **`afterLoad` rewrite** — canonicalization signal. The framework includes the rewrite URL in the SSR response as a `Content-Location` header without redirecting. Browsers receive the original URL with a hint that a canonical version exists.
57
-
58
- See [redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md) for when to use which.
59
-
60
- ### `deny(status?, message?)`
61
-
62
- Stops the request. Default `403 Forbidden`. Common: `deny(401, "Login required")`, `deny(404, "Not found")`.
63
-
64
- ## Writing guards
65
-
66
- A guard is a function from context to a `MiddlewareResult` (or `Promise<MiddlewareResult>`).
67
-
68
- ```ts
69
- // src/lib/guards/auth.ts
70
- import { next, redirect, type NavigationContext } from "@finesoft/front";
71
-
72
- export function authGuard(ctx: NavigationContext) {
73
- const token = ctx.getCookie("token");
74
- if (!token) {
75
- return redirect(`/login?next=${encodeURIComponent(ctx.url.pathname)}`);
76
- }
77
- return next();
78
- }
79
- ```
80
-
81
- ### `NavigationContext` (beforeLoad)
82
-
83
- | Field | Type | Notes |
84
- | ----------------- | ---------------------------------- | ---------------------------------------------------- |
85
- | `url` | `URL` | Full request URL. |
86
- | `intent` | `Intent` | Resolved intent with parsed path params. |
87
- | `container` | `Container` | Request-scoped DI container. |
88
- | `getCookie(name)` | `(name: string) => string \| null` | Read a cookie (server + browser). |
89
- | `getHeader(name)` | `(name: string) => string \| null` | Read a request header (server only; browser → null). |
90
- | `isSsr` | `boolean` | `true` on server, `false` in browser. |
91
-
92
- ### `PostLoadContext` (afterLoad)
93
-
94
- Extends `NavigationContext` with:
95
-
96
- | Field | Type | Notes |
97
- | ------ | ---------- | ------------------------------------ |
98
- | `page` | `BasePage` | The page produced by the controller. |
99
-
100
- ## Attaching guards to routes
101
-
102
- ```ts
103
- defineRoutes(framework, [
104
- {
105
- path: "/admin",
106
- intentId: "admin",
107
- controller: new AdminController(),
108
- beforeLoad: [authGuard, requireAdminRole],
109
- afterLoad: [trackPageView],
110
- },
111
- ]);
112
- ```
113
-
114
- Guards on a route run **in addition** to any global guards registered on the framework (see below). Order: globals first, then route-specific.
115
-
116
- ## Global guards
117
-
118
- Register guards that apply to every navigation:
119
-
120
- ```ts
121
- framework.middleware.use("beforeLoad", trackingGuard);
122
- framework.middleware.use("afterLoad", metricsGuard);
123
- ```
124
-
125
- Use sparingly. Global guards run on every page, including SSR — slow global guards multiply across the entire surface area.
126
-
127
- ## Common patterns
128
-
129
- ### Authentication
130
-
131
- ```ts
132
- function authGuard(ctx: NavigationContext) {
133
- const token = ctx.getCookie("session");
134
- if (!token) return redirect("/login?next=" + encodeURIComponent(ctx.url.pathname));
135
- return next();
136
- }
137
- ```
138
-
139
- ### Role check
140
-
141
- ```ts
142
- async function requireAdmin(ctx: NavigationContext) {
143
- const session = await ctx.container.resolve<SessionService>("session").current();
144
- if (!session?.isAdmin) return deny(403, "Admin only");
145
- return next();
146
- }
147
- ```
148
-
149
- ### Locale prefix redirect
150
-
151
- ```ts
152
- function localePrefixGuard(ctx: NavigationContext) {
153
- if (/^\/(en|zh|ja)\//.test(ctx.url.pathname)) return next();
154
- const detected = detectLocale(ctx); // your own logic
155
- return redirect(`/${detected}${ctx.url.pathname}`, 301);
156
- }
157
- ```
158
-
159
- ### A/B test rewrite
5
+ ## Guard / 守卫
160
6
 
161
7
  ```ts
162
- function abTestGuard(ctx: NavigationContext) {
163
- if (ctx.url.pathname !== "/landing") return next();
164
- const variant = bucket(ctx.getCookie("uid"));
165
- return variant === "B" ? rewrite("/landing-v2") : next();
166
- }
8
+ import { next, redirect, type BeforeLoadGuard } from "@finesoft/front";
9
+ export const signedIn: BeforeLoadGuard = (context) =>
10
+ context.getCookie("session")
11
+ ? next()
12
+ : redirect("/login?next=" + encodeURIComponent(context.url));
13
+ // app.beforeLoad: [signedIn], or home.route("/account", { beforeLoad: [signedIn] })
167
14
  ```
168
15
 
169
- The user sees `/landing` in the address bar; the server renders `/landing-v2`. No client-visible redirect, no flicker.
170
-
171
- ### After-load analytics
172
-
173
- ```ts
174
- function trackPageView(ctx: PostLoadContext) {
175
- ctx.container.resolve<EventRecorder>("eventRecorder").record({
176
- name: "PageView",
177
- fields: { intentId: ctx.intent.intentId, url: ctx.url.pathname },
178
- });
179
- return next();
180
- }
181
- ```
182
-
183
- ## Guard ordering rules
184
-
185
- 1. Global `beforeLoad` guards (registration order)
186
- 2. Route-specific `beforeLoad` guards (array order)
187
- 3. Controller `execute()`
188
- 4. Global `afterLoad` guards
189
- 5. Route-specific `afterLoad` guards
190
-
191
- A non-`next()` result at any step stops the rest. Subsequent guards do not run.
192
-
193
- ## Async guards
194
-
195
- Guards can be `async`. The pipeline awaits each result before moving on. Avoid long awaits in global guards (they multiply across every request).
196
-
197
- ```ts
198
- async function rateLimitGuard(ctx: NavigationContext) {
199
- const limiter = ctx.container.resolve<RateLimiter>("rateLimiter");
200
- const allowed = await limiter.tryConsume(ctx.getCookie("uid") ?? "anon");
201
- return allowed ? next() : deny(429, "Too many requests");
202
- }
203
- ```
204
-
205
- ## Caveats
206
-
207
- - **Guards must be pure with respect to the framework state.** Don't mutate `ctx.intent.params` — make a new intent and `rewrite` if you need to change params.
208
- - **`deny()` in `afterLoad` discards the produced page.** The controller already ran; deny only blocks the response. If `execute()` had side effects (writes), they already happened.
209
- - **Browser-side guards do not have access to request headers.** `getHeader()` returns `null` on the client. Cookies still work.
210
-
211
- ## Try it
212
-
213
- Build a `beforeLoad` chain of three guards, pick the result each one returns, then run it through the **real** `runBeforeLoadGuards()` from `@finesoft/core`. The pipeline below shows where the chain short-circuits and what the final `MiddlewareResult` looks like.
16
+ Guards return `next()`, `deny(status, message)`, `redirect(url, status)` or `rewrite(url)`. First non-next result stops that chain. A browser redirect continues through host admission; SSR emits an HTTP redirect. Operation policies are a separate portable layer: protect data operations there too, instead of relying only on page guards. A warm HTML cache must not bypass the current request’s checks.
214
17
 
215
18
  <Ch03MiddlewarePlayground />
216
-
217
- ## Next
218
-
219
- - [Rendering & hydration](./04-rendering-and-hydration.md) — what happens between `afterLoad` and HTML output
220
- - [Pitfalls: redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md) — choosing between the two
@@ -1,338 +1,11 @@
1
- # 4. Rendering & hydration
1
+ # Rendering and hydration
2
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, the second axis they compose with **app architecture** (flat single page vs structured navigation + islands) and the `PrefetchedIntents` machinery that ties them together.
3
+ SSR, CSR and prerender determine when HTML is produced. All modes use the same page session and one native application root, with layout around Outlet. There are no root/entries renderer modes or separate chrome roots.
4
4
 
5
- ## The three modes side by side
5
+ In the browser, call `createBrowserApp({ definition, target })`, mount or hydrate the same App using native APIs, then await `app.ready`. On the server use `createSSRRender({ definition, render: app => nativeRender(app) })`. See [getting started](./01-getting-started.md) for React and the templates for Vue/Svelte.
6
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 |
7
+ Outlet subscribes to stable `AppSnapshot` values. EntryId keeps its wrapper stable; EntryId plus pageType determines the child component identity. Hidden entries remain in the same native tree with their drafts and context. A pageType change recreates the child. Native commit hooks acknowledge only the revision actually rendered; the browser then restores its scroll and DOM state.
15
8
 
16
- Mode is **per-route**. Mix freely.
9
+ SSR materializes public projections while request resources are alive and performs one native render. Hydration explicitly carries `{ tree, pages }`, with page results associated with entries. Protocol/build mismatches trigger fresh loading; persisted sessions have their own version. Denied data does not enter the wire.
17
10
 
18
- ## Two axes: render mode × app architecture
19
-
20
- Render mode is one axis. The **app architecture** is a second, orthogonal axis:
21
-
22
- - **Flat single page** — `createSSRRender` on the server, a single client mount that re-renders on each navigation. One root, one visible page. (See [SSR pipeline](#ssr-pipeline) below.)
23
- - **Structured navigation + islands** — `createSSRNavigationRender` on the server, per-destination _islands_ on the client: independent roots that stay alive across tab/stack switches. (See [Navigation](./11-navigation.md) and [Islands SSR](#islands-ssr-structured-architecture-approach-c) below.)
24
-
25
- The two axes compose into a matrix — render mode decides _when/where_ HTML is produced; architecture decides _how_ the app is structured:
26
-
27
- | | Flat single page | Structured nav + islands (approach C) |
28
- | ------------- | ------------------------- | ------------------------------------- |
29
- | **ssr** | ✅ `svelte-minimal` | ✅ `vue-minimal`, `react-minimal` |
30
- | **csr** | ◐ shell → one client root | ◐ shell → islands mount client-side |
31
- | **prerender** | ◐ cached flat SSR | ◐ cached approach-C SSR |
32
-
33
- ✅ demonstrated by a starter template · ◐ composes by design, no starter template yet.
34
-
35
- **Islands are SSR'd or CSR'd as a consequence of the mode, not as a separate choice:** under `ssr`/`prerender` the framework server-renders each visible island and the client _adopts and hydrates_ it; under `csr` there is no server HTML, so every island mounts fresh on the client. The per-mode sub-dimensions still apply on top — CSR has two triggers ([below](#csr-client-side-render)), prerender has build-time-static and runtime-ISR forms ([below](#prerender-static--isr)). Session restoration + DOM restore are a further orthogonal layer (client-side, post-hydration) that stacks onto any cell — see [Session restoration](./12-session-restoration.md).
36
-
37
- ## SSR pipeline
38
-
39
- ```
40
- Request URL
41
-
42
-
43
- Router.resolve() → RouteMatch
44
-
45
-
46
- beforeLoad guards → may rewrite (internal) / redirect / deny
47
-
48
-
49
- IntentDispatcher.dispatch() → Page
50
-
51
-
52
- afterLoad guards → may redirect / deny / signal canonicalization
53
-
54
-
55
- renderApp(page) → { html, head, css }
56
-
57
-
58
- injectSSRContent() → final HTML with:
59
- • rendered body in <!--ssr-->
60
- • head fragment in <!--head-->
61
- • serialized PrefetchedIntents in a <script> tag
62
- • <html lang="..." dir="..."> attributes
63
- ```
64
-
65
- ### SSR entry
66
-
67
- ```ts
68
- // src/ssr.ts
69
- import { createSSRRender, serializeServerData } from "@finesoft/front";
70
- import { createSSRApp } from "vue";
71
- import { renderToString } from "vue/server-renderer";
72
- import App from "./App.vue";
73
- import { bootstrap } from "./bootstrap";
74
-
75
- export const render = createSSRRender({
76
- bootstrap,
77
- getErrorPage: () => ({ kind: "error", title: "Something went wrong" }),
78
- async renderApp(page) {
79
- const app = createSSRApp(App, { page });
80
- const html = await renderToString(app);
81
- return {
82
- html,
83
- head: `<title>${escape(page.title)}</title>`,
84
- css: "",
85
- };
86
- },
87
- });
88
-
89
- export { serializeServerData };
90
- ```
91
-
92
- The Vite plugin and adapters call `render(url, options)` for you. You return `{ html, head, css }`; the framework handles injection and serialization.
93
-
94
- ### What `createSSRRender` does for you
95
-
96
- - Runs `bootstrap()` once on the server (cached across requests in the same worker)
97
- - Creates a request-scoped DI container per request
98
- - Runs the middleware pipeline
99
- - Calls your `renderApp()` to produce the body
100
- - Serializes prefetched intent results into a `<script id="__finesoft_data__">` tag
101
- - Sets `<html lang dir>` from the resolved locale
102
- - Sets HTTP status from `deny()` / `redirect()` / `rewrite()` results
103
- - Adds `Content-Location` header when `afterLoad` signaled a rewrite
104
-
105
- ## Islands SSR (structured architecture, "approach C")
106
-
107
- The structured architecture renders the **chrome** (tab bar, headers — the persistent frame) and the **island content** (the active page) as **independent hydration roots**, placed as siblings under the mount node:
108
-
109
- ```html
110
- <div id="app">
111
- <div data-fs-chrome><!-- chrome SSR'd here --></div>
112
- <main data-fs-outlet><!-- each visible island SSR'd here --></main>
113
- </div>
114
- ```
115
-
116
- **Server** — `renderApp` renders the chrome; `renderIslandsHtml(snapshot, renderEntry)` renders each visible destination into the outlet with shared markers (`data-fs-entry` / `data-fs-intent` / `data-fs-key`) so the client can match them:
117
-
118
- ```ts
119
- // src/ssr.ts — structured entry (createSSRNavigationRender)
120
- async renderApp(page, _framework, snapshot) {
121
- const chromeHtml = await renderToString(createSSRApp(App, { snapshot }));
122
- const islandsHtml = await renderIslandsHtml(snapshot, (entry) =>
123
- renderToString(createSSRApp(VIEWS[entry.intent], { page: entry.page })),
124
- );
125
- return {
126
- html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
127
- head: `<title>${page.title}</title>`,
128
- css: "",
129
- };
130
- }
131
- ```
132
-
133
- **Client** — `resolveIslandsShell(target)` locates (or creates) the chrome/outlet siblings and reports whether the chrome was server-rendered (`hydrate`). The island orchestrator adopts each SSR'd container by `data-fs-key` and calls your `mountEntry(entry, container)` with `entry.hydrate = true`, so you hydrate the existing DOM rather than create new:
134
-
135
- ```ts
136
- // src/main.ts
137
- const mountEntry = (entry, container) => {
138
- const factory = entry.hydrate ? createSSRApp : createApp; // hydrate SSR'd vs mount fresh (client nav)
139
- const app = factory(VIEWS[entry.intent], { page: entry.page, controller: ctx.app });
140
- app.mount(container);
141
- return { unmount: () => app.unmount() };
142
- };
143
-
144
- startBrowserApp({
145
- bootstrap,
146
- mount,
147
- callbacks,
148
- navigation: { ...navigation.toBrowserConfig(), mountEntry },
149
- });
150
- ```
151
-
152
- > **Synchronous-mount contract.** After `mountEntry` returns, the island's DOM must already exist: the framework restores `data-restore-root` fields on the next animation frame (see [Session restoration](./12-session-restoration.md)). Vue/Svelte `.mount()` satisfies this synchronously. **React** commits asynchronously, so wrap the **client-mount** path in `flushSync(() => root.render(view))` — only client-mounted islands need it (SSR'd islands already have their DOM from the server). See `templates/react-minimal/src/main.tsx`.
153
-
154
- Complete examples: `templates/vue-minimal` and `templates/react-minimal` (both `ssr` + structured navigation + islands + session restoration).
155
-
156
- ## CSR (client-side render)
157
-
158
- For routes marked `renderMode: "csr"`, the server returns a minimal shell:
159
-
160
- ```html
161
- <!doctype html>
162
- <html lang="en">
163
- <head>
164
- <!-- head injected here -->
165
- </head>
166
- <body>
167
- <div id="app"></div>
168
- <!-- no PrefetchedIntents script — controller runs in browser -->
169
- <script type="module" src="/src/main.ts"></script>
170
- </body>
171
- </html>
172
- ```
173
-
174
- The controller runs in the browser when `startBrowserApp()` triggers the first navigation. Use CSR for:
175
-
176
- - Heavily personalized dashboards behind auth
177
- - Pages where SEO doesn't matter
178
- - Pages where server-side rendering cost outweighs the latency benefit
179
-
180
- ## Prerender (static + ISR)
181
-
182
- ```ts
183
- { path: "/about", intentId: "about", controller: new AboutController(), renderMode: "prerender" }
184
- ```
185
-
186
- At build time the framework:
187
-
188
- 1. Calls `controller.execute({}, container)` (path params from the static path)
189
- 2. Runs `renderApp()` to produce HTML
190
- 3. Writes `dist/about.html` to disk
191
-
192
- The adapter serves these static files directly. No controller runs at request time.
193
-
194
- ### Incremental Static Regeneration (ISR)
195
-
196
- The bundled server (`createServer`) and the preview server (`vp preview`) also cache `prerender` routes at runtime: a route is rendered on its **first** request and the HTML kept in an in-memory LRU (`ISR_CACHE_MAX = 1000` entries, evicted least-recently-used). Subsequent requests serve the cached HTML without re-running the controller.
197
-
198
- Mark routes `prerender` per route (`renderMode: "prerender"`) or per glob via the Vite plugin (config-level wins over route-level):
199
-
200
- ```ts
201
- finesoftFrontViteConfig({
202
- ssr: { entry: "src/ssr.ts" },
203
- renderModes: { "/blog/*": "prerender" },
204
- });
205
- ```
206
-
207
- The runtime cache has **no TTL and no background regeneration** — entries live until LRU-evicted or the process restarts. Time-based stale-while-revalidate is delegated to the CDN by the platform adapters (Netlify emits a real `stale-while-revalidate` header; Cloudflare a plain `max-age`; node/Vercel none). See [server & deployment](./09-server-and-deployment.md#isr-incremental-static-regeneration) for the full picture.
208
-
209
- ## `PrefetchedIntents` — the SSR → CSR bridge
210
-
211
- The crucial mechanic: **the same controller produces a page on the server, and the browser reuses that result without refetching.**
212
-
213
- ### How it works
214
-
215
- 1. SSR: controller runs, returns `Page`. The framework stores `(intentId, paramsKey) → Page` in a `PrefetchedIntents` map.
216
- 2. Render: the map is JSON-stringified into `<script id="__finesoft_data__">{...}</script>`.
217
- 3. Browser: `startBrowserApp` reads the script, calls `createPrefetchedIntentsFromDom()`, passes it to `Framework.create()`.
218
- 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.
219
-
220
- ### Stable key generation
221
-
222
- The lookup key is generated from `intentId` + the **stable JSON stringification** of `params`. Object key order does not affect the key:
223
-
224
- ```ts
225
- // These produce the same paramsKey:
226
- dispatch({ intentId: "product", params: { id: "42", color: "red" } });
227
- dispatch({ intentId: "product", params: { color: "red", id: "42" } });
228
- ```
229
-
230
- If you write a controller that resolves the same logical request from different `params` shapes, factor it into a normalization step before dispatch.
231
-
232
- ### When the cache misses
233
-
234
- - New navigation to an intent not prefetched on the server (e.g., dynamic route the user clicked)
235
- - Stale cache after `PrefetchedIntents.invalidate(intentId, params)`
236
- - Browser-side mutation guards (custom)
237
-
238
- A miss falls through to the regular dispatcher path — `execute()` runs in the browser.
239
-
240
- ## Hydration step-by-step
241
-
242
- ```
243
- Server Browser
244
- ────── ───────
245
- bootstrap(framework)
246
- ▼ │
247
- controller.execute() │
248
- ▼ │
249
- Page A │
250
- ▼ │
251
- serialize → <script> │
252
- ▼ │
253
- HTML response ────────────────▶ Receive HTML
254
-
255
- createPrefetchedIntentsFromDom()
256
-
257
- Framework.create({ prefetchedIntents })
258
-
259
- bootstrap(framework) ← same code, same routes
260
-
261
- dispatch(currentIntent)
262
-
263
- Cache hit → Page A ← no refetch
264
-
265
- mount(app)
266
- ```
267
-
268
- 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.
269
-
270
- ## SSR head injection
271
-
272
- `renderApp()` returns a `head` fragment. The framework injects it at the `<!--head-->` placeholder along with:
273
-
274
- - `<script id="__finesoft_data__">` with serialized data (SSR mode only)
275
- - `<link>` / `<script>` for client entry (production builds)
276
- - `<html lang="..." dir="...">` attributes from the resolved locale
277
-
278
- Custom meta tags go in your `head` string:
279
-
280
- ```ts
281
- async renderApp(page) {
282
- return {
283
- html: await renderToString(/*...*/),
284
- head: [
285
- `<title>${escape(page.title)}</title>`,
286
- `<meta name="description" content="${escape(page.description)}">`,
287
- `<meta property="og:title" content="${escape(page.title)}">`,
288
- ].join(""),
289
- css: "",
290
- };
291
- }
292
- ```
293
-
294
- Always escape user-provided strings — they go straight into HTML.
295
-
296
- ## CSS injection
297
-
298
- If your render produces critical CSS (e.g., Vue scoped styles or `vanilla-extract`), return it as `css`:
299
-
300
- ```ts
301
- return {
302
- html,
303
- head: `<title>${title}</title>`,
304
- css: extractedCriticalCss, // injected as <style> in <head>
305
- };
306
- ```
307
-
308
- For Vite-managed stylesheets, leave `css: ""` — the Vite plugin handles them.
309
-
310
- ## Status codes
311
-
312
- The HTTP status of the SSR response follows this priority:
313
-
314
- 1. Middleware result: `deny(404)` → 404; `redirect(url, 301)` → 301 with `Location` header.
315
- 2. Page-level: a `Page` of `kind: "error"` returned by `fallback()` results in 500 (configurable via `getErrorPage`).
316
- 3. Default: 200.
317
-
318
- Override via `afterLoad`:
319
-
320
- ```ts
321
- afterLoad: [
322
- (ctx) => {
323
- if (ctx.page.kind === "not-found") return deny(404, "Not found");
324
- return next();
325
- },
326
- ],
327
- ```
328
-
329
- ## Streaming SSR
330
-
331
- 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.
332
-
333
- If you need streaming for a specific large page, consider rendering it CSR and using your view layer's own streaming primitives.
334
-
335
- ## Next
336
-
337
- - [i18n](./05-i18n.md) — locale resolution and dictionary loading
338
- - [Pitfalls: SSR hydration mismatch](./pitfalls/ssr-hydration-mismatch.md) — when the two sides disagree
11
+ CSR returns an HTML shell. Prerender produces static HTML. Runtime public HTML reuse still executes current guards and rendering, so it is not a render-skipping guarantee. During cleanup, await session disposal before unmounting the application's native root.