@finesoft/front 0.5.0 → 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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -698
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BYZq9Jp7.mjs +0 -2
- package/dist/browser-JTs2jqVY.d.mts +0 -2811
package/docs/03-middleware.md
CHANGED
|
@@ -1,220 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Guarded page loading
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1
|
+
# Rendering and hydration
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|